iT邦幫忙

2025 iThome 鐵人賽

DAY 25
0
佛心分享-SideProject30

吃出一個SideProject!系列 第 25

Day 25:實作 Google 第三方登入功能 (3)

  • 分享至 

  • xImage
  •  

昨天我們建立了前端頁面,完成將使用者導向驗證後取得授權碼傳送到後端的功能,今天要來開發後端好讓前端可以順利取得使用者資料!

環境變數:加入 client-id, secrete 與 redirect-uri

由於這個專案是透過 docker-compose 啟動的,跟先前的步驟一樣,我們會將實際內容放在 .env 中保存,並透過 docker-compose 在啟動服務時傳入應用程式。

.env

...
// Google OAuth2.0
GOOGLE_CLIENT_ID="從GCP複製的-CLIENT-ID"
GOOGLE_CLIENT_SECRET="從GCP複製的-CLIENT-SECRET"
GOOGLE_REDIRECT_URI="申請憑證時設定的 redirect-uri"

docker-compose.yml

services:
  ...
  auth-service:
	...
    environment:
	  ...
      GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
      GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
      GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI}

application.properties

...
// Google OAuth2.0
google.client-id=${GOOGLE_CLIENT_ID}
google.client-secret=${GOOGLE_CLIENT_SECRET}
google.redirect-uri=${GOOGLE_REDIRECT_URI}

註冊 RestTemplate 與 ObjectMapper

為了處理 Http 請求與回應,我們在 AuthServiceApplication 中註冊 RestTemplate 與 ObjectMapper ,供後續注入使用。

@SpringBootApplication
@EnableJpaAuditing
public class AuthServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(AuthServiceApplication.class, args);
    }

    // 新增 RestTemplate Bean
    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}
  • RestTemplate:是 Spring 框架提供的 HTTP 客戶端工具。

GoogleAuthService:處理 Google 登入邏輯

建立一個新的 Service GoogleAuthService,專門用來處理 Google 登入邏輯的新服務。我們前面有提到,接收到授權後後端有幾個主要的任務,分別是:

  1. 交換 Token:用前端傳來的 code 和我們的 client_secret,向 Google 換取 id_token。
  2. 驗證與解析 id_token:驗證 id_token 的真偽,並從中解析出使用者資訊。
  3. 尋找或建立使用者:根據 Google 的使用者資訊,在我們的資料庫中查找或建立對應的帳號。
  4. 自行簽發 Token:為這個使用者簽發我們自己的 Access Token 和 Refresh Token。

最後回傳我們自行簽發的 Token 與用戶資訊。
在這個過程當中,會連動調整到當前用戶資料表的架構 (UserEntiry),以及登入回傳的類別 (LoginResponse)。

換取 Token 與解析 Payload

首先要實作與 Google Token 端點互動的方法。
根據 Google 官方文件 說明,我們發出的請求應該包含 code (授權碼)、client_idclient_secretredirect_urigrant_type,並且設定 hader 中的 contentType 為 application/x-www-form-urlencoded

// GoogleAuthService.java
private JsonNode exchangeCodeForToken(String code) {
    String tokenEndpoint = "https://oauth2.googleapis.com/token";
    // 有序且支援多值的 Map
    MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
    params.add("code", code);
    params.add("client_id", clientId);
    params.add("client_secret", clientSecret);
    params.add("redirect_uri", redirectUri);
    params.add("grant_type", "authorization_code");

    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
    HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(params, headers);

		// 以 JsonNode 保持彈性
    ResponseEntity<JsonNode> responseEntity = restTemplate.postForEntity(tokenEndpoint, requestEntity, JsonNode.class);
    return responseEntity.getBody();
}

根據我們當初前端發出的請求,預期會收到像下面這樣的回應:

{
	"access_token": "ya29.a0AQQ_BDRuhOEchRx_96CDe7UVSm9NrEcOyyAK5YV..."
	"expires_in": 3599
	"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImM4YWI3MT..."
	"refresh_token": "1//0euQ-YXAWSa33CgYIARAAGA4SNwF-L9Ir1NDKrTIkMKetGJD-3gXIZgxzqis..."
	"scope": "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile openid"
	"token_type": "Bearer"
}

確認回傳包含 id_token 後,後我們可以透過 Google 提供的套件來驗證這些 Payload 資訊。首先加入依賴:

