SpringBoot——企业级应用对接钉钉(扫码)单点登录

前言

企业级应用中,单点登录一般应用的较为广泛。如常见的企微单点快捷登录、QQ登录、微信登录等等。

本篇博客主要说明钉钉单点登录的一些操作流程,从创建应用到实际应用全面说明。

参考资料

(官方)获取登录用户的访问凭证

后端依赖

<!-- 新版依赖 -->
<dependency>
    <groupId>com.aliyun</groupId>
    <artifactId>dingtalk</artifactId>
    <version>1.1.86</version>
</dependency>
<!-- 旧版依赖 -->
<dependency>
    <groupId>com.aliyun</groupId>
    <artifactId>alibaba-dingtalk-service-sdk</artifactId>
    <version>2.0.0</version>
</dependency>

创建应用

1、进入钉钉开放平台应用开发界面,创建应用。
2、进入已创建的应用详情页,在基础信息页面可以查看到应用的SuiteKey/SuiteSecret(第三方企业应用)或AppKey/AppSecret(企业内部应用)。
在这里插入图片描述
3、设置第三方页面的回调地址。
在这里插入图片描述

OAuth 登录授权

当要代表用户使用DingTalk OpenAPI读取和写入资源,需要通过OAuth 2.0授权流程完成授权。OAuth 2.0授权的流程如下图所示。
在这里插入图片描述

前端设置钉钉入口

在这里插入图片描述

方式一:开启第2个标签页展示二维码信息

在中间页面中,编写代码,携带重要参数,请求钉钉,获取钉钉返回的二维码页面信息,进行展示。

https://login.dingtalk.com/oauth2/auth?
redirect_uri=回调地址
&response_type=code
&client_id=dingbbbbbbb
&scope=openid corpid
&state=dddd
&prompt=consent

关于上述的调用url,携带参数说明参照官方说明:OAuth登录授权

当页面展示二维码后,APP端扫码后:
成功时跳转到:https://www.aaaaa.com/a/b?authCode=xxxx&state=dddd
失败时跳转到:https://www.aaaaa.com/a/b?error=yyyyyy&state=dddd

方式二:内嵌二维码方式登录授权

1、在页面中引入钉钉扫码登录JSSDK。

<script src="https://g.alicdn.com/dingding/h5-dingtalk-login/0.21.0/ddlogin.js"></script>

2、在需要引入扫码登录的地方,调用如下方法。

<!-- STEP1:在HTML中添加包裹容器元素 -->
<div id="self_defined_element" class="self-defined-classname"></div>
<style>
    /* STEP2:指定这个包裹容器元素的CSS样式,尤其注意宽高的设置 */
    .self-defined-classname {
        width: 300px;
        height: 300px;
    }
</style>
<script>
    // STEP3:在需要的时候,调用 window.DTFrameLogin 方法构造登录二维码,并处理登录成功或失败的回调。
    window.DTFrameLogin(
        {
            id: 'self_defined_element',
            width: 300,
            height: 300,
        },
        {
            redirect_uri: encodeURIComponent('http://www.aaaaa.com/a/b/'),
            client_id: 'dingxxxxxxxxxxxx',
            scope: 'openid',
            response_type: 'code',
            state: 'xxxxxxxxx',
            prompt: 'consent',
        },
        (loginResult) => {
            const {redirectUrl, authCode, state} = loginResult;
            // 这里可以直接进行重定向
            window.location.href = redirectUrl;
            // 也可以在不跳转页面的情况下,使用code进行授权
            console.log(authCode);
        },
        (errorMsg) => {
            // 这里一般需要展示登录失败的具体原因
            alert(`Login Error: ${errorMsg}`);
        },
    );
</script>

参数说明((TypeScript语言描述)):

// ********************************************************************************
// window.DTFrameLogin方法定义
// ********************************************************************************
window.DTFrameLogin: (
  frameParams: IDTLoginFrameParams, // DOM包裹容器相关参数
  loginParams: IDTLoginLoginParams, // 统一登录参数
  successCbk: (result: IDTLoginSuccess) => void, // 登录成功后的回调函数
  errorCbk?: (errorMsg: string) => void,         // 登录失败后的回调函数
) => void;

// ********************************************************************************
// DOM包裹容器相关参数
// ********************************************************************************
// 注意!width与height参数只用于设置二维码iframe元素的尺寸,并不会影响包裹容器尺寸。
// 包裹容器的尺寸与样式需要接入方自己使用css设置
interface IDTLoginFrameParams {
  id: string;      // 必传,包裹容器元素ID,不带'#'
  width?: number;  // 选传,二维码iframe元素宽度,最小280,默认300
  height?: number; // 选传,二维码iframe元素高度,最小280,默认300
}

