钉钉企业内部应用 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 (个人) :
- 手机钉钉 -> 工作台 -> 搜索你的应用名。
- 点击打开,验证免登成功。
- 点击应用图标旁的设置,将其设为"常用"。
