做钉钉 H5 免登,说难不难,说简单也不简单。尤其是同时要兼容开放平台、政务版钉钉、专有钉钉这三个环境的时候,光是对文档就够你折腾一阵子。最近我又帮一个朋友把业务系统从标准钉钉迁到政务版钉钉环境,踩了不少坑,也把免登这块彻底捋清楚了。这篇就把我带一个完整 H5 免登 Demo 的过程、代码、调试思路全部整理出来,给后面要做同样事情的同学一个可以直接抄作业的参考。
这篇文章适合谁?只要你的业务系统需要以 H5 应用的形式跑在钉钉工作台里,不管你是前端、后端还是全栈,只要你想搞清楚“用户点开应用 → 拿到用户身份 → 建立会话”这条链路上每一步到底在干什么,都可以直接往下看。我不光讲代码怎么写,更会把三个环境之间的差异、配置上的注意事项、以及各种“文档里没写但你一定会遇到”的问题一起讲透。
1. 免登到底在解决什么问题
1.1 免登的价值与适用场景
很多人第一次接触“免登”这个概念,以为就是“不用登录”。其实严格来说,免登指的是:用户已经在钉钉客户端里处于登录状态,H5 页面通过钉钉容器提供的能力,拿到一个临时的授权码,后端再用这个授权码换到用户的真实身份,从而免去用户在你的系统里再输入一遍账号密码。
这个机制的价值在于两点:第一是用户体验,用户从工作台点开应用,直接就是已登录状态,不会觉得你的系统和钉钉是两套东西;第二是安全,身份验证由钉钉侧完成,你的系统并不需要自己维护一套复杂的登录认证体系,只需要信任钉钉返回的用户身份即可。
最常见的应用场景,就是企业内部的 OA、审批、报表、工单系统以 H5 微应用的形式嵌入钉钉工作台。用户从钉钉工作台点击图标进入应用,系统自动识别出“这是哪个用户”,然后直接展示该用户对应的业务数据。也有一种场景是外部系统集成,比如你的系统本身已经有独立的账号体系,通过免登拿到钉钉 userId 后,再映射到你系统内部的用户 ID,照样能一键登录。
1.2 开放平台、政务版、专有钉钉三套环境的差异
这里必须要先说清楚一件事:虽然都叫钉钉,但开放平台、政务版、专有钉钉在技术细节上是有明显差异的。很多人拿开放平台的文档直接去政务版或专有钉钉环境里跑,结果各种鉴权失败,问题往往就出在环境差异上。
| 维度 | 开放平台钉钉 | 政务版钉钉 | 专有钉钉 |
|---|---|---|---|
| 部署方式 | 公有云 SaaS,全国共用 | 政务专属区,通常省级或市级独立部署 | 企业或机构私有化部署,服务器自持 |
| JS-SDK 引用地址 | 使用钉钉官方 CDN | 一般使用政务环境内专门域名 | 由专有钉钉部署方提供,可能是内网域名 |
| 后端接口域名 | 标准 openapi 域名 | 政务环境专属网关地址 | 专有部署网关地址,甚至没有外网入口 |
| 鉴权逻辑 | 统一走开放平台 OAuth 体系 | 与开放平台类似,但应用归属、密钥体系不同 | 可能涉及私有化定制逻辑,参数名都可能变 |
| 常见坑 | 域名白名单配置遗漏 | 内网环境证书/代理问题 | SDK 版本老旧、接口地址需要额外确认 |
这里我多说一句:很多人以为“政务版钉钉 = 专有钉钉”,其实不能完全划等号。政务版通常是在政务云里独立部署的一套钉钉生态,面向政府机关企事业单位;专有钉钉则更像是面向大型企业、机构提供的完全私有化版本。两者在免登开发上的共同点是:** 不能直接照搬开放平台的 API 域名和 CDN 地址,需要切换到对应环境提供的能力。**
所以做这类项目时,我第一件事永远是问清楚:这个项目跑在哪套环境里?有没有对应的开发者后台地址?有没有专门的技术支持群?把这些信息拿到手再动手写代码,能省掉后面一半的返工时间。
1.3 免登的完整数据流
搞清楚免登机制,最关键的是记住一条链路:
用户在钉钉容器内打开 H5 应用 → 前端通过 JS-SDK 获取 authCode → 前端把 authCode 传给后端 → 后端用 authCode 调用钉钉用户接口换取用户身份 → 后端建立本地会话 → 业务系统返回业务数据。
这条链路里,authCode 是核心凭证。它有下面几个特性,务必记住:
- 一次性:使用过后立即失效,不能重复使用。
- 有效期短:一般 5 分钟左右,拿到后要尽快传给后端换取用户信息,不能囤着。
- 应用维度:authCode 是应用绑定的,拿到之后只能通过该应用的 appKey/appSecret 去换用户信息,不能跨应用使用。
我在实际项目里,遇到最多的报错就是“invalid code”和“code expired”。多半是前端拿到了 authCode 之后,在 localStorage 里存了一会儿才去用,或者后端处理了过久再去调钉钉接口,结果 code 已经过期了。所以做免登的时候,拿到 authCode 之后应立即往后端发请求,后端也应尽快完成换身份的逻辑,不要在中间插入太耗时的事务。
2. 开发前的准备:配置和参数一个都不能少
2.1 应用创建与微应用配置
免登开发不止是写代码,配置占了一半的功夫。以标准钉钉开放平台为例,你需要先到开发者后台创建一个“企业内部应用”,然后在这个应用下面配置 H5 微应用。创建完之后,你会拿到几个关键参数,它们的关系可以理解为:
- corpId:企业 ID,相当于你在钉钉生态里的“门牌号”,标识你是哪个企业/组织的。
- agentId:应用 agentId,标识你的应用是哪个应用,同一个企业下可以挂多个应用。
- appKey / appSecret:应用的凭据,相当于应用的账号和密码,后端调用接口时用来鉴权。
这几个参数在不同的环境里可能叫法略有不同,比如专有钉钉环境里可能叫 appId / appSecret,但作用是一样的。拿到参数后,最容易被忽略的是服务器出口 IP 白名单。钉钉开放平台里可以配置服务器出口 IP,配置了之后只有这些 IP 发起的请求才会被放行。我在联调时遇到过几次 401 报错,排查到最后发现是后端服务所在服务器的出口 IP 没有加到白名单里,这个问题在新环境接入时特别常见。
H5 微应用的 URL 配置也是一个大坑。这里的 URL 要求必须是公网可访问的 https 地址,而且必须和你实际部署的地址一致。很多人开发阶段用本地 IP 或者内网域名测试,到了联调阶段就各种问题——最常见的是 JS-SDK 的 config 注入时报“无效签名”,其实不是签名算法写错了,而是你配置的 URL 和实际访问的 URL 不一致。
2.2 动态加载 JS-SDK 与 config 注入
钉钉的 JS-SDK 引用方式,在标准开放平台里是通过固定 CDN 加载。但在政务版/专有钉钉环境里,这个 CDN 地址可能不同,甚至可能是内网地址。我建议的做法是:把 SDK 地址做成配置项,不同环境加载不同地址,而不是硬编码到 HTML 里。
加载完 SDK 后,第二步就是调用dd.config。这一步的目的是让钉钉容器确认“当前这个页面是受信任的”,并且声明你要使用哪些 JS 能力。dd.config需要四个参数:
- agentId:应用 agentId
- corpId:企业 ID
- timeStamp:签名生成的时间戳,单位是秒
- nonceStr:随机字符串
- signature:后端用 jsapi_ticket 和当前 URL 生成的签名
这里有个很容易犯错的地方:signature必须由后端生成,前端不能自己算。原因是生成签名需要用到 jsapi_ticket,而 jsapi_ticket 是用 appKey/appSecret 换取的,如果把这一步放到前端,相当于把密钥暴露给了所有用户。
而且签名的 URL 必须和当前页面的完整 URL 一致。我用过一个血泪经验:页面用了 hash 路由(Vue Router 或 React Router 默认模式),location.href里带着#/xxx,那签名时的 URL 就要带上这个 part。如果后端生成签名时只拿了去掉 hash 的部分,那dd.config就会静默失败,或者报“url not in corpid config”。
2.3 环境差异最容易踩的坑
刚才提到环境差异,这里展开说几个我实测遇到过的问题。
第一个是 SDK 地址问题。开放平台的 CDN 地址在政务内网里不一定能通。有一次我把 demo 部署到政务环境,页面打开后dd对象一直是 undefined,后来才发现是 CDN 地址被内网策略挡住了。解决方法是把 SDK 文件下载下来,放到自己业务系统的静态资源目录里,或者用环境提供的替代地址。
第二个是接口网关地址。后端调用钉钉 OpenAPI 时,不同环境的域名不一样,而且政务版/专有版往往需要走内网网关,如果在配置文件里写死了开放平台的地址,到了内网环境会直接连不上,或者虽然能到但被网关校验拦截。
第三个是 ticket 缓存问题。jsapi_ticket 的有效期是 7200 秒,获取到的 ticket 在获取后两小时内有效,有效期内不需要重复获取。但有些环境的 ticket 是绑定 IP 白名单的,换了出口 IP,原来缓存的 ticket 可能就会失效。我一般会在获取 ticket 的接口里把“出口 IP 变化后强制刷新 ticket”的逻辑加上,虽然听起来有点怪,但确实能避免不少偶发问题。
3. 前端免登核心代码实操
3.1 免登核心代码:requestAuthCode 与 dd.ready
前端部分,免登的核心就是两步:先dd.config完成鉴权,再在dd.ready里调用requestAuthCode获取 authCode。下面这段代码是一个最基本的免登工具,可以直接放入你的项目里用。
// auth.js const CORP_ID = '你的corpId'; const AGENT_ID = '你的agentId'; // 从后端获取签名信息 async function getSignInfo(url) { const response = await fetch('/api/dingtalk/sign?url=' + encodeURIComponent(url)); const data = await response.json(); if (data.code !== 0) { throw new Error(data.message || '获取签名失败'); } return data.data; } // 初始化钉钉配置 export async function initDingTalk() { if (typeof dd === 'undefined') { console.error('当前环境不在钉钉容器内,无法获取钉钉能力'); return false; } const url = location.href.split('#')[0]; const sign = await getSignInfo(url); dd.config({ agentId: AGENT_ID, corpId: CORP_ID, timeStamp: sign.timestamp, nonceStr: sign.nonceStr, signature: sign.signature, jsApiList: ['runtime.permission.requestAuthCode'] }); return new Promise((resolve) => { dd.ready(() => { resolve(true); }); dd.error((error) => { console.error('dd.config 鉴权失败', error); resolve(false); }); }); } // 获取免登授权码 export function getAuthCode() { return new Promise((resolve, reject) => { dd.runtime.permission.requestAuthCode({ corpId: CORP_ID, onSuccess: (result) => { resolve(result.code); }, onFail: (error) => { reject(error); } }); }); }这段代码里有几个细节值得注意。
第一,location.href.split('#')[0]是我故意写的。在 Vue/React 的 hash 路由下,签名时没去掉 hash 会报错,去掉 hash 反而稳。但这取决于后端生成签名时拿到的 URL,两端一定要对齐。我这里统一约定为“不带 hash 的完整 URL”。
第二,dd.ready和dd.error必须同时监听。dd.config失败时,dd.ready永远不会触发,如果只监听 ready,你的应用就会一直卡在初始化状态。我在给一个朋友排查问题时发现,他写的代码里只监听了ready,结果dd.config因为一个签名问题失败后,页面没有任何反馈,白屏了很久。
第三,requestAuthCode必须在dd.ready触发之后调用。如果config还没完成就去调requestAuthCode,大概率会报权限错误。所以我把initDingTalk()和getAuthCode()拆成两个异步步骤,确保调用顺序。
3.2 SPA 多页面适配问题
很多人做钉钉 H5 应用,用的都是 SPA 框架。这里有一个隐形问题:钉钉工作台打开你的应用时,可能不是只有一个入口页面,而是每个菜单对应一个 URL。也就是说,用户可能从工作台的多个入口分别进入你的前端应用的不同路由。
在这种情况下,如果每个入口页面都走一遍“获取 authCode → 后端换取身份 → 建立会话”的流程,体验会很糟糕,而且容易触发验证码的“频繁获取”限制。
我的做法是做一个统一的“会话兜底”逻辑,大致流程是这样的:
- 页面加载时,先检查本地是否有会话标记,比如 localStorage 里的 token 或者 userId。
- 如果有,先尝试静默调用业务接口(后端校验会话是否有效),有效就直接进入业务页面。
- 如果会话不存在或已失效,再去走免登流程,拿到 authCode 后换取身份,成功后再写入会话标记。
这里要提醒的是,不要把 authCode 本身作为会话凭证存在本地,authCode 是一次性的,存了也没用。应该存的是你后端自己签发的业务 token。这个 token 可以由后端在免登成功之后生成,并设置合理的过期时间。我习惯性的做法是让后端返回一个带过期时间的 token,前端只负责存和带着请求,不去解析内容。
3.3 免登、业务请求与登录状态管理
把免登和业务请求统一起来,我一般会封装一个authProvider类。它负责三件事:初始化、免登、自动刷新。
// authProvider.js import { initDingTalk, getAuthCode } from './auth'; const AuthProvider = { async bootstrap() { // 1. 检查本地会话 const localToken = localStorage.getItem('yy_token'); if (localToken) { // 2. 验证本地会话是否有效 const valid = await this.verifyToken(localToken); if (valid) { return localToken; } } // 3. 走钉钉免登流程 const success = await initDingTalk(); if (!success) { throw new Error('钉钉配置初始化失败'); } const code = await getAuthCode(); // 4. 后端用 code 换取身份并返回业务 token const response = await fetch('/api/dingtalk/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) }); const data = await response.json(); localStorage.setItem('yy_token', data.data.token); return data.data.token; }, async verifyToken(token) { // 调用 /api/user/info 验证 token 有效性 try { const response = await fetch('/api/user/info', { headers: { Authorization: 'Bearer ' + token } }); return response.status === 200; } catch (e) { return false; } } }; export default AuthProvider;这个封装思路在多个项目里我都用过,好处是业务代码里不需要关心“当前有没有登录、要不要走免登”,只需要在入口处调用一次AuthProvider.bootstrap(),拿到 token 后放到请求拦截器里就行。
一个可选的优化方向是:给verifyToken加上防抖和重试。因为工作台打开应用瞬间,可能会有多个异步请求同时触发,如果都发现 token 失效,就会同时发起多个免登请求,后端会收到一堆同一或相近时间产生的 code,容易出问题。简单做法是给 bootstrap 加一个 Promise 单例锁,保证同一时间只有一个免登流程在跑。
4. 后端换取用户身份的完整链路
4.1 后端获取 access_token
前端的免登流程跑通之后,真正的身份获取发生在后端。后端拿到 authCode 之后,要做的第一件事是获取 access_token。这个 access_token 是应用维度的凭证,有了它才能继续调用钉钉的接口。
获取 token 的伪代码大概是这样的:
// 以 Java 为例,使用 HTTP 工具调用钉钉接口 String url = "/gettoken?appkey=" + appKey + "&appsecret=" + appSecret; JSONObject result = httpGet(url); String accessToken = result.getString("access_token");注意几个细节:
- access_token 的有效期也是 7200 秒,需要缓存。我一般放在 Redis 里,设置过期时间为 7000 秒,避免刚好在边界时间失效。
- 不同环境的接口域名不同,建议把“token 获取接口地址”和“用户信息接口地址”都放到配置中心,切换环境时只改配置,不动代码。
- 获取 token 时需要用到 appKey 和 appSecret,这两个值是敏感信息,绝不能放到前端代码或前端配置里。
4.2 用 authCode 换取用户身份
拿到 access_token 后,后端再用 authCode 换取用户身份。在标准开放平台里,会调用一个类似“根据 code 获取用户信息”的接口。我拿到的返回值里一般会包含几个重点字段:
- userId:用户在企业的唯一标识,业务系统通常会拿它作为用户主键。
- unionId:用户在一个开发者账号下的唯一 ID,跨应用时保持一致。注意 unionId 在不同环境下可能叫法不同。
- 有些环境下还会返回 userInfo 之类的扩展字段。
下面是一个封装好的服务端逻辑,用伪代码体现思路:
public UserInfo getUserInfoByAuthCode(String authCode) { String accessToken = getAccessTokenFromCache(); // 调用钉钉接口 JSONObject params = new JSONObject(); params.put("code", authCode); JSONObject result = dingTalkClient.post("/topapi/v2/user/getuserinfo", accessToken, params); if (result.getIntValue("errcode") != 0) { throw new BusinessException("免登失败:" + result.getString("errmsg")); } JSONObject userInfo = result.getJSONObject("result"); String userId = userInfo.getString("userid"); // 拿到 userId 后,一般还需要再调一个接口获取用户详情,如姓名、部门等 return getUserDetail(accessToken, userId); }这段逻辑看起来简单,但实际项目里有两件事要特别留意。
第一,authCode 是被消费的。后端拿到 code 后,如果第一次调用失败,再去重试,大概率会报“code is invalid”。所以不要设计成“失败后自动重试同一个 code”的逻辑,应该返回给前端,让前端重新获取一个新的 code。
第二,用户信息接口返回的字段在不同环境里会有差异。比如开放平台返回的是userid,但专有钉钉某些版本返回的是userId,大小写都不同。最好的方式是把响应体直接打日志,对照实际返回的 JSON 来解析字段,而不是完全依赖文档。
4.3 建立本地会话与安全建议
免登成功之后,后端不要直接把钉钉返回的 userId 当成你自己系统的登录态。我建议的做法是:
- 根据 userId 查找或创建你业务系统里的用户记录。
- 生成一个业务自己的 token,设置过期时间(比如 7 天或按企业要求)。
- 把 token 和 userId 的映射关系存到缓存/数据库。
- 返回给前端的是业务 token,不是钉钉的身份凭证。
这样做的原因是,你的业务系统后续可能需要控制权限、记录操作日志、统计在线人数,如果每次都去调钉钉接口,性能和稳定性都会受影响。而且假如钉钉的接口因为网络或权限问题暂时不可用,你的系统仍然可以基于本地会话继续提供服务。
安全上还要注意几点:
- 后端对免登接口要做限流,防止有人伪造大量 code 请求。
- authCode 虽然是一次性的,但也要校验它的来源,最好只接受工作台容器里真实生成的 code。一个简单的办法是校验 requestAuthCode 成功后,顺便校验当前请求里带上的 corpId 与应用配置是否一致。
- 不要把 access_token 存到日志里,打印日志时把 token 打码。
5. 三套环境迁移与常见问题排查
5.1 从开放平台迁到政务版/专有钉钉的改动清单
我最近一次做的就是这类迁移。总结一下,从标准开放平台迁移到政务版/专有钉钉环境时,需要重点检查下面几项:
| 检查项 | 标准开放平台 | 政务版/专有钉钉 | 备注 |
|---|---|---|---|
| JS-SDK 地址 | 官方 CDN | 环境专属域名或自托管 | 页面加载不出dd时先查这里 |
| OpenAPI 域名 | 标准域名 | 环境专属网关 | 后端连接超时的常见原因 |
| appKey/appSecret | 开发后台获取 | 对应环境后台获取 | 别把两套环境的密钥混用 |
| 签名 URL 规则 | 页面完整 URL | 以环境文档为准 | 有些环境要求去掉 queryString |
| ticket 获取接口 | 统一接口 | 可能不同路径 | 按文档调整路由 |
| 用户信息字段 | userid | userId 或自定字段 | 用真实返回 Json 为准 |
迁移的时候,我习惯先把前端 SDK 和后端网关地址分开做两个配置文件,一个“dev 环境配置”,一个“生产环境配置”,切换环境时只改环境开关。
5.2 免登失败的典型问题速查表
我把这段时间遇到的免登问题整理成一个速查表,方便大家快速定位:
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
dd对象不存在 | SDK 未加载成功或引用了错误的 CDN 地址 | 打开控制台确认脚本是否加载,检查是否被环境安全策略拦截 |
dd.config报 url 不匹配 | 签名时的 URL 与当前页面 URL 不一致 | 两端打印实际 URL,统一规则(是否带 hash) |
dd.config报签名错误 | 时间戳、nonceStr、signature 由不同请求产生 | 确认后端签发签名时使用的 URL 与当前页面真实地址一致,检查设备时间偏差 |
requestAuthCode失败 | 未在dd.ready中调用,或未在jsApiList中声明能力 | 调整调用时机,检查 config 里的 jsApiList |
| 后端换身份时 invalid code | authCode 过期、已被消费、或应用不匹配 | 确认 code 是否在一次请求里被重复使用,确认 appKey/agentId 是否对应 |
| 后端返回用户信息为空 | 用户信息接口权限不足或应用没有开通通讯录权限 | 去开发者后台申请对应权限,确认 agentId 与应用绑定一致 |
| 页面白屏且无报错 | dd.config失败但只监听了ready | 补上dd.error监听,把错误信息展示出来 |
这里特别说一下设备时间问题。签名生成时用到的 timestamp 是后端生成的,但钉钉容器在验证签名时会拿它和当前设备时间对比。如果设备时间和服务器时间相差太多,签名会直接失败。遇到这类问题时,先校准手机系统时间,往往能秒杀。
5.3 完整 Demo 的实测流程建议
最后,我给一个从零到一实测免登 Demo 的推荐路径,照着这个顺序做,能减少很多无效排查。
第一步,本地先把后端接口跑通。用 Postman 或命令行工具直接调钉钉的 token 接口和用户信息接口,抓一个真实的 authCode 来测试,这一步不依赖前端页面。如果这一步都过不了,问题一定在应用配置或网络环境上。
第二步,搭一个最简前端页面。页面就一个按钮,点击后动态加载 SDK、config、requestAuthCode,把拿到的 code 显示出来。这一步的目的是验证“前端能不能拿到 code”,先不管身份换取。
第三步,把前端拿到的 code 手动传给后端接口,看看能不能换到正确的用户信息。这一步能暴露后端参数封装、接口路径的问题。
第四步,把前端拿到的 code 自动传给后端,后端建立会话,返回业务 token。这时候你就有了一条完整的免登链路。
第五步,再把“会话失效后自动重新免登”“多页面共享会话”这类健壮性需求加上去。
按这个顺序做,出问题时能快速定位到具体环节。我每次到一个新环境,都会先走一遍这个流程,基本半天内就能把免登链路打通。
我个人在实际操作中的体会是:免登本身不难,难的是三套环境的细节差异和配置一致性。别嫌麻烦,建议先把“获取 code”和“换取身份”这两段单独写成可配置的工具模块,后面切环境、换项目都能复用。在项目里跑这个 demo 的时候,留一个调试面板把dd.config的入参、后端返回的原始 JSON 都打出来,上线前再关掉。这样就算出了问题,也能迅速定位到是前端签名问题、环境配置问题,还是后端接口问题。