新版钉钉扫码免登及钉钉应用内免登的一次实现 (附仓库及Demo)

Vue2, JDK1.8, SpringBoot2.x.x, 钉钉模块, 钉钉模块Demo, 钉钉开放平台



前言

旧版钉钉企业内微应用免登自建系统只支持扫码登录, 公司后台使用此方式在PC端登录了钉钉时还需要掏手机扫码, 用起来很不爽, 在优化自建钉钉模块时, 看了下钉钉文档, 注意到其支持了新版本的免登录方式, 即当PC端钉钉登录后, 新方式支持直接调起钉钉认证登录, 省去了扫码的操作, 方便不少.

本文主要是对基本的集成做介绍, 以及对在集成过程中遇到的一些坑做说明, 阅读本文能极大的减少你实现新版钉钉免登的时间, 尤其是当你在公司里时不时还得写前端的情况.

另外, 极其鄙视直接把人官方文档里的东西原封不动粘贴发文章的行为, 有什么用? 恶心!


一、前置要求

注意
钉钉SDK有新旧两个版本, 旧版本不再迭代更新, 官方文档中的“accessToken”, “access_token"又有多个场景多个种类, 像我似的不仔细, 就特别容易误读. 例如, 其他功能文档中提到accessToken 一般为驼峰命名为新版SDK的token, 提到access_token 一般为下划线命名时为旧版SDK的token.

实现免登时遇到异常, 当时未明确是前端参数问题还是服务端问题, 反正为了避免出现“用前朝的剑斩本朝的官”的情况, 最好在调用旧版API时使用旧版accessToken, 新版亦如此.

1. 微应用

创建微应用

钉钉开放平台 - 我的后台 - 应用开发 - 企业内部开发 - 钉钉应用 - 创建应用
在这里插入图片描述
配置

基础信息-开发管理

  • 服务器出口IP配置应用的网关IP, 即为应用请求钉钉服务端的服务器IP, 不赘述, 图中假设配置为127.0.0.1, 实际为公网IP
  • 应用首页地址配置服务首页地址即可
  • PC端首页地址对应配置, 该地址为PC端钉钉工作台中打开的地址
  • 管理后台地址, 与本次实现无关
    在这里插入图片描述

基础信息-权限管理

  • 在开发时无脑全部批量申请, 如果只开通具体权限, 在参考文档中对应申请即可
    在这里插入图片描述

应用功能-登录与分享

  • 配置回调域名, 这里只配置如 http://ip:port 或者具体域名, 在钉钉获取到你需要重定向的应用的域名与该配置不同时提示异常
  • 参考文档
    在这里插入图片描述
    最后, 发布应用

2. 前端项目

在接入时, 实现了一个完全基于vue项目架构的项目和一个基于Thymeleaf内嵌到SpringBoot项目的前端项目, 所以两种实现方式都会提及.

2.1 钉钉应用内免登依赖

基于钉钉JSAPI提供能力, 本文主要用于在钉钉应用工作台点击企业内微应用实现免登, 并且没有涉及到鉴权场景, 这里的使用很简单

参考文档

通过 npm 安装方式安装依赖

npm install dingtalk-jsapi --save

直接引入到登录, 或者注册到全局, 图方便本文直接放到登录页上

<script>
import * as dd from 'dingtalk-jsapi'; // 此方式为整体加载,也可按需进行加载
</script>

通过浏览器直接引入

<script src="https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.12/dingtalk.open.js"></script>

2.2 扫码免登依赖

该依赖主要用于创建钉钉二维码及扫码登录事件等等, 仅用于扫码免登(新/旧)或直接调起PC端钉钉应用授权免登

参考文档
旧版参考文档

内嵌二维码组件的方式 好看一些
新版

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

旧版

<script src="https://g.alicdn.com/dingding/dinglogin/0.0.5/ddLogin.js"></script>

直接使用钉钉提供的页面的方式 最简单, 实现最方便

无依赖, 直接带参数跳转钉钉提供页面接口即可, 后续详细说明

旧版参考文档

说明: 本文最终使用了新版的钉钉提供的页面方式, 内嵌页面实现时, 在页面中插入了一个跨域的iframe, 其src中是钉钉的域名, 出现 iframe cross-origin 异常, 明显跨域了, 二维码能正常显示, 扫码流程正常, 当初以为是这是用于在钉钉应用工作台打开应用时的场景, 但测试应该不是, 这个iframe内容没细看, 猜测应该是用来调起PC端已登录钉钉的那个组件, 查了半天不知道怎么解决. 求高人指点
在这里插入图片描述


二、实现

  • 注意: 2023-12-x, 新版的扫码登录在重定向后会拼接该参数 : http://xxxxxxx&code=123&authCode=123

1. 服务端

这里只展示核心流程, 最新代码可以从 钉钉模块, 钉钉模块demo 获取.

