Skip to content

Commit f0bf46a

Browse files
codebase/integrating-firebase-authentication-with-spring-security [BAEL-8543] (#17466)
* adding module skeleton * adding firebase auth configuration bean * externalizing configuration properties * adding firebase auth client for login * adding user account creation functionality * adding security configuration * adding user record retrieve functionality * exposing API endpoints * fix: user-id extraction in auth filter * adding API exception handler * adding live tests * removing @ConfigurationProperties * updating FirebaseAuthClient * moving codebase to gcp-firebase module * incorporating review comments * adding refresh token functionality * adding refresh token test cases * returning custom error message from auth filter * minor polishing
1 parent 3216b9b commit f0bf46a

15 files changed

+717
-1
lines changed

gcp-firebase/pom.xml

+10
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
<groupId>org.springframework.boot</groupId>
2222
<artifactId>spring-boot-starter-web</artifactId>
2323
</dependency>
24+
<dependency>
25+
<groupId>org.springframework.boot</groupId>
26+
<artifactId>spring-boot-starter-security</artifactId>
27+
</dependency>
2428
<dependency>
2529
<groupId>org.springframework.boot</groupId>
2630
<artifactId>spring-boot-configuration-processor</artifactId>
@@ -37,6 +41,11 @@
3741
<version>${instancio.version}</version>
3842
<scope>test</scope>
3943
</dependency>
44+
<dependency>
45+
<groupId>org.springframework.security</groupId>
46+
<artifactId>spring-security-test</artifactId>
47+
<scope>test</scope>
48+
</dependency>
4049
</dependencies>
4150

4251
<build>
@@ -53,6 +62,7 @@
5362

5463
<properties>
5564
<java.version>17</java.version>
65+
<spring-boot.version>3.3.0</spring-boot.version>
5666
<firebase-admin.version>9.3.0</firebase-admin.version>
5767
<instancio.version>5.0.1</instancio.version>
5868
</properties>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.baeldung.gcp.firebase.auth;
2+
3+
import org.springframework.http.HttpStatus;
4+
import org.springframework.web.server.ResponseStatusException;
5+
6+
public class AccountAlreadyExistsException extends ResponseStatusException {
7+
8+
public AccountAlreadyExistsException(String reason) {
9+
super(HttpStatus.CONFLICT, reason);
10+
}
11+
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.baeldung.gcp.firebase.auth;
2+
3+
import org.slf4j.Logger;
4+
import org.slf4j.LoggerFactory;
5+
import org.springframework.http.HttpStatus;
6+
import org.springframework.http.ProblemDetail;
7+
import org.springframework.web.bind.annotation.ExceptionHandler;
8+
import org.springframework.web.bind.annotation.RestControllerAdvice;
9+
import org.springframework.web.server.ResponseStatusException;
10+
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
11+
12+
@RestControllerAdvice
13+
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
14+
15+
private static final Logger LOGGER = LoggerFactory.getLogger(ApiExceptionHandler.class);
16+
17+
@ExceptionHandler(ResponseStatusException.class)
18+
public ProblemDetail handle(ResponseStatusException exception) {
19+
log(exception);
20+
return ProblemDetail.forStatusAndDetail(exception.getStatusCode(), exception.getReason());
21+
}
22+
23+
@ExceptionHandler(Exception.class)
24+
public ProblemDetail handle(Exception exception) {
25+
log(exception);
26+
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_IMPLEMENTED, "Something went wrong.");
27+
}
28+
29+
private void log(Exception exception) {
30+
LOGGER.error("Exception encountered: {}", exception.getMessage(), exception);
31+
}
32+
33+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.baeldung.gcp.firebase.auth;
2+
3+
import org.springframework.boot.SpringApplication;
4+
import org.springframework.boot.autoconfigure.SpringBootApplication;
5+
6+
@SpringBootApplication
7+
public class Application {
8+
9+
public static void main(String[] args) {
10+
SpringApplication.run(Application.class, args);
11+
}
12+
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.baeldung.gcp.firebase.auth;
2+
3+
import java.util.Optional;
4+
5+
import org.springframework.security.core.Authentication;
6+
import org.springframework.security.core.context.SecurityContextHolder;
7+
import org.springframework.stereotype.Component;
8+
9+
@Component
10+
public class AuthenticatedUserIdProvider {
11+
12+
public String getUserId() {
13+
return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
14+
.map(Authentication::getPrincipal)
15+
.filter(String.class::isInstance)
16+
.map(String.class::cast)
17+
.orElseThrow(IllegalStateException::new);
18+
}
19+
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package com.baeldung.gcp.firebase.auth;
2+
3+
import org.springframework.beans.factory.annotation.Value;
4+
import org.springframework.http.MediaType;
5+
import org.springframework.stereotype.Component;
6+
import org.springframework.web.client.HttpClientErrorException;
7+
import org.springframework.web.client.RestClient;
8+
9+
@Component
10+
public class FirebaseAuthClient {
11+
12+
private static final String API_KEY_PARAM = "key";
13+
private static final String REFRESH_TOKEN_GRANT_TYPE = "refresh_token";
14+
15+
private static final String INVALID_CREDENTIALS_ERROR = "INVALID_LOGIN_CREDENTIALS";
16+
private static final String INVALID_REFRESH_TOKEN_ERROR = "INVALID_REFRESH_TOKEN";
17+
18+
private static final String SIGN_IN_BASE_URL = "https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword";
19+
private static final String REFRESH_TOKEN_BASE_URL = "https://securetoken.googleapis.com/v1/token";
20+
21+
private final String webApiKey;
22+
23+
public FirebaseAuthClient(@Value("${com.baeldung.firebase.web-api-key}") String webApiKey) {
24+
this.webApiKey = webApiKey;
25+
}
26+
27+
public FirebaseSignInResponse login(String emailId, String password) {
28+
FirebaseSignInRequest requestBody = new FirebaseSignInRequest(emailId, password, true);
29+
return sendSignInRequest(requestBody);
30+
}
31+
32+
public RefreshTokenResponse exchangeRefreshToken(String refreshToken) {
33+
RefreshTokenRequest requestBody = new RefreshTokenRequest(REFRESH_TOKEN_GRANT_TYPE, refreshToken);
34+
return sendRefreshTokenRequest(requestBody);
35+
}
36+
37+
private FirebaseSignInResponse sendSignInRequest(FirebaseSignInRequest firebaseSignInRequest) {
38+
try {
39+
return RestClient.create(SIGN_IN_BASE_URL)
40+
.post()
41+
.uri(uriBuilder -> uriBuilder
42+
.queryParam(API_KEY_PARAM, webApiKey)
43+
.build())
44+
.body(firebaseSignInRequest)
45+
.contentType(MediaType.APPLICATION_JSON)
46+
.retrieve()
47+
.body(FirebaseSignInResponse.class);
48+
} catch (HttpClientErrorException exception) {
49+
if (exception.getResponseBodyAsString().contains(INVALID_CREDENTIALS_ERROR)) {
50+
throw new InvalidLoginCredentialsException("Invalid login credentials provided");
51+
}
52+
throw exception;
53+
}
54+
}
55+
56+
private RefreshTokenResponse sendRefreshTokenRequest(RefreshTokenRequest refreshTokenRequest) {
57+
try {
58+
return RestClient.create(REFRESH_TOKEN_BASE_URL)
59+
.post()
60+
.uri(uriBuilder -> uriBuilder
61+
.queryParam(API_KEY_PARAM, webApiKey)
62+
.build())
63+
.body(refreshTokenRequest)
64+
.contentType(MediaType.APPLICATION_JSON)
65+
.retrieve()
66+
.body(RefreshTokenResponse.class);
67+
} catch (HttpClientErrorException exception) {
68+
if (exception.getResponseBodyAsString().contains(INVALID_REFRESH_TOKEN_ERROR)) {
69+
throw new InvalidRefreshTokenException("Invalid refresh token provided");
70+
}
71+
throw exception;
72+
}
73+
}
74+
75+
record FirebaseSignInRequest(String email, String password, boolean returnSecureToken) {
76+
}
77+
78+
record FirebaseSignInResponse(String idToken, String refreshToken) {
79+
}
80+
81+
record RefreshTokenRequest(String grant_type, String refresh_token) {
82+
}
83+
84+
record RefreshTokenResponse(String id_token) {
85+
}
86+
87+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.baeldung.gcp.firebase.auth;
2+
3+
import java.io.ByteArrayInputStream;
4+
import java.io.IOException;
5+
import java.io.InputStream;
6+
7+
import org.springframework.beans.factory.annotation.Value;
8+
import org.springframework.context.annotation.Bean;
9+
import org.springframework.context.annotation.Configuration;
10+
import org.springframework.core.io.Resource;
11+
12+
import com.google.auth.oauth2.GoogleCredentials;
13+
import com.google.firebase.FirebaseApp;
14+
import com.google.firebase.FirebaseOptions;
15+
import com.google.firebase.auth.FirebaseAuth;
16+
17+
@Configuration
18+
public class FirebaseAuthConfiguration {
19+
20+
@Value("classpath:/private-key.json")
21+
private Resource privateKey;
22+
23+
@Bean
24+
public FirebaseApp firebaseApp() throws IOException {
25+
InputStream credentials = new ByteArrayInputStream(privateKey.getContentAsByteArray());
26+
FirebaseOptions firebaseOptions = FirebaseOptions.builder()
27+
.setCredentials(GoogleCredentials.fromStream(credentials))
28+
.build();
29+
return FirebaseApp.initializeApp(firebaseOptions);
30+
}
31+
32+
@Bean
33+
public FirebaseAuth firebaseAuth(FirebaseApp firebaseApp) {
34+
return FirebaseAuth.getInstance(firebaseApp);
35+
}
36+
37+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.baeldung.gcp.firebase.auth;
2+
3+
import org.springframework.http.HttpStatus;
4+
import org.springframework.web.server.ResponseStatusException;
5+
6+
public class InvalidLoginCredentialsException extends ResponseStatusException {
7+
8+
public InvalidLoginCredentialsException(String reason) {
9+
super(HttpStatus.UNAUTHORIZED, reason);
10+
}
11+
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.baeldung.gcp.firebase.auth;
2+
3+
import org.springframework.http.HttpStatus;
4+
import org.springframework.web.server.ResponseStatusException;
5+
6+
public class InvalidRefreshTokenException extends ResponseStatusException {
7+
8+
public InvalidRefreshTokenException(String reason) {
9+
super(HttpStatus.FORBIDDEN, reason);
10+
}
11+
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.baeldung.gcp.firebase.auth;
2+
3+
import org.springframework.context.annotation.Bean;
4+
import org.springframework.context.annotation.Configuration;
5+
import org.springframework.http.HttpMethod;
6+
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
7+
import org.springframework.security.web.SecurityFilterChain;
8+
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
9+
10+
@Configuration
11+
public class SecurityConfiguration {
12+
13+
private static final String[] WHITELISTED_API_ENDPOINTS = { "/user", "/user/login", "/user/refresh-token" };
14+
15+
private final TokenAuthenticationFilter tokenAuthenticationFilter;
16+
17+
public SecurityConfiguration(TokenAuthenticationFilter tokenAuthenticationFilter) {
18+
this.tokenAuthenticationFilter = tokenAuthenticationFilter;
19+
}
20+
21+
@Bean
22+
public SecurityFilterChain configure(HttpSecurity http) throws Exception {
23+
http
24+
.authorizeHttpRequests(authManager -> {
25+
authManager.requestMatchers(HttpMethod.POST, WHITELISTED_API_ENDPOINTS)
26+
.permitAll()
27+
.anyRequest()
28+
.authenticated();
29+
})
30+
.addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
31+
32+
return http.build();
33+
}
34+
35+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package com.baeldung.gcp.firebase.auth;
2+
3+
import java.io.IOException;
4+
import java.util.Optional;
5+
6+
import org.springframework.http.HttpStatus;
7+
import org.springframework.http.MediaType;
8+
import org.springframework.http.ProblemDetail;
9+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
10+
import org.springframework.security.core.context.SecurityContextHolder;
11+
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
12+
import org.springframework.stereotype.Component;
13+
import org.springframework.web.filter.OncePerRequestFilter;
14+
15+
import com.fasterxml.jackson.core.JsonProcessingException;
16+
import com.fasterxml.jackson.databind.ObjectMapper;
17+
import com.google.firebase.auth.FirebaseAuth;
18+
import com.google.firebase.auth.FirebaseAuthException;
19+
import com.google.firebase.auth.FirebaseToken;
20+
21+
import jakarta.servlet.FilterChain;
22+
import jakarta.servlet.ServletException;
23+
import jakarta.servlet.http.HttpServletRequest;
24+
import jakarta.servlet.http.HttpServletResponse;
25+
26+
@Component
27+
public class TokenAuthenticationFilter extends OncePerRequestFilter {
28+
29+
private static final String BEARER_PREFIX = "Bearer ";
30+
private static final String USER_ID_CLAIM = "user_id";
31+
private static final String AUTHORIZATION_HEADER = "Authorization";
32+
33+
private final FirebaseAuth firebaseAuth;
34+
private final ObjectMapper objectMapper;
35+
36+
public TokenAuthenticationFilter(FirebaseAuth firebaseAuth, ObjectMapper objectMapper) {
37+
this.firebaseAuth = firebaseAuth;
38+
this.objectMapper = objectMapper;
39+
}
40+
41+
@Override
42+
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
43+
throws IOException, ServletException {
44+
String authorizationHeader = request.getHeader(AUTHORIZATION_HEADER);
45+
46+
if (authorizationHeader != null && authorizationHeader.startsWith(BEARER_PREFIX)) {
47+
String token = authorizationHeader.replace(BEARER_PREFIX, "");
48+
Optional<String> userId = extractUserIdFromToken(token);
49+
50+
if (userId.isPresent()) {
51+
var authentication = new UsernamePasswordAuthenticationToken(userId.get(), null, null);
52+
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
53+
SecurityContextHolder.getContext().setAuthentication(authentication);
54+
} else {
55+
setAuthErrorDetails(response);
56+
return;
57+
}
58+
}
59+
filterChain.doFilter(request, response);
60+
}
61+
62+
private Optional<String> extractUserIdFromToken(String token) {
63+
try {
64+
FirebaseToken firebaseToken = firebaseAuth.verifyIdToken(token, true);
65+
String userId = String.valueOf(firebaseToken.getClaims().get(USER_ID_CLAIM));
66+
return Optional.of(userId);
67+
} catch (FirebaseAuthException exception) {
68+
return Optional.empty();
69+
}
70+
}
71+
72+
private void setAuthErrorDetails(HttpServletResponse response) throws JsonProcessingException, IOException {
73+
HttpStatus unauthorized = HttpStatus.UNAUTHORIZED;
74+
response.setStatus(unauthorized.value());
75+
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
76+
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(unauthorized, "Authentication failure: Token missing, invalid or expired");
77+
response.getWriter().write(objectMapper.writeValueAsString(problemDetail));
78+
}
79+
80+
}

0 commit comments

Comments
 (0)