將 Firebase 身份驗證與 Spring Security 集成
1. 概述
在現代 Web 應用程式中,使用者身份驗證和授權是關鍵元件。從頭開始建立我們的身份驗證層是一項具有挑戰性且複雜的任務。然而,隨著基於雲端的身份驗證服務的興起,這個過程變得更加簡單。
Firebase 驗證就是這樣一個例子,它是Firebase 和 Google提供的一項完全託管的身份驗證服務。
在本教程中,我們將探索如何將 Firebase 驗證與 Spring Security 整合來建立和驗證我們的使用者。我們將逐步完成必要的配置,實現使用者註冊和登入功能,並建立自訂身份驗證過濾器來驗證私有 API 端點的使用者令牌。
2. 設定項目
在深入實施之前,我們需要包含 SDK 依賴項並正確配置我們的應用程式。
2.1.依賴關係
首先,我們將Firebase 管理相依性新增至專案的pom.xml
檔案:
<dependency>
<groupId>com.google.firebase</groupId>
<artifactId>firebase-admin</artifactId>
<version>9.3.0</version>
</dependency>
此依賴項為我們提供了與應用程式中的 Firebase 驗證服務互動所需的類別。
2.2.定義 Firebase 配置 Bean
現在,為了與 Firebase 身份驗證交互,我們需要配置私鑰來對 API 請求進行身份驗證。
為了進行演示,我們將在src/main/resources
目錄中建立private-key.json
檔案。然而,在生產中,應該從環境變數載入私鑰或從秘密管理系統取得私鑰以增強安全性。
我們將使用@Value
註解載入我們的私鑰並使用它來定義我們的 bean:
@Value("classpath:/private-key.json")
private Resource privateKey;
@Bean
public FirebaseApp firebaseApp() {
InputStream credentials = new ByteArrayInputStream(privateKey.getContentAsByteArray());
FirebaseOptions firebaseOptions = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(credentials))
.build();
return FirebaseApp.initializeApp(firebaseOptions);
}
@Bean
public FirebaseAuth firebaseAuth(FirebaseApp firebaseApp) {
return FirebaseAuth.getInstance(firebaseApp);
}
我們先定義FirebaseApp
bean,然後用它來建立FirebaseAuth
bean。這使我們能夠在使用多個 Firebase 服務(例如 Cloud Firestore 資料庫、Firebase 訊息傳遞等)時重複使用FirebaseApp
bean。
FirebaseAuth
類別是與 Firebase 驗證服務互動的主要入口點。
3. 在 Firebase 身份驗證中建立用戶
現在我們已經定義了FirebaseAuth
bean,讓我們建立一個UserService
類別並引用它以在 Firebase 驗證中建立新使用者:
private static final String DUPLICATE_ACCOUNT_ERROR = "EMAIL_EXISTS";
public void create(String emailId, String password) {
CreateRequest request = new CreateRequest();
request.setEmail(emailId);
request.setPassword(password);
request.setEmailVerified(Boolean.TRUE);
try {
firebaseAuth.createUser(request);
} catch (FirebaseAuthException exception) {
if (exception.getMessage().contains(DUPLICATE_ACCOUNT_ERROR)) {
throw new AccountAlreadyExistsException("Account with given email-id already exists");
}
throw exception;
}
}
在我們的create()
方法中,我們使用使用者的email
和password
初始化一個新的CreateRequest
物件。為了簡單起見,我們還將emailVerified
值設為true
,但是,我們可能希望在生產應用程式中執行此操作之前實現電子郵件驗證過程。
此外,我們還處理具有給定emailId
的帳戶已存在的情況,並將其投出自定義AccountAlreadyExistsException
。
4. 實現使用者登入功能
現在我們可以創建用戶了,我們自然必須允許他們在訪問我們的私有 API 端點之前對自己進行身份驗證。我們將實作使用者登入功能,該功能以 JWT 形式傳回 ID 令牌,並在身分驗證成功時傳回刷新令牌。
Firebase 管理 SDK 不支援與電子郵件/密碼憑證進行令牌交換,因為此功能通常由用戶端應用程式處理。但是,在我們的演示中,我們將直接從後端應用程式呼叫登入 REST API 。
首先,我們將聲明一些記錄來表示請求和回應負載:
record FirebaseSignInRequest(String email, String password, boolean returnSecureToken) {}
record FirebaseSignInResponse(String idToken, String refreshToken) {}
**要呼叫 Firebase 驗證 REST API,我們需要 Firebase 專案的Web API 金鑰**。我們將其儲存在application.yaml
檔案中,並使用@Value
註解將其註入到新的FirebaseAuthClient
類別中:
private static final String API_KEY_PARAM = "key";
private static final String INVALID_CREDENTIALS_ERROR = "INVALID_LOGIN_CREDENTIALS";
private static final String SIGN_IN_BASE_URL = "https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword";
@Value("${com.baeldung.firebase.web-api-key}")
private String webApiKey;
public FirebaseSignInResponse login(String emailId, String password) {
FirebaseSignInRequest requestBody = new FirebaseSignInRequest(emailId, password, true);
return sendSignInRequest(requestBody);
}
private FirebaseSignInResponse sendSignInRequest(FirebaseSignInRequest firebaseSignInRequest) {
try {
return RestClient.create(SIGN_IN_BASE_URL)
.post()
.uri(uriBuilder -> uriBuilder
.queryParam(API_KEY_PARAM, webApiKey)
.build())
.body(firebaseSignInRequest)
.contentType(MediaType.APPLICATION_JSON)
.retrieve()
.body(FirebaseSignInResponse.class);
} catch (HttpClientErrorException exception) {
if (exception.getResponseBodyAsString().contains(INVALID_CREDENTIALS_ERROR)) {
throw new InvalidLoginCredentialsException("Invalid login credentials provided");
}
throw exception;
}
}
在我們的login()
方法中,我們使用使用者的email
、 password
建立FirebaseSignInRequest
,並將returnSecureToken
設定為true
。然後,我們將此請求傳遞給私有sendSignInRequest()
方法,該方法使用RestClient
向 Firebase 驗證 REST API 發送 POST 請求。
如果請求成功,我們將包含使用者的idToken
和refreshToken
的回應傳回給呼叫者。如果登入憑證無效,我們會拋出自定義的InvalidLoginCredentialsException
。
需要注意的是,我們從 Firebase 收到的idToken
的有效期是一小時,我們無法更改它。在下一節中,我們將探討如何允許客戶端應用程式使用傳回的refreshToken
來取得新的 ID 令牌。
5. 用刷新令牌交換新的 ID 令牌
現在我們已經有了登入功能,讓我們看看如何在當前 idToken 過期時使用refreshToken
來取得新的idToken
。這使得我們的客戶端應用程式可以讓使用者長時間保持登入狀態,而無需他們重新輸入憑證。
我們首先定義代表請求和回應有效負載的記錄:
record RefreshTokenRequest(String grant_type, String refresh_token) {}
record RefreshTokenResponse(String id_token) {}
接下來,在我們的FirebaseAuthClient
類別中,我們呼叫刷新令牌交換 REST API :
private static final String REFRESH_TOKEN_GRANT_TYPE = "refresh_token";
private static final String INVALID_REFRESH_TOKEN_ERROR = "INVALID_REFRESH_TOKEN";
private static final String REFRESH_TOKEN_BASE_URL = "https://securetoken.googleapis.com/v1/token";
public RefreshTokenResponse exchangeRefreshToken(String refreshToken) {
RefreshTokenRequest requestBody = new RefreshTokenRequest(REFRESH_TOKEN_GRANT_TYPE, refreshToken);
return sendRefreshTokenRequest(requestBody);
}
private RefreshTokenResponse sendRefreshTokenRequest(RefreshTokenRequest refreshTokenRequest) {
try {
return RestClient.create(REFRESH_TOKEN_BASE_URL)
.post()
.uri(uriBuilder -> uriBuilder
.queryParam(API_KEY_PARAM, webApiKey)
.build())
.body(refreshTokenRequest)
.contentType(MediaType.APPLICATION_JSON)
.retrieve()
.body(RefreshTokenResponse.class);
} catch (HttpClientErrorException exception) {
if (exception.getResponseBodyAsString().contains(INVALID_REFRESH_TOKEN_ERROR)) {
throw new InvalidRefreshTokenException("Invalid refresh token provided");
}
throw exception;
}
}
在我們的exchangeRefreshToken()
方法中,我們建立一個具有refresh_token
授予類型和提供的refreshToken
RefreshTokenRequest
。然後,我們將此請求傳遞給我們的私有sendRefreshTokenRequest()
方法,該方法將 POST 請求傳送到所需的 API 端點。
如果請求成功,我們將傳回包含新idToken
的回應。如果提供的refreshToken
無效,我們會拋出自定義的InvalidRefreshTokenException
。
此外,如果我們需要強制使用者重新進行身份驗證,我們可以撤銷他們的刷新令牌:
firebaseAuth.revokeRefreshTokens(userId);
我們呼叫FirebaseAuth
類別提供的revokeRefreshTokens()
方法。這不僅會使發放給使用者的所有refreshTokens
無效,還會使使用者的活動idToken
無效,從而有效地將它們從我們的應用程式中註銷。
6. 與 Spring Security 集成
完成使用者建立和登入功能後,讓我們將 Firebase 驗證與 Spring Security 整合以保護我們的私有 API 端點。
6.1.建立自訂身份驗證Filter
首先,我們將建立擴充OncePerRequestFilter
類別的自訂身份驗證篩選器:
@Component
class TokenAuthenticationFilter extends OncePerRequestFilter {
private static final String BEARER_PREFIX = "Bearer ";
private static final String USER_ID_CLAIM = "user_id";
private static final String AUTHORIZATION_HEADER = "Authorization";
private final FirebaseAuth firebaseAuth;
private final ObjectMapper objectMapper;
// standard constructor
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) {
String authorizationHeader = request.getHeader(AUTHORIZATION_HEADER);
if (authorizationHeader != null && authorizationHeader.startsWith(BEARER_PREFIX)) {
String token = authorizationHeader.replace(BEARER_PREFIX, "");
Optional<String> userId = extractUserIdFromToken(token);
if (userId.isPresent()) {
var authentication = new UsernamePasswordAuthenticationToken(userId.get(), null, null);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
setAuthErrorDetails(response);
return;
}
}
filterChain.doFilter(request, response);
}
private Optional<String> extractUserIdFromToken(String token) {
try {
FirebaseToken firebaseToken = firebaseAuth.verifyIdToken(token, true);
String userId = String.valueOf(firebaseToken.getClaims().get(USER_ID_CLAIM));
return Optional.of(userId);
} catch (FirebaseAuthException exception) {
return Optional.empty();
}
}
private void setAuthErrorDetails(HttpServletResponse response) {
HttpStatus unauthorized = HttpStatus.UNAUTHORIZED;
response.setStatus(unauthorized.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(unauthorized,
"Authentication failure: Token missing, invalid or expired");
response.getWriter().write(objectMapper.writeValueAsString(problemDetail));
}
}
在doFilterInternal()
方法中,我們從傳入的 HTTP 請求中提取Authorization
標頭,並刪除Bearer
前綴以取得 JWT token
。
然後,使用我們的私有extractUserIdFromToken()
方法,我們驗證令牌的真實性並檢索其user_id
聲明。
如果令牌驗證失敗,我們將建立ProblemDetail
錯誤回應,使用ObjectMapper
將其轉換為 JSON,並將其寫入HttpServletResponse
。
如果令牌有效,我們將建立一個UsernamePasswordAuthenticationToken
的新實例,並將userId
作為Principal
,然後將其設定在SecurityContext
中。
驗證成功後,我們可以從服務層的SecurityContext
中檢索經過驗證的使用者的userId
:
String userId = Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.map(Authentication::getPrincipal)
.filter(String.class::isInstance)
.map(String.class::cast)
.orElseThrow(IllegalStateException::new);
為了遵循單一職責原則,我們可以將上述邏輯放在單獨的AuthenticatedUserIdProvider
類別中。這有助於服務層維護目前經過身份驗證的使用者和他們執行的操作之間的關係。
6.2.配置SecurityFilterChain
最後,讓我們配置SecurityFilterChain
以使用自訂身份驗證過濾器:
private static final String[] WHITELISTED_API_ENDPOINTS = { "/user", "/user/login", "/user/refresh-token" };
private final TokenAuthenticationFilter tokenAuthenticationFilter;
// standard constructor
@Bean
public SecurityFilterChain configure(HttpSecurity http) {
http
.authorizeHttpRequests(authManager -> {
authManager.requestMatchers(HttpMethod.POST, WHITELISTED_API_ENDPOINTS)
.permitAll()
.anyRequest()
.authenticated();
})
.addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
我們允許對/user
、 /user/login
和/user/refresh-token
端點進行未經身份驗證的訪問,這些端點對應於我們的用戶註冊、登入和刷新令牌交換功能。
最後,我們將自訂的TokenAuthenticationFilter
新增至過濾器鏈中的UsernamePasswordAuthenticationFilter
之前。
此設定可確保我們的私有 API 端點受到保護,並且僅允許具有有效 JWT 令牌的請求存取它們。
七、結論
在本文中,我們探討如何將 Firebase 驗證與 Spring Security 整合。
我們完成了必要的配置,實現了使用者註冊、登入和刷新令牌交換功能,並建立了自訂 Spring Security 過濾器來保護我們的私有 API 端點。
透過使用 Firebase 身份驗證,我們可以減輕管理使用者憑證和存取的複雜性,使我們能夠專注於建立核心功能。
與往常一樣,本文中使用的所有程式碼範例都可以在 GitHub 上找到。