// ********************************************************************************
// 统一登录参数
// ********************************************************************************
// 参数意义与“拼接链接发起登录授权”的接入方式完全相同(缺少部分参数)
// 增加了isPre参数来设定运行环境
interface IDTLoginLoginParams {
  redirect_uri: string;     // 必传,注意url需要encode
  response_type: string;    // 必传,值固定为code
  client_id: string;        // 必传
  scope: string;            // 必传,如果值为openid+corpid,则下面的org_type和corpId参数必传,否则无法成功登录
  prompt: string;           // 必传,值为consent。
  state?: string;           // 选传
  org_type?: string;        // 选传,当scope值为openid+corpid时必传
  corpId?: string;          // 选传,当scope值为openid+corpid时必传
  exclusiveLogin?: string;  // 选传,如需生成专属组织专用二维码时,可指定为true,可以限制非组织帐号的扫码
  exclusiveCorpId?: string; // 选传,当exclusiveLogin为true时必传,指定专属组织的corpId
}

// ********************************************************************************
// 登录成功后返回的登录结果
// ********************************************************************************
interface IDTLoginSuccess {
  redirectUrl: string;   // 登录成功后的重定向地址,接入方可以直接使用该地址进行重定向
  authCode: string;      // 登录成功后获取到的authCode,接入方可直接进行认证,无需跳转页面
  state?: string;        // 登录成功后获取到的state
}

获取访问凭证 user_token (后端)

使用返回的auth_code和应用的信息,调用获取用户token接口得到access_token。

accessToken的有效期为7200秒(2小时),有效期内重复获取会返回相同结果并自动续期,过期后获取会返回新的accessToken。

// This file is auto-generated, don't edit it. Thanks.
package com.aliyun.sample;

import com.aliyun.tea.*;
import com.aliyun.teautil.*;
import com.aliyun.dingtalkoauth2_1_0.*;
import com.aliyun.dingtalkoauth2_1_0.models.*;
import com.aliyun.teaopenapi.*;
import com.aliyun.teaopenapi.models.*;

public class Sample {

    /**
     * 使用 Token 初始化账号Client
     * @return Client
     * @throws Exception
     */
    public static com.aliyun.dingtalkoauth2_1_0.Client createClient() throws Exception {
        Config config = new Config();
        config.protocol = "https";
        config.regionId = "central";
        return new com.aliyun.dingtalkoauth2_1_0.Client(config);
    }

    public static void main(String[] args_) throws Exception {
        java.util.List<String> args = java.util.Arrays.asList(args_);
        com.aliyun.dingtalkoauth2_1_0.Client client = Sample.createClient();
        GetUserTokenRequest getUserTokenRequest = new GetUserTokenRequest()
                .setClientId("dingxxx")
                .setClientSecret("1234")
                
                // 前端请求后界面返回的参数值
                // https://www.aaaaa.com/a/b?authCode=xxxx&state=dddd
                // 对应 authCode
                .setCode("abcd") 
                //.setRefreshToken("abcd")
                .setGrantType("authorization_code");
        try {
            client.getUserToken(getUserTokenRequest);
        } catch (TeaException err) {
            if (!com.aliyun.teautil.Common.empty(err.code) && !com.aliyun.teautil.Common.empty(err.message)) {
                // err 中含有 code 和 message 属性,可帮助开发定位问题
            }

        } catch (Exception _err) {
            TeaException err = new TeaException(_err.getMessage(), _err);
            if (!com.aliyun.teautil.Common.empty(err.code) && !com.aliyun.teautil.Common.empty(err.message)) {
                // err 中含有 code 和 message 属性,可帮助开发定位问题
            }

        }        
    }
}

返回示例:

{
  "accessToken" : "abcd",
  "refreshToken" : "abcd",
  "expireIn" : 7200,
  "corpId" : "corpxxxx"
}

根据 user_token 获取当前扫码者的信息

获取用户通讯录个人信息

// This file is auto-generated, don't edit it. Thanks.
package com.aliyun.sample;

import com.aliyun.tea.*;
import com.aliyun.teautil.*;
import com.aliyun.teautil.models.*;
import com.aliyun.dingtalkcontact_1_0.*;
import com.aliyun.dingtalkcontact_1_0.models.*;
import com.aliyun.teaopenapi.*;
import com.aliyun.teaopenapi.models.*;

