前言
當前的項目系統中,需要第二種登錄方式,即,釘釘掃碼登錄。然後,鑑於已經有成員實現了微信登錄,就想嘗試實現一下釘釘的登錄。為此做一個記錄流程
環境背景
- 當前是前後端分離: 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之後)畫完時序圖之後,給出一些意見,有了正確的時序流程圖,後面實現起來就很快。