钉钉企业内部应用 SSO 免登集成实战 (Spring Boot 版)
pc端添加应用 ,手机端的话点击右上角齿轮
1. 场景描述
目标:实现员工点击钉钉工作台图标,直接静默登录进入企业 OA 系统,无需输入账号密码。
环境:
- 企业状态:未认证企业/团队(开发测试环境)。
- 后端:Spring Boot 2.x + JDK 1.8。
- SDK:阿里 Tea 架构新版 SDK (2.0) + 旧版 TopApi 混用(解决未认证数据获取问题)。
2. 核心依赖配置 (Maven)
使用阿里最新的聚合包以避免依赖冲突,同时引入 Tea 核心库。
XML
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.aliyun</groupId> <artifactId>dingtalk</artifactId> <version>2.2.40</version> </dependency> <dependency> <groupId>com.aliyun</groupId> <artifactId>tea-openapi</artifactId> <version>0.3.1</version> </dependency> <dependency> <groupId>com.aliyun</groupId> <artifactId>tea-util</artifactId> <version>0.2.16</version> </dependency> </dependencies>3. 后端实现:混合 API 策略
痛点复盘:对于未认证企业,新版 SDK 的 contactClient.getUser 接口往往会因合规原因返回 404 Not Found。
解决方案:采用“新版鉴权 + 旧版取数”的混合策略。
- Step 1: 使用新版 SDK 获取
AppAccessToken。 - Step 2: 使用旧版
Oapi接口换取UserId和详情。
import com.aliyun.teaopenapi.models.Config; import com.aliyun.dingtalkoauth2_1_0.Client; import com.aliyun.dingtalkoauth2_1_0.models.*; import com.aliyun.dingtalkcontact_1_0.models.*; // 1. 新增:引入 RuntimeOptions (必选) import com.aliyun.teautil.models.RuntimeOptions; import org.springframework.web.bind.annotation.*; import org.springframework.web.client.RestTemplate; import java.util.HashMap; import java.util.Map; @RestController @RequestMapping("/login") public class DingLoginController { // 客户端ID private static final String CLIENT_ID = "dingxxxxfqjrr"; // 密钥 private static final String CLIENT_SECRET = "uiGdxxxxxxv0O1nOiPoe0-vmkfYWHxMDAeiDIv42QEFY"; @GetMapping("/sso") @ResponseBody public Map<String, Object> ssoLogin(@RequestParam("authCode") String authCode) { Map<String, Object> result = new HashMap<>(); try { // ========================================== // 步骤 1:获取 AppToken (OAuth2 Client) // ========================================== Config config = new Config(); config.protocol = "https"; config.regionId = "central"; Client oauthClient = new Client(config); GetAccessTokenRequest appTokenRequest = new GetAccessTokenRequest() .setAppKey(CLIENT_ID) .setAppSecret(CLIENT_SECRET); GetAccessTokenResponse appTokenResponse = oauthClient.getAccessToken(appTokenRequest); String appAccessToken = appTokenResponse.getBody().getAccessToken(); System.out.println("1. 获取 AppToken 成功: " + appAccessToken); // ========================================== // 步骤 2:用 Code 换 UserId (旧版 API) ,新版获取信息有问题、获取不到; // ========================================== RestTemplate restTemplate = new RestTemplate(); // 接口A: 根据免登码换取用户ID String getUserInfoUrl = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo?access_token=" + appAccessToken; Map<String, Object> bodyA = new HashMap<>(); bodyA.put("code", authCode); Map responseMapA = restTemplate.postForObject(getUserInfoUrl, bodyA, Map.class); if ((Integer) responseMapA.get("errcode") != 0) { throw new RuntimeException("换取UserId失败: " + responseMapA.get("errmsg")); } Map resultDataA = (Map) responseMapA.get("result"); String userId = (String) resultDataA.get("userid"); System.out.println("2. 获取 UserId 成功: " + userId); // 步骤 3:用 UserId 获取详细信息 (【修改点】改用旧版 API) // 接口: https://oapi.dingtalk.com/topapi/v2/user/get String getUserDetailUrl = "https://oapi.dingtalk.com/topapi/v2/user/get?access_token=" + appAccessToken; Map<String, Object> bodyB = new HashMap<>(); bodyB.put("userid", userId); // 注意这里参数名是 userid // 发送请求获取详情(昵称、头像等) Map responseMapB = restTemplate.postForObject(getUserDetailUrl, bodyB, Map.class); if ((Integer) responseMapB.get("errcode") != 0) { // 如果详情也拿不到,至少我们有 UserId,可以算作登录成功降级处理 System.err.println("获取用户详情失败: " + responseMapB.get("errmsg")); } // 提取详情 Map resultDataB = (Map) responseMapB.get("result"); String name = (resultDataB != null) ? (String) resultDataB.get("name") : "未知用户"; String avatar = (resultDataB != null) ? (String) resultDataB.get("avatar") : ""; String unionId = (resultDataB != null) ? (String) resultDataB.get("unionid") : ""; // ========================================== // 步骤 4:返回成功 // ========================================== result.put("success", true); result.put("nick", name); result.put("avatar", avatar); result.put("unionId", unionId); result.put("userId", userId); System.out.println("3. 获取详情成功,用户: " + name); } catch (Exception e) { e.printStackTrace(); result.put("success", false); result.put("message", "后端异常: " + e.getMessage()); } return result; } }4. 前端实现:免登交互
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>OA系统进场中...</title> <script src="https://g.alicdn.com/dingding/dingtalk-jsapi/2.10.3/dingtalk.open.js"></script> <style> body { text-align: center; padding-top: 100px; font-family: sans-serif; } .loading { color: #0089FF; font-weight: bold; } </style> </head> <body> <div id="msg" class="loading">正在验证身份...</div> <script> // 【配置区】 const CORP_ID = "dinxxxx7ba0"; // 替换为你钉钉后台首页的 CorpId // 钉钉环境准备就绪 dd.ready(function() { // 1. 获取免登授权码 (这个过程用户无感知) dd.runtime.permission.requestAuthCode({ corpId: CORP_ID, onSuccess: function(result) { const code = result.code; console.log("获取免登Code成功:", code); // 2. 将 Code 发给后端换取用户信息 loginBackend(code); }, onFail : function(err) { document.getElementById('msg').innerText = "免登失败: " + JSON.stringify(err); } }); }); function loginBackend(authCode) { fetch(`http://xxxx:28088/login/sso?authCode=${authCode}`) .then(response => response.json()) .then(data => { if(data.success) { // ✅ 修改点:构建富文本 HTML 来展示所有数据 // 注意:这里使用反引号 ` (模板字符串) 方便拼接 const htmlContent = ` <div style="display: flex; flex-direction: column; align-items: center;"> <img src="${data.avatar}" style="width: 64px; height: 64px; border-radius: 50%; margin-bottom: 10px; border: 2px solid #eee;"> <h3 style="margin: 5px 0;">欢迎你,${data.nick}</h3> <div style="font-size: 12px; color: #888; text-align: left; background: #f9f9f9; padding: 10px; border-radius: 4px; margin-top: 10px;"> <p style="margin: 2px 0;"><strong>UserId:</strong> ${data.userId}</p> <p style="margin: 2px 0;"><strong>UnionId:</strong> ${data.unionId}</p> </div> </div> `; // 将构建好的 HTML 放入 msg 容器 document.getElementById('msg').innerHTML = htmlContent; } else { document.getElementById('msg').innerText = "登录失败: " + data.message; } }) .catch(err => { console.error(err); document.getElementById('msg').innerText = "网络错误"; }); }</script> </body> </html>5. 关键配置:让应用“现身”工作台
这是最容易卡住的一步。代码没问题,但手机工作台上就是找不到图标?请严格执行以下三步走:
第一步:添加“应用能力”并配置地址
- 登录 钉钉开发者后台。
- 进入应用详情 -> 点击左侧“添加应用能力”-> 选择“网页应用”。
- 进入“网页应用”配置页:
- PC端首页地址:填入
http://你的域名/sso.html - 移动端首页地址:填入
http://你的域名/sso.html - (注意:安全设置里的“重定向URL”和“白名单”只是为了安全校验,不决定图标跳转地址,这里才是入口)
- PC端首页地址:填入
第二步:发布版本 (不发布 = 不生效)
- 点击左侧底部“版本管理与发布”。
- 点击“创建版本”。
- 填写版本号(如
1.0.0),设置可见范围为“全部员工”。 - 点击“保存”并“发布”。
- 状态变为“已上线”后,配置才算真正下发到手机端。
第三步:添加到常用栏 (解决“藏得太深”)
发布后,应用默认在“全部应用”里。
- 方法 A (管理员):
- 登录 钉钉管理后台 (oa.dingtalk.com)。
- 进入“工作台”->“工作台设计”。
- 在“常用栏”或指定分组中,点击“添加应用”,搜索你的应用名并添加。
- 方法 B (个人):
- 手机钉钉 -> 工作台 -> 搜索你的应用名。
- 点击打开,验证免登成功。
- 点击应用图标旁的设置,将其设为“常用”。