public class Sample {

    /**
     * 使用 Token 初始化账号Client
     * @return Client
     * @throws Exception
     */
    public static com.aliyun.dingtalkcontact_1_0.Client createClient() throws Exception {
        Config config = new Config();
        config.protocol = "https";
        config.regionId = "central";
        return new com.aliyun.dingtalkcontact_1_0.Client(config);
    }

    public static void main(String[] args_) throws Exception {
        java.util.List<String> args = java.util.Arrays.asList(args_);
        com.aliyun.dingtalkcontact_1_0.Client client = Sample.createClient();
        GetUserHeaders getUserHeaders = new GetUserHeaders();
        getUserHeaders.xAcsDingtalkAccessToken = "<your access token>";
        try {
        	// 如需获取当前授权人的信息,unionId参数可以传me。
            client.getUserWithOptions("me", getUserHeaders, new RuntimeOptions());
        } catch (TeaException err) {
            if (!com.aliyun.teautil.Common.empty(err.code) && !com.aliyun.teautil.Common.empty(err.message)) {
                // err 中含有 code 和 message 属性,可帮助开发定位问题
            }

        } catch (Exception _err) {
            TeaException err = new TeaException(_err.getMessage(), _err);
            if (!com.aliyun.teautil.Common.empty(err.code) && !com.aliyun.teautil.Common.empty(err.message)) {
                // err 中含有 code 和 message 属性,可帮助开发定位问题
            }

        }        
    }
}

返回示例:

{
  "nick" : "zhangsan",
  "avatarUrl" : "https://xxx",
  "mobile" : "150xxxx9144",
  "openId" : "123",
  "unionId" : "z21HjQliSzpw0Yxxxx",
  "email" : "zhangsan@alibaba-inc.com",
  "stateCode" : "86"
}

根据 unionId 获取用户id

上面的接口返回结果中,虽然有用户昵称用户手机号等信息,如果当前企业应用中已经做了同步钉钉的组织架构与人员信息的话,这些信息并不能与数据库中的数据进行匹配。

此时为了解决这个问题,需要调用另一个接口根据unionid获取用户userid

但是调用这个接口,需要注意一个坑:

请求这个接口的token,并不是上面的 user_token
而是access_token!!!

如果不是按照这个处理,即使在应用管理页面中,添加有qyapi_get_member权限,依旧会出现无权限的问题。

1、获取 access_token。

import com.alibaba.fastjson.JSONObject;
import com.aliyun.dingtalkoauth2_1_0.models.GetAccessTokenResponse;
import lombok.extern.slf4j.Slf4j;


/**
 * https://open.dingtalk.com/document/orgapp/obtain-the-access_token-of-an-internal-app
 */
@Slf4j
public class GetAccessTokenDemo {

    /**
     * 使用 Token 初始化账号Client
     * @return Client
     * @throws Exception
     */
    public static com.aliyun.dingtalkoauth2_1_0.Client createClient() throws Exception {
        com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config();
        config.protocol = "https";
        config.regionId = "central";
        return new com.aliyun.dingtalkoauth2_1_0.Client(config);
    }


    public static void main(String[] args) throws Exception {
        com.aliyun.dingtalkoauth2_1_0.Client client = createClient();
        com.aliyun.dingtalkoauth2_1_0.models.GetAccessTokenRequest getAccessTokenRequest = new com.aliyun.dingtalkoauth2_1_0.models.GetAccessTokenRequest()
                .setAppKey("应用的 Client ID")
                .setAppSecret("应用的  Client Secret");
        GetAccessTokenResponse accessToken = client.getAccessToken(getAccessTokenRequest);
        log.info("请求回执信息:{}", JSONObject.toJSONString(accessToken.getBody()));
        // {"accessToken":"6315e4d453583c69b1fb89ae455ba772","expireIn":7200}
        System.out.println(accessToken.getBody().accessToken);
    }
}

2、根据 access_token 和 unionId 调用接口获取 user_id 信息。

DingTalkClient client = new DefaultDingTalkClient("https://oapi.dingtalk.com/topapi/user/getbyunionid");
OapiUserGetbyunionidRequest req = new OapiUserGetbyunionidRequest();
req.setUnionid("用户信息的 unionId");
OapiUserGetbyunionidResponse rsp = client.execute(req, access_token);
System.out.println(rsp.getBody());

返回示例:

{
  "errcode":"0",
  "errmsg":"ok",
  "result":{
   "contact_type":"0",
   "userid":"zhangsan"
  },
  "request_id": "zcqi5450rpit"
}