注意: 集成时会获取到unionId, userId, 这里推荐使用userId实现与系统用户的关联, unionId类似微信公众号的下的用户的意思, 不唯一, userId在钉钉第三方服务商实现的应用中被用作用户Id + 企业Id来做多租户的唯一性区分, 所以在自建系统中也推荐userId.

登录接口
支持钉钉应用内工作台免登和浏览器访问应用的免登

@PostMapping("/test/login")
public String login(@RequestBody DingTalkLoginRequest request) {
     final OapiV2UserGetResponse.UserGetResponse userGetResponse = dingTalkLoginService.queryDingDing(request);
     final String dingTalkUserId = userGetResponse.getUserid();
     /*
         ...后续查询系统用户信息校验等等
         如果集成Spring Security则可以直接在自定义的认证过滤器直接实现
      */
     return "token";
}

请求体
属性的含义固定, 由于以前实现过旧版扫码登录, 其授权码名称为code, 钉钉应用内工作台免登时获取的临时授权码也为code, 但服务端处理逻辑不同, 这里用inDingTalkAuthCode描述工作台免登的授权码.

package com.hp.dingtalk.demo.domain.login.request;

import lombok.Getter;
import lombok.Setter;

/**
 * @author hp 2023/3/24
 */
@Getter
@Setter
public class DingTalkLoginRequest {
    /**
     * 目前该值确定的是可以获取老版本的钉钉扫码登录临时授权码
     * 场景:
     * 钉钉扫码,确认后,重定向到开发指定的url并携带code和当初自定义的state,页面拿到这两个值后调登录业务
     * <p>
     * 通过code+普通应用accessToken获取unionId,之后根据unionId获取userId
     */
	//注意: 2023-12-x, 新版的扫码登录在重定向后会拼接该参数=》 &code=123&authCode=123 
    private String code;

    /**
     * 新版扫码/免登录钉钉临时授权码
     * 场景1:
     * 页面内嵌iframe,没成功,报iframe cross-origin,nginx加请求头没搞定
     * 同上述code场景
     * 场景2:
     * 使用钉钉提供的页面登录,最简单
     * 同上述code场景
     * <p>
     * 通过单独的userToken接口再获取用户信息
     * 通过authCode获取userToken,之后根据userToken获取个人联系人信息,其中包含unionId,之后同上
     */
    private String authCode;

    /**
     * 场景:
     * 通过页面上的钉钉函数调用获取临时授权码,然后直接用这个码做登录
     * <p>
     * 钉钉应用内不需要登录直接点击微应用图标就直接登录,返回的临时授权码
     * 实际上给的也是code
     */
    private String inDingTalkAuthCode;

    /**
     * 曾经在业务里实现为长短码区分扫码或者绑定
     * 这个值在新旧版扫码免登中自定义,与钉钉应用内免登不相关
     */
    private String state;
}

查询钉钉用户信息
如果不需要钉钉用户详情, 实现到查询到钉钉userId时即可, 这里实现为查询到详情返回

package com.hp.dingtalk.demo.domain.login.service;

import com.aliyun.dingtalkcontact_1_0.models.GetUserResponseBody;
import com.aliyun.dingtalkoauth2_1_0.models.GetUserTokenResponseBody;
import com.dingtalk.api.response.OapiV2UserGetResponse;
import com.dingtalk.api.response.OapiV2UserGetuserinfoResponse;
import com.hp.dingtalk.component.application.IDingMiniH5;
import com.hp.dingtalk.component.factory.app.DingAppFactory;
import com.hp.dingtalk.demo.domain.login.request.DingTalkLoginRequest;
import com.hp.dingtalk.service.IDingContactHandler;
import com.hp.dingtalk.service.IDingLoginHandler;
import com.hp.dingtalk.service.IDingUserHandler;
import com.hp.dingtalk.service.contact.DingContactHandler;
import com.hp.dingtalk.service.login.DingLoginHandler;
import com.hp.dingtalk.service.user.DingUserHandler;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;

/**
 * @author hp 2023/3/24
 */
@Slf4j
@Service
public class DingTalkLoginService {

