Spring Security 整合 Firebase Authentication
1、概览
在现代 Web 应用中,用户身份认证和授权是至关重要的组成部分。从零开始构建身份认证层是一项具有挑战性的复杂任务。不过,随着基于云的身份认证服务的兴起,这一过程变得简单多了。
Firebase Authentication 就是这样一个例子,它是 Firebase 和谷歌 提供的一种完全托管的身份认证服务。
本文将带你了解如何将 Firebase Authentication 与 Spring Security 整合,以创建和认证我们的用户。我们要进行必要的配置,实现用户注册和登录功能,并创建一个自定义 Authentication
Filter 来验证私有 API 端点的用户 Token。
2、项目设置
在实现之前,需要加入 SDK 依赖并正确配置应用。
2.1、依赖
首先,在项目的 pom.xml 文件中添加 Firebase admin 依赖:
<dependency>
<groupId>com.google.firebase</groupId>
<artifactId>firebase-admin</artifactId>
<version>9.3.0</version>
</dependency>
该依赖提供了必要的类,用于在应用中与 Firebase Authentication 服务交互。
2.2、定义 Firebase 配置 Bean
现在,为了与 Firebase Authentication 交互,我们需要配置私钥(Private Key)来验证 API 请求。
在本例中,我们在 src/main/resources 目录下创建 private-key.json 文件。不过,在生产中,私钥应从环境变量中加载,或从 secret 管理系统中获取,以提高安全性。
使用 @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。这样,我们就可以在使用云 Firestore
数据库、Firebase
消息等多个 Firebase
服务时重复使用 FirebaseApp
Bean。
FirebaseAuth
类是与 Firebase
身份认证服务交互的主要入口。
3、在 Firebase Authentication 中创建用户
定义了 FirebaseAuth
Bean 后,创建一个 UserService
类并引用它在 Firebase Authentication 中创建新用户:
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
,不过,在生产应用中,我们可能需要先实现 email
验证流程。
此外,如果给定 emailId
的账户已经存在,就会抛出一个自定义的 AccountAlreadyExistsException
异常。
4、实现用户登录功能
既然我们可以创建用户,自然就必须允许他们在访问我们的私有 API 端点之前进行身份认证。我们要实现用户登录功能,以 JWT 的形式返回 ID Token,并在成功认证后返回 Refresh Token。
Firebase admin SDK 不支持使用 email/password 凭证进行 Token 交换,因为这种功能通常由客户端应用处理。然而,为了演示,我们直接从我们的后端应用中调用 登录 REST API。
首先,声明几个 record
来表示请求和响应参数:
record FirebaseSignInRequest(String email, String password, boolean returnSecureToken) {}
record FirebaseSignInResponse(String idToken, String refreshToken) {}
要调用 Firebase Authentication REST API,我们需要 Firebase 项目的 Web API Key。
在 application.yaml
文件中存储该 Key,并使用 @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 Authentication REST API 发送 POST 请求。
如果请求成功,会向调用者返回包含用户 idToken 和 refreshToken 的响应。如果登录凭证无效,会抛出一个自定义的 InvalidLoginCredentialsException
异常。
需要注意的是,从 Firebase 收到的 idToken 的有效期为 一小时,而且不能更改。在下一节中,我们将介绍如何让客户端应用使用返回的 refreshToken 来获取新的 ID Token。
5、使用 Refresh Token 申请新的 ID Token
现在,登录功能已经就绪,接着来看看如何使用 refreshToken 在当前 idToken 过期时获取新的 idToken。这样,客户端程序就可以让用户长时间保持登录状态,而无需重新输入凭证。
首先,定义封装请求和响应参数的 record
:
record RefreshTokenRequest(String grant_type, String refresh_token) {}
record RefreshTokenResponse(String id_token) {}
接下来,在 FirebaseAuthClient
类中调用 Refresh Token Exchange 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
grant type(授权类型)和提供的 refreshToken
创建了一个 RefreshTokenRequest
。然后,将该请求传递给 sendRefreshTokenRequest()
方法,该方法会向所需的 API 端点发送 POST 请求。
如果请求成功,会返回包含新 idToken 的响应。如果提供的 refreshToken 无效,会抛出一个自定义的 InvalidRefreshTokenException
。
此外,如果需要强制用户重新进行身份认证,可以撤销他们的 Refresh Token:
firebaseAuth.revokeRefreshTokens(userId);
调用 FirebaseAuth
类提供的 revokeRefreshTokens()
方法。这不仅会使发给用户的所有 refreshTokens 失效,还会使用户的活动 idToken 失效,从而有效地将他们从应用中注销。
6、与 Spring Security 整合
创建用户和登录功能就绪后,接下来让我们将 Firebase Authentication 与 Spring Security 集成,以保护我们的私有 API 端点。
6.1、创建自定义 Authentication Filter
首先,创建自定义 Authentication
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;
// 标准的构造函数略。。。
@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
Header,并移除 Bearer 前缀,以获取 JWT Token。
然后,使用专用的 extractUserIdFromToken()
方法验证 Token 的真实性,并检索其 user_id claim。
如果 Token 验证失败,就创建 ProblemDetail
错误响应,使用 ObjectMapper
将其转换为 JSON 格式,并写入 HttpServletResponse
。
如果 Token 有效,就创建一个以 userId
为 Principal
的 UsernamePasswordAuthenticationToken
新实例,然后将其设置到 SecurityContext
中。
认证成功后,我们可以从 Service 层的 SecurityContext
中获取已认证用户的 userId
:
String userId = Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.map(Authentication::getPrincipal)
.filter(String.class::isInstance)
.map(String.class::cast)
.orElseThrow(IllegalStateException::new);
为了遵循 “单一责任原则”,我们可以将上述逻辑放在一个单独的 AuthenticatedUserIdProvider
类中。这有助于 Service 层维护当前已通过身份认证的用户与他们执行的操作之间的关系。
6.2、配置 SecurityFilterChain
最后,配置 SecurityFilterChain
以使用自定义 Authentication Filter:
private static final String[] WHITELISTED_API_ENDPOINTS = { "/user", "/user/login", "/user/refresh-token" };
private final TokenAuthenticationFilter tokenAuthenticationFilter;
// 标准构造函数略。。。
@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
端点,这些端点即用户注册、登录和 Refresh Token 交换功能。
最后,我们在过滤器链(filter chain)中的 UsernamePasswordAuthenticationFilter
之前添加自定义 TokenAuthenticationFilter
。
该设置可确保我们的私有 API 端点受到保护,只有带有有效 JWT Token 的请求才允许访问这些端点。
7、总结
本文介绍了如何将 Firebase Authentication 与 Spring Security 集成,通过示例介绍了必要的配置,实现了用户注册、登录和 Refresh Token 交换功能,并创建了一个自定义 Spring Security Filter 来保护私有 API 端点。
通过使用 Firebase Authentication,我们可以摆脱管理用户凭证和访问权限的复杂性,从而能够专注于构建核心的业务功能。
Ref:https://www.baeldung.com/spring-security-firebase-authentication