前言
当前的项目系统中,需要第二种登录方式,即,钉钉扫码登录。然后,鉴于已经有成员实现了微信登录,就想尝试实现一下钉钉的登录。为此做一个记录流程
环境背景
- 当前是前后端分离: Angular + SpringBoot
- 同时,采用 spring security 的认证模式
基础流程
大概流程
- 渲染二维码
- 设置回调地址,拿到dingTalk server 颁发的授权码(authCode)向后端请求
- 通过授权码(authCode)拿到对应钉钉用户的 accessToken
- 使用 accessToken 获取对应的钉钉用户
- 拿到该钉钉用户去我们的数据库里面查。有,登录成功;反之失败(或者,直接自动注册)☹️
图形展示
时序图
值得注意的是,不同于微信获取二维码的方式,这里的钉钉的二维码获取不需要我们的后端去向 dingTalk server。而是,在前端引用 ddlogin.js的情况下,由前端的 SDK 自动生成二维码,而不是我们的后台自己去请求钉钉服务器来获取二维码
💡 图中画的虽然是前端去请求 dingTalk server,但是本质上是 ddlogin.js,前端 SDK 自动生成。也可以理解为,是 DDLogin(等同于下文的 DingtalkQrCodeComponentComponent) 去请求
流程图(部分)
下面展示的是,拿到 dingTalkUser 后,我们该如何判断是否在我们数据存在的简化流程:
具体实现
前置工作
- 登录钉钉开发者后台,确定已获取开发者权限
- 创建应用:单击应用开发 > 企业内部应用 > 钉钉应用 > 创建应用
单击保存,进入应用详情页,单击基础信息 > 凭证与基础信息,查看应用的Client ID 和 Client Secret
注意:请保存 Client ID 和 Client Secret,后续会使用
- 设置重定向URL:(在上一步创建的应用界面中)单击开发配置 > 安全设置 > 重定向URL(回调域名)
- 发布
发布的流程作者没有接触,因为我是基于老师已经发布好的应用来进行开发的,所以我只需要将回调地址填写好,以及保存好 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,并生成二维码
- 初始化 DingtalkQrCodeComponentComponent,并使用 window.DTFrameLogin 来生成二维码
| 参数 | 是否必填 | 本文中的示例值 | 说明 |
|---|---|---|---|
| id | 是 | dingtalk-login-container | 包裹容器元素ID,不带'#'用于确定二维码渲染在哪个<div>元素中 |
| width | 否 | 300 | 二维码iframe元素宽度,最小280,默认300 |
| height | 否 | 300 | 二维码iframe元素宽度,最小280,默认300 |
| redirect_uri | 是 | localhost:8088/login | 授权通过/拒绝后回调地址前置工作中步骤四填写的回调域名‼️ redirect_uri需要进行urlencode |
| client_id | 是 | dingxxxxxxxxxxxx | 前置工作中步骤三获取到的应用的 Client ID |
| prompt | 是 | consent | 值为consent时,会进入授权确认页💡 补充:授权确认页就是手机扫码后的“是否趣确认授权”页面 |
| response_type | 是 | code | 固定值为code授权通过后返回authCode。 |
| scope | 是 | openid | 如果值为openid+corpid,则下面的org_type和corpId参数必传,否则无法成功登录 |
| corpId | 否 | - | 当scope值为openid+corpid时必传 |
| org_type | 否 | - | 当scope值为openid+corpid时必传 |
| state | 否 | 1 | 跟随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);
}
}
}对应的 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>
✅ 完成上述两个步骤之后,就应该出现下面的效果:
后端(SpringBoot)
前端拿到 authCode 之后,我们就需要向后端去进行免密登录的操作了
后端需要的做的工作:
- 获取前端传来的 authCode
- 通过 authCode 来获取扫码用户的 accessToken
- 利用 accessToken 获取该扫码的钉钉用户(dingTalkUser)
- 拿到该 dingTalkUser 去系统数据库中比对是否存在该用户。存在,登录成功;反之,登录失败/进行注册
步骤一:补充 application.yml 配置
将我们在前置工作中拿到的 Client ID 和 Client Secret 补充到我们的 application.yml 配置文件中:
app:
client-id: "dingxxxxxxxxxxs"
client-secret: "Pxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx8_o"步骤二:完善DingTalkServiceImpl.java
- 根据 authCode,调用服务端获取用户token接口,获取用户个人token(accessToken)
- 根据用户个人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);
}
}
}效果图:
到此,我们就可以获取到当前扫码登录的钉钉用户了
💡 调用获取用户通讯录个人信息接口,获取当前授权人的信息,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:
扫码登录成功:
⚠️ 扫码登录成功,进行跳转的时候可能会遇到下面的错误:
原因大概率是,你打断点了,或者有一些操作延慢后端获取 authCode 的操作
这样就会导致 DingTalk SDK 在获取 accessToken 时所使用的 authCode 已经过期了
‼️ 官方文档说了:钉钉 authCode 有效期只有 5 秒
总结
- 官方文档的重要性
- 学会看官网提供的 Demo
- 学会画时序图
以上的精简总结是我个人认为必不可少的,每一步都是至关重要。
官方文档的重要性:通过去查找相应的官方文档,你可以大概知道它所调用的 api 接口返回的什么值,知道他的每一步是在干什么e.g. 查看官方文档才知道是通过 dingTalk server 返回的 authCode(code)来获取对应钉钉用户的 accessToken, 最终在通过 accessToken 来获取钉钉用户
学会看官网提供的 Demo:写得不错的官方文档会提供一些 Demo,而我们要做到的就是如何通过 Demo来更加快速的加深对第一步看的官方文档的理解,确定它的返回值是些什么。每一步是如何处理的学会画时序图:这个真的超级超级重要‼️‼️,当我画完时序图,然后学长提出问题之后,我再去改,直到一个可落实的时序图出来之后,后续的步骤很简单了。只需要关注其中的难点,将难点先攻克,然后在一一实现之前有幸去尝试写过 cas 的统一认证,一开始也是说了解 cas 工作的机制,但是却止步于如何结合到我们当前的这个项目系统,这个时候
时序图显得尤为重要了,你一旦把一个较完善的时序图画出来了,这意味着:- 你对它的工作原理已经完全了解
- 从思想层面上,已经实现了你所需要的流程了
感谢
首先是感谢潘老师提供一个锻炼的机会,之前一直都没有去接触过与第三方app对接的 issue,这次接触到这个,从各个层面都是成长。尤其对 spring security 更是进一步的了解;
接着是感谢柯晓彬学长,在我根据自己的能力(查官方文档、Google之后)画完时序图之后,给出一些意见,有了正确的时序流程图,后面实现起来就很快。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用。你还可以使用@来通知其他用户。