Spring Boot微信扫码登录实战:OAuth2授权码流程与开放平台配置指南
标题里的So Easy不是标题党但前提是你把流程底层先捋清楚。Spring Boot 做微信登录准确说是微信扫码登录这件事拆开了看就是三个HTTP调用加一个回调接口跳转授权页、拿code换access_token、拿access_token换用户信息最后在自己系统里签发一个登录态。之所以那么多人被卡住不是Spring Boot代码难写而是把三套概念搞混了开放平台、公众平台、小程序这三类微信登录长得像走错一个后面全废。这篇文章我把整个链路按“准备—配置—编码—排查”的顺序给你梳理一遍适合正在做PC网站、管理后台、SaaS系统接入微信登录的开发者参考不论你用的是Spring Boot 2.3.x还是2.6.x思路完全一致。1. 先搞明白微信登录的底层流程再动手写代码很多新手上来就搜“Spring Boot 微信登录代码”拿到Demo就跑结果换了AppID就报错。我建议先花半个小时把OAuth2授权码这套机制看清楚后面所有代码都是它的翻译。1.1 微信登录的本质一次OAuth2授权码交换微信登录不是“输入密码登录”而是一次标准的OAuth2授权码模式authorization code。可以想象成用户去微信这个“保险柜”里取你的系统需要的资料但微信不会直接把保险柜钥匙给你而是给一个一次性领取凭证code系统拿着code去微信后台换取临时钥匙access_token再用这把钥匙读取用户昵称、头像、openid这些资料。实际请求链路如下前端/后端生成微信授权页地址用户打开后看到二维码。用户用微信扫码并确认授权。微信服务器302重定向到你的回调地址URL上携带code和state。后端拿code加上AppSecret调用微信接口换取access_token和openid。后端拿access_token调用用户信息接口拿到昵称、头像、unionid等。在自己的系统里查库、注册或更新用户然后签发登录态JWT或Session返回前端。这套流程里code是最关键的临时凭证有效期只有5分钟且只能用一次。微信设计成“用code换token”而不是直接把token给前端就是为了避免access_token暴露在浏览器端被截获。理解这一点后面看到“invalid code”“code已被使用”这类报错就不会慌。1.2 三个“微信登录”别搞混开放平台、公众平台、小程序我在不同项目里见过有人把公众号的AppID填到扫码登录里或者拿小程序的AppSecret调网页授权接口最后微信返回“invalid appid”这类提示。要区分清楚登录类型所属平台适用范围授权scope网站扫码登录微信开放平台PC网站、管理后台、Web应用snsapi_login公众号网页授权微信公众平台微信内置浏览器打开的H5页面snsapi_base / snsapi_userinfo小程序登录微信公众平台小程序微信小程序内wx.login code2Session本文讲的是第一类开放平台的网站应用扫码登录。如果你登录页是在普通浏览器里打开的用户需要扫码那一定走开放平台如果你的页面是在微信里打开的那才走公众号网页授权如果你做的是小程序那又是一套完全不同的接口体系。这三个AppID不能混用尤其是很多人手里有公众号AppID想当然拿来扫码一定失败。1.3 为什么是扫码登录而不是账号密码微信没有开放“账号密码登录”这种接口给第三方网站这是刻意的。让用户把微信号密码交给某家SaaS系统泄露风险太大微信不可能承担这个责任。扫码登录的本质是把身份认证这件事委托给微信这个高可信身份源你的系统只负责接收认证结果。这样做有三个好处用户不用再记一套新密码你不用设计复杂的密码找回流程用户资料昵称头像由微信统一维护拿到就是官方可信的。所以不要纠结“为什么不能直接输微信号密码”这不是技术做不到是商业和合规上的设计。2. 账号准备开放平台、网站应用、回调域名一个都不能少代码什么时候都能写但账号和域名配置错了代码写得再对也调不通。我把前期准备分成三步每一步都是踩坑换来的。2.1 创建开放平台网站应用拿到AppID和AppSecret首先去微信开放平台注册开发者账号然后在“管理中心—网站应用”里创建应用填好网站信息后就能拿到AppID和AppSecret。这里有一个很容易忽略的点新注册的开放平台账号创建的应用能否调用完整的用户信息接口和账号认证状态有很大关系。如果应用未完成认证可能只能拿到openid拿不到昵称、头像这些字段或者接口返回受限。不同时期的策略有差异以你开发者后台看到的权限状态为准。我的建议是先走完开发流程确认业务没问题后再考虑认证如果测试阶段就拿不到用户资料优先检查账号认证状态别怀疑代码。2.2 配置授权回调域名记住这三点开放平台后台的“网站应用—接口信息”里有一项“授权回调域名”。这里很多人填错微信在回调阶段会严格校验这个值和实际回调地址是否匹配。需要记住三点第一只填域名不要带协议和路径。比如你有回调地址是https://example.com/wx/callback后台填的是example.com不要把https://或/wx/callback填进去。第二域名要和实际请求的Host完全匹配包括端口都要一致。微信公众号网页授权对端口也有严格限制网站应用同理如果你本地起的是8080端口微信服务器访问不到localhost:8080这一点在开发调试时特别麻烦。第三域名需要按微信要求完成合规备案。微信对授权回调域名的备案状态有要求个人开发者在测试阶段最好用一个已经备案好的域名临时域名或未备案域名很容易在回调校验时被拦下来。2.3 先把配置参数整理成一张清单不管用什么语言写微信登录最终依赖的配置就这几项。写代码之前先列出来后面会反复用到配置项示例值说明appIdwxf9b8...开放平台网站应用的AppIDappSecret32位字符串网站应用对应的AppSecretredirectUrihttps://example.com/wx/callback必须是授权回调域名下的地址scopesnsapi_login网站扫码登录固定值jwtSecret自定义长字符串用于给用户签发登录态这些参数建议放到Spring Boot的配置文件里不要写死在业务代码中。原因有两个一是测试环境和生产环境的回调域名大概率不同二是AppSecret这种敏感信息如果你哪天不小心把代码提交到公开仓库整个账号的安全性就没了。3. Spring Boot工程配置依赖、yml、参数读取3.1 选择技术栈Spring Boot 2.x Hutool JWT Redis本文的实现基于Spring Boot 2.6.x在2.3.x上一样能运行代码没有用到版本特有API。HTTP调用我用Hutool的HttpUtil相比手写RestTemplate能省掉不少样板代码返回的JSON也用Hutool的JSONUtil解析。用户登录态签发我用JWT用户表存储用MyBatis-Plus分布式场景下的state校验和code防重依赖Redis。如果你项目里没有Redis用本地Map加分布式锁也能撑过单机测试但生产环境最好还是按本文方案来。pom.xml中核心依赖如下按需裁剪即可dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdcn.hutool/groupId artifactIdhutool-all/artifactId version5.8.25/version /dependency dependency groupIdcom.auth0/groupId artifactIdjava-jwt/artifactId version4.4.0/version /dependency3.2 application.yml配置示例与说明很多教程喜欢把微信参数一股脑扔在application.yml的wx节点下我也一样但会单独用一个配置类去读取而不是在Controller里写Value满天飞。下面的配置可以直接抄server: port: 8080 spring: redis: host: 127.0.0.1 port: 6379 wx: login: app-id: wx1234567890abcdef app-secret: 你的AppSecret redirect-uri: https://example.com/wx/callback state-expire-minutes: 5 jwt: secret: change-me-to-a-long-random-string expire-hours: 24关于redirect-uri有一点必须强调这里的值不要预先URL编码。因为代码里拼接授权地址时我会单独对redirect_uri这个参数做一次编码如果你在yaml里已经写成了https%3A%2F%2F...后面再编码一次就会变成双重编码微信回调时比对不通过报“redirect_uri参数错误”。我在项目里见过至少三次这个坑。3.3 参数绑定与Bean注入控制Spring Boot读取自定义配置最常见的方式是ConfigurationProperties它和Component配合后配置项会被自动绑定到Bean属性上你需要使用时直接注入这个Bean即可依赖注入完全交给Spring容器控制不需要手动new。Component ConfigurationProperties(prefix wx.login) public class WxLoginProperties { private String appId; private String appSecret; private String redirectUri; private Integer stateExpireMinutes; // getter/setter 省略IDE自动生成即可 }这里有个细节yaml里app-id这样的短横线写法Spring Boot在绑定到appId时是能自动匹配的不用额外加Value(${wx.login.app-id})。如果你在多环境部署还可以在这个类上配合Profile实现不同环境的配置隔离基础的Bean注入控制逻辑就是这些和普通业务Bean没有任何区别。4. 核心代码落地从授权二维码到用户自动注册账号和配置都准备好后编码就没多少东西了。我按一个完整闭环来写后端生成授权URL、前端展示二维码、用户扫码、微信回调、后端换token、拉取用户信息、自动注册登录、返回登录态。4.1 生成授权URL并处理redirect_uri编码微信网站应用的授权地址固定是https://open.weixin.qq.com/connect/qrconnect核心参数有五个。注意结尾必须带#wechat_redirect这是微信要求的固定锚点少了它页面会显示异常。Service public class WxLoginService { private final WxLoginProperties wxProperties; public WxLoginService(WxLoginProperties wxProperties) { this.wxProperties wxProperties; } public String buildAuthUrl(String state) { String encodedRedirectUri URLEncoder.encode( wxProperties.getRedirectUri(), StandardCharsets.UTF_8 ); return https://open.weixin.qq.com/connect/qrconnect?appid wxProperties.getAppId() redirect_uri encodedRedirectUri response_typecode scopesnsapi_login state state #wechat_redirect; } }这段代码里的关键点有两个。第一redirect_uri只编码一次别双重编码第二state参数不要用固定字符串最好每次请求生成一个随机值并保存下来防CSRF的作用我后面专门讲。实际生成二维码时一般由后端接口返回这个完整URL前端拿到后用qrcode.js渲染成二维码图片也可以直接在新窗口打开这个URL看微信官方的二维码页。4.2 微信回调接口接收code换取access_token用户扫码确认后微信会GET请求你的回调地址带code和state两个参数。回调接口要做三步校验state、用code换token、用token换用户信息。第一步先看代码重点在后面。RestController RequestMapping(/wx) public class WxCallbackController { private final WxLoginService wxLoginService; private final WxUserService userService; private final TokenService tokenService; private final StateCacheService stateCacheService; GetMapping(/callback) public void callback(RequestParam(code) String code, RequestParam(state) String state, HttpServletResponse response) throws IOException { if (!stateCacheService.consume(state)) { throw new BizException(state校验失败请求可能被伪造); } WxToken wxToken wxLoginService.getAccessToken(code); WxUserInfo wxUserInfo wxLoginService.getUserInfo(wxToken); SysUser user userService.loginByWx(wxUserInfo); String token tokenService.createToken(user.getId(), user.getOpenId()); String html buildCallbackHtml(token); response.setContentType(text/html;charsetUTF-8); response.getWriter().write(html); } }换token的接口封装在WxLoginService里调用的是微信官方地址https://api.weixin.qq.com/sns/oauth2/access_tokenpublic WxToken getAccessToken(String code) { MapString, Object params new HashMap(); params.put(appid, wxProperties.getAppId()); params.put(secret, wxProperties.getAppSecret()); params.put(code, code); params.put(grant_type, authorization_code); String resp HttpUtil.get(https://api.weixin.qq.com/sns/oauth2/access_token, params); JSONObject json JSONUtil.parseObj(resp); if (json.containsKey(errcode) json.getInt(errcode) ! 0) { throw new BizException(微信换token失败 json.getStr(errmsg)); } return new WxToken(json.getStr(access_token), json.getStr(openid), json.getStr(unionid)); }每次换token返回的access_token有效期约2小时但我们不需要存库因为紧接着就要用它去拿用户信息属于“用完即弃”的临时票据。真正要长期保存的是openid、unionid和用户资料。4.3 获取用户信息与自动注册登录逻辑拿到access_token和openid之后再调一次https://api.weixin.qq.com/sns/userinfo接口把微信用户资料拉下来public WxUserInfo getUserInfo(WxToken wxToken) { MapString, Object params new HashMap(); params.put(access_token, wxToken.getAccessToken()); params.put(openid, wxToken.getOpenid()); params.put(lang, zh_CN); String resp HttpUtil.get(https://api.weixin.qq.com/sns/userinfo, params); JSONObject json JSONUtil.parseObj(resp); if (json.containsKey(errcode) json.getInt(errcode) ! 0) { throw new BizException(获取微信用户信息失败 json.getStr(errmsg)); } WxUserInfo info new WxUserInfo(); info.setOpenid(json.getStr(openid)); info.setUnionid(json.getStr(unionid)); info.setNickname(json.getStr(nickname)); info.setHeadimgurl(json.getStr(headimgurl)); return info; }这里返回的openid是用户在微信下的唯一标识同一个用户在你的同一个网站应用下openid永远不变所以可以直接作为用户表的唯一键。接着就是自动注册逻辑这也是登录功能的核心Transactional public SysUser loginByWx(WxUserInfo wxInfo) { SysUser user userMapper.selectOne(new LambdaQueryWrapperSysUser() .eq(SysUser::getOpenId, wxInfo.getOpenid())); if (user null) { user new SysUser(); user.setOpenId(wxInfo.getOpenid()); user.setUnionId(wxInfo.getUnionid()); user.setNickname(wxInfo.getNickname()); user.setAvatar(wxInfo.getHeadimgurl()); userMapper.insert(user); } else { user.setNickname(wxInfo.getNickname()); user.setAvatar(wxInfo.getHeadimgurl()); userMapper.updateById(user); } return user; }首次登录自动注册非首次登录就更新一下头像昵称这个逻辑没有半点多余的地方。用户表至少要有这几个字段openid加上唯一索引防止重复用户CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, openid VARCHAR(64) NOT NULL COMMENT 微信openid, unionid VARCHAR(64) DEFAULT NULL COMMENT 微信unionid, nickname VARCHAR(128) DEFAULT NULL COMMENT 微信昵称, avatar VARCHAR(512) DEFAULT NULL COMMENT 微信头像, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_openid (openid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT微信登录用户表;4.4 前端闭环弹窗回传token与内嵌二维码两种衔接方式Spring Boot后端把token返回给前端有几种方式最常用的是弹窗方式。点击“微信登录”后前端新开一个窗口指向授权URL用户扫码完成后微信回调后端后端返回一个极简HTML页面页面里通过postMessage把token传递给父窗口然后自动关闭。上面的回调接口里buildCallbackHtml就是这个作用private String buildCallbackHtml(String token) { return !DOCTYPE htmlhtmlheadmeta charsetutf-8/headbody script window.opener.postMessage({type:wx-login, token: token }, *); window.close(); /script/body/html; }父窗口在页面里监听message事件收到token后存起来刷新用户登录状态。这个方案简单直接很多中大型项目都在用。注意token是后端生成的随机JWT字符串本身不含HTML特殊字符拼进页面不会有XSS风险但生产环境更严谨的做法是让后端先返回一个一次性票据前端再拿票据调一次换取token的接口避免token出现在URL或HTML中。另一种方式是让后端只返回授权URL前端用qrcode.js把URL画成二维码内嵌在登录页里这种方案需要同时提供一个扫码状态查询接口让前端轮询“用户有没有扫完”。实现上多一个接口和多一个状态存储适合不想弹窗的产品设计。两种方式后端代码几乎一样差别只在前端展示层。5. 把登录态玩明白state防CSRF、Redis缓存、JWT签发扫码登录的微信接口部分到这里已经通了但真正上线前还有几个安全细节绕不开我把它们单独拎出来讲因为每个都对应一个真实事故。5.1 为什么必须加state参数防CSRF实战微信授权URL里的state参数是官方定义的“用于保持请求和回调的状态授权请求后原样带回”实际价值就是防CSRF。试想一个场景攻击者构造自己的微信授权URL诱骗一个已登录你系统的用户去扫码授权然后攻击者带着code访问你的回调接口。如果不校验state你的系统会拿攻击者的code去处理最终把攻击者的微信账号绑定到受害者的本地账号上这等于白送攻击者一个账号权限。加了state之后后端在生成授权URL时给每个请求分配一个随机状态值存入Redis并设置5分钟过期回调时拿回来的state必须在Redis里存在并且只能被消费一次。这样攻击者伪造请求时拿不出对应的state回调直接拒绝。生成授权URL时的完整逻辑GetMapping(/wx/qrcode) public ResultWxQrCodeVO qrcode() { String state UUID.randomUUID().toString().replace(-, ); stateCacheService.save(state, wxProperties.getStateExpireMinutes()); String authUrl wxLoginService.buildAuthUrl(state); return Result.ok(new WxQrCodeVO(authUrl, state)); }5.2 code的一次性消费与分布式环境下的缓存策略微信的code本身只能用一次理论上不需要我们限制重复使用但回调接口可能因为网络重试或者用户手动刷新被触发多次导致第二次调用微信接口时报invalid code。为了幂等可以在Redis里对code做一层消费标记。回调处理的第一步同时校验state和code两个都删除成功才继续public boolean consumeStateAndCode(String state, String code) { Boolean stateOk stringRedisTemplate.delete(wx:login:state: state); Boolean codeOk stringRedisTemplate.delete(wx:login:code: code); return Boolean.TRUE.equals(stateOk) Boolean.TRUE.equals(codeOk); }很多人问为什么state和code要放Redis而不是用用户Session因为扫码登录的回调请求和前端页面的Session不一定绑定而且在生产环境通常有多台后端实例第一个实例生成state回调可能落在第二个实例上Session不共享就校验失败。Redis天然解决分布式状态共享的问题这个方案能从单机平滑扩展到集群。如果你项目里还没引Redis用数据库也行但性能和时效性都会差一些。5.3 登录态方案JWT签发与用户识别自动注册完成后需要给前端一个登录凭证。这里用JWT比较省事不依赖Session存储后端无状态对前后端分离和集群部署都友好。签发逻辑很简单Service public class TokenService { Value(${jwt.secret}) private String jwtSecret; Value(${jwt.expire-hours}) private Integer expireHours; public String createToken(Long userId, String openId) { return JWT.create() .withSubject(String.valueOf(userId)) .withClaim(openId, openId) .withIssuedAt(new Date()) .withExpiresAt(new Date(System.currentTimeMillis() expireHours * 3600 * 1000L)) .sign(Algorithm.HMAC256(jwtSecret)); } }之后用户在访问业务接口时前端在Header里带上Authorization: Bearer token后端用一个拦截器解析JWT、校验签名和有效期再往当前线程上下文里放入userId。这样整个系统就拥有了“微信登录过的人是谁”的能力。JWT的secret一定要足够长并且不要硬编码在代码里放进配置中心或者环境变量更稳妥。5.4 用unionid做多应用用户打通同一个微信用户在你的网站应用下的openid是固定的但如果你们公司还有公众号、小程序用户在不同应用下的openid互不相同想识别“这是同一个人”就得靠unionid。前提是这些应用都绑定在同一个微信开放平台账号下并且用户授权后微信才会返回unionid。表设计里预留了unionid字段在loginByWx时一起落库。如果后续要做多端用户统一就以unionid为主键合并账号而不是用openid。这一点现在不提等你真正遇到“网页登录的A用户和小程序里的B用户其实是同一个人”时会感谢这个字段。6. 常见问题排查手册与避坑指南微信登录的报错看似千奇百怪其实绝大多数集中在AppID、回调地址、scope这三个区域。我把高频问题整理成速查表建议收藏一份。6.1 高频报错速查表现象大概率原因处理建议报redirect_uri参数错误授权URL中redirect_uri编码错误、和后台回调域名不匹配、双重编码检查配置文件里的redirectUri是否未编码后台只填域名且与Host完全匹配报scope参数错误网站应用填了snsapi_userinfo或者在公众号授权里填了snsapi_login网站扫码登录固定使用snsapi_login小程序报appid不合法用了开放平台AppID去调小程序登录接口小程序登录必须用小程序的AppID和AppSecret拿到code但换token失败AppSecret填错或被调用的code已过期/已使用检查AppSecret用户重扫一次生成新code拿不到昵称头像开放平台应用未认证或调用接口顺序不对查看开发者后台应用认证状态确保scope为snsapi_login回调请求一直收不到回调域名未备案或域名解析到无法访问的地址用已备案域名部署到公网可访问环境state校验失败回调里state和生成时不一致或Redis中已过期/已被消费检查Redis存储和过期时间确保生成与回调同一套值重复扫码导致invalid codecode被消费过或者前端重复提交回调接口对code做一次性消费标记并给出友好提示这里再单独说一个很多人忽略的点不要用公众号的“网页授权域名”替代开放平台的“授权回调域名”两个配置入口不同校验逻辑也不同。每次遇到回调相关报错先把前后台域名配置截个图对比一遍比胡乱改代码有效得多。6.2 开发调试时的三个实操建议第一个建议是回调地址一定要用公网可访问的域名。扫码登录必须由微信服务器主动回调你的后端微信访问不到localhost。开发阶段最省事的办法是把应用部署到一台有公网域名的测试服务器上域名配好HTTPS再测本地只做普通业务调试。第二个建议是日志要把关键参数记录下来。回调接口建议至少打印code、state、openid、返回的errcode和errmsg排查问题时有据可查尤其是微信接口偶发失败时。日志格式我习惯写成wx callback|codexxx|statexxx|respxxx一行一个请求好检索。第三个建议是善用微信接口调试工具和开发者文档。微信公众平台和开放平台后台都有在线接口调试能力可以手动填参数看返回遇到问题先在工具里复现一遍能快速确认是微信侧参数问题还是你代码问题。工具返回和你代码返回一致那就是代码没传对工具返回正常而你代码报错八成是参数拼接或编码问题。6.3 踩坑实录从报错到解决的三段真实经历第一段经历发生在很多年前第一次做扫码登录时我拿着公众号的AppID往开放平台授权URL里填扫码后微信返回“invalid appid”我一度怀疑是签名算法问题查了半天才意识到两个平台的AppID体系根本不是一回事。后来我把这个教训做成了一张配置检查单先确认平台类型再填AppID再配回调域名顺序不能乱。第二段经历是redirect_uri双重编码。当时在配置中心里已经把域名写成了URL编码形态代码里又做了一次URLEncoder.encode结果就是用户扫码后微信一直报redirect_uri参数错误。排查时我把授权URL复制到浏览器里手动改参数改掉编码后立刻通了才定位到问题。这个坑极容易踩因为配置文件不像代码那么容易被审查。第三段经历是关于开放平台认证的。测账号阶段发现用户信息接口返回的数据里昵称头像全是空的代码反复审核都没问题最后登录开发者后台一看应用没认证部分用户信息接口根本不给完整返回。所以如果你按本文流程走到“换token成功拉用户信息为空”这一步先别改代码去后台看认证状态。这几段经历给我最深的感受是微信登录的代码部分真的不难难的是对平台规则的理解。按照开放平台、网站应用、回调域名、scope、state这条线走下来基本半小时就能跑通整个闭环。等你看到用户的微信头像真正落进数据库的那个瞬间会觉得标题里的So Easy其实挺诚实。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →