Step 4: Production Implementation – Spring Security Series

🎥 YouTube Video: Coming Soon – Subscribe to JavaiOS Channel


AdventureTube Authentication Flow Implementation 🔐

This post details the complete implementation of Spring Security authentication flow in the AdventureTube auth-service module. We’ll walk through the step-by-step process of how Google ID token authentication is implemented, how custom authentication providers are registered, and how the entire authentication flow works in practice.


🏗️ Authentication Flow Architecture

The AdventureTube authentication system implements a sophisticated flow that combines:

  • Google ID Token Validation – Primary authentication mechanism
  • Custom Authentication Provider – Handles business logic and validation
  • JWT Token Generation – Issues access and refresh tokens
  • Microservice Communication – Integrates with member-service for user data

High-Level Flow Diagram

Client Request → AuthController → AuthService → CustomAuthenticationProvider 
       ↓              ↓            ↓                      ↓
   Google ID     Validate      Load User            Authenticate
    Token        Request       Details              & Generate JWT

🔧 Core Implementation Components

1. Custom Authentication Provider

The heart of our authentication system is the CustomAuthenticationProvider:

@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {

    private final CustomUserDetailService customUserDetailService;
    private final PasswordEncoder passwordEncoder;

    public CustomAuthenticationProvider(
            CustomUserDetailService customUserDetailService,
            PasswordEncoder passwordEncoder) {
        this.customUserDetailService = customUserDetailService;
        this.passwordEncoder = passwordEncoder;
    }

    @Override
    public Authentication authenticate(Authentication authentication) 
            throws AuthenticationException {
        
        String email = authentication.getName();
        String googleId = authentication.getCredentials().toString();

        log.info("Authenticating user with email: {}", email);

        try {
            // Load user details from member-service
            UserDetails userDetails = customUserDetailService.loadUserByUsername(email);
            
            // Validate Google ID
            if (userDetails instanceof CustomUserDetails customUser) {
                if (!googleId.equals(customUser.getGoogleId())) {
                    log.warn("Google ID mismatch for user: {}", email);
                    throw new BadCredentialsException("Invalid credentials");
                }
                
                log.info("Authentication successful for user: {}", email);
                return new UsernamePasswordAuthenticationToken(
                    userDetails, googleId, userDetails.getAuthorities());
            }
            
            throw new AuthenticationServiceException("Invalid user details type");
            
        } catch (UsernameNotFoundException e) {
            log.warn("User not found: {}", email);
            throw new BadCredentialsException("Invalid credentials");
        } catch (Exception e) {
            log.error("Authentication error for user: {}", email, e);
            throw new AuthenticationServiceException("Authentication failed");
        }
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }
}

2. Custom User Details Service

This service integrates with the member-service to load user information:

@Service
public class CustomUserDetailService implements UserDetailsService {

    private final RestTemplate restTemplate;
    
    @Value("${member.service.url}")
    private String memberServiceUrl;

    public CustomUserDetailService(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        try {
            log.debug("Loading user details for email: {}", email);
            
            // Call member-service to get user information
            String url = memberServiceUrl + "/api/members/email/" + email;
            MemberDTO memberDTO = restTemplate.getForObject(url, MemberDTO.class);
            
            if (memberDTO == null) {
                log.warn("User not found in member service: {}", email);
                throw new UsernameNotFoundException("User not found: " + email);
            }

            log.debug("User found: {} with role: {}", email, memberDTO.getRole());
            
            // Convert to UserDetails
            return CustomUserDetails.builder()
                .username(memberDTO.getEmail())
                .password(memberDTO.getPassword())
                .googleId(memberDTO.getGoogleId())
                .role(memberDTO.getRole())
                .enabled(true)
                .accountNonExpired(true)
                .accountNonLocked(true)
                .credentialsNonExpired(true)
                .build();
                
        } catch (RestClientException e) {
            log.error("Error calling member service for user: {}", email, e);
            throw new AuthenticationServiceException("User service unavailable");
        }
    }
}

3. Security Configuration

The security configuration registers our custom provider and sets up the filter chain:

@Configuration
@EnableWebSecurity
public class AuthServiceConfig {

    private final CustomAuthenticationProvider customAuthenticationProvider;
    private final JwtAuthFilter jwtAuthFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(SecurityConstants.OPEN_ENDPOINTS).permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .authenticationProvider(customAuthenticationProvider)
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(jwtAuthenticationEntryPoint())
            )
            .build();
    }

    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }

    @Bean
    public JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint() {
        return new JwtAuthenticationEntryPoint();
    }
}

🚀 Complete Authentication Flow

Step 1: Client Login Request

The authentication process begins when a client sends a login request:

@RestController
@RequestMapping("/auth")
public class AuthController {

    private final AuthService authService;

    @PostMapping("/token")
    public ResponseEntity<AuthResponse> issueToken(@RequestBody AuthRequest request) {
        try {
            log.info("Token request received for email: {}", request.getEmail());
            
            AuthResponse response = authService.issueToken(request);
            
            log.info("Token issued successfully for user: {}", request.getEmail());
            return ResponseEntity.ok(response);
            
        } catch (AuthenticationException e) {
            log.warn("Authentication failed for user: {}", request.getEmail());
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(AuthResponse.error("Authentication failed"));
        }
    }
}

Step 2: Google ID Token Validation

The auth service validates the Google ID token before proceeding:

@Service
public class AuthService {

    private final AuthenticationManager authenticationManager;
    private final JwtUtil jwtUtil;
    private final GoogleIdTokenVerifier tokenVerifier;

    public AuthResponse issueToken(AuthRequest request) {
        // 1. Validate Google ID token
        GoogleIdToken idToken = validateGoogleToken(request.getGoogleIdToken());
        String email = idToken.getPayload().getEmail();
        
        // 2. Verify email matches request
        if (!email.equals(request.getEmail())) {
            throw new BadCredentialsException("Email mismatch");
        }

        // 3. Create authentication token
        UsernamePasswordAuthenticationToken authToken = 
            new UsernamePasswordAuthenticationToken(
                email, 
                request.getGoogleId()
            );

        // 4. Authenticate using custom provider
        Authentication authentication = authenticationManager.authenticate(authToken);

        // 5. Generate JWT tokens
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        String accessToken = jwtUtil.generateAccessToken(userDetails);
        String refreshToken = jwtUtil.generateRefreshToken(userDetails);

        // 6. Store refresh token
        refreshTokenService.storeRefreshToken(email, refreshToken);

        return AuthResponse.builder()
            .accessToken(accessToken)
            .refreshToken(refreshToken)
            .expiresIn(jwtUtil.getAccessTokenExpiration())
            .tokenType("Bearer")
            .build();
    }

    private GoogleIdToken validateGoogleToken(String tokenString) {
        try {
            GoogleIdToken idToken = tokenVerifier.verify(tokenString);
            if (idToken == null) {
                throw new BadCredentialsException("Invalid Google ID token");
            }
            return idToken;
        } catch (Exception e) {
            log.error("Google token validation failed", e);
            throw new BadCredentialsException("Invalid Google ID token");
        }
    }
}

Step 3: JWT Token Generation

The JWT utility generates both access and refresh tokens:

@Component
public class JwtUtil {

    @Value("${jwt.secret}")
    private String secret;
    
    @Value("${jwt.access.expiration}")
    private long accessTokenExpiration;
    
    @Value("${jwt.refresh.expiration}")
    private long refreshTokenExpiration;

    public String generateAccessToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("role", userDetails.getAuthorities().iterator().next().getAuthority());
        claims.put("type", "access");
        
        return createToken(claims, userDetails.getUsername(), accessTokenExpiration);
    }

    public String generateRefreshToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("type", "refresh");
        
        return createToken(claims, userDetails.getUsername(), refreshTokenExpiration);
    }

    private String createToken(Map<String, Object> claims, String subject, long expiration) {
        return Jwts.builder()
            .setClaims(claims)
            .setSubject(subject)
            .setIssuedAt(new Date(System.currentTimeMillis()))
            .setExpiration(new Date(System.currentTimeMillis() + expiration))
            .signWith(SignatureAlgorithm.HS512, secret)
            .compact();
    }
}

🔒 JWT Authentication Filter

