ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot 实现微信登录:OAuth2 授权流程与避坑指南

Spring Boot 实现微信登录:OAuth2 授权流程与避坑指南 1. 先说结论微信登录到底怎么做我之前帮好几个项目接入过微信登录从最初的公众号网页授权到后来开放平台的扫码登录踩过不少坑。这里直接先给结论微信登录本质上就是一次标准的 OAuth2 授权流程微信充当授权服务器你的 Spring Boot 后端充当客户端。用户点一下授权微信返回一个临时 code后端拿着 code 去换 access_token再拿着 access_token 拉取用户资料最后把用户信息落到自己库里签发自己的登录态。整套东西跟微信的“支付”比简直简单到不行。难点不在代码而在配置和边界情况。很多人卡住是因为 AppID 和 Secret 对应错了或者回调地址没有配置或者没理解 code 是一次性的。这篇文章就是帮你把整个链路理顺把代码直接贴出来照着改就能跑。适合三类人看一是刚接手 Spring Boot 项目需要加微信登录的开发者二是想弄清楚 OAuth2 到底在做什么的后端小白三是遇到各种报错找不到原因的苦逼运维。文章里我会按“配置 - 授权链接 - 回调处理 - 拉取用户 - 签发 token”的顺序讲最后给出几个我实际踩过的坑。2. 准备工作申请账号与基础配置2.1 你需要哪些账号和资质先分清场景。如果是在微信 App 内嵌浏览器里做登录走的是微信公众号的“网页授权”需要去微信公众平台注册一个服务号拿到AppID和AppSecret。如果是在 PC 端网页用微信扫码登录走的是微信开放平台的“网站应用登录”需要去开放平台注册一个网站应用同样会拿到一对 AppID 和 Secret。很多人搞混的点就在这公众号的 AppID 不能直接用在小程序的登录里开放平台的 AppID 也不能用到公众号网页授权里。虽然接口路径长得差不多但令牌体系是不相通的。资质方面个人主体也能注册订阅号但网页授权里的snsapi_userinfo获取用户详细信息必须用认证过的服务号。个人主体注册的服务号也需要微信认证每年要交 300 元。如果只是测试可以用测试号微信公众平台提供一个“测试号”申请入口不需要认证申请下来就有 AppID 和 Secret也能用网页授权就是有白名单 IP 限制。我建议第一次做的时候直接申请测试号跑通了再换成正式号。这样不会因为资质审核耽误时间而且测试号和正式号的接口调用方式完全一致。2.2 在微信后台配置回调域名这个配置决定了微信授权跳转时能不能带回调地址。以公众号网页授权为例在公众号后台“设置 - 公众号设置 - 功能设置”里有一个“网页授权域名”的配置项。这里只能填域名不能填 IP不能带http://或https://也不能带路径。比如你的回调地址是https://example.com/api/wx/login/callback那这里就填example.com。如果填了 IP微信会直接报错提示“redirect_uri 参数错误”。调试阶段如果不想买域名可以把微信后台的配置和本地代码的域名都改成内网穿透工具的临时域名比如xxx.ngrok.com。我当时就是这么干的本地起一个 Spring Boot打成 jar 包后挂到穿透服务上微信能访问到就行。还有一点开放平台网站应用的“授权回调域”配置逻辑类似但它是针对整个应用的。而且开放平台要求回调地址必须和你提交审核的网站域名一致审核完成后才能用。2.3 Spring Boot 项目里的基础 yml 配置微信登录离不了几个常量我习惯写进application.yml。建议用wx.login.app-id和wx.login.app-secret这样的命名方便后续扩展。另外注意AppSecret是敏感信息放到配置中心或环境变量里别直接硬编码到代码库。server: port: 8080 wx: login: app-id: your_app_id app-secret: your_app_secret redirect-uri: https://example.com/api/wx/login/callback # 授权作用域snsapi_userinfo 可以获取用户详细信息 scope: snsapi_userinfo # 一个标识防止 CSRF后面代码里会用到 state: from-spring-boot这里把redirect-uri放在配置里是因为本地调试和生产环境地址不一样改了配置不用改代码。还有个小细节有些老项目的 yml 里会把app-secret用明文写出来然后误传到 git 仓库非常危险。我一般会在 gitignore 里忽略掉application-local.yml生产配置走环境变量。3. 核心代码OAuth2 授权流程的实现3.1 第一步拼接授权链接引导用户点击微信登录的起点是让用户访问一个微信官方的授权页面。这个页面的 URL 可以自己拼不需要调微信接口。关键参数有四个appid、redirect_uri、response_type、scope还有一个state用来防跨站伪造。先写一个配置类把 yml 里的值映射进来。用ConfigurationProperties最方便。package com.example.wxlogin.config; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; Component ConfigurationProperties(prefix wx.login) public class WxLoginProperties { private String appId; private String appSecret; private String redirectUri; private String scope; private String state; // getter 和 setter 必须写全 public String getAppId() { return appId; } public void setAppId(String appId) { this.appId appId; } public String getAppSecret() { return appSecret; } public void setAppSecret(String appSecret) { this.appSecret appSecret; } public String getRedirectUri() { return redirectUri; } public void setRedirectUri(String redirectUri) { this.redirectUri redirectUri; } public String getScope() { return scope; } public void setScope(String scope) { this.scope scope; } public String getState() { return state; } public void setState(String state) { this.state state; } }然后写一个 Controller提供两个接口一个/wx/login用来生成授权链接并 302 重定向另一个/wx/login/callback接收微信的回调。package com.example.wxlogin.controller; import com.example.wxlogin.config.WxLoginProperties; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; Controller public class WxLoginController { private final WxLoginProperties properties; public WxLoginController(WxLoginProperties properties) { this.properties properties; } GetMapping(/wx/login) public void wxLogin(HttpServletResponse response) throws IOException { // 拼接授权链接 String redirectUri URLEncoder.encode(properties.getRedirectUri(), StandardCharsets.UTF_8.name()); String url https://open.weixin.qq.com/connect/oauth2/authorize ?appid properties.getAppId() redirect_uri redirectUri response_typecode scope properties.getScope() state properties.getState() #wechat_redirect; // 这个锚点必须加尤其微信内置浏览器 response.sendRedirect(url); } }这里有个关键点微信要求redirect_uri做 URL 编码而且编码后和都不能变回原样。用URLEncoder.encode默认会把空格变成但 URL 里标准做法应该是%20不过在 query 参数里也能被微信接受。为了保险我会把空格替换成%20或者直接用UriComponentsBuilder。还有一个坑#wechat_redirect这个 anchor 不能丢。不管是 PC 端扫码还是公众号内跳转如果少了这个标志微信有时候会提示“该链接无法访问”。这不是什么高技术问题纯属微信的规矩。3.2 第二步处理回调用 code 换取 access_token 和 openid用户在上面那个页面点了“同意授权”微信就会带着code和state跳回你的回调地址。回调地址长这样https://example.com/api/wx/login/callback?codeXXXXXXstatefrom-spring-boot后端拿这个code去调微信的接口换access_token。注意这里有个命名混淆微信返回的access_token是“用户级网页授权 access_token”有效期通常是 7200 秒而且这个 token 可以刷新。它和你调微信接口用的“全局 access_token”完全不是一个东西很多人搞混导致调接口的时候拿错了 key。换取的接口是 GET 请求参数直接拼在 URL 上。响应是 JSON里面包含access_token、expires_in、refresh_token、openid和scope。这里必须重点记录openidopenid 是用户的唯一标识同一个用户在同一公众号下openid 是固定的。注意不同公众号或不同应用同一个微信用户的 openid 是不同的。所以你的用户表设计中openid 一定要和 appid 捆绑别只存一个 openid 就完事否则以后接多端登录会乱。我平时用RestTemplate调微信接口。Spring Boot 默认装配了一个RestTemplateBuilder可以注入使用。但为了控制超时建议自己定义一个RestTemplateBean。package com.example.wxlogin.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestTemplate; Configuration public class RestTemplateConfig { Bean public RestTemplate restTemplate() { SimpleClientHttpRequestFactory factory new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(3000); factory.setReadTimeout(5000); return new RestTemplate(factory); } }回调接口的实现。先校验state防止 CSRF。然后拿code去换 token。微信官方文档写的换 token 接口是https://api.weixin.qq.com/sns/oauth2/access_token?appidAPPIDsecretSECRETcodeCODEgrant_typeauthorization_code直接用RestTemplate.getForObject就能拿到结果。我习惯用一个Map来接收虽然不如定义个 DTO 优雅但微信返回字段不多用 Map 够用。package com.example.wxlogin.controller; import com.example.wxlogin.config.WxLoginProperties; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; import java.util.HashMap; import java.util.Map; RestController public class WxCallbackController { private final WxLoginProperties properties; private final RestTemplate restTemplate; public WxCallbackController(WxLoginProperties properties, RestTemplate restTemplate) { this.properties properties; this.restTemplate restTemplate; } GetMapping(/wx/login/callback) public MapString, Object callback(RequestParam(code) String code, RequestParam(state) String state) { // 1. 校验 state if (!properties.getState().equals(state)) { throw new RuntimeException(state 校验失败可能存在 CSRF 攻击); } // 2. 用 code 换取 access_token String url UriComponentsBuilder .fromHttpUrl(https://api.weixin.qq.com/sns/oauth2/access_token) .queryParam(appid, properties.getAppId()) .queryParam(secret, properties.getAppSecret()) .queryParam(code, code) .queryParam(grant_type, authorization_code) .toUriString(); MapString, Object tokenResponse restTemplate.getForObject(url, Map.class); // 3. 判断是否成功 if (tokenResponse null || tokenResponse.containsKey(errcode)) { throw new RuntimeException(获取 token 失败: tokenResponse); } String openid (String) tokenResponse.get(openid); String accessToken (String) tokenResponse.get(access_token); String refreshToken (String) tokenResponse.get(refresh_token); // 这里可以做后续处理 MapString, Object result new HashMap(); result.put(openid, openid); result.put(accessToken, accessToken); result.put(refreshToken, refreshToken); return result; } }注意微信返回的错误信息是{errcode:40029,errmsg:invalid code}这种格式。所以判断是否成功直接看 Map 里有没有errcode就行。但是区分一下换 token 成功时 key 是access_token失败时 key 是errcode。这个别搞反了。3.3 第三步拉取用户信息并完成登录拿到access_token和openid后就可以调用户信息接口。接口如下https://api.weixin.qq.com/sns/userinfo?access_tokenACCESS_TOKENopenidOPENIDlangzh_CN这个接口拿到的数据包括openid、nickname、sex、province、city、country、headimgurl、privilege。注意在snsapi_base作用域下这个接口不能调用只有用snsapi_userinfo才能拿到详细资料。所以配置里的scope要选对。调用完成后拿到用户信息接下来要判断新老用户。如果openid appid在你库里能查到说明是老用户直接登录查不到就 insert 一条新用户记录。这个过程我建议放到 Service 层Controller 只做参数提取和返回。为了篇幅我把用户实体简化成UserId和Nickname两个字段你自己按表的实际情况扩展。package com.example.wxlogin.service; import com.example.wxlogin.config.WxLoginProperties; import com.example.wxlogin.model.User; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import java.util.Map; import java.util.UUID; Service public class WxLoginService { private final RestTemplate restTemplate; public WxLoginService(RestTemplate restTemplate) { this.restTemplate restTemplate; } public User loginByWeChat(String code, String state, WxLoginProperties properties) { if (!properties.getState().equals(state)) { throw new RuntimeException(非法 state); } // 换取 token String tokenUrl String.format( https://api.weixin.qq.com/sns/oauth2/access_token?appid%ssecret%scode%sgrant_typeauthorization_code, properties.getAppId(), properties.getAppSecret(), code); MapString, Object tokenMap restTemplate.getForObject(tokenUrl, Map.class); if (tokenMap null || tokenMap.containsKey(errcode)) { throw new RuntimeException(获取 access_token 失败); } String openid (String) tokenMap.get(openid); String accessToken (String) tokenMap.get(access_token); // 拉取用户信息 String userInfoUrl String.format( https://api.weixin.qq.com/sns/userinfo?access_token%sopenid%slangzh_CN, accessToken, openid); MapString, Object userInfoMap restTemplate.getForObject(userInfoUrl, Map.class); if (userInfoMap null || userInfoMap.containsKey(errcode)) { throw new RuntimeException(获取用户信息失败); } String nickname (String) userInfoMap.getOrDefault(nickname, 微信用户); String avatar (String) userInfoMap.getOrDefault(headimgurl, ); // 这里模拟查库或插入请替换成自己的 UserMapper User user findUserByOpenId(openid); if (user null) { user new User(); user.setId(UUID.randomUUID().toString()); user.setOpenId(openid); user.setNickname(nickname); user.setAvatar(avatar); // 省略 saveToDb } return user; } private User findUserByOpenId(String openid) { // 对应库表查询逻辑 return null; } }这里有个体验问题微信返回的nickname大部分是用户自己起的但有些用户会设置为空字符串甚至有一些特殊表情符号。MySQL 如果字符集不是 utf8mb4插入 emoji 会直接报错Incorrect string value。所以我强烈建议建表时把所有可能存用户昵称的字段都设成utf8mb4同时 JDBC 连接串也加上characterEncodingutf8mb4实际上 MySQL 驱动是utf8mb4。3.4 第四步会话管理——签发自己的 token微信登录成功不代表客户端就能一直用微信的access_token来调用你的业务接口。毕竟用户的登录态应该由你的系统管理而不是每次请求都去微信验一次。正确做法是在后台登录成功后生成一个你自己的 tokenJWT 或者 UUID 都行把这个 token 返回给前端后续前端请求带上这个 token后端校验后确认用户身份。这里我用 JWT因为它可以直接携带 openid、userId、过期时间等信息无状态解析快。依赖jjwt或java-jwt都行。package com.example.wxlogin.service; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import org.springframework.stereotype.Service; import java.util.Date; import java.util.HashMap; import java.util.Map; Service public class JwtTokenService { private static final String SECRET your-256-bit-secret-key-change-me; private static final long EXPIRE_TIME 24 * 60 * 60 * 1000; // 24小时 public String generateToken(String userId, String openid) { MapString, Object claims new HashMap(); claims.put(userId, userId); claims.put(openid, openid); return Jwts.builder() .setClaims(claims) .setSubject(userId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() EXPIRE_TIME)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } }在回调接口里调用loginByWeChat拿到用户实体然后签发 token 返回。前端拿到 token 后后续请求Authorization头带上即可。4. 避坑指南我实测遇到的几个典型问题4.1 redirect_uri 参数错误与 URL 编码问题这是新手遇到最多的报错。微信提示“redirect_uri 参数错误”几乎都不是你链接拼错了而是因为以下三种情况后台配置的域名和实际回调地址域名不一致。比如配置填了example.com回调地址是https://api.example.com/path虽然子域名是同一个主域微信也认为是错的必须一字不差。回调地址是http后台配置的域名不支持http。微信规定回调地址必须使用https除非你在测试阶段用特殊情况。实际生产一定要上 HTTPS。redirect_uri没有 URL 编码。很多人用${redirectUri}直接拼字符串导致微信收到带?的参数解析失败。正确的处理是用java.net.URLEncoder.encode(redirectUri, UTF-8)或 Spring 的UriComponentsBuilder。但要注意URLEncoder会把空格编码成某些环境微信不认所以最好再.replace(, %20)。4.2 code 只能用一次而且 5 分钟失效微信返回的这个code是一次性的使用过一次后立刻过期再次使用会报40029 invalid code。而且 code 有效期很短官方写的是 5 分钟。如果用户在授权页面停留太久再回来回调code 可能已经失效用户会看到一个错误页。我处理这种场景时会在前端设置一个超时交互如果用户超过 2 分钟没有完成授权前端就自动刷新页面重新发起登录。后端的 code 换 token 失败时也要返回一个明确的前端提示别直接抛异常页。4.3 scope 填错导致登录后没有头像和昵称如果你在配置里写的是snsapi_base那用户信息接口sns/userinfo调用会报错错误码是48001提示 api unauthorized。很多项目只需要登录不需要昵称头像那用snsapi_base就够了回调里只能拿到 openid拿不到其他信息。如果你拿不到头像昵称检查一下 scope 是不是snsapi_userinfo。注意snsapi_userinfo需要服务号认证测试号也能用。但开放平台网站扫码登录的方式默认能拿到用户的基本信息昵称头像和公众号网页授权不同。扫码登录那套接口是https://api.weixin.qq.com/sns/oauth2/access_token加上https://api.weixin.qq.com/sns/userinfo流程上几乎一样只是授权页地址不同。开放平台的授权页是https://open.weixin.qq.com/connect/qrconnect?appidAPPIDredirect_uriREDIRECT_URIresponse_typecodescopesnsapi_loginstateSTATE#wechat_redirect公众号内嵌页是https://open.weixin.qq.com/connect/oauth2/authorize?appidAPPIDredirect_uriREDIRECT_URIresponse_typecodescopesnsapi_userinfostateSTATE#wechat_redirect两者区别在于授权页路径不同以及扫码登录 scope 固定是snsapi_login。代码逻辑几乎一致。4.4 获取 access_token 时与全局 token 混淆微信有两个 token 体系。一个是sns/oauth2/access_token用于网页授权可以获取用户信息另一个是cgi-bin/token用于调用服务端接口比如发送模板消息、获取用户列表。这两个 token 都叫 access_token但完全不是一回事。我之前在项目里公司内部封装了一个获取全局 token 的工具结果回调里没注意把全局 token 当成网页授权 token 去调userinfo接口报40001invalid credential排查了很久。建议代码里变量命名明确区分wxAuthToken和wxApiToken注释写清楚来源。4.5 本地调试时微信后台无法访问内网本地起服务后微信回调打不到localhost因为微信服务器在公网。这时候要用内网穿透工具。局域网内也可以用 Nginx 反向代理或者直接把服务部署到测试服务器上。我之前本地调试用的穿透方案是把整个 Spring Boot 服务暴露到临时域名然后在微信后台配置临时域名调试完再改回正式域名。穿透工具虽然方便但会暴露本机服务调试完一定要关掉不然有安全风险。4.6 同一用户多应用登录的 openid 不通用如果你同时做了公众号登录和开放平台扫码登录同一个微信用户在这两个应用里拿到的 openid 是不同的。所以 user 表里必须有app_id字段查询时用openid app_id联合定位用户否则会出现同一个人有两套账号历史订单对不上。我当时做一个 B 端系统既有公众号登录又要在 PC 端扫码登录一开始没区分上线后一查数据重复用户一大堆。后面加了一个字段做合并清洗很麻烦。所以设计表时一定提前考虑多端。5. 实操心得与扩展建议5.1 生产环境建议使用 Redis 保存会话状态上面的示例为了方便直接发了 JWT。如果你们系统没有现成的 JWT 依赖用 UUID 也行。但生产环境下我更推荐用 Redis 保存 token 到用户信息的映射。理由很简单JWT 一旦签发在过期前无法主动作废用户踢人下线很麻烦。微信登录的会话时效通常是 30 天或更长万一用户被封禁JWT 还是能继续访问。用 Redis 的话每次请求拦截器里去 Redis 查一下 token 是否存在存在就放行不存在就 401。这样管理员想强制下线直接删掉 Redis key 就行。5.2 与 Spring Security 或网关整合如果你的项目用了 Spring Security不要直接拦截微信回调。回调接口应该放在白名单里允许匿名访问。微信回调完成后用自己的 token 机制生成登录态再接入 Spring Security 的过滤器链。如果是微服务架构建议把微信登录逻辑放在认证服务Auth Service里其他服务通过 JWT 或 token 校验。网关层面统一校验 token回调接口放行。这个设计会让业务服务不用关心微信相关的一切后续接小程序登录、App 登录都能在认证服务里扩展。5.3 扫码登录、小程序登录、App 登录怎么复用这套逻辑把核心逻辑抽出来不要在每个 controller 里写重复代码。微信登录的模型是code - access_token openid - userinfo - 本地用户。其中唯一不同的地方是“获取用户信息的来源”。公众号网页授权上面这套scope 是snsapi_userinfo开放平台扫码登录授权 URL 不同scope 是snsapi_login其他一致小程序登录登录时调用wx.login拿到 code后端调https://api.weixin.qq.com/sns/jscode2session换 openid 和 session_key注意这个接口不是返回 access_token而是返回openid和session_keyApp 微信登录接入微信 SDK拿到 code 后可能还需要openid和unionid如果绑定过开放平台可以通过unionid打通多个应用如果你在公众号和开放平台下面都绑定了同一个开放平台账号且用户在同一种主体下登录就能拿到unionid这可以解决 openid 不同的问题。前提是必须在微信开放平台将公众号和应用绑定到同一个开放平台账号下。5.4 一个完整的授权状态机很多人不重视 state 参数直接写死一个字符串。更好的做法是用 session 或 Redis 存一个随机 state在发起授权时下发回调时校验。这样能防止攻击者伪造跳转。我一般这样操作用户请求/wx/login后端生成一个随机 state存到 Rediskey 是wx:state:{uuid}value 是当前用户的 sessionId过期时间 5 分钟。拼接授权链接带上这个 state跳转到微信。用户同意后回到回调接口后端拿出state去 Redis 查有没有对应的 key有则校验通过并删除 key无则拒绝。这个方法比固定字符串安全很多。虽然微信登录带来的安全风险主要不在 CSRF但养成好习惯总没错。5.5 遇到“版本过低”或平台限制时先查环境最后补充一个很多人咨询的问题手机微信版本过低打不开授权页或者企业微信环境里打开授权页异常。这其实不完全是后端问题。微信授权的网页本身对旧版微信有限制如果测试用户的客户端太老官网都维护不过来更别说接收回调。这种情况建议提示用户升级微信或者换到最新版测试。企业微信内部浏览器和普通微信的环境也有差异如果需要在企业微信内使用要调用企业微信的 OAuth2 接口和公众号网页授权不是一套体系。别拿公众号的 AppID 去企业微信里试。6. 最后再分享一个小技巧回调接口里一定要打印完整参数我做微信登录的调试时会在回调接口第一行就把code、state、以及整个请求的全参数都打出来。因为微信回调的时机不可控出现问题的时候日志就是唯一的现场。GetMapping(/wx/login/callback) public MapString, Object callback(RequestParam MapString, String allParams) { System.out.println(微信回调参数: allParams); // 后续逻辑 }另外接微信登录始终要有异常兜底。微信接口偶尔会超时或者返回明明成功却没有openid。我的习惯是每次 getForObject 之后检查 Map如果是空或缺关键字段直接抛一个自定义异常统一被全局异常处理器捕获返回给前端一个可读的错误信息。不要把NullPointerException直接暴露给用户。这整套流程下来Spring Boot 实现微信登录确实也就是半天功夫核心不是代码量而是要把官方文档里的 OAuth2 流程吃透。你按照这个顺序做基本不会走弯路。
返回列表