iT邦幫忙

2025 iThome 鐵人賽

DAY 27
0
佛心分享-SideProject30

吃出一個SideProject!系列 第 27

Day 27:使用 Spring Security OAuth2.0 套件實現 Google 第三方登入

  • 分享至 

  • xImage
  •  

昨天,我們完成了 Google 第三方登入的手動實作 ~
今天,想來學習如何使用 Spring Security 的 oauth2-client 套件來達成相同的目標,順便透過這次的重構,體驗一下兩種作法的差異:)!

介紹 Spring Security OAuth2.0

今天主要想透過 spring security 的 OAuth2 Client 模組,讓應用程式能以簡單的流程實現第三方登入功能。這個套件的核心價值,就是將繁瑣的 OAuth 2.0 標準流程自動化。啟用後,它會在背後為我們處理好以下流程:

  1. 產生預設授權 URL:框架自動產生一個標準的登入觸發點,預設為 /oauth2/authorization/google,前端只需要導向這個連結,即可讓使用者進入第三方登入的驗證流程。
  2. 產生預設 Redirect URL:當 Google 完成驗證將使用者導回網頁時,它會自動攔截請求並解析出 code,這個預設的 url 為/login/oauth2/code/google
  3. 交換並驗證 Token:攜帶 codeclient_secret,在後端向 Google 交換 Access TokenID Token,並自動完成 ID Token 的所有驗證工作
  4. 建立 Authentication 物件:在取得並驗證完使用者資訊後,它會將這些資訊打包成一個標準的 OAuth2AuthenticationToken 物件,並自動將其放入 SecurityContextHolder 中,完成身份驗證。

上述的流程主要由以下幾個 Spring Security 的核心元件協作完成:

元件 作用
ClientRegistration 該物件會根據 application.properties 替每個OAuth 2.0 服務商(如 google) 建立的所有設定資訊,例如 client-id, scope, 以及各種端點 URL。
OAuth2AuthorizationRequestRedirectFilter 負責監聽觸發登入的 URL(/oauth2/authorization/{registrationId}),並將使用者安全地重新導向到Google的授權頁面。
OAuth2LoginAuthenticationFilter 負責監聽 Redirec URL(/login/oauth2/code/{registrationId}),處理 code 的交換、Token 的驗證,並在成功後建立 Authentication 物件。
OAuth2User 該介面用來標準化從第三方登入成功後取得的使用者屬性(Attributes),無論來源是 Google、GitHub 還是 Facebook。

流程如何改變

根據上述對於 Spring Security OAuth2.0 的描述,可以預期我們的流程跟一開始自行實作會有很大的不同。

框架在預設的情況下,後端幫我們處理好驗證流程後,最後會將瀏覽器導向最初訪問的頁面並夾帶 SessionId 的資訊。

但因為我們希望整個系統保持無狀態的架構,並且希望登入時可以回傳使用者資訊(像昨天的實作),因此進行了一些調整,整理流程如下:

https://ithelp.ithome.com.tw/upload/images/20251011/20178099ATrWpFmR9Q.png

  1. 使用者 -> 前端: 在前端點擊「使用 Google 登入」按鈕。
  2. 前端:直接將瀏覽器導向到框架預設端點/oauth2/authorization/{registrationId}
  3. 後端 -> 使用者 (瀏覽器) :後端的 OAuth2AuthorizationRequestRedirectFilter 攔截請求,自動組合好包含 client_id, redirect_uri , scope, state 等參數的完整 Google 授權 URL,並重新導向使用者的瀏覽器前往 Google。
  4. 使用者 -> Google: 使用者在 Google 的頁面上輸入帳號密碼並同意授權。
  5. Google -> 使用者 (瀏覽器) Google 將瀏覽器重新導向回後端的 GET /login/oauth2/code/google,並在 URL 中附上一次性的 code
  6. 後端 -> Google : 後端的 OAuth2LoginAuthenticationFilter 攔截這個請求,取出 code,並在後端與 Google 的 Token 端點交換 id_token 等憑證。
  7. 後端 (內部處理):
    • 驗證Token自動驗證 id_token,並建立一個 OAuth2AuthenticationToken 物件。
    • 建立HttpSession:並將這個 Authentication 物件存入 Session。
    • 產生 sessionId 並加入Cookie:Tomcat 自動在回應中加入 Set-Cookie: JSESSIONID=... 的Header。
  8. 後端 -> 使用者 (瀏覽器) : 我們自訂的 OAuth2LoginSuccessHandler 被觸發。它的任務,是將使用者重新導向前端callback頁面 。
  9. 前端 -> 後端 (GET /api/auth/oauth2/success): 前端的 CallbackComponent 載入後,立刻帶著JSESSIONID向後端的一個新 API 端點發起 GET 請求。
  10. 後端 (內部處理):
    • 驗證身分:後端根據 JSESSIONID Cookie 找到了對應的 Session,並還原出 OAuth2AuthenticationToken
    • 處理請求:請求順利抵達我們的 AuthController@AuthenticationPrincipal OAuth2User 被成功注入。執行「尋找或建立使用者」的邏輯,並簽發我們自家應用程式的 Access Token & Refresh Token。
    • 銷毀HttpSession
  11. 後端 -> 前端 : 後端會確認使用者建檔狀況,並在 ResponseBody 中,回傳包含我們自行簽發的 token 以及使用者資訊的 LoginResponse 物件。
  12. 前端 -> 使用者: 前端收到 JSON 回應,將 JWT 儲存起來,更新 UI 狀態,並將使用者導向登入成功的首頁。

