4

前言

当前的项目系统中,需要第二种登录方式,即,钉钉扫码登录。然后,鉴于已经有成员实现了微信登录,就想尝试实现一下钉钉的登录。为此做一个记录流程

环境背景

  • 当前是前后端分离: Angular + SpringBoot
  • 同时,采用 spring security 的认证模式

基础流程

大概流程

钉钉实现网页方式登录应用(登录第三方网站)

  • 渲染二维码
  • 设置回调地址,拿到dingTalk server 颁发的授权码(authCode)向后端请求
  • 通过授权码(authCode)拿到对应钉钉用户的 accessToken
  • 使用 accessToken 获取对应的钉钉用户
  • 拿到该钉钉用户去我们的数据库里面查。有,登录成功;反之失败(或者,直接自动注册)☹️

图形展示

时序图

image.png

值得注意的是,不同于微信获取二维码的方式,这里的钉钉的二维码获取不需要我们的后端去向 dingTalk server。而是,在前端引用 ddlogin.js的情况下,由前端的 SDK 自动生成二维码,而不是我们的后台自己去请求钉钉服务器来获取二维码

💡 图中画的虽然是前端去请求 dingTalk server,但是本质上是 ddlogin.js,前端 SDK 自动生成。也可以理解为,是 DDLogin(等同于下文的 DingtalkQrCodeComponentComponent) 去请求

流程图(部分)

下面展示的是,拿到 dingTalkUser 后,我们该如何判断是否在我们数据存在的简化流程:

image.png

具体实现

前置工作

  1. 登录钉钉开发者后台,确定已获取开发者权限
  2. 创建应用:单击应用开发 > 企业内部应用 > 钉钉应用 > 创建应用
  3. 单击保存,进入应用详情页,单击基础信息 > 凭证与基础信息,查看应用的Client ID 和 Client Secret

    注意:请保存 Client ID 和 Client Secret,后续会使用

image.png

  1. 设置重定向URL:(在上一步创建的应用界面中)单击开发配置 > 安全设置 > 重定向URL(回调域名)

image.png

  1. 发布
发布的流程作者没有接触,因为我是基于老师已经发布好的应用来进行开发的,所以我只需要将回调地址填写好,以及保存好 Client ID 和 Client Secret

⚠️ 这两步(第三步和第四步)才是关键

相关代码实现

因为我当前的环境情况是:前后端分离。所以这里分为两个部分来记录和介绍

前端(Angular)

首先明确我们的前端做的是哪些工作:

  • 生成二维码
  • 拿到 authCode 后,向后台去请求登录
步骤一:引入ddlogin.js,并初始化 DingtalkQrCodeComponentComponent

在 index.html 中的 <head> 引入 ddlogin.js

Angular 的组件 HTML 不能直接用</script>引入第三方脚本,所以必须放在 index.html
<script src="https://g.alicdn.com/dingding/h5-dingtalk-login/0.21.0/ddlogin.js"></script>
步骤二:初始化 DingtalkQrCodeComponentComponent,并生成二维码
  1. 初始化 DingtalkQrCodeComponentComponent,并使用 window.DTFrameLogin 来生成二维码
参数是否必填本文中的示例值说明
iddingtalk-login-container包裹容器元素ID,不带'#'
用于确定二维码渲染在哪个<div>元素中
width300二维码iframe元素宽度,最小280,默认300
height300二维码iframe元素宽度,最小280,默认300
redirect_urilocalhost:8088/login授权通过/拒绝后回调地址
前置工作中步骤四填写的回调域名
‼️ redirect_uri需要进行urlencode
client_iddingxxxxxxxxxxxx前置工作中步骤三获取到的应用的 Client ID
promptconsent值为consent时,会进入授权确认页
💡 补充:授权确认页就是手机扫码后的“是否趣确认授权”页面
response_typecode固定值为code
授权通过后返回authCode。
scopeopenid如果值为openid+corpid,则下面的org_type和corpId参数必传,否则无法成功登录
corpId-当scope值为openid+corpid时必传
org_type-当scope值为openid+corpid时必传
state1跟随authCode原样返回
/**
 * 钉钉扫码登录组件
 */
@Component({
  selector: 'app-dingtalk-qr-code-component',
  standalone: true,
  imports: [],
  templateUrl: './dingtalk-qr-code-component.component.html',
  styleUrl: './dingtalk-qr-code-component.component.css'
})
export class DingtalkQrCodeComponentComponent implements OnInit {
  clientId = input.required<string>();    // 应用ID
  redirectUrl = input.required<string>(); // 重定向地址
  width = input(300);                     // 二维码宽度
  height = input(300);                    // 二维码高度

  constructor(private dingtalkService: DingtalkService,
              private router: Router) {
  }


  ngOnInit(): void {
    this.initDingLogin();
  }

