钉钉企业内部应用 SSO 免登集成实战 (Spring Boot 版)

钉钉企业内部应用 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. 关键配置:让应用"现身"工作台

这是最容易卡住的一步。代码没问题,但手机工作台上就是找不到图标? 请严格执行以下三步走:

第一步:添加"应用能力"并配置地址

  1. 登录 钉钉开发者后台
  2. 进入应用详情 -> 点击左侧 "添加应用能力" -> 选择 "网页应用"
  3. 进入 "网页应用" 配置页:
    • PC端首页地址 :填入 http://你的域名/sso.html
    • 移动端首页地址 :填入 http://你的域名/sso.html
    • (注意:安全设置里的"重定向URL"和"白名单"只是为了安全校验,不决定图标跳转地址,这里才是入口)

第二步:发布版本 (不发布 = 不生效)

  1. 点击左侧底部 "版本管理与发布"
  2. 点击 "创建版本"
  3. 填写版本号(如 1.0.0),设置可见范围为 "全部员工"
  4. 点击 "保存""发布"
    • 状态变为"已上线"后,配置才算真正下发到手机端。

第三步:添加到常用栏 (解决"藏得太深")

发布后,应用默认在"全部应用"里。

  1. 方法 A (管理员)
    • 登录 钉钉管理后台 (oa.dingtalk.com)
    • 进入 "工作台" -> "工作台设计"
    • 在"常用栏"或指定分组中,点击"添加应用",搜索你的应用名并添加。
  2. 方法 B (个人)
    • 手机钉钉 -> 工作台 -> 搜索你的应用名。
    • 点击打开,验证免登成功。
    • 点击应用图标旁的设置,将其设为"常用"。
相关推荐
石工记2 小时前
Spring Boot + Nacos + 微服务中使用Jasypt加密配置
spring boot·后端·微服务
秋邱2 小时前
Java包装类:基本类型与包装类转换、自动装箱与拆箱原理
java·开发语言·python
万邦科技Lafite2 小时前
淘宝开放API获取订单信息教程(2025年最新版)
java·开发语言·数据库·人工智能·python·开放api·电商开放平台
七夜zippoe2 小时前
Spring Boot Starter自定义开发 构建企业级组件库
java·spring boot·starter·自动装配·配置元
C雨后彩虹2 小时前
ConcurrentHashMap 扩容机制:高并发下的安全扩容实现
java·数据结构·哈希算法·集合·hashmap
ha_lydms2 小时前
6、Spark 函数_u/v/w/x/y/z
java·大数据·python·spark·数据处理·dataworks·spark 函数
胡闹542 小时前
MyBatis-Plus 更新字段为 null 为何失效?
java·数据库·mybatis
糕......2 小时前
JDK安装与Java开发环境配置全攻略
java·开发语言·网络·学习
日日行不惧千万里2 小时前
Java中Lambda Stream详解
java·开发语言·python