钉钉组织架构人员信息同步

钉钉的组织架构同步操作,并不能知道具体是哪个人员进行的登录操作。

所以同步需要统一使用access_token进行鉴权操作。

获取 access_token

import com.alibaba.fastjson.JSONObject;
import com.aliyun.dingtalkoauth2_1_0.models.GetAccessTokenResponse;
import lombok.extern.slf4j.Slf4j;


/**
 * https://open.dingtalk.com/document/orgapp/obtain-the-access_token-of-an-internal-app
 */
@Slf4j
public class GetAccessTokenDemo {

    /**
     * 使用 Token 初始化账号Client
     * @return Client
     * @throws Exception
     */
    public static com.aliyun.dingtalkoauth2_1_0.Client createClient() throws Exception {
        com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config();
        config.protocol = "https";
        config.regionId = "central";
        return new com.aliyun.dingtalkoauth2_1_0.Client(config);
    }


    public static void main(String[] args) throws Exception {
        com.aliyun.dingtalkoauth2_1_0.Client client = createClient();
        com.aliyun.dingtalkoauth2_1_0.models.GetAccessTokenRequest getAccessTokenRequest = new com.aliyun.dingtalkoauth2_1_0.models.GetAccessTokenRequest()
                .setAppKey("应用的 Client ID")
                .setAppSecret("应用的  Client Secret");
        GetAccessTokenResponse accessToken = client.getAccessToken(getAccessTokenRequest);
        log.info("请求回执信息:{}", JSONObject.toJSONString(accessToken.getBody()));
        // {"accessToken":"6315e4d453583c69b1fb89ae455ba772","expireIn":7200}
        System.out.println(accessToken.getBody().accessToken);
    }
}

获取当前企业根部门信息

获取部门详情

public class Main {
    public static void main(String[] args) {
        try {
            DingTalkClient client = new DefaultDingTalkClient("https://oapi.dingtalk.com/topapi/v2/department/get");
			OapiV2DepartmentGetRequest req = new OapiV2DepartmentGetRequest();
			// 默认值,可以不写
			req.setDeptId(1L);
			OapiV2DepartmentGetResponse rsp = client.execute(req, "access_token值");
			System.out.println(rsp.getBody());
        } catch (ApiException e) {
            e.printStackTrace();
        }
    }
}

返回示例:

{
    "errcode": 0,
    "errmsg": "ok",
    "result": {
        "dept_permits": [
            3,
            4,
            5
        ],
        "outer_permit_users": [
            "user123",
            "1234"
        ],
        "dept_manager_userid_list": [
            "1020302901-431772414"
        ],
        "org_dept_owner": "manager9153",
        "outer_dept": false,
        "dept_group_chat_id": "chat1fccdb4b921f2bde18c26xxxx",
        "group_contain_sub_dept": true,
        "auto_add_user": true,
        "hide_dept": false,
        "name": "测试",
        "outer_permit_depts": [
            500,
            600
        ],
        "user_permits": [],
        "dept_id": 1,
        "create_dept_group": true,
        "order": 0,
        "code":"100",
        "union_dept_ext":{
          "corp_id": "test",
          "dept_id": 1234567
        }
    },
    "request_id": "4e7ljtq91rgo"
}

获取当前企业中的部门列表信息

package test;

import com.dingtalk.api.DefaultDingTalkClient;
import com.dingtalk.api.DingTalkClient;
import com.dingtalk.api.request.OapiV2DepartmentListsubRequest;
import com.dingtalk.api.response.OapiV2DepartmentListsubResponse;
import com.taobao.api.ApiException;
import lombok.extern.slf4j.Slf4j;

/**
 * https://open.dingtalk.com/document/orgapp/obtain-the-department-list-v2
 */
@Slf4j
public class GetDeptMentInfo {
    /**
     * 使用 Token 初始化账号Client
     * @return Client
     * @throws Exception
     */
    public static com.aliyun.dingtalkoauth2_1_0.Client createClient() throws Exception {
        com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config();
        config.protocol = "https";
        config.regionId = "central";
        return new com.aliyun.dingtalkoauth2_1_0.Client(config);
    }

