1. 为什么要在系统里接入扫码登录
做企业级后台系统这些年,账号体系这块踩过的坑比任何模块都多。早期项目清一色账号密码登录,用户记不住密码、管理员天天帮忙重置、离职员工账号忘了停用,这些事堆在一起能把人逼疯。后来不少团队上了企业内部的统一身份平台,登录体验确实好了一截,但一旦系统要交付给外部客户、或者部署在客户自己的环境里,那套内部账号体系就带不出去了。这时候扫码登录就成了一个特别务实的选择——用户不用记新密码,系统不用维护密码库,身份校验交给成熟的第三方平台,安全性和体验都能兼顾。
这篇内容聊的就是Vue + SpringBoot 整合钉钉扫码登录的完整落地过程。前端用 Vue 做扫码入口和回调处理,后端用 SpringBoot 完成授权码换取用户信息、建立本地会话这一整套链路。它解决的核心问题很明确:让企业内部员工用钉钉扫一下码就能进入业务系统,同时后端能拿到可信的用户身份,用于后续的权限控制。适合谁看?前端会点 Vue、后端熟悉 SpringBoot 基础、正在做企业应用登录模块的同学,尤其是第一次接触第三方扫码登录、被回调地址和跨域问题折磨过的朋友。下面我会把配置、代码、踩坑一条线讲透,能直接抄的部分我都尽量给全。
需要先说明一点:扫码登录的本质是OAuth 类的授权码模式,只不过第三方平台把"授权"这一步做成了扫码确认。理解了这个底层模型,后面所有的配置项和代码结构就都顺了,不会出现"照着文档抄能跑、换个场景就懵"的情况。
2. 整体方案设计与技术选型思路
2.1 扫码登录的底层链路拆解
很多人一上来就找 SDK、抄示例,结果卡在"回调没反应"上。我建议先把链路在纸上画一遍,钉钉扫码登录大致分这么几步:前端页面嵌入扫码入口(可以是内嵌二维码,也可以是跳转到授权页),用户用钉钉客户端扫码并确认授权,钉钉把浏览器重定向到你配置的回调地址并带上一个临时授权码(code),后端拿这个 code 去换 access_token,再用 token 拉取用户信息,最后在本地建立会话、签发自己的登录凭证返回给前端。
这里有个关键认知:code 是一次性的、有时效的,而且必须由后端去换取 token,绝不能放在前端。原因很简单,换取 token 需要用到应用的密钥(AppSecret),这个密钥一旦暴露在前端代码里,等于把整个应用的授权能力交出去了。所以链路上前端只负责"拿到 code 并交给后端",后端负责"用 code 换身份"。这个职责划分是整个方案的地基,后面所有代码都围绕它展开。
2.2 为什么选内嵌二维码而不是跳转授权页
钉钉提供了两种扫码形态:一种是跳转到钉钉的授权页面扫码,另一种是在自己的页面里内嵌二维码。我最终选了内嵌二维码,理由有三个。第一,跳转授权页会离开业务系统,用户心理上会觉得"跳出去了",体验割裂;内嵌二维码则是在登录页原地扫码,扫完页面直接跳转,流畅得多。第二,内嵌方式对登录页的视觉控制权更强,二维码的大小、位置、样式都能自己调,跟系统整体风格统一。第三,跳转方式在移动端和桌面端的行为差异较大,内嵌方式相对可控。
当然内嵌也有代价:需要引入钉钉的前端脚本,并且要处理好脚本加载失败、二维码过期刷新这些边界情况。但相比体验收益,这些成本完全值得。下面配置和代码部分我会以内嵌二维码为主线来讲,跳转方式在需要的地方会提一句差异。
2.3 前后端职责边界与凭证设计
后端换到用户信息之后,不能直接把钉钉的 token 丢给前端用,那样每次请求都要带着第三方 token,既慢又不安全。正确做法是后端在本地建立自己的会话体系:校验通过后生成一个系统自己的登录凭证(比如 JWT 或者服务端 session),前端后续请求只带这个本地凭证。这样第三方平台的角色就被隔离在"登录那一刻",业务运行期间完全不依赖它,即使钉钉侧短暂不可用也不影响已登录用户。
我这次用的是JWT + Redis的组合:JWT 里放用户标识和过期时间,Redis 里存一份用于主动失效(比如管理员踢人、用户登出)。为什么不全用 session?因为系统后续要做多端登录和水平扩展,无状态的 JWT 更合适;为什么又要配 Redis?因为纯 JWT 没法主动作废,加一层 Redis 白名单就补上了这个短板。这个设计不是唯一解,但对大多数企业后台来说够用且好维护。
3. 钉钉开放平台的关键配置
3.1 应用创建与凭证获取
登录钉钉开放平台后台,创建一个"企业内部应用",创建完会拿到两个核心凭证:AppKey(也叫 ClientId)和AppSecret(也叫 ClientSecret)。这两个东西就是后端换 token 的钥匙,AppSecret 必须严格保密,只能放在后端配置文件或配置中心里,绝对不能出现在前端代码、Git 提交记录、日志输出里。我见过有团队把密钥硬编码在前端然后打包上线,等于把门钥匙贴在了门上。
创建应用后还要配置登录与分享相关的权限,具体是在应用的权限管理里勾选"个人手机号信息""通讯录个人信息读权限"这类基础权限。这里有个容易忽略的点:权限勾选后需要提交审核(企业内部应用一般是管理员审批,流程较快),没审批通过的权限在换用户信息时会报权限不足。我第一次就是权限没生效就去调接口,排查了半天以为是代码问题,其实是审批还没走完。
3.2 回调地址(Redirect URI)的配置陷阱
回调地址是整个流程里最容易出问题的地方,我把它单独拎出来讲。你在开放平台配置的回调地址,必须和前端实际发起授权时传的地址完全一致,包括协议(http/https)、域名、端口、路径,一个字符都不能差。差一个斜杠都会导致授权失败,而且报错信息往往很含糊,只说"回调地址不匹配",不告诉你哪里不匹配。
我的建议是:回调地址统一配置成一个专门的路由,比如/auth/dingtalk/callback,前端授权时把这个地址作为参数传过去,后端配置里也存同一份,两边从同一个配置源读取,避免手写两遍对不上。另外,本地开发时回调地址填localhost往往不被接受,需要用开发环境的域名,或者用内网穿透工具映射一个临时域名(这里只提思路,具体工具自行选择合规方案)。测试阶段我一般会准备两套配置:本地开发一套、测试环境一套,用 Spring 的 profile 隔离,切换环境时不会互相干扰。
3.3 前端扫码脚本的引入方式
内嵌二维码依赖钉钉提供的前端脚本。引入方式有两种:一种是在index.html里用 script 标签直接引,另一种是动态加载。我倾向动态加载,因为登录页之外的其他页面根本用不到这个脚本,全局引入会拖慢首屏。动态加载的写法是在登录组件挂载时创建 script 标签,加载完成后再初始化二维码,加载失败要有兜底提示。
脚本加载完成后,通过全局对象初始化扫码组件,传入应用标识、回调地址、二维码容器等参数。这里要注意脚本是异步加载的,初始化代码必须放在加载完成的回调里,否则会报"对象未定义"。这个坑很典型,本质是没处理好异步时序,后面实操部分我会给出带加载状态判断的完整写法。
4. 后端核心实现:从 code 到本地会话
4.1 换取 access_token 的接口封装
后端第一步是拿前端传来的 code 去换 access_token。这个接口是服务端到服务端的调用,用 HTTP 客户端发起 POST 请求,请求体里带上应用标识、密钥和 code。我用的是 Spring 的RestTemplate(项目里已经有它,没必要为这一个接口再引 WebClient),封装成一个独立的服务类,把请求、响应、异常处理都收拢在一起。
响应里会返回 access_token 和过期时间。这里有个细节:access_token 是有有效期的,但扫码登录场景下每次登录都是新的 code,换新的 token,所以不需要做 token 缓存复用。有些同学看到 token 有有效期就想缓存起来省调用,这在扫码登录里是错的——不同用户的 code 换出来的 token 对应不同用户,缓存会串号。这个认知很关键,我见过有人为了"优化性能"缓存 token,结果 A 用户登录后 B 用户扫码进来看到的是 A 的信息,属于严重的安全事故。
4.2 拉取用户信息的字段映射
拿到 access_token 后,再调用户信息接口,返回的字段包括用户标识、昵称、头像、手机号(需权限)、所属部门等。我一般只取用户唯一标识、昵称、头像这三个必需字段,手机号和部门按需取。为什么不多取?一是权限申请越多审批越麻烦,二是敏感信息取多了存储和合规成本都上来了,够用就行。
用户唯一标识是本地账号体系关联的关键。我的做法是:本地用户表里加一个字段存这个第三方标识,首次扫码登录时如果本地没有对应记录,可以选择自动创建账号(适合内部系统)或者提示"账号未开通,请联系管理员"(适合受控系统)。自动创建时要给个默认角色,别给太高权限,后续由管理员调整。这个策略要在项目初期就定好,中途改会很麻烦。
4.3 本地 JWT 签发与 Redis 白名单
用户信息校验通过后,签发本地 JWT。payload 里放用户 ID、昵称、角色标识,过期时间设成比如 2 小时(按业务敏感度调整)。同时生成一个随机的会话 ID 存进 Redis,key 是会话 ID,value 是用户 ID,过期时间和 JWT 对齐。JWT 里也带上这个会话 ID,后续每次请求的拦截器里做两件事:验签 JWT、查 Redis 里会话 ID 是否存在。存在就放行,不存在就说明被主动登出或踢下线了,直接拒绝。
这套组合的好处是兼顾了无状态和可控性。纯 JWT 验签很快但没法作废,加了 Redis 查询多一次 IO 但换来了主动失效能力。Redis 查询这点开销在绝大多数系统里可以忽略,如果实在在意,可以在本地加一层短时效缓存,但要注意缓存和 Redis 的一致性,别为了省这点 IO 引入更难查的 bug。
4.4 拦截器与放行规则配置
登录相关的接口(发起授权、回调、换取本地凭证)必须放行,不能要求已登录。拦截器配置里把这些路径加入白名单,其余接口一律校验。这里有个常见错误:白名单路径写得太宽,比如写成/auth/**,结果把需要鉴权的接口也放行了。我的习惯是白名单精确到具体路径,宁可多写几行也不留口子。
另外,跨域问题要在网关或全局配置里统一处理,别在每个 Controller 上贴注解。扫码登录涉及前端页面和后端接口的交互,如果前后端分离部署,跨域配置不对会导致回调请求被浏览器拦截,表现就是"扫码后页面没反应、控制台报跨域错误"。这个后面问题排查部分会细说。
5. 前端实现:扫码入口与回调处理
5.1 登录页扫码组件的封装
登录页我封装了一个独立的扫码组件,职责很单一:加载脚本、渲染二维码、监听扫码结果、把 code 抛给父组件。组件内部维护三个状态:脚本加载中、二维码就绪、加载失败。加载失败时展示重试按钮,而不是干等着。二维码渲染出来后,用户扫码确认,钉钉会重定向到回调地址并带上 code,所以严格来说"监听扫码结果"这一步是通过页面跳转完成的,组件本身不需要轮询。
这里要区分清楚:内嵌二维码的扫码确认动作,最终仍然表现为浏览器跳转到回调地址。所以前端真正要处理的是回调页——回调页拿到 URL 上的 code,调后端接口换取本地凭证,成功后跳转到首页,失败则回登录页并提示。这个流程要写清楚,不然新手容易以为扫码后前端能直接拿到用户信息。
5.2 回调页的参数解析与请求转发
回调页的核心逻辑就三步:从 URL 解析 code、调后端换凭证接口、根据结果跳转。解析 code 时要注意 URL 上可能还带其他参数(比如 state),用标准 URL 解析 API 取,别用字符串截取,截取遇到参数顺序变化就挂了。调接口时把 code 放在请求体里 POST 过去,别放 URL 上,避免被日志记录。
拿到后端返回的凭证后,存到本地(localStorage 或内存,按安全要求选),然后跳转首页。这里有个体验细节:跳转前把回调页的 URL 参数清掉,否则用户刷新页面会带着过期的 code 再请求一次,后端报"code 已使用",用户看到莫名其妙的错误。用路由的 replace 跳转就能解决,历史记录里不留回调页。
5.3 路由守卫与登录态判断
路由守卫里判断本地有没有有效凭证,没有就跳登录页。判断逻辑别只看"本地有没有存 token",还要考虑 token 是否过期。我的做法是解析 JWT 的过期时间(前端只解析不验签,验签在后端),过期了直接清掉跳登录。这样避免带着过期 token 请求一堆接口全部 401 的糟糕体验。
还有一个细节:多个标签页同时打开时,一个标签页登出,其他标签页的登录态要同步。可以用 storage 事件监听本地凭证变化,变化了就刷新页面或跳登录。这个不是必须做,但做了体验会好很多,尤其是后台系统用户经常开多个标签页。
6. 实操全流程与关键代码
6.1 后端配置项与依赖准备
先在配置文件里放好应用标识、密钥、回调地址,用 profile 区分环境。依赖方面,SpringBoot 的 web 起步依赖、Redis 起步依赖、JWT 工具库,这三个是核心。HTTP 客户端用内置的 RestTemplate 就够,不需要额外引。
dingtalk: app-key: your-app-key app-secret: your-app-secret redirect-uri: https://your-domain/auth/dingtalk/callback配置类里用@ConfigurationProperties绑定,别用@Value一个个取,字段多了容易漏。密钥这类敏感配置建议放配置中心或环境变量,配置文件里只放占位符。
6.2 换取 token 与用户信息的服务实现
服务类里两个方法:getAccessToken(code)和getUserInfo(accessToken)。前者 POST 到 token 接口,后者 GET 用户信息接口。两个方法都要处理 HTTP 非 200、业务错误码、网络超时三种异常。超时时间我设的连接 3 秒、读取 5 秒,第三方接口偶尔抖动,超时设太长会拖垮登录页响应。
public String getAccessToken(String code) { String url = "https://oapi.dingtalk.com/sns/gettoken" + "?appid=" + appKey + "&appsecret=" + appSecret; // 实际调用按平台最新接口文档调整参数与路径 ResponseEntity<String> resp = restTemplate.postForEntity(url, null, String.class); // 解析响应,取 access_token,失败抛业务异常 return parseToken(resp.getBody()); }上面是示意结构,实际接口路径和参数以平台文档为准,我这里强调的是封装思路:把第三方调用收拢在一个类里,Controller 只调服务方法,不直接碰 HTTP 细节。这样以后接口变了只改一处。
6.3 回调接口与本地凭证签发
回调接口接收 code,依次调两个服务方法,拿到用户信息后查本地用户表,存在则更新最后登录时间,不存在按策略创建或拒绝。然后签发 JWT、写 Redis 白名单、返回给前端。
@PostMapping("/auth/dingtalk/exchange") public Result<LoginVO> exchange(@RequestBody CodeDTO dto) { String token = dingTalkService.getAccessToken(dto.getCode()); DingUser user = dingTalkService.getUserInfo(token); LocalUser local = userService.findOrCreate(user); String jwt = jwtUtil.sign(local); redisTemplate.opsForValue().set("session:" + local.getSessionId(), local.getId(), 2, TimeUnit.HOURS); return Result.ok(new LoginVO(jwt)); }注意 code 只能用一次,如果前端因为网络重试发了两次请求,第二次会失败。所以前端请求要加防重,后端也可以对同一 code 做短时间幂等缓存,返回第一次的结果。这个细节能省掉不少"偶发登录失败"的投诉。
6.4 前端扫码与回调页代码
扫码组件挂载时动态加载脚本,加载完成后初始化。回调页在onMounted里解析 code 并请求后端。
onMounted(() => { const params = new URLSearchParams(window.location.search); const code = params.get('code'); if (!code) { router.replace('/login'); return; } axios.post('/api/auth/dingtalk/exchange', { code }) .then(res => { localStorage.setItem('token', res.data.token); router.replace('/home'); }) .catch(() => router.replace('/login')); });这段代码短但要点全:参数用标准 API 解析、请求体传 code、成功后 replace 跳转、失败回登录。照着改改就能用。
7. 踩坑实录与问题排查速查
7.1 回调地址不匹配的排查方法
这是出现频率最高的问题。排查顺序:先看开放平台配置的回调地址,再看前端发起授权时传的地址,最后看后端配置里的地址,三者逐字符比对。重点检查协议、端口、末尾斜杠、大小写。我遇到过一次是测试环境用了 https 但配置里写的是 http,报错信息完全没提协议,纯靠比对才发现。
7.2 code 失效与重复使用的处理
code 有效期很短(通常几分钟),且一次性。常见触发场景:用户扫码后停在回调页很久才点确认、前端网络重试、用户手动刷新回调页。处理办法前面提过:前端防重、后端幂等缓存、回调页 replace 跳转清参数。三管齐下基本能杜绝。
7.3 跨域与 Cookie 携带问题
前后端分离部署时,如果后端用 Cookie 存会话,要配置withCredentials和响应头的允许凭证,且Allow-Origin不能是通配符。我用 JWT 放请求头,避开了 Cookie 跨域的坑,这也是选 JWT 的一个附带好处。如果非要用 Cookie,记得 SameSite 属性在新版浏览器里的默认值变化,跨站请求可能不带 Cookie。
7.4 常见问题速查表
| 现象 | 可能原因 | 处理方向 |
|---|---|---|
| 扫码后页面无反应 | 回调地址不匹配或跨域拦截 | 比对三处地址,查控制台网络请求 |
| 提示 code 无效 | code 过期或重复使用 | 前端防重,后端幂等缓存 |
| 换用户信息报权限不足 | 应用权限未审批通过 | 开放平台确认权限状态 |
| 登录成功但接口全 401 | 凭证未正确携带或已过期 | 查请求头、解析 JWT 过期时间 |
| 二维码不显示 | 脚本加载失败或容器未就绪 | 加加载状态判断与重试 |
7.5 几个文档里不会写的实操心得
第一个心得:先把回调链路跑通,再美化页面。我见过不少同学花两天调二维码样式,结果回调根本没通,本末倒置。正确顺序是先让 code 能到后端、能换出用户信息,这条链路通了再谈体验。
第二个心得:日志要打全但别打敏感信息。换 token 的请求和响应要打日志方便排查,但 AppSecret、access_token、完整手机号这些必须脱敏。我一般只打 code 的前几位、响应里的错误码和错误信息,足够定位问题又不泄露。
第三个心得:准备一个"模拟回调"的调试入口。开发阶段不方便每次都真扫码,可以在测试环境加一个手动输入 code 的调试页(仅测试环境启用),把从别处拿到的 code 贴进去走完整流程。这个入口能大幅提升联调效率,但上线前务必关掉或加环境判断。
第四个心得:第三方接口一定要做降级预案。钉钉侧偶发不可用时,登录页要给出明确提示"授权服务暂时不可用,请稍后再试",而不是转圈到超时。后端对第三方调用设超时、加一次重试(仅对网络超时重试,业务错误不重试),能显著降低偶发失败率。
8. 安全加固与后续扩展方向
8.1 必须做的几项安全加固
扫码登录涉及身份认证,安全上不能马虎。第一,state 参数防 CSRF:发起授权时生成随机 state 存本地,回调时比对,不一致直接拒绝。第二,回调接口限流:防止有人拿大量伪造 code 刷接口,按 IP 或按会话限流。第三,JWT 密钥强度:别用默认的弱密钥,定期轮换。第四,敏感配置加密存储:AppSecret 放配置中心并加密,别明文躺在代码库里。
这几项里 state 参数最容易被忽略,因为不加也能跑通,但它是防跨站请求伪造的关键。加的成本很低,前端生成一个随机串存 sessionStorage,授权时带上,回调时比对,几行代码的事。
8.2 多端登录与账号绑定策略
后续如果要支持一个本地账号绑定多个第三方身份(比如同时支持钉钉和企业微信扫码),用户表要拆出独立的身份绑定表,一个本地用户对应多条第三方身份记录。登录时按第三方类型和标识查绑定关系,查不到再走创建或拒绝策略。这个扩展在项目初期设计表结构时就该预留,后期加表迁移成本高。
多端登录方面,JWT + Redis 白名单的方案天然支持:每个端签发独立会话 ID,Redis 里存多份,登出时删对应那份即可。如果要限制同时在线端数,就在签发时检查该用户已有会话数,超了就踢最早的。这些策略按业务需要选,不必一开始全上。
8.3 登录审计与异常监控
生产环境建议记录登录审计日志:谁、什么时间、从哪个 IP、用哪种方式登录、成功还是失败。失败日志尤其重要,连续失败可能意味着有人在尝试攻击。审计日志单独存表或推到日志系统,别和业务日志混在一起。监控方面,对"换 token 失败率""回调接口错误率"设告警阈值,第三方服务抖动时能第一时间发现,而不是等用户投诉。
我在实际项目里发现,扫码登录的故障八成集中在配置和网络两类,代码逻辑本身出问题的比例很低。所以监控重点放在第三方调用的成功率和耗时上,比盯着业务代码有用得多。这套东西搭起来之后,登录模块基本可以进入"不用管"的状态,偶尔看一眼告警就行。