昨天我們建立了前端頁面,完成將使用者導向驗證後取得授權碼傳送到後端的功能,今天要來開發後端好讓前端可以順利取得使用者資料!
由於這個專案是透過 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}
為了處理 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();
}
}
GoogleAuthService
:處理 Google 登入邏輯建立一個新的 Service GoogleAuthService
,專門用來處理 Google 登入邏輯的新服務。我們前面有提到,接收到授權後後端有幾個主要的任務,分別是:
最後回傳我們自行簽發的 Token 與用戶資訊。
在這個過程當中,會連動調整到當前用戶資料表的架構 (UserEntiry),以及登入回傳的類別 (LoginResponse)。
首先要實作與 Google Token 端點互動的方法。
根據 Google 官方文件 說明,我們發出的請求應該包含 code
(授權碼)、client_id
、client_secret
、redirect_uri
與 grant_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 的方法中進行轉換,最後回傳物件。
剛剛我們從 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);
});
}
新增使用者後,我們的下一步應該是簽發我們自己的 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 目前需要前端帶給我們的主要就是授權碼:
public record GoogleAuthRequest(
@NotBlank(message = "Authorization code cannot be blank")
String code
) {
}
注入 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,來體會看看這個工具如何將一切化繁為簡~~