    public static void main(String[] args) throws ApiException {
        DingTalkClient client = new DefaultDingTalkClient("https://oapi.dingtalk.com/topapi/v2/department/listsub");
        OapiV2DepartmentListsubRequest req = new OapiV2DepartmentListsubRequest();
        // 根目录 传递 1
        req.setDeptId(1L);
        OapiV2DepartmentListsubResponse rsp = client.execute(req, "access_token 值");
        log.info(rsp.getBody());
        /**
         * {"errcode":0,"errmsg":"ok",
         * "result":[
         * {"auto_add_user":true,"create_dept_group":true,"dept_id":982430535,"name":"部门3","parent_id":1},
         * {"auto_add_user":true,"create_dept_group":true,"dept_id":982694169,"name":"部门1","parent_id":1},
         * {"auto_add_user":true,"create_dept_group":true,"dept_id":982793129,"name":"部门2","parent_id":1}],
         * "request_id":"16lt30q49vmno"}
         */
    }
}

返回示例:

/**
         * {"errcode":0,"errmsg":"ok",
         * "result":[
         * {"auto_add_user":true,"create_dept_group":true,"dept_id":982430535,"name":"部门3","parent_id":1},
         * {"auto_add_user":true,"create_dept_group":true,"dept_id":982694169,"name":"部门1","parent_id":1},
         * {"auto_add_user":true,"create_dept_group":true,"dept_id":982793129,"name":"部门2","parent_id":1}],
         * "request_id":"16lt30q49vmno"}
         */

根据部门id 查询部门下的人员信息

人员每次最大查询数为 100

import com.dingtalk.api.DefaultDingTalkClient;
import com.dingtalk.api.DingTalkClient;
import com.dingtalk.api.request.OapiUserListsimpleRequest;
import com.dingtalk.api.response.OapiUserListsimpleResponse;
import com.taobao.api.ApiException;
import lombok.extern.slf4j.Slf4j;

/**
 * https://open.dingtalk.com/document/orgapp/queries-the-simple-information-of-a-department-user
 */
@Slf4j
public class GetUserFromDeptDemo {
    public static void main(String[] args) throws ApiException {
        DingTalkClient client = new DefaultDingTalkClient("https://oapi.dingtalk.com/topapi/user/listsimple");
        OapiUserListsimpleRequest req = new OapiUserListsimpleRequest();
        req.setDeptId(982430535L);
        req.setCursor(0L);
        req.setSize(100L);
        req.setOrderField("modify_desc");
        req.setContainAccessLimit(false);
        req.setLanguage("zh_CN");
        OapiUserListsimpleResponse rsp = client.execute(req, "access_token值");
        log.info(rsp.getBody());

        /**
         * {"errcode":0,"errmsg":"ok",
         * "result":{"has_more":false,"list":[]},"request_id":"15rd4f5edtl9l"}
         */
    }
}

其他参考

扫码登录第三方网站旧版SDK(旧版sdk)

### OWA与钉钉SSO单点登录配置和集成方案 #### 实现OWA与钉钉之间单点登录(Single Sign-On, SSO)的关键在于利用安全断言标记语言(Security Assertion Markup Language, SAML)[^1] 或者OAuth 2.0协议来作为中介桥梁。 对于基于SAML的方式,在Office Web App (OWA)端需设置成为服务提供商(Service Provider),而钉钉则扮演身份提供者(IdP)的角色。具体操作如下: - **注册应用程序**:前往钉钉开发者平台创建应用并获取AppKey 和 AppSecret,同时指定回调URL指向OWA服务器地址[^2]。 - **配置IdP元数据文件**:下载钉钉提供的IdP元数据XML文档,并将其上传至Exchange Online管理控制台中的信任关系部分[^3]。 - **调整SP设定**:在Exchange Admin Center里定义新的Relying Party Trusts依赖方信任项,输入必要的参数比如实体ID(Entity ID), 登录页面URL等信息[^4]。 如果采用OAuth 2.0,则主要流程涉及客户端授权模式(Client Credentials Grant Type): - 开发人员同样要在钉钉开放平台上登记第三方程序获得凭证; - 使用这些凭据向钉钉请求访问令牌(Access Token); - 将此Token附带于HTTP头内发送给Microsoft Graph API完成验证过程[^5]. ```python import requests def get_access_token(app_key, app_secret): url = "https://oapi.dingtalk.com/gettoken" params = { 'appkey': app_key, 'appsecret': app_secret } response = requests.get(url=url,params=params).json() return response['access_token'] ``` 为了确保整个系统的安全性,建议定期审查API权限范围以及保持软件版本更新到最新状态。另外值得注意的是,实际部署过程中可能还会遇到诸如网络策略、防火墙规则等因素的影响,因此需要根据具体情况做出相应调整[^6]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值