1. 概述
在現代 Web 應用程序中,用户身份驗證和授權是關鍵組成部分。從頭開始構建身份驗證層既具有挑戰性又複雜。然而,隨着基於雲的身份驗證服務興起,這個過程變得更加簡單。
例如,Firebase 身份驗證 是一種由 Firebase 和 Google 提供的完全託管身份驗證服務。
在本教程中,我們將探索如何將 Firebase 身份驗證與 Spring Security 集成,以創建和驗證我們的用户。 我們將逐步完成必要的配置、實現用户註冊和登錄功能,並創建一個自定義身份驗證過濾器,以驗證用户令牌,用於私有 API 端點。
2. 項目設置
在深入實施之前,我們需要添加 SDK 依賴項並正確配置我們的應用程序。
2.1. 依賴項
讓我們首先將 Firebase Admin 依賴項 添加到我們項目的 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 請求認證。
對於我們的演示,我們將創建 private-key.json 文件,該文件位於我們的 src/main/resources 目錄中。 但是,在生產環境中,私鑰應從環境變量中加載或從密鑰管理系統中檢索,以增強安全性。
我們將使用 @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 服務時重用 FirebaseApp Bean,例如 Cloud Firestore 數據庫、Firebase 消息傳遞等。
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端點之前。我們將實現用户登錄功能,該功能將在成功身份驗證後返回一個ID令牌(以JWT形式)和刷新令牌。
Firebase Admin SDK 不支持使用電子郵件/密碼憑據進行令牌交換,因為此功能通常由客户端應用程序處理。但是,為了演示目的,我們將直接從我們的後端應用程序調用 sign-in 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() 方法中,我們創建一個 FirebaseSignInRequest 對象,其中包含用户的 email、password,並將 returnSecureToken 設置為 true。然後我們將該請求傳遞給我們的私有 sendSignInRequest() 方法,該方法使用 RestClient 向 Firebase Authentication REST API 發送 POST 請求。
如果請求成功,我們將包含用户 idToken 和 refreshToken 的響應返回給調用者。如果登錄憑據無效,我們將拋出一個自定義的 InvalidLoginCredentialsException。
需要注意的是,我們從 Firebase 接收到的 idToken 的有效性為一小時,並且我們無法更改它。在下一部分,我們將探討如何允許我們的客户端應用程序使用返回的 refreshToken 來獲取新的 ID 令牌。
5. 交換刷新令牌獲取新的身份令牌
現在我們已經完成了登錄功能,讓我們看看如何使用 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() 方法中,我們創建一個 RefreshTokenRequest,使用 refresh_token grant type 和提供的 refreshToken。然後我們將此請求傳遞給我們的私有 sendRefreshTokenRequest() 方法,該方法向指定的 API 端點發送一個 POST 請求。
如果請求成功,我們返回包含新 idToken 的響應。如果提供的 refreshToken 無效,則拋出自定義的 InvalidRefreshTokenException。
此外,如果我們需要強制用户重新認證,我們可以撤銷他們的 refresh tokens:
firebaseAuth.revokeRefreshTokens(userId);我們調用 revokeRefreshTokens() 方法,該方法由 FirebaseAuth 類提供。它不僅無效了用户發出的所有 refreshTokens,還無效了用户的活動 idToken,從而有效地將用户從我們的應用程序中註銷。
6. 與 Spring Security 集成
有了我們現有的用户創建和登錄功能,現在讓我們將 Firebase Authentication 與 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() 方法,驗證 token 的有效性並提取其 user_id 聲明。
如果 token 驗證失敗,我們創建一個 ProblemDetail 錯誤響應,使用 ObjectMapper 將其轉換為 JSON,並將其寫入 HttpServletResponse。
如果 token 有效,我們創建一個新的 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 令牌的請求訪問它們.
7. 結論
在本文中,我們探討了如何將 Firebase 身份驗證與 Spring Security 集成。
我們完成了必要的配置,實現了用户註冊、登錄和刷新令牌交換功能,並創建了一個自定義 Spring Security 過濾器,以安全我們的私有 API 端點。
通過使用 Firebase 身份驗證,我們可以將管理用户憑據和訪問的複雜性外包出去,從而使我們能夠專注於構建核心功能。