把团队里七八个系统的登录账号收敛到同一个入口,这件事我拖了快一年。年前终于下了决心,用 Hadess 做统一认证服务,同时把钉钉集成进去,员工在钉钉里扫个码就能登录内部系统,再也不用记七八套账号密码。整个落地过程比预想中曲折一些,钉钉的回调域名校验、敏感字段权限申请、账号自动匹配策略,每个环节都有各自的坑。这篇文章把我最终跑通的方案、踩过的坑、排查思路一次讲清楚,代码和配置都是可以直接抄走的程度。
项目标题是 Hadess 实战,但它并不神秘。你可以把它理解为团队内部的一个认证中心,负责"你是谁"这个核心问题,所有系统都通过它来确认用户身份。钉钉集成是身份源对接,统一认证登录是最终的业务效果。把这两件事串起来,你会发现企业内部的账号体系其实可以收敛得很清爽。
1. 为什么做统一认证,以及 Hadess 的整体思路
1.1 内部系统账号乱象
先说背景。很多团队都有这个问题:公司内部跑着 OA、Wiki、日志平台、BI 报表、工单系统等一堆内部工具,每个系统都有自己的账号体系。有的系统是自己建的 user 表,有的是直接套开源框架的登录模块,还有一两个系统只支持 LDAP。员工入职时要挨个系统注册账号,离职时又要在每个系统里手动停用,管理员想查一下某个人的整体权限分布都无从下手。
这些系统还有个通病:密码策略不统一。有的要求三个月改一次,有的要求必须有大小写加特殊符号,有的干脆是初始密码从来不提醒改。结果就是员工在电脑边上贴便利贴,或者把密码存在手机备忘录里,安全风险反而比"什么都不做"更大。
我当时的诉求很明确:所有内部系统统一到一个登录入口,一次登录,处处通行;账号生命周期跟着钉钉通讯录走,入职自动开通、离职自动失效;员工用钉钉扫码就能登录,不需要额外记忆任何密码。
1.2 Hadess 的定位:把认证逻辑从业务系统里抽出来
Hadess 在这套方案里扮演的角色是统一认证中心,它要替所有业务系统解决三件事:一是接收用户的登录请求,二是对接钉钉身份源完成身份确认,三是签发和管理统一的登录凭证。
业务系统这边不需要再关心用户密码怎么存、密码怎么校验、会话怎么维持,只需要在入口处做一次重定向:用户未登录就跳到 Hadess,Hadess 认证通过后带着一次性票据回来,业务系统用这个票据换一个访问凭证,之后该干嘛干嘛。
这个"把认证抽出来"的思路看似简单,但能解决一个很现实的问题:以后新接入一个系统,不需要再开发一套登录模块,只需要在 Hadess 里配置一个应用,填好回调地址和密钥,业务系统侧做几百行代码的接入即可。团队里新系统上线的速度提升是很明显的。
1.3 为什么选钉钉作为身份源
员工身份信息从哪来,这是统一认证落地前必须想清楚的问题。我们团队内部员工全部使用钉钉,钉钉通讯录里的组织架构是现成的人员台账,天然适合做身份源。相比自建账号体系,钉钉的优势非常明显。
第一个优势是无需预先录入。新员工入职后人事把账号开通进钉钉,他天然就拥有了进入内部系统的资格,不需要管理员再手动在认证中心里建一条用户记录。第二个优势是扫码免密,钉钉 App 本身就是企业内部高频应用,扫码授权是员工已经习惯的操作,几乎零学习成本。第三个优势是钉钉开放平台提供了标准 OAuth2 授权码模式,和主流的 SSO 协议衔接非常顺畅,Hadess 对接起来不需要写什么偏门代码。
当然也有替代方案,比如用企业微信、飞书、LDAP 或云身份服务。但我当时的判断标准很简单:哪个工具全员覆盖率高、用户不需要额外装 App,就选哪个。从这个角度看,钉钉是很多企业里最不需要犹豫的选择。
2. 钉钉侧配置:从建应用到拿权限
2.1 创建企业内部应用,获取 AppKey 和 AppSecret
钉钉集成第一步是在钉钉开放平台创建一个企业内部应用。登录钉钉开发者后台,选择"企业内部应用",创建应用后,系统会生成一对关键凭证:AppKey 和 AppSecret。这两个值对应 OAuth2 协议里的 client_id 和 client_secret,Hadess 配置身份源时要用到的就是它们。
注意 AppSecret 只在创建时可以完整查看,后面任何时候再次进入页面都只能重置,所以创建后马上复制到安全的地方保存。这个密钥是要放在 Hadess 服务端配置里的,绝对不能写进前端代码或提交到代码仓库。
应用创建时还要选择"登录与免登"这个能力。钉钉开放平台把很多能力集中在同一个应用下,扫码登录、H5 微应用免登、消息推送等都在应用内管理,我们只需要用到登录相关能力,其他保持不开启就好,权限面越小越安全。
2.2 配置回调域名,勾选必要的权限点
应用创建完,接下来要配置回调域名。这个配置在应用详情页的"登录与免登"区域,作用是声明钉钉授权成功后允许把用户跳转回哪个地址。我们的实际配置指向 Hadess 的一个回调接口,比如:
https://sso.example.com/callback/dingtalk这条记录的关键在于:协议、域名、端口、路径必须与 Hadess 回调接口完全一致。钉钉在回调时会做精确匹配,哪怕末尾多一个斜杠都会校验失败。这个细节在测试环境很常见,我一度以为是代码写错了,排查半天发现是回调 URL 末尾多了个斜杠,钉钉直接拒绝了跳转。
权限点方面,需要根据账号匹配策略来选择。如果打算用手机号自动匹配内部账号,通常要申请"个人手机号信息"权限。这个权限属于敏感权限,审批比较谨慎,建议尽早提交申请。如果拿不到手机号权限,也可以退而求其次,用 unionId 做首次绑定、后续识别的方案,这个我后面详细展开。
这里给一个权限点参考表:
| 权限点 | 用途 | 建议 |
|---|---|---|
| 个人基础信息 | 获取用户昵称、头像、unionId | 必选 |
| 个人手机号信息 | 获取用户手机号,用于自动匹配内部账号 | 按需申请,审批较慢 |
| 通讯录个人信息 | 通过 userid 查询企业通讯录用户详情 | 需要查组织架构时再申请 |
| 通讯录部门信息 | 获取部门列表和成员归属 | 需要做数据权限时再申请 |
权限申请通过后,建议在配置里把应用的"服务器出口 IP"白名单也设置一下。钉钉开放平台支持限制 API 调用来源 IP,只允许填写 Hadess 服务所在的公网出口 IP,这样即使 AppSecret 泄露,外部也无法调用接口。但要注意,一旦设置了白名单,Hadess 服务的所有出网请求都会走钉钉 API,如果服务部署在多个地区或多个出口,必须把所有出口 IP 都加进去,漏了一个就会看到接口突然报 Forbidden。
3. 认证核心流程拆解与代码实现
3.1 钉钉扫码登录的完整时序
整个登录链路可以拆成七个步骤,我画不出来时序图,用文字描述也一样直观:
- 用户访问业务系统,发现未登录,被重定向到 Hadess 的登录页。
- Hadess 登录页提供"钉钉扫码登录"按钮,点击后跳转到钉钉开放平台的 OAuth2 授权页。
- 用户在钉钉 App 里确认授权,钉钉服务器将浏览器重定向回 Hadess 回调地址,并携带授权码 code。
- Hadess 拿着 code 调钉钉接口,换取用户访问令牌 accessToken。
- Hadess 拿着 accessToken 调钉钉用户信息接口,拿到用户唯一标识 unionId、昵称、头像。
- Hadess 根据 unionId 查找本地账号绑定关系,识别出这是哪个内部用户,创建统一登录会话。
- Hadess 生成一次性票据,重定向回业务系统,业务系统拿着票据到 Hadess 换取访问凭证,后续携带凭证访问。
这个流程里每一个环节都有对应的钉钉官方接口,但有几个细节值得先说出来。授权码 code 是一次性的,只能用一次,有效期通常只有 5 分钟。如果服务端换 token 失败想重试,不能拿同一个 code 再换一次,只能让用户重新扫码。我们也因此在回调接口里做了完善的状态记录,避免因为网络抖动导致用户明明扫码成功却要再扫一遍。
还有一个容易忽略的是 state 参数。跳转钉钉授权页之前,Hadess 会生成随机 state 值存在当前会话里,钉钉回调时会原样带回这个 state,服务端必须校验它与会话中的值一致,否则拒绝处理。这个机制用来防止登录流程被 CSRF 攻击,不能省。实际开发中我们同时把来源系统编号和回调地址编码进 state,回调后直接就能知道该跳回哪个业务系统,省了一步去查会话的时间。
3.2 服务端换取用户信息并匹配内部账号
代码层面,Hadess 回调接口的核心逻辑大致如下,我用 Spring Boot 风格写了一个精简版本:
@GetMapping("/callback/dingtalk") public void dingtalkCallback(String code, String state, HttpServletRequest request, HttpServletResponse response) { // 1. 校验 state,防止 CSRF if (!state.equals(request.getSession().getAttribute("oauth_state"))) { throw new BizException("state校验失败"); } // 2. 用 code 换取钉钉 userAccessToken Map<String, Object> tokenBody = Map.of( "clientId", appKey, "clientSecret", appSecret, "code", code, "grantType", "authorization_code" ); String tokenJson = restTemplate.postForObject( "https://api.dingtalk.com/v1.0/oauth2/userAccessToken", tokenBody, String.class); String accessToken = parseAccessToken(tokenJson); // 3. 用 accessToken 获取用户基础信息 HttpHeaders headers = new HttpHeaders(); headers.set("x-acs-dingtalk-access-token", accessToken); ResponseEntity<DingUser> userResp = restTemplate.exchange( "https://api.dingtalk.com/v1.0/contact/users/me", HttpMethod.GET, new HttpEntity<>(headers), DingUser.class ); DingUser dingUser = userResp.getBody(); // 4. 根据 unionId 查找账号绑定关系,未绑定时按手机号匹配或进入绑定页 UserAccount account = accountService.findByDingUnionId(dingUser.getUnionId()); if (account == null) { account = accountService.matchByMobile(dingUser.getMobile()); if (account == null) { // 跳转绑定引导页,让用户输入工号或企业邮箱完成首次绑定 redirectToBindPage(response, dingUser.getUnionId()); return; } accountService.bindDingUnionId(account.getId(), dingUser.getUnionId()); } // 5. 生成统一会话和一次性票据,重定向回业务系统 String ticket = sessionService.createTicket(account); redirectBackToBusinessSystem(response, ticket); }这段代码里有几个值得展开的设计点。
首先是账号匹配策略。第一次扫码登录时,用户还没有和内部账号建立绑定关系,此时如果钉钉接口能返回手机号,我们可以尝试用手机号匹配内部账号。这个方法在国内企业非常实用,因为员工在钉钉里的手机号基本都与人事系统一致。但手机号匹配存在误配风险,比如员工更换手机号或钉钉账号是私人号码注册的,所以匹配成功后要强制走一次绑定确认,而不是直接放行。
如果手机号不可用,或者匹配失败,就让用户进入一个绑定引导页,输入工号或企业邮箱,Hadess 验证通过后建立 unionId 和内部账号的绑定关系。这里我们做了一个权衡:首次绑定多花十几秒,换来之后每次登录都快速且安全。实际上批量老员工迁移时,我们还专门开发了一个导入工具,管理员可以从钉钉通讯录导出手机号,批量灌入绑定关系,省去了大量首次登录时的绑定操作。
其次是登录凭证。ticket 是一次性的,有效期 5 分钟,只能被业务系统兑换一次。兑换成功后 Hadess 返回一个 JWT 访问令牌,里面包含用户 ID、姓名、unionId 和过期时间,密钥使用 HS256 签名。各业务系统共享同一个密钥,或者通过 Hadess 暴露的公钥接口验签,之后每次请求只需要校验 JWT 合法性,不需要再回 Hadess 查会话,这样既支持无状态服务,也方便水平扩展。
3.3 业务系统接入:几十行代码换掉整套登录模块
业务系统接入 Hadess 属于标准的 OAuth2 客户端接入流程。核心就三步:配置 Hadess 回跳地址、处理票据兑换、校验令牌。
业务系统的拦截器检测到当前请求未携带合法 JWT 时,会构造一个跳转地址指向 Hadess 登录页,同时带上自己的应用 ID 和回调地址。用户登录完成,Hadess 会把用户带回业务系统回调地址并携带 ticket,业务系统后端调用 Hadess 的票据兑换接口,拿到 JWT 后写入本地 Cookie 或前端存储,后续请求都带着这个 JWT 访问。
// 业务系统一侧的接入伪代码 public class LoginFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; String token = extractToken(request); if (StringUtils.isBlank(token) || !jwtService.verify(token)) { String loginUrl = ssoConfig.getHadessLoginUrl() + "?app_id=" + ssoConfig.getAppId() + "&redirect_uri=" + URLEncoder.encode(currentFullUrl(request), "UTF-8"); response.sendRedirect(loginUrl); return; } // 从 token 解析用户信息,写入 request 上下文 request.setAttribute("currentUser", jwtService.parse(token)); chain.doFilter(req, res); } }接入过程中最容易踩的坑是业务系统回调地址的闭环问题。我们有一个系统部署在内网,用户从内网域名访问时回调地址是 http://intra.example.com 这样的形式,Hadess 登录成功后跳回这个地址,但如果用户是从公网域名访问的,跳回内网地址就会断掉。最终的处理方案是:业务系统重定向到 Hadess 时,把所有可能的回调域名都注册到 Hadess 应用配置里,Hadess 只放行已注册域名下的回调地址。这个白名单列表一开始比较小,后面加两个域名是常有的事,所以建议应用配置里预留扩展空间,而不是写死一个地址。
4. 常见问题与排查技巧实录
4.1 问题速查表
上线至今,我整理了实际运维中遇到的典型问题,直接做成表格方便大家对照排查。
| 现象 | 常见原因 | 排查方式 |
|---|---|---|
| 跳转钉钉授权页后提示 redirect_uri 不匹配 | 回调域名末尾斜杠、端口不一致或路径大小写错误 | 登录钉钉开发者后台,比对配置的回调 URL 与实际请求地址 |
| 扫码后回调接口收到 code 但换 token 失败 | 授权码超过 5 分钟时效,或已使用过一次 | 让用户重新走一遍登录流程,确认没有重复回调 |
| 回调后页面死循环跳转 | 业务系统 Cookie 域与 Hadess 会话域不一致 | 检查业务系统写 Cookie 的 domain 配置,建议统一为一二级域名 |
| 获取用户信息时接口报 Forbidden | 应用未拿到对应权限点,或服务器出口 IP 不在白名单 | 到钉钉开发者后台检查权限审批状态、IP 白名单配置 |
| 用户信息里手机号为空 | 未申请"个人手机号信息"权限,或接口不返回该字段 | 改用 unionId 绑定方案,不依赖手机号做匹配 |
| 登录成功后跳回业务系统,但业务系统换票失败 | ticket 只能换一次,可能回调或网关重试导致二次兑换 | 在票据兑换接口做幂等处理,记录 ticket 状态并返回第一次的令牌 |
4.2 踩过的几个隐蔽坑
第一个隐蔽的坑是 IP 白名单。早前使用钉钉接口一直正常,某天突然全部返回无权限错误,一开始怀疑是密钥被重置,排查发现是因为 Hadess 服务迁移到了新的云服务器,出口 IP 变了,而钉钉开发者后台的服务器出口 IP 白名单还停留在旧地址。这个问题的诡异之处在于它不影响登录页跳转,只影响服务端 API 调用,容易让人定位到错误方向上。
第二个坑是账号绑定表的脏数据。Hadess 上线第二天就出现了"两个员工互相登录到对方账号"的现象,排查后发现问题出在手机号匹配逻辑上:一名员工在钉钉里绑定的手机号已经停用,同一号码又被另一个新员工使用,导致新员工首次登录时匹配到了旧账号。此后我们加了保护策略:手机号匹配成功后,如果账号已经绑定过其他钉钉 unionId,则禁止自动绑定,必须走人工确认流程。
第三个坑是 JWT 注销不及时。JWT 本身是无状态的,服务端无法主动让一个已签发的 token 失效。员工从某个系统退出登录,另外几个系统里的 token 仍然有效,直到过期。我们最终的做法是给 JWT 设置较短的有效期,比如 30 分钟,同时引入一个刷新 token 机制,会话要持续存在时由客户端用刷新 token 换新的访问 token,一旦用户主动登出,刷新 token 被撤销,访问 token 很快也会自然过期。这个方案在安全和体验之间取了平衡,目前用起来比较顺手。
5. 一些值得收藏的实战建议
经过这一轮完整的实战,有几个经验我认为比代码本身更值得分享。
第一,钉钉的应用权限申请要尽早启动。内部应用创建的审批通常是即时完成的,但敏感权限的审批要走企业管理员流程,有的企业还需要多个审批层级,耗时可能从几小时到数天。我在项目启动第一天就提交了手机号权限申请,开发过程中其他功能做完时权限正好审批通过,几乎没有等待。
第二,回调地址和域名使用统一的公共前缀。Hadess 的登录页、回调接口、票据兑换接口建议使用同一套域名,不要测试环境用一套、生产环境用另一套,也不要让不同环境共享同一个回调地址。我们因为开发环境和生产环境的回调 URL 长得非常像,曾经出现过配置复制粘贴错误导致生产环境跳转测试环境回调地址的事故。
第三,绑定流程一定要做可逆操作。员工在钉钉里换了手机号或者重新注册了钉钉账号,会导致原 unionId 无法识别。Hadess 里必须提供"解绑"和"重新绑定"的入口,并且解绑操作要记录日志。这个功能平时用不到,一旦有人换手机就会发现它是救命的。
第四,从运维视角看,建议给 Hadess 的凭证和相关密钥建立定期轮换机制。AppSecret、JWT 签名密钥都不应该永久不变,至少半年换一次。换密钥时要注意协调好所有业务系统的更新时间,尽量在低峰期操作,并准备好回滚方案。
对我来说,这套方案最大的收获不是省掉了多少登录页面,而是把账号相关的事情从各个业务系统里彻底解放了出来。新系统接入时不用再考虑密码存储、会话管理、权限模型这些重复劳动,只要对接 Hadess 就能获得一套完整的身份体系。钉钉集成和统一认证登录听起来是个挺大的工程,拆开看无非是把跳转、回调、换令牌、建会话这几个动作做扎实,剩下的就是耐心填坑了。