微信扫码登录实战:Spring Boot + Vue实现OAuth2.0授权码流程

发布时间:2026/10/8 2:36:19
微信扫码登录实战:Spring Boot + Vue实现OAuth2.0授权码流程 微信扫码注册登录现在几乎是网站应用的标配能力尤其做面向普通用户的 PC 端产品、To B 管理后台或者独立部署的业务系统扫码登录都能直接砍掉一截账号体系成本。用户不用记密码站方也不用发验证码一套 OAuth2.0 授权码流程就能把身份验证这件事转交给微信。今天这篇不写空话就基于 Spring Boot Vue 这套常见技术栈把微信开放平台网站应用的扫码注册登录从账号申请、参数配置、后端换 token、前端扫码回调一直写到上线前的常见坑和安全加固。你要是正准备接手登录模块或者刚被分配了一个“做个微信扫码登录”的需求可以直接拿这篇当落地清单用。1. 扫码登录背后的思路拆解为什么是 OAuth2.0 授权码模式1.1 我要解决的业务场景到底是什么先说场景。最常见的需求是这样的一个网站应用需要支持用户用微信扫码完成注册和登录。用户首次扫码时自动注册之后每次扫码直接登录站方后台能看到微信昵称、头像这些基础信息后续可以再绑定手机号、补齐邮箱。这套东西放在 PC 网站、SaaS 系统、运维后台、企业内部系统上都成立。这个需求里最大的难点不在于“弹个二维码”本身而在于三个问题怎么安全地拿到用户微信身份标识怎么把微信身份和站内用户绑定怎么让前端拿到登录态。微信开放平台的网站应用扫码登录本质上是一套标准 OAuth2.0 授权码流程我先把这一句话讲清楚后面所有代码都围绕这句话展开。1.2 为什么一定选授权码模式而不是把密钥交给前端要理解为什么用授权码模式你得先知道微信为什么不能让你直接把用户信息暴露给一个“不可信”的前端页面。网站的 JS 代码是公开的任何人都能打开 DevTools 看到请求参数如果把 AppSecret 这种凭证放前端等于把家里钥匙贴在大门上。OAuth2.0 授权码模式的做法是前端只负责把用户引导到微信的授权页微信在用户确认之后通过服务器端重定向给后端一个一次性的code由后端拿着code去换access_token再用access_token去拉用户信息。整个过程里真正值钱的密钥AppSecret只存在于后端服务器和微信服务器之间的链路中。你把这个事理解成酒店前台流程用户拿一张一次性餐券去前台前台验证餐券后给用户一套带权限的门卡而不是直接把总卡密码写在取号机上。1.3 一次扫码背后实际流转了哪些环节把整个时序过一遍你在写代码前心里就有谱了用户打开网站登录页点击“微信扫码登录”。前端调用后端接口后端生成一个随机state并缓存到 Redis同时拼接微信授权链接返回给前端。前端用弹窗或 iframe 打开这个微信授权链接。用户用手机微信扫码在手机上确认授权。微信前端将页面重定向302到后端配置好的回调地址地址上带code和state。后端先校验state防止伪造授权请求。后端用code调用微信接口换取access_token和openid。后端再用access_token拉取微信用户信息拿到昵称、头像。后端用openid查本地用户表不存在则自动注册。后端签发自己的登录凭证比如 JWT把前端重定向到登录成功页并携带 token。前端保存 token刷新登录状态。这里code是一次性的有效期只有五分钟用过一次就作废这也是授权码模式的安全基础。后面章节里的所有代码都是把这一条链路逐段落地。2. 微信开放平台账号申请与参数配置这一步错了后面全白搭2.1 网站应用创建流程个人开发者最容易卡住微信扫码登录不是拿公众号的 AppID 就能接的必须走微信开放平台open.weixin.qq.com而且要创建“网站应用”。创建流程大概是注册开放平台开发者账号完成开发者资质认证。在“管理中心”里创建“网站应用”。填写应用名称、官网地址、应用简介。配置授权回调域。提交审核一般一两个工作日完成审核通过后拿到 AppID 和 AppSecret。这里最要命的是主体资质。个人开发者经常卡在这一步因为网站应用目前主要面向企业或者组织类主体个人主体开通限制很多申请时要有企业相关的执照或认证材料。如果你只是个人项目自己测试可以把思路转向微信测试号或者本地 mock真要做生产环境流程上绕不开企业资质。注意:AppID 是公开的可以出现在前端AppSecret 是机密的绝不能打包进前端代码或者提交到 Git。我见过有人为了省事把 AppSecret 直接写在 Vue 的.env文件里上线当天晚上账号就被刷爆了。2.2 回调域名和 redirect_uri 之间的关系开放平台里配置的是“授权回调域”比如auth.example.com。你在代码里拼接redirect_uri参数时这个参数的域名必须和配置的授权回调域完全一致否则微信直接拒绝回调。这里还有个很隐蔽的坑redirect_uri参数必须做 URL 编码后再放进授权链接。很多新手拼接 URL 时不编码或者编码后又被框架解了一层回调时域名匹配不上页面一直卡在白屏或者提示“redirect_uri 参数错误”。我在项目里常写的标准配置如下配置项示例值常见错误授权回调域auth.example.com写成了https://auth.example.com/callbackredirect_urihttps://auth.example.com/api/auth/wechat/callback没做 URLEncodescopesnsapi_login写成了公众号网页授权的snsapi_userinfo2.3 审核和上线前容易踩的三个小细节创建应用审核时一般要求应用有实际可访问的官网主页并且网页内容里有清晰的应用介绍。我见过审核被拒的几个典型原因官网打不开、页面是空模板、应用名称和网站内容对不上。提交前自己先拿无痕浏览器验证一遍重要页面能不能正常访问。另外授权回调域配置之后不要频繁改。每次修改虽然不需要重新审核但如果正在线上跑流量老回调链接会出现短暂不匹配问题。最好先在测试域名上把所有联调做完再把生产环境的域名一致替换。3. Spring Boot 后端实现令牌换取、用户绑定与会话签发3.1 用户表设计openid 才是真正的身份锚点先想清楚库表结构再写接口。微信扫码登录对接的用户表最关键的是要有openid字段。openid是用户在某个应用同一个 AppID下的唯一标识同一个微信用户在不同 AppID 下的openid不一样。这个字段必须建唯一索引防止并发扫码导致重复创建用户。我这里给一个常见的精简设计CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT, openid varchar(64) NOT NULL COMMENT 微信开放平台下的唯一标识, nickname varchar(64) DEFAULT NULL, avatar varchar(512) DEFAULT NULL, phone varchar(20) DEFAULT NULL, last_login_time datetime DEFAULT NULL, create_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_openid (openid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;如果你接入了多个微信产品公众号、小程序、网站应用并且做了同主体绑定理论上可以拿到unionid去做账号打通。但在只做网站扫码登录的场景下openid已经完全够用。不要为了以后可能存在的多端打通去过度设计先满足当前需求留一个unionid字段备用即可。3.2 生成扫码链接的后端接口state 要进 Redis后端先提供一个接口比如GET /auth/wechat/qr-url前端点开弹窗时调用。这个接口的核心动作是生成随机state把state存 Redis 并设置 120 秒过期拼接微信授权 URL。示例代码如下RestController RequestMapping(/auth/wechat) public class WechatLoginController { Value(${wechat.appid}) private String appid; Value(${wechat.redirect-uri}) private String redirectUri; Resource private StringRedisTemplate stringRedisTemplate; GetMapping(/qr-url) public ResultQrUrlVO getQrUrl() { String state UUID.randomUUID().toString().replace(-, ); stringRedisTemplate.opsForValue().set(wechat:state: state, pending, 120, TimeUnit.SECONDS); String url https://open.weixin.qq.com/connect/qrconnect ?appid appid redirect_uri URLEncoder.encode(redirectUri, StandardCharsets.UTF_8) response_typecode scopesnsapi_login state state #wechat_redirect; return Result.ok(new QrUrlVO(url, state)); } }为什么state要进 Redis因为回调接口收到state后必须核对它是不是我们下发出去的。如果不校验攻击者可以构造一个假回调引导用户绑定到恶意账号这就是典型的登录 CSRF。Redis 里把state和时间绑定过期值设为 120 秒用户扫码慢一点可能就过期了但这是安全性和用户体验的合理折中过期后提示用户重新生成二维码即可。3.3 回调接口校验 state、用 code 换 access_token微信在用户扫码确认后会把浏览器重定向到redirect_uri?codexxxstatexxx。后端在这个回调里按顺序做四件事校验state是否在 Redis 中存在且匹配。用code请求微信的sns/oauth2/access_token接口。用返回的access_token和openid去sns/userinfo拉用户信息。根据openid查本地用户做登录或注册。第二步请求微信接口时推荐用 RestTemplate 或者 OkHttp代码大概这样GetMapping(/callback) public void callback(RequestParam(code) String code, RequestParam(state) String state, HttpServletResponse response) throws IOException { // 1. 校验 state String cached stringRedisTemplate.opsForValue().get(wechat:state: state); if (StringUtils.isBlank(cached)) { response.sendRedirect(frontendUrl /login?errorstate_invalid); return; } // 用过的 state 立即删除防止二次使用 stringRedisTemplate.delete(wechat:state: state); // 2. 换取 access_token String tokenUrl https://api.weixin.qq.com/sns/oauth2/access_token ?appid appid secret secret code code grant_typeauthorization_code; String tokenResp restTemplate.getForObject(tokenUrl, String.class); JsonNode tokenJson objectMapper.readTree(tokenResp); if (tokenJson.has(errcode)) { response.sendRedirect(frontendUrl /login?errorwechat_errordetail tokenJson.get(errmsg).asText()); return; } String accessToken tokenJson.get(access_token).asText(); String openid tokenJson.get(openid).asText(); // 3. 拉取用户信息 String userInfoUrl https://api.weixin.qq.com/sns/userinfo ?access_token accessToken openid openid; String userResp restTemplate.getForObject(userInfoUrl, String.class); JsonNode userJson objectMapper.readTree(userResp); // 4. 处理本地用户 User user userService.findOrCreate(openid, userJson); String jwt jwtUtils.generateToken(user.getId()); // 前端重定向时带上 token response.sendRedirect(frontendUrl /login-callback?token jwt state state); }这里有一点必须强调code换access_token的请求一定要放在后端不能放在前端。appsecret请求参数如果暴露在浏览器里攻击者就能冒充应用去换取任意微信用户的身份这属于事故级漏洞。3.4 注册绑定逻辑自动建号的细节坑userService.findOrCreate里其实藏着很多新手会踩的坑。微信返回的nickname是用户自己的名字可能带表情符号、可能重复、可能很长。如果你把nickname设成数据库唯一字段两个微信用户都叫“张三”就会直接炸掉。我的建议是用户名允许重复唯一约束只放在openid上。如果业务上必须要求站内用户名唯一就在注册时拼上随机后缀比如“张三_8f3k”。另外每次扫码登录时最好都从微信拉一次最新的nickname和headimgurl并更新到本地。因为用户在微信里换头像后如果本地一直存旧头像两周后用户就会觉得你的系统“数据不同步”。当然拉用户信息接口有频率限制不要每次页面刷新都去拉微信只在扫码登录成功这个时机更新一次。JWT 签发时payload 里放用户 ID、签发时间、过期时间就够了不要放openid和access_token。JWT 是要放在浏览器 localStorage 里的存越多的敏感信息泄露面越大。我用 JWT 的过期时间一般设成 7 天后台管理系统视安全要求缩到 2 小时。4. Vue 前端实现扫码弹窗、登录回调与登录态保存4.1 弹窗扫码方案iframe 和独立窗口怎么选前端有两个方案可以把用户送到微信授权页iframe 内嵌二维码或者window.open打开独立窗口。iframe 的优点是不脱离当前页面用户视觉上“没有离开网站”。微信的qrconnect页面本身允许被 iframe 嵌套所以在大多数浏览器环境里都能正常显示。但 iframe 有一个实际麻烦用户扫码确认后微信页面会把自己重定向到后端回调后端再重定向到前端登录回调页。如果这一切都发生在 iframe 里顶层页面并不知道 iframe 内部已经跳转完成你拿不到跨域 iframe 的状态。独立新窗口方案更稳。点击登录按钮后window.open打开微信授权地址主页面继续停留同时前端以轮询方式向后端查询扫码结果。用户看完授权页后手动关掉窗口或者前端检测到成功后自动关窗。这个方案在几十个真实项目里跑下来基本没有浏览器兼容性问题。4.2 弹窗组件和轮询逻辑这样写最清晰配套的还有一个底层数据库访问代码选型需要自行控制。先看前端页面点击微信登录按钮时调用后端接口拿授权链接const openWechatLogin async () { const { data } await axios.get(/auth/wechat/qr-url) const qrUrl data.data.url const state data.data.state // 打开微信授权窗口 window.open(qrUrl, wechatLogin, width700,height600) // 启动轮询 startPolling(state) }轮询接口就是后端额外加的一个查询状态接口比如GET /auth/wechat/status?statexxx。后端在用户被重定向回callback并且成功签发 JWT 后把 JWT 临时存到 Rediskey 为wechat:login:success:{state}再让前端轮询取走。let timer null const startPolling (state) { timer setInterval(async () { const { data } await axios.get(/auth/wechat/status, { params: { state } }) if (data.data data.data.token) { clearInterval(timer) localStorage.setItem(token, data.data.token) location.href / } }, 1500) // 90秒没扫停掉轮询 setTimeout(() clearInterval(timer), 90000) }轮询间隔 1.5 秒比较平衡。间隔太短会对后端和 Redis 造成额外压力太长用户扫码后会有明显等待感。临时 JWT 在 Redis 里的过期时间设 5 分钟基本等于用户扫码的完成时限。4.3 登录成功后的状态管理Vue 这边我建议把 token 存在 localStorage同时在状态管理库Pinia 或 Vuex里维护一个isLogin状态。路由守卫里每次跳转前读取 token 并解析是否过期这部分网上资料很多不展开讲。要特别提醒的是扫码登录后页面跳转携带 token 的形式在真实部署里要注意 URL 长度不会超限因为 JWT 可能比较长。更好的做法是后端回调时只返回一个一次性login_code前端再拿着login_code去后端换正式 JWT。不过对常规内部系统来说直接在重定向 URL 带 JWT 也能接受但日志里记得别把 URL 整条打印否则 JWT 就泄露了。5. 高频故障排查与上线安全加固这里都是能直接抄的实操经验5.1 扫码登录高频疑难杂症排查表我把自己踩过的和身边同事踩过的问题整理成一张表基本覆盖了 95% 以上的接入问题现象可能原因处理办法二维码一直转圈加载不出来授权回调域没配置或 redirect_uri 没编码核对开放平台上回调域检查 redirect_uri 前后端拼装后是否被转码回调后提示 state 无效state 过期或缓存 key 拼写不一致确认 Redis 过期时间检查 state 前后是否加了多余空格用 code 换 token 报 40029code 只能使用一次可能回调接口被重复调用在回调接口做幂等借助日志确认请求次数拉取用户信息报 48001scope 不是 snsapi_login或接口权限未开通确认 scope 参数确认网站应用审核已完成内嵌 iframe 时微信页面空白部分浏览器禁止第三方 Cookie 或嵌套受限改用独立窗口打开授权链接或直接整页跳转页面提示无法从此网站添加应用扩展或用户脚本浏览器安全设置或扩展拦截了登录页脚本换无痕模式或换个浏览器测试同时检查页面 CSP 是否限制了外部脚本这里有一条通用排查经验微信接口返回的errcode和errmsg一定要打印到日志。限制微信接口的正常返回也会给你返回errcode别把errcode当成正常 JSON 字段忽略掉它往往是定位问题最快的线索。5.2 安全加固这几条上线前必须检查第一AppSecret 绝不能写进代码仓库。很多团队喜欢把配置写在application.yml里然后整个项目传到 Git这等于公开了密钥。正确做法是放到环境变量、配置中心或者密钥管理服务里并且定期轮换。第二回调接口要用state做双重校验而且用完立即删除防止重放攻击。第三JWT 的签名密钥要足够长32 个字节以上不能是 123456 这种弱密钥。第四日志脱敏。code、access_token、openid这些字段在日志里打码或者只打印后半段。第五用户信息接口的调用频率要限制微信对sns/userinfo有频率控制突发流量下容易限流可以在本地 Redis 里对同一个openid加一分钟缓存。还有一点不太有人提扫码登录页本身的接口也要做限流。否则有人写脚本高频请求qr-url每次都往 Redis 里塞state能把 Redis 内存打爆。我在生产里给这个接口做过一个简单的每秒并发限制成本很低但效果很明显。5.3 登录模块上线自检清单登录是系统的门上线前最好对着清单逐项过一遍开放平台授权回调域与生产环境 redirect_uri 完全一致state入库且有过期时间回调时校验并删除AppSecret从环境变量读取不落 Git前端 token 存储在安全位置不放进 URL 以外的日志回调、轮询接口有基本限流openid字段建了唯一索引日志中检索不到明文access_token和code微信返回的非 0 错误码都有日志记录和前端提示我接过的项目里凡是上线后出问题的八成都是前三条没做到位尤其是回调域名不一致和 state 校验缺失。这两个问题排查起来特别费劲因为前端看到的永远只是“登录失败”四个大字用户压根不会去翻网络请求里的重定向地址。微信扫码登录本身不复杂复杂度主要藏在配置细节和流程安全性里。把这套流程完整走一遍你收获的不只是“会调接口”而是对 OAuth2.0 授权码模式和前后端会话管理有了一个真实的工程化认知。以后接 GitHub、Google、飞书登录思路完全一样只是换了个授权服务器和参数名。真到那时候你会感谢当初把 state 校验和 Redis 过期时间都认真写了的自己。