  initDingLogin() {
    if (window.DTFrameLogin) {
      window.DTFrameLogin(
        {
          id: 'dingtalk-login-container',
          width: this.width(),
          height: this.height()
        },
        {
          // redirect_uri 需要为完整的URL,扫码后钉钉会带着code跳转到这里
          redirect_uri: encodeURIComponent(this.redirectUrl()),
          client_id: this.clientId(),
          scope: 'openid',
          response_type: 'code',
          state: '1',
          prompt: 'consent'
        },
        (loginResult: any) => {
          const {authCode} = loginResult;
          this.dingtalkService.loginByAuthCode(authCode).subscribe({
            next: () => {
              this.router.navigate(['/']).then();
            }
          })
        },
        (errorMsg: string) => {
          // 这里一般需要展示登录失败的具体原因
          alert(`Login Error: ${errorMsg}`);
        },
      );
    } else {
      setTimeout(() => this.initDingLogin(), 100);
    }
  }

}
  1. 对应的 V 层。
    ⚠️ 注意其中的 id="dingtalk-login-container"必须与 ts 中的 id 一致。这表达的意思:在 id 为 dingtalk-login-container的元素中生成二维码

    <ng-container>
     <div class="row">
         <div class="col text-center">
           <div id="dingtalk-login-container"></div>
           <div class="login-tip">请使用钉钉App扫码登录</div>
         </div>
     </div>
    </ng-container>

✅ 完成上述两个步骤之后,就应该出现下面的效果:

image.png

后端(SpringBoot)

前端拿到 authCode 之后,我们就需要向后端去进行免密登录的操作了
后端需要的做的工作:

  • 获取前端传来的 authCode
  • 通过 authCode 来获取扫码用户的 accessToken
  • 利用 accessToken 获取该扫码的钉钉用户(dingTalkUser)
  • 拿到该 dingTalkUser 去系统数据库中比对是否存在该用户。存在,登录成功;反之,登录失败/进行注册
步骤一:补充 application.yml 配置

将我们在前置工作中拿到的 Client IDClient Secret 补充到我们的 application.yml 配置文件中:

app:
  client-id: "dingxxxxxxxxxxs"
  client-secret: "Pxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx8_o"
步骤二:完善DingTalkServiceImpl.java
  1. 根据 authCode,调用服务端获取用户token接口,获取用户个人token(accessToken)
  2. 根据用户个人token(accessToken),调用获取用户通讯录个人信息接口,获取授权用户个人信息
/**
 * 钉钉服务实现类
 */
@Service
public class DingTalkServiceImpl implements DingTalkService {

/**
 * 用于钉钉扫码登录获取钉钉用户的 accessToken
 * @param authCode 授权码(扫码成功后发的授权码)
 * @return accessToken
 */
private String getAccessToken(String authCode) {
    Config config = new Config();
    config.protocol = "https";
    config.regionId = "central";
    try {
        com.aliyun.dingtalkoauth2_1_0.Client client = new com.aliyun.dingtalkoauth2_1_0.Client(config);
        GetUserTokenRequest getUserTokenRequest = new GetUserTokenRequest()
                .setClientId(CLIENT_ID)
                .setClientSecret(CLIENT_SECRET)
                .setCode(authCode)
                .setGrantType("authorization_code");
        GetUserTokenResponse getUserTokenResponse = client.getUserToken(getUserTokenRequest);
        return getUserTokenResponse.getBody().getAccessToken();
    } catch (Exception e) {
        throw new RuntimeException("获取钉钉 accessToken 失败", e);
    }
}

/**
 * 通过 authCode 获取当前扫码的钉钉用户
 * @param authCode 授权码(扫码成功后发的授权码)
 * @return DingTalkDto.DingTalkUserResponse
 */
private DingTalkDto.DingTalkUserResponse getUserInfoByAuthCode(String authCode) {
        String accessToken = this.getAccessToken(authCode);
        Config config = new Config();
        config.protocol = "https";
        config.regionId = "central";
        Client client;
        try {
            client = new Client(config);
        } catch (Exception e) {
            throw new RuntimeException("初始化钉钉Client失败:", e);
        }

        GetUserHeaders getUserHeaders = new GetUserHeaders();
        getUserHeaders.xAcsDingtalkAccessToken = accessToken;

        try {
            GetUserResponse resp = client.getUserWithOptions("me", getUserHeaders, new RuntimeOptions());
            DingTalkDto.DingTalkUserResponse dingTalkUser = new DingTalkDto.DingTalkUserResponse();
            dingTalkUser.setNick(resp.getBody().getNick());
            dingTalkUser.setPhone(resp.getBody().getMobile());
            dingTalkUser.setUnionId(resp.getBody().getUnionId());
            dingTalkUser.setStateCode(resp.getBody().getStateCode());

            return dingTalkUser;
        } catch (TeaException e) {
            throw new RuntimeException("钉钉接口异常:", e);
        } catch (Exception e) {
            throw new RuntimeException("未知异常:", e);
        }
    }
}

效果图:

image.png

到此,我们就可以获取到当前扫码登录的钉钉用户了