    /**
     * 查询用户详情
     * 这里的示例是直接查询到用户的详情,如果不要钉钉用户的其他信息,在查询到userId后就可以和库的信息做认证和授权了
     *
     * @param request 扫码请求体
     * @return 用户详情
     */
    public OapiV2UserGetResponse.UserGetResponse queryDingDing(DingTalkLoginRequest request) throws Exception {
        final IDingMiniH5 app = DingAppFactory.app(IDingMiniH5.class);
        //旧版扫码登录
        if (StringUtils.isNotEmpty(request.getCode())) {
            final IDingUserHandler handler = new DingUserHandler(app);
            return handler.findUserByCode(request.getCode());
        }
        //钉钉应用内,企业内微应用免登
        else if (StringUtils.isNotEmpty(request.getInDingTalkAuthCode())) {
            final IDingUserHandler handler = new DingUserHandler(app);
            final OapiV2UserGetuserinfoResponse.UserGetByCodeResponse response = handler.findUserByLoginAuthCode(request.getInDingTalkAuthCode());
            return handler.findUserByUserId(response.getUserid());
        }
        //新版免登录
        else {
            //获取用户个人信息,如需获取当前授权人的信息,unionId参数必须传me
            final String me = "me";
            final IDingLoginHandler dingLoginHandler = new DingLoginHandler(app);
            final GetUserTokenResponseBody userToken = dingLoginHandler.getUserToken(request.getAuthCode(), IDingLoginHandler.GrantType.authorization_code);
            final IDingContactHandler dingContactHandler = new DingContactHandler(app);
            final GetUserResponseBody contactUserResponse = dingContactHandler.personalContactInfo(userToken.getAccessToken(), me);
            final String unionId = contactUserResponse.getUnionId();
            final IDingUserHandler handler = new DingUserHandler(app);
            final String userId = handler.findUserIdByUnionId(unionId);
            return handler.findUserByUserId(userId);
        }
    }
}

2. 前端

vue项目基于ruoyi-vue, thymeleaf方式, 到底js方法都一样.
本人对vue的理解尚浅, 不在这班门弄斧了, 这里就主要介绍下业务, 方法供参考.

若依项目登录重定向注意点: gitee pr

判断是否为钉钉应用内访问来区分应用内免登和扫码免登

window.navigator.userAgent.includes("DingTalk")

授权码无脑认为一次性使用,有效期5分钟即可

  • 旧版扫码登录通过钉钉重定向后获得queryString参数 code(临时授权码)state(自定义的参数原样返回)
  • 新版扫码登录通过钉钉重定向后获得queryString参数authCode(临时授权码)state(自定义的参数原样返回)
  • 钉钉应用内免登, 页面上主动调用JSAPI获得一个返回值code(临时授权码), 在普通浏览器环境, 直接调用钉钉JSAPI会报错

拿到授权码后直接调用登录即可

部分代码

<template>
  <div>
    <!--  .... 省去其他业务表单-->

    <!--渲染钉钉登录组件的部分,新版会在这里插一个跨域的iframe,导致报错,没有显示调起已登录钉钉应用的组件,可能是这个原因,二维码能正确显示,扫码也可以执行-->
    <div id="login_container" class="login_container_class"></div>

    <!--  .... 省去其他业务表单-->
  </div>
</template>
<!--旧版的钉钉扫码登录,只扫码-->
<script src="https://g.alicdn.com/dingding/dinglogin/0.0.5/ddLogin.js"></script>
<!--新版钉钉免登录,支持扫码和调起钉钉应用免登-->
<script src="https://g.alicdn.com/dingding/h5-dingtalk-login/0.21.0/ddlogin.js"></script>
<script>
//npm install dingtalk-jsapi --save
import * as dd from "dingtalk-jsapi";

