首页 / 资讯中心 / 文章详情

钉钉微应用免登实战:Java服务端与H5全链路接入指南

钉钉微应用免登实战:Java服务端与H5全链路接入指南 ★ FEATURED ARTICLE
简介针对Java开发者实现钉钉微应用免登进入H5系统首页的完整技术方案文档面向企业内部应用开发人员解决用户通过钉钉打开微应用后免登录直登H5首页的授权与身份校验问题。压缩包内为1个PDF文件整包大小仅129KB内容精炼便于快速查阅。目前已有2113人学习下载。文档完整梳理了从钉钉开放平台创建微应用、配置白名单与接口权限到ddNoLogin.html前端页面获取免登授权码、后端通过access_token换取用户信息的全流程并附有Java定时刷新token的实现代码。读者可参照其中前后端协作思路结合钉钉API快速落地免登功能同时借鉴消息通知与权限判断等扩展设计提升应用安全性与用户体验。1. 钉钉微应用免登为什么你的 H5 系统还在让用户重复输密码很多公司把内部的 OA、报表、工单系统做成 H5 挂到钉钉工作台用户每天第一件事就是在手机浏览器里再输一遍账号密码。钉钉微应用免登的意义就在于用户在钉钉里点开微应用钉钉客户端已经替他完成了身份认证H5 通过一次授权码交换就能拿到钉钉用户身份Java 服务端据此签发自己的登录态用户直接落到 H5 系统首页全程看不到登录页。这篇文章面向正在做企业自建应用接入、被登录态打通卡住的后端和前端同学把免登链路、Java 实现、前端配合和常见坑一次讲透。适合谁有 Java 后端基础H5 是普通 SPA 或服务端渲染想把钉钉身份和自有账号体系接起来的人。2. 免登背后的认证链路一次 code 交换一次钉钉身份2.1 免登时序钉钉客户端、H5 页面、Java 服务端三方怎么配合钉钉微应用免登不是“钉钉直接把用户名密码发给你的系统”它是一条三方配合的授权链。整个链路拆成五步用户在钉钉工作台点开微应用钉钉客户端注入 JSAPI 环境H5 页面调用钉钉 JSAPI 获取免登授权码 authCodeH5 把 authCode 通过后端接口传给 Java 服务端Java 服务端拿 authCode 加上自己的 appKey/appSecret向钉钉开放平台换取用户身份 userid最后服务端用 userid 查到或创建自有系统用户签发 JWT 或会话把登录态返回 H5H5 带上登录态跳首页。这里面最关键的设计是H5 永远拿不到 appSecret也拿不到钉钉用户的通讯录敏感资料它只传递一次性 code。这样即使 H5 前端被攻击泄露的也只是几分钟就失效的授权码而不是整个企业通讯录权限。很多第一次接免登的同学会困惑“为什么前端还要传一个 code 给后端不能直接告诉后端我是谁”原因就在这钉钉只认后端用 appSecret 签发的请求前端说的话它一句都不信。时序上还有一个容易忽略的点authCode 发到 H5 之后如果前端没有立刻传给后端而是等用户填了个表单再提交code 很可能已经过期了。所以免登动作要在页面加载后第一时间触发拿到 code 马上走后端接口不要把它暂存到某个全局变量里等业务操作完成再提交。这个顺序错一步线上就会有 1% 到 2% 的免登失败率表现为用户偶尔要重新登录。和传统账号密码登录对比免登省掉的不是安全校验而是输入动作。系统该做的身份校验、权限校验一步都不能少只是把“用户名密码”换成了“钉钉说这个人是张三”后端再根据张三去匹配系统角色。这也是为什么面试八股里常把单点登录和免登混在一起问实际两者解决的不是同一个问题单点登录解决多个系统共享登录态免登解决移动端工作台到 H5 的身份传递。2.2 免登必需的参数appKey、appSecret、agentId 从哪来、怎么配要跑通免登你得先在钉钉开放平台创建一个“企业内部应用”。创建完成后应用后台的基本信息里能看到三个关键参数参数说明获取位置appKey应用标识客户端公开但不敏感开发者后台 → 应用详情 → 凭证与基础信息appSecret应用密钥等价于密码只能后端持有同上建议放配置中心或环境变量agentId微应用 ID部分接口和鉴权场景需要应用详情 → 基本信息除了这三个还有两个容易漏的配置。一是“开发管理 → 服务器域名”要把 H5 系统实际部署的域名加进白名单否则免登接口会被钉钉拦截本地 localhost 联调时还要把测试域名临时配上去二是“版本管理与发布”企业内部应用要发布一个版本后工作台里才能被员工看到调试时自己能看到不代表全员可见很多团队开发完点开应用发现界面没更新就是版本没发布。注意这里说的是企业自建应用。如果你做的是第三方应用ISV凭证名称会变成 suiteKey/suiteSecret调用链路和自建应用完全不同别拿第三方应用的配置套自建应用的代码反过来也一样。自己没建过应用、直接抄别人代码最容易在这个地方翻车跑起来后 gettoken 一直报错排查半天发现是凭证类型不对。2.3 访问令牌 access_token免登的“门票”与两点核心约束Java 服务端拿 authCode 换 userid 之前得先向钉钉开放平台获取 access_token。这个 token 通过 gettoken 接口用 appKey 加 appSecret 换取有效时长 7200 秒是整个企业应用共享的通行证调用通讯录、审批等接口都需要它。第一个约束是必须缓存。如果每个免登请求都去调一次 gettoken钉钉会按照应用维度限流高并发下直接报“触发访问限制”。常见做法是把 access_token 放进程内缓存或 Redis设置过期时间略短于 7200 秒比如 6900 秒留出刷新余量。放内存要考虑多实例问题放 Redis 要考虑所有实例统一读一个 key这两种做法各有取舍后面代码里先给进程内缓存实现。第二个约束是安全问题。access_token 能换取整个企业应用的数据权限所以 appSecret 绝对不能出现在前端代码、静态资源包或 GitHub 仓库里。有些团队为图方便把 appSecret 写死在 H5 的 config.js 里一旦源码泄露攻击者直接调 gettoken 接口拿到企业通讯录全量数据这种事故比免登失败严重得多。正确做法是 appSecret 只存在于 Java 服务端的配置中心通过环境变量注入代码仓库里只留占位符。提示access_token 过期后必须用 appSecret 重新换取没有刷新 token 这个说法。所以服务端要做的是提前刷新而不是等请求报错后再补一次获取后者会造成偶发的免登失败。3. Java 服务端实现免登接口从 code 到登录态的完整代码3.1 免登接口设计请求参数、响应体与会话策略先定接口契约我一般这么设计URLPOST /api/auth/dingtalk请求体{ authCode: 一次性免登授权码 }成功响应{ token: JWT字符串, userName: 张三, expireAt: 1720000000000 }失败响应{ code: 40001, message: 免登失败authCode 已失效 }会话策略推荐用 JWT 而不是传统 HttpSession。原因是微应用 H5 经常会被嵌入到别的页面或者从钉钉工作台跳到第三方浏览器内核跨域场景下 Cookie 传递很麻烦JWT 放在 Authorization 头里最省事。JWT 要设合理过期时间比如 2 小时同时服务端配合 Redis 做黑名单实现登出。另一个设计点是免登成功后要不要自动创建用户我建议首次免登查到 userid 后先到自有用户表按 dingUserId 查查不到就自动建档初始角色给“未激活”或“普通员工”等管理员分配权限。这样员工第一次点开微应用就能直接进首页不用等管理员手工建号。如果你们系统对权限敏感可以把初始状态设计为“只能看首页不能操作任何菜单”既保体验又控风险。3.2 调用钉钉免登 API用 Java 的 HttpClient 实现 code 换 userid这里不依赖钉钉官方 SDK用 Java 11 自带的 java.net.http.HttpClient 就能跑通省掉一堆历史版本冲突。只要 java 环境变量配好JDK 11 以上直接能编译执行不需要额外引包。先写一个 DingTalkClient 组件负责两件事获取并缓存 access_token、用 authCode 换 userid。package com.example.dingtalk; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; Component public class DingTalkClient { private static final String GET_TOKEN_URL https://oapi.dingtalk.com/gettoken; private static final String GET_USER_INFO_URL https://oapi.dingtalk.com/topapi/v2/user/getuserinfo; private final HttpClient httpClient HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); private final ObjectMapper objectMapper new ObjectMapper(); Value(${dingtalk.app-key}) private String appKey; Value(${dingtalk.app-secret}) private String appSecret; // access_token 本地缓存volatile 保证多线程可见 private volatile String accessToken; private volatile long expireAt; /** * 获取 access_token带进程内缓存 */ public String getAccessToken() { if (accessToken ! null System.currentTimeMillis() expireAt) { return accessToken; } String url GET_TOKEN_URL ?appkey appKey appsecret appSecret; String body get(url); try { JsonNode node objectMapper.readTree(body); if (node.get(errcode).asInt() ! 0) { throw new RuntimeException(获取钉钉access_token失败: node.get(errmsg).asText()); } accessToken node.get(access_token).asText(); int expiresIn node.get(expires_in).asInt(); // 提前300秒过期给刷新留余量 expireAt System.currentTimeMillis() (expiresIn - 300) * 1000L; return accessToken; } catch (Exception e) { throw new RuntimeException(解析钉钉access_token响应异常, e); } } /** * 免登 code 换取钉钉用户信息 */ public DingTalkUser getUserByAuthCode(String authCode) { String token getAccessToken(); String url GET_USER_INFO_URL ?access_token token; String requestBody {\code\:\ authCode \}; String body post(url, requestBody); try { JsonNode node objectMapper.readTree(body); if (node.get(errcode).asInt() ! 0) { throw new RuntimeException(钉钉免登失败: node.get(errmsg).asText()); } JsonNode result node.get(result); DingTalkUser user new DingTalkUser(); user.setUserId(result.get(userid).asText()); return user; } catch (Exception e) { throw new RuntimeException(解析钉钉免登响应异常, e); } } private String get(String url) { try { HttpRequest request HttpRequest.newBuilder() .uri(URI.create(url)) .GET() .timeout(Duration.ofSeconds(5)) .build(); return httpClient.send(request, HttpResponse.BodyHandlers.ofString()).body(); } catch (Exception e) { throw new RuntimeException(GET请求钉钉接口异常, e); } } private String post(String url, String jsonBody) { try { HttpRequest request HttpRequest.newBuilder() .uri(URI.create(url)) .header(Content-Type, application/json) .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) .timeout(Duration.ofSeconds(5)) .build(); return httpClient.send(request, HttpResponse.BodyHandlers.ofString()).body(); } catch (Exception e) { throw new RuntimeException(POST请求钉钉接口异常, e); } } }这段代码里几个参数说明GET_TOKEN_URL使用 oapi.dingtalk.com 域名下的 gettoken 接口appKey 和 appSecret 直接跟在 query 上服务端保存的 appSecret 永远不会暴露给 H5。getAccessToken()加了进程内缓存提前 300 秒过期避免临界点失效并发环境下用 volatile 保证拿到的是最新 token但要注意多实例部署时每个进程各缓存一份生产环境建议换成 Redis。超时设 5 秒是实际联调攒出来的经验。钉钉开放平台接口在高峰期偶发慢响应超时太长会拖垮业务线程池太短又容易误判失败。5 秒是大多数团队权衡后的常用值你可以根据线上接口 P99 再微调。getUserByAuthCode()调的 topapi/v2/user/getuserinfo 是当前免登推荐接口access_token 在 URL query 上authCode 在 JSON body 里返回的 result 中 userid 是必取字段。3.3 打通自有用户体系userid 映射、JWT 签发与登录接口拿到钉钉 userid 只是第一步下一步是把钉钉身份映射到自有系统用户表并签发自己的登录态。下面是 Controller 和 Service 的完整实现package com.example.dingtalk; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; RestController RequestMapping(/api/auth) public class AuthController { private final DingTalkClient dingTalkClient; private final SysUserService userService; private final JwtTokenService tokenService; public AuthController(DingTalkClient dingTalkClient, SysUserService userService, JwtTokenService tokenService) { this.dingTalkClient dingTalkClient; this.userService userService; this.tokenService tokenService; } PostMapping(/dingtalk) public ResultLoginVo loginByDingTalk(RequestBody DingTalkLoginRequest request) { if (request.getAuthCode() null || request.getAuthCode().isBlank()) { return Result.fail(缺少authCode); } // 第一步免登 code 换钉钉用户身份 DingTalkUser dingUser dingTalkClient.getUserByAuthCode(request.getAuthCode()); // 第二步按 userid 映射自有用户不存在则自动建档 SysUser sysUser userService.findOrCreateByDingUserId(dingUser.getUserId()); // 第三步签发 JWT 登录态 String jwt tokenService.createToken(sysUser.getId()); LoginVo vo new LoginVo(jwt, sysUser.getNickname(), tokenService.getExpireAt()); return Result.ok(vo); } }Controller 里的三步顺序不能乱先做认证再做用户映射最后发登录态。不要把“查到钉钉用户”当成“登录成功”钉钉通讯录里的人和业务系统用户表不是一一对应的它只是一条映射关系有没有权限要落到自有用户表上判断。如果用户被管理员停用即使钉钉身份有效系统也应该拒绝签发 token这一步要在findOrCreateByDingUserId里检查状态。Service public class SysUserService { private final SysUserMapper sysUserMapper; private final DingTalkContactClient contactClient; public SysUserService(SysUserMapper sysUserMapper, DingTalkContactClient contactClient) { this.sysUserMapper sysUserMapper; this.contactClient contactClient; } public SysUser findOrCreateByDingUserId(String dingUserId) { SysUser user sysUserMapper.selectByDingUserId(dingUserId); if (user ! null) { return user; } // 自动建档同步钉钉昵称初始状态设为未激活 SysUser newUser new SysUser(); newUser.setDingUserId(dingUserId); newUser.setNickname(contactClient.getNickname(dingUserId)); newUser.setStatus(INACTIVE); sysUserMapper.insert(newUser); return newUser; } }user 表的ding_user_id字段要建唯一索引否则自动建档并发时会插入两条重复用户。JWT 的密钥从环境变量读取别写死在 yml 配置里。token 里只放 userId 和一个会话随机数不放钉钉 userid避免之后钉钉人员调整时 JWT 里残留旧身份信息这也是安全审计时容易被问到的一个点。4. H5 前端接免登从钉钉 JSAPI 拿 code 到跳回首页4.1 引入 dingtalk-jsapi 与环境判断钉钉里和浏览器里两条路H5 页面要拿到免登 code需要引入钉钉官方 JSAPI 库npm 包名是 dingtalk-jsapi安装后在入口文件里引入并配置import * as dd from dingtalk-jsapi;const DING_CONFIG { corpId: dingxxxxxxxxxxxxxxxx, agentId: 123456789 }; // 判断是否在钉钉客户端内 const isInDingTalk typeof window ! undefined window.DingTalkJSBridge;代码里corpId是企业的唯一标识agentId是微应用 ID两者在钉钉开发者后台都能看到注意 agentId 是数字corpId 是 ding 开头的字符串写反了调用 JSAPI 会直接报错。isInDingTalk判断依赖钉钉客户端注入的 JSBridge 对象这个对象只在钉钉内置浏览器里有普通浏览器或微信里打开 H5 时不存在。这个降级判断必须有。很多用户会把微应用链接收藏到浏览器里每天直接打开如果一进去就调 requestAuthCode只会得到一个失败回调页面卡在空白。正确做法是检测到不在钉钉环境直接跳转到自有系统的账号密码登录页登录成功后进同一个首页。这样钉钉内打开走免登浏览器打开走账密两条路都通。4.2 获取免登 code 并交给 Java 服务端完整前端登录流程有了环境判断接着写完整的免登登录方法import axios from axios; function getDingTalkAuthCode() { return new Promise((resolve, reject) { dd.ready(function () { dd.runtime.permission.requestAuthCode({ corpId: DING_CONFIG.corpId, onSuccess: function (info) { resolve(info.code); }, onFail: function (err) { reject(new Error(获取免登code失败: JSON.stringify(err))); } }); }); }); } async function loginByDingTalk() { if (!window.DingTalkJSBridge) { // 非钉钉环境跳转账号密码登录页兜底 window.location.href /login; return; } try { const authCode await getDingTalkAuthCode(); const { data } await axios.post(/api/auth/dingtalk, { authCode }); if (data.code 0) { localStorage.setItem(token, data.data.jwt); axios.defaults.headers.common[Authorization] Bearer data.data.jwt; window.location.href /home; // 进入H5系统首页 } } catch (e) { // 失败时跳登录页避免卡死 window.location.href /login?errordingtalk_auth_failed; } }dd.runtime.permission.requestAuthCode是钉钉 JSAPI 里最常用的免登授权方法onSuccess 的info.code就是一次性授权码有效时间大约 5 分钟只能用一次。拿到后立刻传给后端不要在中间穿插其他业务逻辑。axios 拦截器里统一设置 Authorization 头后续请求就都带上 JWT不再重复走免登。token 放 localStorage 是最简单做法但要注意 XSS 风险如果 H5 系统存在富文本渲染漏洞攻击者能直接偷走 token。更稳的做法是放内存变量刷新时重新请求一个短期 token或者放 HttpOnly Cookie 并配好 SameSite 策略。取舍取决于你们前端架构没有标准答案。4.3 微应用首页 URL 配置与登录态续期两个容易翻车的小细节钉钉后台配置微应用首页 URL 时可以直接填 H5 系统地址也可以带固定参数比如https://your-domain.com?fromdingtalk。但有一个容易被忽略的约束如果配置 URL 时已经带了 query 参数钉钉跳转时可能还会在后面追加自己的参数前端解析时一旦用了错误的分隔方式authCode 就是坏的。我见过一个真实例子首页 URL 配成https://a.com/index?channelding跳转过后的链接变成https://a.com/index?channeldingauthCodexxx前端直接把 authCode 值取出来传给后端忘记做 URL decode后端拿到的 code 带转义字符去换 userid 必然失败。建议统一用 JSAPI 方式拿 code不要在 URL 上解析参数省得踩编码的坑。另一个细节是登录态续期。JWT 2 小时过期员工可能上午打开微应用挂到下午再点某个按钮时 token 已失效。常见做法是前端在 axios 响应拦截器里判断 401弹一个“登录已过期”的提示然后跳回免登流程重新拿 code 换新 token。用户体验上重新免登比重新输密码好得多钉钉环境里登录过期对员工来说几乎是无感的。5. 免登接入避坑指南5 个真实踩坑记录5.1 同样的 code 调两次接口第二次报“无效的 code”现象用户反馈偶尔登录失败后端日志里同一个 authCode 第一次请求成功第二次报“无效的 code”。原因钉钉免登 code 是一次性的用了就作废。前端在弱网环境里请求超时后自动重试了一次同一个 code 被消费两遍。解决前端做防重复提交点击登录后按钮置灰加 loading 状态后端再做一层幂等把消费过的 code 指纹存 Rediskey 设为ding:code:{authCode}过期时间 10 分钟用 SETNX 判断是否已消费。这里要说明code 有效期 5 分钟Redis key 过期时间比它长一倍是为了覆盖重复请求窗口。5.2 本地联调全通部署到测试服务器就免登失败报“域名不合法”现象本地跑得好好的代码发到测试环境一进微应用就报错钉钉返回“域名不在白名单”。原因钉钉开放平台对微应用服务器域名做白名单校验本地 localhost 配过测试域名没配。解决登录钉钉开发者后台进入应用详情 → 开发管理 → 服务器域名把 H5 系统实际使用的域名加进去。dev、test、prod 三套环境的域名要分别配换域名后同步更新后台否则下个环境上线就是一次线上事故。这里特别提醒域名校验是精确匹配的不要尝试配泛域名通配符钉钉不支持配了第二天接口就报错。5.3 gettoken 接口报“appKey 无效”明明从后台复制出来的没错现象代码里 appKey/appSecret 看着没问题gettoken 一直返回“appKey 无效”。原因多半是把第三方应用的 suiteKey/suiteSecret 当成自建应用的 appKey/appSecret 用了或者用的是旧版应用遗留的凭证新建应用后参数没同步。解决回到应用详情页重新复制“凭证与基础信息”里的 appKey/appSecret只复制当前自建应用的。同时检查代码里有没有把 appSecret 硬编码在公共常量类如果有改到配置中心和环境变量。排查时可以用钉钉开放平台的在线调试工具先验证凭证本身是否有效再回头查代码少走半小时弯路。5.4 免登成功后端拿到 userid但自有系统里查不到用户页面空转现象员工第一次点微应用后端免登接口正常返回但页面一直停在加载中。原因自有用户表和钉钉通讯录没建立映射免登只认证身份不创建业务账号后端查不到用户直接抛异常。解决按 3.3 的 findOrCreateByDingUserId 自动建档或者上线前跑一次全量同步任务调用钉钉通讯录接口把所有有效员工的 userid、手机号、姓名同步进来。注意同步要考虑离职员工要包含停用逻辑避免人走了系统还在自动建档。自动建档还有个好处员工首次进入就是可用的不用管理员逐个手工建号。5.5 微应用能打开但 requestAuthCode 一直 onFail提示 permission denied现象H5 页面正常渲染一调 requestAuthCode 就走 onFail错误是 permission denied。原因钉钉 JSAPI 授权不仅看应用域名白名单还看应用是否发布、当前用户是否在应用可见范围内。很多团队开发完没点“版本发布”员工侧访问的还是旧版本JSAPI 权限不完整。解决确认应用已在开发者后台完成版本发布工作台可见范围包含测试账号。调试时用钉钉扫码登录管理后台把测试账号加入可见范围。另外注意如果 H5 是 SPA 动态切换路由每次进入需要重新触发 dd.ready 的页面都要保证初始化逻辑执行了不要在某个子路由里直接调 requestAuthCode 绕过初始化。6. 免登做得再稳一点凭证缓存、幂等校验与安全留痕免登链路跑通只是起点线上稳定才是目标。我建议在三件事上继续投入。第一access_token 缓存从进程内存迁到 Redis并加定时刷新任务。多实例部署时如果每个实例各自缓存一份 token钉钉按应用维度限流高并发下部分实例会报“触发调用限制”。用 Redis 存 tokenkey 带 appKey 后缀定时任务每 30 分钟检查一次剩余有效时间小于 30 分钟就主动刷新所有实例共享一份 token回源频率降下来。第二免登 code 消费做成幂等。生产环境里前端重试、用户连点、网关重放都会让同一个 code 被提交多次用 Redis 的 SETNX 做指纹去重过期时间 10 分钟正好覆盖 code 的 5 分钟有效期。顺手把这个去重结果写进日志方便事后回溯是哪一次请求真正完成了登录。第三安全留痕要记录四样东西钉钉 userid、本系统 userId、来源 IP、UA。日志格式统一成dingLogin|userId1001|dingIdxxx|ip1.2.3.4|uaxxx后面排查异常登录全靠它。这是血泪经验早期免登不打日志出问题时钉钉侧说有调用记录我们这边却对不上人两边黑匣子一样无从查起。再加一条习惯每周抽一次线上日志关注免登成功率和失败率。正常情况员工在钉钉里点开微应用免登成功率应该接近 100%如果某天失败率涨到 5% 以上优先看 access_token 是否过期、域名有没有变更。把失败率做成监控指标后免登问题基本都能在员工感知之前被发现。如果你后面要接飞书嵌入 H5 免登录流程逻辑几乎一样差别在 token 换取接口、code 命名和 corpId 的传法。把钉钉相关代码收敛到独立的 dingtalk 包下适配层和业务层分开写以后换平台只改适配层业务登录逻辑一行都不用动。这是我对所有接入免登型项目的最后一条建议别让钉钉代码散落在 Controller 和 Service 各处维护起来会轻松很多。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站