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