ARTICLE DETAIL

资讯详情

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

Vue3企业级后台接入钉钉扫码登录实战:OAuth 2.0与SSO单点登录全流程

Vue3企业级后台接入钉钉扫码登录实战:OAuth 2.0与SSO单点登录全流程 1. 企业级后台为什么绕不开钉钉扫码登录做中后台系统的朋友大概率都遇到过这个需求老板说我们公司内部系统太多了每个人记五六套账号密码IT 部门天天处理忘记密码的工单能不能统一用钉钉扫码登录。这个需求听起来简单但真正落地的时候涉及的东西比想象中多——前端要处理二维码的生成与轮询、后端要对接钉钉的 OAuth 2.0 接口、还要考虑用户首次扫码后的账号绑定逻辑、token 的存储与刷新、以及和现有权限体系的对接。我最近刚在一个 Vue3 的企业级后台项目里完整走了一遍这个流程从零到上线大概花了三天中间踩了不少坑。这篇文章就把整个流程拆开讲清楚包括每一步为什么这么做、参数怎么配、代码怎么写、以及那些文档里不会告诉你的注意事项。适合正在做企业级 Vue3 项目、需要接入钉钉扫码登录的开发者也适合对 OAuth 2.0 和 SSO 单点登录机制感兴趣、想找一个真实场景来理解的朋友。先明确一下技术栈和场景前端是 Vue3 Vite Pinia Vue Router 4后端是 Node.js用其他语言逻辑一样钉钉侧使用的是企业内部应用的扫码登录能力。整个流程的核心是 OAuth 2.0 的授权码模式钉钉在这个基础上做了自己的封装。理解了这个前提后面的每一步就都有据可循了。提示钉钉扫码登录分两种场景——企业内部应用和第三方企业应用。本文聚焦企业内部应用因为大多数公司做内部系统统一登录都是这个场景。第三方应用的授权流程会更复杂涉及应用授权和 suiteKey 等概念不在本文范围内。2. 钉钉开放平台的应用配置那些容易配错的地方2.1 创建应用与获取 AppKey/AppSecret第一步是在钉钉开放平台创建应用。登录开放平台后进入应用开发→企业内部应用→创建应用。创建时选择H5 微应用或网页应用类型都可以扫码登录主要用到的是应用的 AppKey 和 AppSecret。创建完成后在应用详情页的凭证与基础信息里能看到 AppKey 和 AppSecret。这两个东西是整个流程的钥匙——AppKey 相当于应用的身份证号AppSecret 相当于密码。AppSecret 绝对不能暴露在前端代码里所有涉及 AppSecret 的请求必须走后端。这里有个容易忽略的点AppSecret 在页面上是隐藏的需要点击查看才能看到完整值。我第一次配的时候以为复制到了结果复制了个掩码字符串调接口一直报签名错误排查了半天才发现是这个问题。建议复制后立刻存到后端的配置文件或环境变量里不要留在浏览器剪贴板里。2.2 回调域名与重定向 URL 的配置逻辑在应用的登录与分享配置里需要设置回调域名。这个域名是你前端页面部署的域名钉钉在用户扫码确认后会带着授权码跳转回这个域名下的指定页面。配置规则是这样的假设你的登录页是https://admin.example.com/login那么回调域名填admin.example.com即可不需要带协议和路径。但实际跳转的完整 URL 需要在发起授权请求时通过redirect_uri参数指定这个参数的值必须在你配置的回调域名范围内。我踩过的坑是开发环境用localhost调试时钉钉不允许把localhost配成回调域名。解决办法有两个——要么用内网穿透工具给本地开发环境分配一个临时域名注意这里说的是开发调试用的常规内网穿透用于让外部服务回调到本地和任何网络访问工具无关要么直接在测试服务器上部署一版来调试。我选的是后者因为更接近真实环境避免了一些只在本地才有的问题。2.3 权限申请不要漏掉个人信息读取在权限管理页面需要申请以下权限权限名称用途是否必须个人手机号信息获取用户手机号用于账号绑定视业务而定通讯录个人信息读权限获取用户昵称、头像、unionId必须企业员工信息读权限获取用户在企业的 userId必须这里要注意权限申请后需要管理员审批审批通过才生效。我在测试环境配好后忘了走审批流程调接口一直返回权限不足还以为是代码问题后来才发现是审批没通过。建议创建应用后第一时间把权限申请提交了审批通常几分钟到几小时不等。另外unionId和userId的区别要搞清楚unionId是用户在整个钉钉体系内的唯一标识同一个用户在不同企业下unionId相同userId是用户在当前企业内的标识不同企业下不同。做账号绑定的时候如果你只服务一个企业用userId就够了如果未来可能扩展到多企业建议用unionId作为绑定依据。3. 前端 Vue3 侧的扫码登录实现细节3.1 二维码的两种生成方式与选型钉钉扫码登录的二维码有两种获取方式第一种是直接跳转到钉钉的授权页面由钉钉展示二维码。这种方式最简单前端只需要拼接一个 URL 然后window.location.href跳过去就行。URL 格式大致是https://login.dingtalk.com/oauth2/auth?redirect_urixxxresponse_typecodeclient_idxxxscopeopenidstatexxxpromptconsent第二种是使用钉钉提供的 JS SDK在页面内嵌入二维码。这种方式用户体验更好二维码直接显示在你的登录页上不用跳转。SDK 的引入方式是在页面里加载https://g.alicdn.com/dingding/dinglogin/0.0.5/ddLogin.js然后调用DDLogin方法。我最终选的是第二种原因是跳转方式会让用户离开你的页面回来的时候如果 state 处理不好容易出问题而且嵌入方式可以自定义二维码的容器样式和登录页的整体设计更协调。但嵌入方式有个限制——二维码容器的大小是固定的钉钉规定了几个尺寸选项不能随意缩放。如果你的登录页设计对二维码尺寸有严格要求可能需要用 CSS 做一层包裹来适配。3.2 用组合式 API 封装登录逻辑在 Vue3 里我习惯把扫码登录的逻辑封装成一个 composable这样登录页组件本身只负责渲染逻辑抽离出来也方便复用和测试。核心代码如下// composables/useDingTalkLogin.js import { ref, onUnmounted } from vue export function useDingTalkLogin(options) { const { appKey, redirectUri, onSuccess, onError } options const loading ref(false) const qrContainer ref(null) let checkTimer null // 生成 state 并存入 sessionStorage用于防 CSRF function generateState() { const state Math.random().toString(36).slice(2) Date.now().toString(36) sessionStorage.setItem(dingtalk_login_state, state) return state } // 初始化二维码 function initQrCode() { const state generateState() const goto encodeURIComponent( ${redirectUri}?state${state} ) // 动态加载钉钉登录 SDK const script document.createElement(script) script.src https://g.alicdn.com/dingding/dinglogin/0.0.5/ddLogin.js script.onload () { // eslint-disable-next-line no-undef const obj DDLogin({ id: dingtalk_qr_container, goto: goto, style: border:none;background-color:#FFFFFF;, width: 300, height: 300 }) // 监听扫码结果 const handleMessage (event) { const origin event.origin if (origin ! https://login.dingtalk.com) return const loginTmpCode event.data if (loginTmpCode) { window.removeEventListener(message, handleMessage) // 拿到临时授权码跳转到后端换取用户信息 exchangeToken(loginTmpCode, state) } } window.addEventListener(message, handleMessage) } document.body.appendChild(script) } async function exchangeToken(loginTmpCode, state) { loading.value true try { const res await fetch(/api/auth/dingtalk/callback, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ code: loginTmpCode, state }) }) const data await res.json() if (data.success) { onSuccess(data) } else { onError(data.message) } } catch (err) { onError(err.message) } finally { loading.value false } } onUnmounted(() { if (checkTimer) clearInterval(checkTimer) }) return { loading, qrContainer, initQrCode } }这段代码有几个关键点值得展开说。第一state参数的作用是防止 CSRF 攻击。生成一个随机字符串存到 sessionStorage跳转回来的时候比对。虽然钉钉的扫码登录本身安全性已经不错但加上 state 校验是 OAuth 2.0 的标准做法成本很低建议都加上。第二postMessage的 origin 校验不能省。钉钉的 SDK 是通过 iframe 嵌入二维码的扫码结果通过postMessage传回来。如果不校验 origin理论上存在被恶意页面伪造消息的风险。我见过有项目为了图省事直接不校验这是不安全的。第三SDK 是动态加载的因为不是每个页面都需要扫码登录没必要在入口就加载。但要注意加载失败的情况实际项目中应该加上script.onerror的处理给用户一个降级方案比如显示二维码加载失败请刷新重试。3.3 登录成功后的路由跳转与状态同步拿到后端返回的用户信息后需要做几件事把 token 存到 Pinia 的 user store 里、更新路由权限、跳转到目标页面。这里有个细节——如果用户是从某个内页被重定向到登录页的登录成功后应该跳回原来那个页面而不是首页。我的做法是在路由守卫里记录来源// router/guards.js router.beforeEach((to, from, next) { const userStore useUserStore() if (to.meta.requiresAuth !userStore.token) { next({ path: /login, query: { redirect: to.fullPath } }) } else { next() } })然后在登录成功的回调里读取route.query.redirect有值就跳过去没值就跳首页。这个逻辑不复杂但很多项目会忽略导致用户每次登录后都要重新找自己刚才在看的页面体验很差。4. 后端换取用户信息的完整链路4.1 从临时授权码到 access_token前端拿到loginTmpCode后传给后端后端要做的第一件事是用这个 code 换取用户的access_token。注意钉钉的扫码登录流程里这个 code 是临时授权码有效期很短大概几分钟而且只能用一次。后端调用的接口是POST https://api.dingtalk.com/v1.0/oauth2/userAccessToken请求体{ clientId: 你的AppKey, clientSecret: 你的AppSecret, code: 前端传来的loginTmpCode, grantType: authorization_code }返回结果里包含accessToken和refreshToken。这个accessToken是用户级别的代表这个用户授权了你的应用和应用的全局 access_token 不是一回事。很多刚接触的人会搞混这两个概念导致调接口一直报错。4.2 用 access_token 获取用户详情拿到用户级别的accessToken后再调GET https://api.dingtalk.com/v1.0/contact/users/me Header: x-acs-dingtalk-access-token: {accessToken}返回的用户信息包括nick昵称、avatarUrl头像、mobile手机号需要权限、unionId、openId等。这里有个实际项目中必须处理的问题用户首次扫码时你的系统里还没有这个人的账号。这时候有两种策略第一种是自动创建账号。用钉钉返回的昵称和手机号在系统里建一个用户赋予默认角色。这种方式适合内部系统因为能扫码的都是企业员工信任度较高。第二种是要求绑定已有账号。用户扫码后如果系统里没有匹配的记录跳转到一个绑定页面让用户输入已有的账号密码完成绑定。这种方式适合对权限控制要求严格的系统。我两个项目都做过建议是如果系统是全新的用第一种如果是替换已有的账号体系用第二种更稳妥因为可以平滑迁移不会出现权限混乱。4.3 账号绑定表的设计不管用哪种策略都需要一张绑定表来记录钉钉用户和系统用户的对应关系。表结构大概是这样CREATE TABLE user_dingtalk_binding ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 系统用户ID, dingtalk_union_id VARCHAR(64) NOT NULL COMMENT 钉钉unionId, dingtalk_user_id VARCHAR(64) COMMENT 钉钉userId, dingtalk_nick VARCHAR(128) COMMENT 钉钉昵称, bind_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_union_id (dingtalk_union_id), KEY idx_user_id (user_id) ) COMMENT 钉钉账号绑定表;unionId上建唯一索引保证一个钉钉账号只能绑定一个系统账号。user_id上建普通索引方便反查。这个表看起来简单但实际用起来会发现查询频率很高——每次登录都要查一次所以索引不能省。5. 踩坑实录那些让我加班到深夜的问题5.1 跨域问题不是加个 header 就完事前端调后端接口时的跨域问题比较常规后端加 CORS 头就行。但钉钉扫码登录有个特殊的跨域场景SDK 的 iframe 和你的页面之间通过postMessage通信这个不受 CORS 限制但受X-Frame-Options和Content-Security-Policy影响。如果你的页面设置了X-Frame-Options: DENY钉钉的 iframe 可能无法正常加载。我遇到的情况是二维码区域一片空白控制台报Refused to display in a frame。解决办法是把X-Frame-Options改成SAMEORIGIN或者在 CSP 里允许frame-src https://login.dingtalk.com。这个问题在开发环境可能不会出现因为开发服务器通常不设置这些安全头。一部署到生产环境就暴露了所以建议在测试环境就配好这些头提前验证。5.2 state 校验失败sessionStorage 的坑前面说了用 sessionStorage 存 state但这里有个坑如果用户在扫码过程中刷新了页面sessionStorage 里的 state 还在但二维码重新生成了新的 state 覆盖了旧的。等扫码结果回来的时候比对的是新 state而钉钉那边带回来的是旧 state校验就失败了。我的解决办法是state 生成后不覆盖而是存成一个数组或者对象校验的时候检查是否存在即可而不是严格相等。当然更稳妥的做法是把 state 和二维码的生成时间绑定设置一个过期时间比如 5 分钟过期的 state 自动清理。5.3 移动端钉钉内打开时的特殊处理如果用户是在钉钉 App 内打开你的 H5 页面扫码登录的逻辑就不适用了——因为用户已经在钉钉里了不需要再扫码。这时候应该走免登流程直接通过钉钉的 JSAPI 获取免登码然后后端换取用户信息。判断是否在钉钉内打开可以通过 UA 判断const ua navigator.userAgent.toLowerCase() const isDingTalk ua.includes(dingtalk)如果是钉钉内走免登否则走扫码。这个分支逻辑建议在登录页的onMounted里就判断好避免用户看到二维码后又跳转体验割裂。5.4 token 过期与刷新策略钉钉返回的用户级accessToken有效期是两小时refreshToken有效期是三十天。但注意这个 token 是用来调钉钉接口的不是你自己系统的登录 token。你自己的系统应该签发自己的 token比如 JWT钉钉的 token 只在登录那一刻用来获取用户信息用完就可以丢弃。我见过有项目直接把钉钉的 token 当登录凭证用这是不对的。一是因为钉钉 token 有效期短二是因为这样你的系统就强依赖钉钉的可用性。正确的做法是登录成功后后端签发自己的 JWT 返回给前端后续所有请求都用这个 JWT和钉钉无关。6. 安全加固与上线前的检查清单6.1 必须做的安全措施扫码登录涉及用户身份安全上不能马虎。以下是我总结的必做项AppSecret 只存后端前端代码、Git 仓库、日志里都不能出现 AppSecret。用环境变量管理.env文件加入.gitignore。state 参数必校验防止 CSRF前面已经讲过。回调域名白名单钉钉后台配置的回调域名要精确到具体域名不要用泛域名。HTTPS 强制生产环境必须用 HTTPS否则授权码在传输过程中可能被截获。登录日志记录记录每次扫码登录的时间、IP、设备信息便于审计和异常排查。6.2 上线前的自测清单在正式上线前我会按这个清单过一遍检查项验证方式预期结果首次扫码自动创建账号用未注册的钉钉号扫码系统自动创建用户并登录已绑定账号扫码用已绑定的钉钉号扫码直接登录不重复创建state 校验手动篡改 URL 中的 state登录失败提示错误二维码过期打开登录页放置 5 分钟再扫提示二维码已过期可刷新钉钉内免登在钉钉 App 内打开登录页不显示二维码直接登录token 过期登录后等待超过 token 有效期再操作自动跳转登录页或刷新 token移动端适配手机浏览器打开登录页二维码正常显示可扫码这个清单看起来简单但每一条我都遇到过问题。特别是二维码过期这一条钉钉的二维码默认有效期是 5 分钟过期后不会自动刷新需要用户手动点击刷新。如果你的登录页没有做这个提示用户会一脸茫然地对着一个失效的二维码反复扫。6.3 性能与体验优化最后说几个提升体验的小细节。二维码的加载速度直接影响用户的第一印象建议把 SDK 的加载提前到登录页的路由进入时而不是等组件挂载后才开始加载。可以用link relpreload预加载 SDK 脚本。另外扫码成功后到跳转之间有一个短暂的后端请求过程这段时间要给用户明确的反馈。我的做法是在二维码区域覆盖一个 loading 遮罩显示正在登录...避免用户以为没反应而重复扫码。还有一个容易被忽略的点如果用户扫码后取消了授权钉钉会跳转回来但不带 code 参数。前端要处理这种情况给出您取消了授权的提示而不是一直转圈等待。整个流程走下来最深的体会是扫码登录的技术难度不在于代码本身而在于对各种边界情况的处理。正常流程跑通可能只要半天但把异常情况都覆盖到需要反复测试和打磨。建议在开发阶段就把这些边界情况列出来逐个验证不要等到上线后才发现问题。
返回列表