<dependency>
    <groupId>com.google.api-client</groupId>
    <artifactId>google-api-client</artifactId>
    <version>2.7.2</version>
</dependency>

再來新增一個 verifyAndParseIdToken 的方法如下:

// GoogleAuthService.java
private GoogleIdTokenPayload verifyAndParseIdToken(String idTokenString) throws GeneralSecurityException, IOException, GeneralSecurityException {
	// 傳入 NetHttpTransport 與 Google 的 Json Factory 來建立 verifier 
    GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new GsonFactory())
            .setAudience(Collections.singletonList(clientId))
            .build();

    //若驗證失敗,這個方法會回傳null
    GoogleIdToken idToken = verifier.verify(idTokenString);

    if (idToken != null) {
        logger.info("Google ID Token 驗證成功!");
        // 將 GoogleIdToken.Payload 轉換為我們自己的 DTO
        GoogleIdToken.Payload googlePayload = idToken.getPayload();
        return googlePayload.getFactory().fromString(googlePayload.toString(), GoogleIdTokenPayload.class);
    } else {
        logger.error("無效的 ID Token:驗證回傳 null。");
        throw new GeneralSecurityException("無效的 ID Token。");
    }
}

verify 這個方法會驗證 JWT 簽章、受眾(aud)、發行者 (iss) 和過期時間 (exp) 的驗證,其中 aud 是我們自己的 client_id,因此需在 Builder 就傳入進行設定。

在驗證成功後,我們可以將 Payload 轉換為我們自己的 DTO,僅留下我們需要的資訊。若直接將取 Payload 到 jwt.io,會看到以下的 Claims:

// 解析出來的payload包含下列資訊
{
    "at_hash": "SWxiDgj0Nd_03f...",
    "aud": "628650734323-9tbbjhkqpg2c1kfncr4r2i...",
    "azp": "628650735328-9tbbjhkqpg2c1kfncr4r2i...",
    "email": "meyawara326@gmail.com",
    "email_verified": true,
    "exp": 1759977403,
    "family_name": "xxx",
    "given_name": "xxx",
    "iat": 1759973803,
    "iss": "https://accounts.google.com",
    "name": "xxx",
    "picture": "https://lh3.googleusercontent.com...",
    "sub": "10512180014002177.."
}

此處大部分的 key 值皆為 OIDC 的縮寫,對應的意義可參考這份文件

考量到未來個人資訊在網頁上的顯示,我們需要取得使用者的 id(對應到sub欄位),email, name, picture,另外考量到未來 email 認證,也可先保留 email_verified 欄位資訊。

確認所需資訊後,將這些資訊在DTO中定義成一個新的類別GoogleIdTokenPayload 。實作上要繼承 GoogleIdToken.Payload 的父類別 GenericJson 以在之後使用相關方法,另外,可透過 @Key 註解來取得對應 JSON payload 中的欄位:

public class GoogleIdTokenPayload extends GenericJson {

    @Key("sub")
    @Getter
    private String googleId;

    @Key("email")
    @Getter
    private String email;

    @Key("email_verified")
    @Getter
    private Boolean emailVerified;

    @Key("name")
    @Getter
    private String name;

    @Key("picture")
    @Getter
    private String pictureUrl;
}

建立好這個類別後,就用於剛剛 verifyAndParseIdToken 中的 formString 的方法中進行轉換,最後回傳物件。

調整對應資料表 UserEntity

剛剛我們從 id_token 中取得了用戶的姓名、pictureUrl 跟 Google 的用戶 Id,這幾個欄位是我們先前沒有建立,但未來會需要的,因此要調整 user 資料表結構。除了新增上述欄位以外,因為以 Google 驗證的用戶不會有密碼,這邊改讓密碼可以為空:

@Entity
@Table(name = "users")
@Getter
@Setter
@EntityListeners(AuditingEntityListener.class)
public class UserEntity implements UserDetails {
		
		...
		
		// 原本的 nullable 從 false 改為 true
    @Column(nullable = true)
    private String password;

    // 除了原本的欄位,新增 Google 登入相關欄位
    
    @Column(unique = true) // Google ID 必須是唯一的
    private String googleId;

    @Column // 姓名
    private String name;

    @Column(length = 1024) // 圖片 URL 可能很長
    private String pictureUrl;