雖然流程看起來比最初自行實作還冗長,但事實上大部分內容都由框架替我們完成(第3點 ~ 第7點)。

我們今天主要要做的,除了啟用框架的第三方登入功能外,還要自訂身分驗證成功後的流程,包含用戶建檔、簽發 tokens,及回傳統一的登入回應(第8點~第10點)。

實作內容

加入依賴

欲使用 spring security 的 oauth2-client ,需先匯入模組:

...
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
...

前置作業:申請一組新的 Google OAuth2.0 cilent

如同第一節模組的介紹,Oauth2Login 功能有預設的 Redirect Url,如果使用不同於申請憑證時的 Redirect Url ,會造成驗證請求失敗。

因此按照 Day 23 的流程,新增一組 Google OAuth2.0 憑證,並將 redirect_url 設定為:http://localhost:8081/login/oauth2/code/google (注意因為要將伺服器位置換成後端資訊)。已授權的 JavaScript 來源欄位則保持前端url資訊(即 http://localhost:4200)。

調整前端登入導向頁面

在登入頁中的「以 Google 進行登入」,是呼叫Auth Service的 loginWithGoogle 方法。因此將該方法內容改成直接將瀏覽器導向到後端由 Spring Security 自動產生的端點:

export class AuthService {
...
	loginWithGoogle(): void {
	  window.location.href = `${environment.apiBaseUrl}/oauth2/authorization/google`;
	}
}

application.properties:設定檔中加入預設變數

以下使用官方指定的變數名稱 spring.security.oauth2.client.registration.google.<properties>,讓套件自動偵測這些設定內容:

spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
spring.security.oauth2.client.registration.google.scope=openid,profile,email

其他預設的變數名稱可以參考此文件

(本專案透過 docker-compose 啟動服務,傳入變數內容值,因此跟昨天的實作一樣,會到 .envdocker-compose.yml 中新增對應內容。)

SecurityConfig:啟用 Oauth2Login 功能並開放預設端點的權限

這邊要加上兩個設定:(1) oauth2Login 功能啟用 (2) 開放框架預設的兩個 url(負責導向 google 授權頁面的 url 與 redirect url )未經驗證即可拜訪的權限:

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
						...
            // 加入這次需要公開訪問的路徑
            .requestMatchers(
                    "/oauth2/**",          // OAuth2 登入觸發點 (如 /oauth2/authorization/google)
                    "/login/oauth2/code/*",// OAuth2 登入成功後的 redirect url
            ).permitAll()
            .anyRequest().authenticated()
            )
            
            // 加入這項設定啟用 Oauth2.0 Login 功能
            .oauth2Login(oauth2 -> oauth2
                    .successHandler(oAuth2LoginSuccessHandler) // 加入自訂的Handler
            )
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    return http.build();
}

另外,重導向時瀏覽器可能自動請求 favicon.ico 或 .well-known 相關資源,可以自訂 WebSecurityCustomizer Bean 來開放這些靜態資源的請求權限,或一樣在此暫時 permitAll 即可。

OAuth2LoginSuccessHandler :自訂 Oauth2Login 成功登入後處理流程

建立一個 OAuth2LoginSuccessHandler ,並繼承 AuthenticationSuccessHandler ,透過覆寫其 onAuthenticationSuccess 方法來自訂驗證成功後的動作,這邊僅簡單讓瀏覽器重新導向 我們在前端的 Callback 頁面:

@Component
public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {
    
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        
        String frontendRedirectUrl = "http://localhost:4200/auth/callback"; // 前端接收回調的頁面

        // 執行重新導向
        response.sendRedirect(frontendRedirectUrl);
    }
}

之所以要導回前端 Callback 頁面是因為我希望驗證成功、登入後,可以取得跟以帳號密碼登入時相同的回應(即包含我們自行簽發的 tokens 跟使用者資訊的回應)。
但框架的預設行為是驗證成功後,會將使用者導回當初訪問的頁面,並在 Cookie 附上 SessionId。如果希望附上其他資訊,可能必須要帶在URL中,較不安全。
因此,這邊想到的方法是先導回 Callback 頁面,由該元件發出請求,並在該請求的 Response Body 中取得登入資訊。

調整前端 Callback Component 行為