💡 调用获取用户通讯录个人信息接口,获取当前授权人的信息,unionId参数值传字符串me

实现一个 check 方法,用来校验当前扫码的钉钉用户 dingTalkUser 是否存在于我们系统中

@Override
public User loginByAuthCode(String authCode) {
    return check(getUserInfoByAuthCode(authCode));
}

User check(DingTalkDto.DingTalkUserResponse dingTalkUser) {
        Optional<DingdingUser> dingdingUserOptional = this.dingdingUserRepository.findByUnionId(dingTalkUser.getUnionId());
        if (dingdingUserOptional.isPresent()) {
            // 如果 dingdingUser 表中存在该用户,说明不是第一次使用钉钉登录
            // 必定存在与之对应的 user
            return this.userRepository.findByDingdingUser(dingdingUserOptional.get()).orElseThrow(EntityNotFoundException::new);
        } else {
            // dingdingUser 表不存在,说明是第一次使用钉钉扫码登录
            // 使用 nick 和 phone 来查找当前用户是否在我们的 User 表中
            User user = this.userRepository.findByNameAndPhone(dingTalkUser.getNick(), dingTalkUser.getPhone()).orElseThrow(EntityNotFoundException::new);

            // 持久化该 dingTalkUser,并维护好与 user 表的一对一关系
            DingdingUser newDdUser = new DingdingUser();
            newDdUser.setNick(dingTalkUser.getNick());
            newDdUser.setPhone(dingTalkUser.getPhone());
            newDdUser.setUnionId(dingTalkUser.getUnionId());
            newDdUser.setStateCode(dingTalkUser.getStateCode());

            DingdingUser result = this.dingdingUserRepository.save(newDdUser);
            user.setDingdingUser(result);

            return this.userRepository.save(user);
        }
    }
步骤三:新增一个 DingtalkController

‼️ 记得为下面这个接口放行,不然会返回 401 未认证

/**
 * 通过钉钉授权码获取用户信息
 * @param authCode 授权码
 */
@GetMapping("/loginByAuthCode")
@JsonView(LoginJsonView.class)
public UserDetails loginByAuthCode(@RequestParam String authCode,
                                   HttpServletRequest request) {
   User user = this.dingTalkService.loginByAuthCode(authCode);

    UsernamePasswordAuthenticationToken authentication =
            new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());

    // 创建 SecurityContext 并设置认证信息
    SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
    securityContext.setAuthentication(authentication);

    // 将 SecurityContext 存入 session
    request.getSession(true).setAttribute("SPRING_SECURITY_CONTEXT", securityContext);

    return (UserDetails) authentication.getPrincipal();
}

✅ 到这里,核心的步骤就记录完毕了!

效果图:

正确的生成了 securityContext:

image.png

扫码登录成功:

image.png

⚠️ 扫码登录成功,进行跳转的时候可能会遇到下面的错误:

原因大概率是,你打断点了,或者有一些操作延慢后端获取 authCode 的操作
这样就会导致 DingTalk SDK 在获取 accessToken 时所使用的 authCode 已经过期了
‼️ 官方文档说了:钉钉 authCode 有效期只有 5 秒

image.png

总结

  • 官方文档的重要性
  • 学会看官网提供的 Demo
  • 学会画时序图

以上的精简总结是我个人认为必不可少的,每一步都是至关重要。

  1. 官方文档的重要性:通过去查找相应的官方文档,你可以大概知道它所调用的 api 接口返回的什么值,知道他的每一步是在干什么

    e.g. 查看官方文档才知道是通过 dingTalk server 返回的 authCode(code)来获取对应钉钉用户的 accessToken, 最终在通过 accessToken 来获取钉钉用户
  2. 学会看官网提供的 Demo:写得不错的官方文档会提供一些 Demo,而我们要做到的就是如何通过 Demo来更加快速的加深对第一步看的官方文档的理解,确定它的返回值是些什么。每一步是如何处理的
  3. 学会画时序图:这个真的超级超级重要‼️‼️,当我画完时序图,然后学长提出问题之后,我再去改,直到一个可落实的时序图出来之后,后续的步骤很简单了。只需要关注其中的难点,将难点先攻克,然后在一一实现

    之前有幸去尝试写过 cas 的统一认证,一开始也是说了解 cas 工作的机制,但是却止步于如何结合到我们当前的这个项目系统,这个时候 时序图 显得尤为重要了,你一旦把一个较完善的时序图画出来了,这意味着:

    • 你对它的工作原理已经完全了解
    • 从思想层面上,已经实现了你所需要的流程了

感谢

首先是感谢潘老师提供一个锻炼的机会,之前一直都没有去接触过与第三方app对接的 issue,这次接触到这个,从各个层面都是成长。尤其对 spring security 更是进一步的了解;

接着是感谢柯晓彬学长,在我根据自己的能力(查官方文档、Google之后)画完时序图之后,给出一些意见,有了正确的时序流程图,后面实现起来就很快。


vuxuan
31 声望7 粉丝