Spring Security 整合 Firebase Authentication

1、概览

在现代 Web 应用中,用户身份认证和授权是至关重要的组成部分。从零开始构建身份认证层是一项具有挑战性的复杂任务。不过,随着基于云的身份认证服务的兴起,这一过程变得简单多了。

Firebase Authentication 就是这样一个例子,它是 Firebase 和谷歌 提供的一种完全托管的身份认证服务。

本文将带你了解如何将 Firebase AuthenticationSpring 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() 方法中,我们用用户的 emailpassword 初始化了一个新的 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() 方法中,我们用用户的 emailpassword 创建了一个 FirebaseSignInRequest,并将 returnSecureToken 设为 true。然后,将此请求传递给 sendSignInRequest() 方法,该方法会使用 RestClientFirebase Authentication REST API 发送 POST 请求。

如果请求成功,会向调用者返回包含用户 idTokenrefreshToken 的响应。如果登录凭证无效,会抛出一个自定义的 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 AuthenticationSpring 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 有效,就创建一个以 userIdPrincipalUsernamePasswordAuthenticationToken 新实例,然后将其设置到 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 AuthenticationSpring Security 集成,通过示例介绍了必要的配置,实现了用户注册、登录和 Refresh Token 交换功能,并创建了一个自定义 Spring Security Filter 来保护私有 API 端点。

通过使用 Firebase Authentication,我们可以摆脱管理用户凭证和访问权限的复杂性,从而能够专注于构建核心的业务功能。


Ref:https://www.baeldung.com/spring-security-firebase-authentication