承上述,Callback 元件只負責做一件事,就是當使用者被導向 Callback 這個頁面時,發出取得登入資訊的請求:

export class CallbackComponent implements OnInit {
    ...
    ngOnInit(): void {
      this.authService.fetchOauthLoginResult();
    }
}

AuthSevice 新增fetchOauthLoginResult方法:

export class AuthService {
    ...
    fetchOauthLoginResult(): void {
      const backendApi = `${environment.apiBaseUrl}/users/oauth2/success`;

      // 加入 withCredentials: true
      this.http.get<UserProfile>(backendApi, { withCredentials: true }).subscribe({
        next: (loginResponse) => {

          //以下接收使用者資訊的後續處理與先前相同
          this.currentUserSubject.next(loginResponse);
          localStorage.setItem('currentUser', JSON.stringify(loginResponse));
          this.router.navigate(['/home']);
        },
        error: (err) => {
          console.error('從後端獲取 Token 失敗:', err);
          this.router.navigate(['/auth/login']);
        }
      });
    }
}

在這個方法中,我們設定withCredentials: true ,瀏覽器會自動將先前由後端設定的 Session Cookie (JSESSIONID) 附加到這個請求上。 後端會根據 SessionId 解析使用者身分,驗證後,該端點會回傳標準的登入資訊 (tokens 與 使用者資訊),接收資訊後的處理方式同與先前實作無異。

AuthController:取得登入資訊的端點

接著說明 oauth2/success 這個對應的端點。這個端點專門給前端在上述身分驗證流程成功後,取得登入資訊使用:

public class AuthController {
...
    @GetMapping("/oauth2/success")
    public ResponseEntity<?> getOauth2LoginSuccessInfo(@AuthenticationPrincipal OAuth2User oauth2User, HttpServletRequest request) {

        if (oauth2User == null) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("User not authenticated via OAuth2 session.");
        }

        LoginResponse loginResponse = googleAuthService.getOauth2LoginSuccessInfo(oauth2User);

          // 註銷 sessionId、清除 session 內容
          HttpSession session = request.getSession(false); 
          if (session != null) {
              session.invalidate();
          }
        return ResponseEntity.ok(loginResponse);
    } 
}

因為本身希望這個系統是無狀態的,因此完成到這一步,把 JWT 給前端後,就會將本次請求的 sessionId 註銷,未來請求應該帶上 JWT 而非透過 session 來驗證身分。

GoogleAuthService:成功登入 Google 後的流程

新增一個 getOauth2LoginSuccessInfo 的方法在 GoogleAuthService 中,這個方法主要負責兩件事,分別是:(1) 使用者查詢與建檔 (2)產生我們登入所需的資訊 (tokens 與使用者資訊),實作內容如下:

public LoginResponse getOauth2LoginSuccessInfo( OAuth2User oauth2User) {

    // 尋找或建立使用者
    String email = oauth2User.getAttribute("email");
    String googleId = oauth2User.getName();
    String name = oauth2User.getAttribute("name");
    String pictureUrl = oauth2User.getAttribute("picture");
    UserEntity user = findOrCreateUser(googleId, email, name, pictureUrl);

    //  簽發 Access Token 和 Refresh Token
    String accessToken = jwtUtils.generateJwtToken(user);
    RefreshTokenEntity refreshToken = refreshTokenService.createRefreshToken(user);

    // 回傳 LoginResponse 物件
    List<String> roles = user.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority).toList();

    LoginResponse loginResponse = new LoginResponse(
            accessToken,
            refreshToken.getToken(),
            user.getId(),
            user.getName(),
            user.getEmail(),
            user.getPictureUrl(),
            roles
    );

    return loginResponse;
}

今天,我們使用了 spring security 的 OAuth2 Client 模組來實作 Google 第三登入的功能,為了滿足框架沒有替我們完成的的其他需求(使用者建檔、簽發 JWT、回傳逼準登入資訊),自訂了驗證成功後的流程。

比較特別的是,我們透過 Callbcack 這個過渡頁,暫時以 SeesionId 來驗證身分,取得標準登入資訊的流程。會這樣做是因為框架的預設行為,在驗證成功後會直接將瀏覽器導回某個頁面。如果我們想取得像先前實作中的登入資訊,放在 QueryString 中會有安全性的問題,好像比較難以這種方式取得我們期望的標準,所以才繞了這樣一圈。
(當然,如果有更好的做法,或這麼做會造成什麼不良影響,都歡迎提出建議,感激不盡!!)

總而言之,這個框架最大的好處就是將繁瑣的 OAuth 2.0 流程自動化,為我們處理了從請求授權到交換權杖的所有標準步驟。這讓我們能省去重複的樣板程式碼,更專注於實現應用程式自身的核心業務邏輯。


上一篇
Day 26:實作 Google 第三方登入功能 (4)
下一篇
Day 28:實作註冊帳號 Email 驗證功能 (1)
系列文
吃出一個SideProject!29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言