export default {
  name: "Login",
  data() {
    return {
      appKey:"your app key",
      dingRedirectUrl: encodeURIComponent("your login page uri"),
      corpId: "your corp id", 
      dingTalkLoginPage: true
    }
  },
  mounted() {
    //如果用账号密码 走其他流程

    //钉钉登录
    const {code, authCode, state} = this.$route.query;
    //如果是钉钉登录重定向带参数
    if ((code || authCode) && state) {
      //登录接口
      this.handleCodeLogin(code, authCode, null, state);
    } else {
      //如果是钉钉App内工作台进入应用免登
      //这个方法在浏览器由于缺少钉钉环境,组件报错无法执行
      //如果纯pc浏览器开发测试巨麻烦
      if (window.navigator.userAgent.includes("DingTalk")) {
        this.initInDingTalkLogin();
      }
      //普通浏览器场景
      else {
        //如果为重定向方式
        if (this.dingTalkLoginPage) {
          this.redirectToDingTalkLoginPage()
        }
        // TODO 内嵌,iframe有限制,哪个牛b的能解决吗?内嵌的帅很多 :)
        else {
          console.log(" 内嵌,iframe有限制,")
          this.initDingLoginV2();
        }
      }
    }
  },
  methods: {
    //以下的 dd.xxxxx方法参考钉钉文档JSAPI开发部分
    handleCodeLogin(code, authCode, inDingTalkAuthCode, state) {
      const loading = this.$loading({
        lock: true,
        text: "正在登录",
        spinner: "el-icon-loading",
        background: "rgba(0, 0, 0, 0.7)",
      });
      //参考ruoyi账号密码登录再写一个差不多的
      this.$store
          .dispatch("DDLogin", {code, authCode, inDingTalkAuthCode, state, uuid: state})
          .then((rsp) => {
            loading.close();
            this.$router.push({path: this.redirect || "/"}).catch(() => {
            });
          })
          .catch(() => {
            this.loading = false;
            if (this.captchaOnOff) {
              this.getCode();
            }
            this.$router.push({path: this.redirect || "/"}).catch(() => {
            });
          });
    },
    // 钉钉应用内免登
    initInDingTalkLogin() {
      var _that = this;
      dd.ready(function () {
        dd.runtime.permission.requestAuthCode({
          corpId: _that.corpId, // 企业id
          onSuccess: function (info) {
            let code = info.code // 通过该免登授权码可以获取用户身份
            let state = _that._getRandomString(10);
            _that.handleCodeLogin(null, null, code, state);
          },
          onFail: function (msg) {
            dd.device.notification.toast({
              icon: 'error', //icon样式,不同客户端参数不同,请参考参数说明
              text: `${msg}`, //提示信息
              duration: 3, //显示持续时间,单位秒,默认按系统规范[android只有两种(<=2s >2s)]
              delay: 0, //延迟显示,单位秒,默认0
              onSuccess: function (result) {
              }
            })
          }
        });
      });
    },
    // 使用直接跳转到钉钉提供的登录页的方式
    redirectToDingTalkLoginPage() {
      let url = this.dingRedirectUrl
      let state = this._getRandomString(10);
      window.location.href = "https://login.dingtalk.com/oauth2/auth?" +
          // 授权通过/拒绝后回调地址。
          "redirect_uri=" + url +
          // 固定值为code。授权通过后返回authCode。
          "&response_type=code" +
          // 企业内部应用:client_id为应用的AppKey。
          "&client_id=" + this.appKey +
          // 授权范围,授权页面显示的授权信息以应用注册时配置的为准。当前只支持两种输入:
          // openid:授权后可获得用户userid
          // openid corpid:授权后可获得用户id和登录过程中用户选择的组织id,空格分隔。注意url编码。
          "&scope=openid" +
          // 跟随authCode原样返回
          "&state=" + state +
          // 值为consent时,会进入授权确认页。
          "&prompt=consent";
      // 其他参数参考:https://open.dingtalk.com/document/isvapp/tutorial-enabling-login-to-third-party-websites
    },
    //新版方式,可以调起钉钉应用,内嵌
    initDingLoginV2() {
      let url = this.dingRedirectUrl
      window.DTFrameLogin(
          {
            id: 'login_container',
            width: 350,
            height: 350,
          },
          {
            redirect_uri: url,
            client_id: this.appKey,
            scope: 'openid',
            response_type: 'code',
            state: this._getRandomString(10),
            prompt: 'consent',
          },
          (loginResult) => {
            const {redirectUrl, authCode, state} = loginResult;
            // 这里可以直接进行重定向
            console.log("redirectUrl ", redirectUrl)
            window.location.href = redirectUrl;
            // 也可以在不跳转页面的情况下,使用code进行授权
            console.log("authCode ", authCode);
          },
          (errorMsg) => {
            // 这里一般需要展示登录失败的具体原因
            alert(`登录异常: ${errorMsg}`);
          },
      );
    },
    _getRandomString(len) {
      len = len || 10;
      let $chars = "ABCDEFGHIJKMNOPQRSTUVWXYZ"; // 默认去掉了容易混淆的字符oOLl,9gq,Vv,Uu,I1
      let maxPos = $chars.length;
      let pwd = "";
      for (var i = 0; i < len; i++) {
        pwd += $chars.charAt(Math.floor(Math.random() * maxPos));
      }
      return pwd;
    }
  }
}
</script>
<style rel="stylesheet/scss" lang="scss">
/* 指定这个包裹容器元素的CSS样式,尤其注意宽高的设置 */
.login_container_classname {
  width: 300px;
  height: 300px;
}
</style>

总结

在实现的时候查到很多乱七八糟的文章, 照抄文档的就懒得说了, 其他的一些也只是一部分东西, 本文主要是提供一个完整的前后端实现, 一文实现整个功能.

最终效果这里不展示了, 太麻烦, 公司项目已经上线新版免登了, 按照本文可以无脑实现集成钉钉免登的需求, 第一个项目升级新版花了两天研究文档, 第二个项目按此方式实现时, 仅花了30分钟, 前后端都完成集成, 并更新上线. 当然了前提是基于我自建的钉钉模块. 同好不清楚啥就直接问吧, 互相学习. peace out 😃

仓库

  • dingtalk-module 利用设计模式封装的钉钉SDK, 方便调用和整合SpringBoot
  • dingtalk-module-demo 服务端的一些API demo, 以及钉钉登录前端的部分js代码
Logo

前往低代码交流专区

更多推荐