博客 / 詳情

返回

記錄實現釘釘掃碼登錄第三方網站

前言

當前的項目系統中,需要第二種登錄方式,即,釘釘掃碼登錄。然後,鑑於已經有成員實現了微信登錄,就想嘗試實現一下釘釘的登錄。為此做一個記錄流程

環境背景

  • 當前是前後端分離: 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 來生成二維碼
參數 是否必填 本文中的示例值 説明
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);
    }
  }

}
  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之後)畫完時序圖之後,給出一些意見,有了正確的時序流程圖,後面實現起來就很快。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.