For subsequent requests, the JWT filter handles authentication:

@Component
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final CustomUserDetailService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
            HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        // Skip filtering for open endpoints
        if (isOpenEndpoint(request.getRequestURI())) {
            filterChain.doFilter(request, response);
            return;
        }

        String authHeader = request.getHeader("Authorization");
        String token = null;
        String username = null;

        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            token = authHeader.substring(7);
            try {
                username = jwtUtil.extractUsername(token);
            } catch (Exception e) {
                log.warn("Invalid JWT token: {}", e.getMessage());
            }
        }

        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            
            if (jwtUtil.validateToken(token, userDetails)) {
                UsernamePasswordAuthenticationToken authToken = 
                    new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                        
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authToken);
                
                log.debug("JWT authentication successful for user: {}", username);
            }
        }

        filterChain.doFilter(request, response);
    }

    private boolean isOpenEndpoint(String requestURI) {
        return Arrays.stream(SecurityConstants.OPEN_ENDPOINTS)
            .anyMatch(endpoint -> requestURI.matches(endpoint.replace("**", ".*")));
    }
}

📊 Error Handling and Security

Custom Authentication Entry Point

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, 
            HttpServletResponse response,
            AuthenticationException authException) throws IOException {
            
        log.warn("Unauthorized access attempt: {}", authException.getMessage());
        
        response.setContentType("application/json");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        
        ObjectMapper mapper = new ObjectMapper();
        ErrorResponse errorResponse = ErrorResponse.builder()
            .error("Unauthorized")
            .message("Access token is missing or invalid")
            .timestamp(Instant.now())
            .path(request.getRequestURI())
            .build();
            
        response.getWriter().write(mapper.writeValueAsString(errorResponse));
    }
}

Global Exception Handler

@RestControllerAdvice
public class AuthExceptionHandler {

    @ExceptionHandler(BadCredentialsException.class)
    public ResponseEntity<ErrorResponse> handleBadCredentials(BadCredentialsException e) {
        log.warn("Bad credentials: {}", e.getMessage());
        
        ErrorResponse error = ErrorResponse.builder()
            .error("Authentication Failed")
            .message("Invalid email or Google ID")
            .timestamp(Instant.now())
            .build();
            
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
    }

    @ExceptionHandler(AuthenticationServiceException.class)
    public ResponseEntity<ErrorResponse> handleAuthServiceException(
            AuthenticationServiceException e) {
        log.error("Authentication service error: {}", e.getMessage());
        
        ErrorResponse error = ErrorResponse.builder()
            .error("Service Error")
            .message("Authentication service temporarily unavailable")
            .timestamp(Instant.now())
            .build();
            
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(error);
    }
}

🚀 Production Considerations

1. Security Best Practices

  • Token Rotation – Implement refresh token rotation
  • Rate Limiting – Add rate limiting to auth endpoints
  • Audit Logging – Log all authentication attempts
  • Secret Management – Use secure secret storage

2. Performance Optimizations

  • Connection Pooling – Configure RestTemplate with connection pool
  • Caching – Cache user details for short periods
  • Async Processing – Use async for non-critical operations

3. Monitoring and Metrics

@Component
public class AuthMetrics {

    private final MeterRegistry meterRegistry;
    private final Counter authSuccessCounter;
    private final Counter authFailureCounter;
    private final Timer authTimer;

    public AuthMetrics(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
        this.authSuccessCounter = Counter.builder("auth.success")
            .description("Successful authentications")
            .register(meterRegistry);
        this.authFailureCounter = Counter.builder("auth.failure")
            .description("Failed authentications")
            .register(meterRegistry);
        this.authTimer = Timer.builder("auth.duration")
            .description("Authentication duration")
            .register(meterRegistry);
    }

    public void recordAuthSuccess() {
        authSuccessCounter.increment();
    }

    public void recordAuthFailure() {
        authFailureCounter.increment();
    }

    public Timer.Sample startTimer() {
        return Timer.start(meterRegistry);
    }
}

🚀 Continue Your Security Journey

Part of the AdventureTube technical blog series supporting the JavaiOS YouTube channel.

Leave a Comment

Your email address will not be published. Required fields are marked *