		...
}

使用者資料查詢與建檔

Token 驗證完成後,接下來我們要從 Payload 中取得 Googld 拿去查詢 user 資料表中有無這個使用者,若查無資料,則新建一筆資料。

因此,我們要先在 UserRepository 新增 findByGoogleId 的方法:

public interface UserRepository extends JpaRepository<UserEntity, Long> {
		...
    Optional<UserEntity> findByGoogleId(String googleId);
}

再回到 GoogleAuthServiceImpl 中新增 findOrCreateUser 方法。若查無資料則將 Payload 中的個人資訊用來新增使用者,實作如下:

private UserEntity findOrCreateUser(GoogleIdTokenPayload payload) {
    // 使用 googleId (sub) 作為唯一識別碼來查找使用者
    return userRepository.findByGoogleId(payload.getGoogleId()).orElseGet(() -> {
        logger.info("在資料庫中找不到使用者,將為 {} 建立新帳號", payload.getEmail());
        UserEntity newUser = new UserEntity();
        newUser.setGoogleId(payload.getGoogleId());
        newUser.setEmail(payload.getEmail());
        newUser.setName(payload.getName());
        newUser.setPictureUrl(payload.getPictureUrl());
        return userRepository.save(newUser);
    });
}

彙整 Google 登入處理步驟

新增使用者後,我們的下一步應該是簽發我們自己的 Token,方法在前面實作 JWT 的章節就完成了,因此我們可以針對目前完成的功能進行整理:

    @Transactional
    public LoginResponse processGoogleLogin(String code) throws IOException, GeneralSecurityException {

        // 1. 使用授權碼向 Google 交換 Tokens
        JsonNode tokenResponse = exchangeCodeForToken(code);
        String idTokenString = tokenResponse.get("id_token").asText();

        // 2. 驗證 ID Token 並從中解析出 Payload
        GoogleIdTokenPayload payload = verifyAndParseIdToken(idTokenString);

        // 3. 根據驗證後的 Payload,在資料庫中尋找或建立使用者
        UserEntity user = findOrCreateUser(payload);

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

        List<String> roles = user.getAuthorities().stream()
                .map(grantedAuthority -> grantedAuthority.getAuthority())
                .collect(Collectors.toList());

        // 回傳統一的登入回應
        return new LoginResponse(accessToken, refreshToken.getToken(), user.getId(),user.getName(), user.getEmail(),user.getPictureUrl(), roles);
    }

在此我們也順便調整了登入的回傳,讓回傳資訊新增 name 與 pictureUrl ,先前登入的程式碼也在此一併進行調整。

建立 API 端點與相關 DTO

新增DTO:GoogleAuthRequest

這個 API 目前需要前端帶給我們的主要就是授權碼:

public record GoogleAuthRequest(
    @NotBlank(message = "Authorization code cannot be blank")
    String code
) {
}

新增端點:/auth/google

注入 googleAuthService 後呼叫我們彙整的方法:

@RestController
@RequestMapping("/users")
public class AuthController {
   
    ...
    
    @Autowired
    private GoogleAuthService googleAuthService; 
    
    // 新增 Google 登入端點
      @PostMapping("/auth/google")
      public ResponseEntity<?> authenticateWithGoogle(@Valid @RequestBody GoogleAuthRequest authRequest) {
          try {
              LoginResponse loginResponse = googleAuthService.processGoogleLogin(authRequest.code());
              return ResponseEntity.ok(loginResponse);
          } catch (IOException | GeneralSecurityException e) {
              return ResponseEntity.internalServerError().body("Error processing Google login");
          } 
      }
}

這邊先簡單的回傳 500 錯誤,明天測試過後再來優化例外處理。


至此,我們實作了第三方登入功能在前、後端分離的情況下,後端端點該負責的種種任務,明天讓我們設定好權限,處理 CORS 的問題,再回到前端看看功能是否正常,並接續處理收到使用者資料後的資料儲存與傳遞。

待這兩天我們自行實作、親自走過大部分的流程,之後有機會也會試著使用 spring-boot-starter-oauth2-client,來體會看看這個工具如何將一切化繁為簡~~


上一篇
Day 24:實作 Google 第三方登入功能 (2)
下一篇
Day 26:實作 Google 第三方登入功能 (4)
系列文
吃出一個SideProject!26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言