Spring Security và JWT

1. Spring Security Core Concepts

1.1 Authentication và Authorization

  • Authentication: Xác thực danh tính người dùng (Who are you?)
  • Authorization: Phân quyền truy cập tài nguyên (What can you do?)
  • Principal: Đối tượng đại diện cho người dùng đã được xác thực
  • Authorities/Roles: Các quyền và vai trò của người dùng
  • Security Context: Container chứa thông tin authentication hiện tại

1.2 Spring Security Architecture

// Modern Spring Security Configuration (Spring Security 6.x)
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig {

    private final UserDetailsService userDetailsService;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

    public SecurityConfig(UserDetailsService userDetailsService,
                         JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
                         JwtAccessDeniedHandler jwtAccessDeniedHandler) {
        this.userDetailsService = userDetailsService;
        this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
        this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .exceptionHandling(exceptions -> exceptions
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler))
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/api/auth/**", "/api/public/**").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/users/*/profile").hasRole("USER")
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers("/actuator/**").hasRole("ACTUATOR")
                .anyRequest().authenticated())
            .addFilterBefore(jwtAuthenticationFilter(), 
                UsernamePasswordAuthenticationFilter.class)
            .headers(headers -> headers
                .frameOptions().deny()
                .contentSecurityPolicy("default-src 'self'; script-src 'self'")
                .and()
                .httpStrictTransportSecurity(hstsConfig -> hstsConfig
                    .maxAgeInSeconds(31536000)
                    .includeSubdomains(true)));

        return http.build();
    }

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

    @Bean
    public DaoAuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(userDetailsService);
        authProvider.setPasswordEncoder(passwordEncoder());
        authProvider.setHideUserNotFoundExceptions(false);
        return authProvider;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(12);
    }

    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() {
        return new JwtAuthenticationFilter();
    }
}

2. JWT (JSON Web Token) Implementation

2.1 JWT Structure và Security

@Component
public class JwtTokenProvider {

    private static final Logger logger = LoggerFactory.getLogger(JwtTokenProvider.class);

    @Value("${app.jwt.secret}")
    private String jwtSecret;

    @Value("${app.jwt.access-token.expiration:3600000}") // 1 hour
    private long accessTokenExpiration;

    @Value("${app.jwt.refresh-token.expiration:86400000}") // 24 hours
    private long refreshTokenExpiration;

    private Key getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(jwtSecret);
        return Keys.hmacShaKeyFor(keyBytes);
    }

    public TokenResponse generateTokens(UserDetails userDetails) {
        String subject = userDetails.getUsername();
        Date now = new Date();

        // Access Token
        String accessToken = Jwts.builder()
            .setSubject(subject)
            .setIssuedAt(now)
            .setExpiration(new Date(now.getTime() + accessTokenExpiration))
            .claim("type", "access")
            .claim("authorities", userDetails.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList()))
            .signWith(getSigningKey(), SignatureAlgorithm.HS512)
            .compact();

        // Refresh Token
        String refreshToken = Jwts.builder()
            .setSubject(subject)
            .setIssuedAt(now)
            .setExpiration(new Date(now.getTime() + refreshTokenExpiration))
            .claim("type", "refresh")
            .signWith(getSigningKey(), SignatureAlgorithm.HS512)
            .compact();

        return TokenResponse.builder()
            .accessToken(accessToken)
            .refreshToken(refreshToken)
            .tokenType("Bearer")
            .expiresIn(accessTokenExpiration / 1000)
            .build();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token);
            return true;
        } catch (SecurityException ex) {
            logger.error("Invalid JWT signature");
        } catch (MalformedJwtException ex) {
            logger.error("Invalid JWT token");
        } catch (ExpiredJwtException ex) {
            logger.error("Expired JWT token");
        } catch (UnsupportedJwtException ex) {
            logger.error("Unsupported JWT token");
        } catch (IllegalArgumentException ex) {
            logger.error("JWT claims string is empty");
        }
        return false;
    }

    public Claims getClaimsFromToken(String token) {
        return Jwts.parserBuilder()
            .setSigningKey(getSigningKey())
            .build()
            .parseClaimsJws(token)
            .getBody();
    }

    public String getUsernameFromToken(String token) {
        return getClaimsFromToken(token).getSubject();
    }

    public Date getExpirationDateFromToken(String token) {
        return getClaimsFromToken(token).getExpiration();
    }

    public List<String> getAuthoritiesFromToken(String token) {
        Claims claims = getClaimsFromToken(token);
        return claims.get("authorities", List.class);
    }

    public boolean isTokenExpired(String token) {
        Date expiration = getExpirationDateFromToken(token);
        return expiration.before(new Date());
    }

    public String getTokenType(String token) {
        Claims claims = getClaimsFromToken(token);
        return claims.get("type", String.class);
    }
}

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class TokenResponse {
    private String accessToken;
    private String refreshToken;
    private String tokenType;
    private Long expiresIn;
}

2.2 Advanced JWT Security Features

@Service
public class TokenBlacklistService {

    private final RedisTemplate<String, String> redisTemplate;
    private final JwtTokenProvider jwtTokenProvider;

    public TokenBlacklistService(RedisTemplate<String, String> redisTemplate,
                                JwtTokenProvider jwtTokenProvider) {
        this.redisTemplate = redisTemplate;
        this.jwtTokenProvider = jwtTokenProvider;
    }

    public void blacklistToken(String token) {
        try {
            Date expiration = jwtTokenProvider.getExpirationDateFromToken(token);
            long ttl = expiration.getTime() - System.currentTimeMillis();

            if (ttl > 0) {
                redisTemplate.opsForValue().set(
                    "blacklisted_token:" + token,
                    "true",
                    Duration.ofMilliseconds(ttl)
                );
            }
        } catch (Exception e) {
            logger.error("Error blacklisting token", e);
        }
    }

    public boolean isTokenBlacklisted(String token) {
        return Boolean.TRUE.equals(redisTemplate.hasKey("blacklisted_token:" + token));
    }

    public void blacklistAllUserTokens(String username) {
        String pattern = "active_token:" + username + ":*";
        Set<String> keys = redisTemplate.keys(pattern);

        if (keys != null && !keys.isEmpty()) {
            keys.forEach(key -> {
                String token = redisTemplate.opsForValue().get(key);
                if (token != null) {
                    blacklistToken(token);
                }
            });
            redisTemplate.delete(keys);
        }
    }
}

@Service
public class TokenManagerService {

    private final RedisTemplate<String, String> redisTemplate;
    private final JwtTokenProvider jwtTokenProvider;

    public void storeActiveToken(String username, String token, String deviceId) {
        String key = "active_token:" + username + ":" + deviceId;
        Date expiration = jwtTokenProvider.getExpirationDateFromToken(token);
        long ttl = expiration.getTime() - System.currentTimeMillis();

        if (ttl > 0) {
            redisTemplate.opsForValue().set(key, token, Duration.ofMilliseconds(ttl));
        }
    }

    public void removeActiveToken(String username, String deviceId) {
        String key = "active_token:" + username + ":" + deviceId;
        redisTemplate.delete(key);
    }

    public boolean isTokenActive(String username, String token) {
        String pattern = "active_token:" + username + ":*";
        Set<String> keys = redisTemplate.keys(pattern);

        if (keys != null) {
            for (String key : keys) {
                String storedToken = redisTemplate.opsForValue().get(key);
                if (token.equals(storedToken)) {
                    return true;
                }
            }
        }
        return false;
    }
}

3. Advanced Authentication Filter

3.1 Enhanced JWT Authentication Filter

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);

    private final JwtTokenProvider jwtTokenProvider;
    private final UserDetailsService userDetailsService;
    private final TokenBlacklistService tokenBlacklistService;
    private final TokenManagerService tokenManagerService;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider,
                                  UserDetailsService userDetailsService,
                                  TokenBlacklistService tokenBlacklistService,
                                  TokenManagerService tokenManagerService) {
        this.jwtTokenProvider = jwtTokenProvider;
        this.userDetailsService = userDetailsService;
        this.tokenBlacklistService = tokenBlacklistService;
        this.tokenManagerService = tokenManagerService;
    }

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

        try {
            String jwt = getJwtFromRequest(request);

            if (StringUtils.hasText(jwt) && validateJwtToken(jwt)) {
                String username = jwtTokenProvider.getUsernameFromToken(jwt);

                // Check if token is active
                if (!tokenManagerService.isTokenActive(username, jwt)) {
                    logger.warn("Inactive token attempted for user: {}", username);
                    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                    return;
                }

                UserDetails userDetails = userDetailsService.loadUserByUsername(username);

                if (userDetails != null && userDetails.isEnabled()) {
                    List<SimpleGrantedAuthority> authorities = jwtTokenProvider
                        .getAuthoritiesFromToken(jwt)
                        .stream()
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

                    UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(
                            userDetails, null, authorities);

                    authentication.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request));

                    SecurityContextHolder.getContext().setAuthentication(authentication);

                    // Add user info to MDC for logging
                    MDC.put("username", username);
                    MDC.put("userId", userDetails.getUsername());
                }
            }
        } catch (Exception ex) {
            logger.error("Could not set user authentication in security context", ex);
            SecurityContextHolder.clearContext();
        }

        try {
            filterChain.doFilter(request, response);
        } finally {
            MDC.clear();
        }
    }

    private boolean validateJwtToken(String jwt) {
        return jwtTokenProvider.validateToken(jwt) &&
               !tokenBlacklistService.isTokenBlacklisted(jwt) &&
               "access".equals(jwtTokenProvider.getTokenType(jwt));
    }

    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String path = request.getRequestURI();
        return path.startsWith("/api/auth/") ||
               path.startsWith("/api/public/") ||
               path.startsWith("/swagger-") ||
               path.startsWith("/v3/api-docs") ||
               path.equals("/favicon.ico");
    }
}

4. Comprehensive Authentication Flow

4.1 Enhanced Authentication Controller

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

    private final AuthenticationManager authenticationManager;
    private final JwtTokenProvider jwtTokenProvider;
    private final UserService userService;
    private final TokenManagerService tokenManagerService;
    private final TokenBlacklistService tokenBlacklistService;
    private final LoginAttemptService loginAttemptService;

    @PostMapping("/login")
    public ResponseEntity<AuthResponse> login(
            @Valid @RequestBody LoginRequest loginRequest,
            HttpServletRequest request) {

        String clientIp = getClientIP(request);
        String deviceId = getDeviceId(request);

        // Check for brute force attacks
        if (loginAttemptService.isBlocked(clientIp)) {
            throw new AuthenticationException("IP address is temporarily blocked due to too many failed attempts");
        }

        try {
            Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                    loginRequest.getUsername(),
                    loginRequest.getPassword()
                )
            );

            SecurityContextHolder.getContext().setAuthentication(authentication);
            UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();

            // Generate tokens
            TokenResponse tokens = jwtTokenProvider.generateTokens(userPrincipal);

            // Store active token
            tokenManagerService.storeActiveToken(
                userPrincipal.getUsername(), 
                tokens.getAccessToken(),
                deviceId
            );

            // Record successful login
            loginAttemptService.loginSucceeded(clientIp);
            userService.updateLastLogin(userPrincipal.getId());

            log.info("User {} successfully logged in from IP: {}", 
                userPrincipal.getUsername(), clientIp);

            return ResponseEntity.ok(AuthResponse.builder()
                .tokens(tokens)
                .user(UserResponse.from(userPrincipal))
                .build());

        } catch (BadCredentialsException e) {
            loginAttemptService.loginFailed(clientIp);
            throw new AuthenticationException("Invalid username or password");
        }
    }

    @PostMapping("/refresh")
    public ResponseEntity<TokenResponse> refreshToken(
            @Valid @RequestBody RefreshTokenRequest request,
            HttpServletRequest httpRequest) {

        String refreshToken = request.getRefreshToken();

        if (!jwtTokenProvider.validateToken(refreshToken) ||
            !"refresh".equals(jwtTokenProvider.getTokenType(refreshToken))) {
            throw new AuthenticationException("Invalid refresh token");
        }

        String username = jwtTokenProvider.getUsernameFromToken(refreshToken);
        UserDetails userDetails = userService.loadUserByUsername(username);

        // Generate new tokens
        TokenResponse tokens = jwtTokenProvider.generateTokens(userDetails);

        // Store new active token
        String deviceId = getDeviceId(httpRequest);
        tokenManagerService.storeActiveToken(username, tokens.getAccessToken(), deviceId);

        return ResponseEntity.ok(tokens);
    }

    @PostMapping("/logout")
    @PreAuthorize("isAuthenticated()")
    public ResponseEntity<ApiResponse> logout(
            HttpServletRequest request,
            Authentication authentication) {

        String jwt = getJwtFromRequest(request);
        String deviceId = getDeviceId(request);

        if (jwt != null) {
            // Blacklist current token
            tokenBlacklistService.blacklistToken(jwt);

            // Remove from active tokens
            tokenManagerService.removeActiveToken(
                authentication.getName(), deviceId);
        }

        SecurityContextHolder.clearContext();

        return ResponseEntity.ok(
            new ApiResponse(true, "Logged out successfully"));
    }

    @PostMapping("/logout-all")
    @PreAuthorize("isAuthenticated()")
    public ResponseEntity<ApiResponse> logoutAll(Authentication authentication) {
        // Blacklist all user tokens
        tokenBlacklistService.blacklistAllUserTokens(authentication.getName());

        SecurityContextHolder.clearContext();

        return ResponseEntity.ok(
            new ApiResponse(true, "Logged out from all devices"));
    }

    private String getClientIP(HttpServletRequest request) {
        String xfHeader = request.getHeader("X-Forwarded-For");
        if (xfHeader == null) {
            return request.getRemoteAddr();
        }
        return xfHeader.split(",")[0].trim();
    }

    private String getDeviceId(HttpServletRequest request) {
        String deviceId = request.getHeader("X-Device-ID");
        return deviceId != null ? deviceId : "unknown";
    }

    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

4.2 Login Attempt Service (Brute Force Protection)

@Service
public class LoginAttemptService {

    private static final int MAX_ATTEMPT = 5;
    private static final int BLOCK_DURATION_MINUTES = 15;

    private final RedisTemplate<String, String> redisTemplate;

    public LoginAttemptService(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void loginSucceeded(String key) {
        redisTemplate.delete("failed_attempt:" + key);
    }

    public void loginFailed(String key) {
        String redisKey = "failed_attempt:" + key;
        String attempts = redisTemplate.opsForValue().get(redisKey);

        int attemptCount = attempts != null ? Integer.parseInt(attempts) : 0;
        attemptCount++;

        if (attemptCount >= MAX_ATTEMPT) {
            // Block the IP
            redisTemplate.opsForValue().set(
                "blocked_ip:" + key,
                "true",
                Duration.ofMinutes(BLOCK_DURATION_MINUTES)
            );
            redisTemplate.delete(redisKey);
        } else {
            redisTemplate.opsForValue().set(
                redisKey,
                String.valueOf(attemptCount),
                Duration.ofMinutes(BLOCK_DURATION_MINUTES)
            );
        }
    }

    public boolean isBlocked(String key) {
        return Boolean.TRUE.equals(redisTemplate.hasKey("blocked_ip:" + key));
    }

    public int getFailedAttempts(String key) {
        String attempts = redisTemplate.opsForValue().get("failed_attempt:" + key);
        return attempts != null ? Integer.parseInt(attempts) : 0;
    }
}

5. Advanced User Management

5.1 Enhanced UserDetails Implementation

@Entity
@Table(name = "users", uniqueConstraints = {
    @UniqueConstraint(columnNames = "username"),
    @UniqueConstraint(columnNames = "email")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank
    @Size(max = 40)
    @Column(nullable = false, unique = true)
    private String username;

    @NotBlank
    @Size(max = 100)
    @Email
    @Column(nullable = false, unique = true)
    private String email;

    @NotBlank
    @Size(max = 100)
    @Column(nullable = false)
    private String password;

    @ManyToMany(fetch = FetchType.LAZY)
    @JoinTable(name = "user_roles",
        joinColumns = @JoinColumn(name = "user_id"),
        inverseJoinColumns = @JoinColumn(name = "role_id"))
    private Set<Role> roles = new HashSet<>();

    @Column(name = "account_non_expired")
    private boolean accountNonExpired = true;

    @Column(name = "account_non_locked")
    private boolean accountNonLocked = true;

    @Column(name = "credentials_non_expired")
    private boolean credentialsNonExpired = true;

    @Column(name = "enabled")
    private boolean enabled = true;

    @Column(name = "created_at")
    private Instant createdAt;

    @Column(name = "updated_at")
    private Instant updatedAt;

    @Column(name = "last_login")
    private Instant lastLogin;

    @Column(name = "failed_login_attempts")
    private int failedLoginAttempts = 0;

    @Column(name = "locked_until")
    private Instant lockedUntil;

    @PrePersist
    protected void onCreate() {
        createdAt = Instant.now();
        updatedAt = Instant.now();
    }

    @PreUpdate
    protected void onUpdate() {
        updatedAt = Instant.now();
    }
}

public class UserPrincipal implements UserDetails {
    private Long id;
    private String username;
    private String email;
    private String password;
    private Collection<? extends GrantedAuthority> authorities;
    private boolean accountNonExpired;
    private boolean accountNonLocked;
    private boolean credentialsNonExpired;
    private boolean enabled;

    public UserPrincipal(Long id, String username, String email, 
                        String password, Collection<? extends GrantedAuthority> authorities,
                        boolean accountNonExpired, boolean accountNonLocked,
                        boolean credentialsNonExpired, boolean enabled) {
        this.id = id;
        this.username = username;
        this.email = email;
        this.password = password;
        this.authorities = authorities;
        this.accountNonExpired = accountNonExpired;
        this.accountNonLocked = accountNonLocked;
        this.credentialsNonExpired = credentialsNonExpired;
        this.enabled = enabled;
    }

    public static UserPrincipal create(User user) {
        List<GrantedAuthority> authorities = user.getRoles().stream()
            .map(role -> new SimpleGrantedAuthority(role.getName().name()))
            .collect(Collectors.toList());

        return new UserPrincipal(
            user.getId(),
            user.getUsername(),
            user.getEmail(),
            user.getPassword(),
            authorities,
            user.isAccountNonExpired(),
            user.isAccountNonLocked(),
            user.isCredentialsNonExpired(),
            user.isEnabled()
        );
    }

    // UserDetails interface implementation
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public boolean isAccountNonExpired() {
        return accountNonExpired;
    }

    @Override
    public boolean isAccountNonLocked() {
        return accountNonLocked;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return credentialsNonExpired;
    }

    @Override
    public boolean isEnabled() {
        return enabled;
    }

    // Getters
    public Long getId() { return id; }
    public String getEmail() { return email; }
    public String getUsername() { return username; }
    public String getPassword() { return password; }
}

6. Security Best Practices

6.1 Password Security

@Service
public class PasswordSecurityService {

    private final PasswordEncoder passwordEncoder;
    private final PasswordHistoryRepository passwordHistoryRepository;
    private static final int PASSWORD_HISTORY_LIMIT = 5;

    public PasswordSecurityService(PasswordEncoder passwordEncoder,
                                  PasswordHistoryRepository passwordHistoryRepository) {
        this.passwordEncoder = passwordEncoder;
        this.passwordHistoryRepository = passwordHistoryRepository;
    }

    public boolean isPasswordValid(String password) {
        // Minimum requirements
        if (password.length() < 8) return false;
        if (!password.matches(".*[A-Z].*")) return false; // Uppercase
        if (!password.matches(".*[a-z].*")) return false; // Lowercase
        if (!password.matches(".*\\d.*")) return false;   // Digit
        if (!password.matches(".*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?].*")) return false; // Special char

        // Check for common patterns
        if (containsCommonPatterns(password)) return false;

        return true;
    }

    public boolean isPasswordReused(Long userId, String newPassword) {
        List<PasswordHistory> history = passwordHistoryRepository
            .findByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(0, PASSWORD_HISTORY_LIMIT));

        return history.stream()
            .anyMatch(ph -> passwordEncoder.matches(newPassword, ph.getPasswordHash()));
    }

    public void savePasswordHistory(Long userId, String password) {
        PasswordHistory passwordHistory = PasswordHistory.builder()
            .userId(userId)
            .passwordHash(passwordEncoder.encode(password))
            .createdAt(Instant.now())
            .build();

        passwordHistoryRepository.save(passwordHistory);

        // Keep only recent passwords
        List<PasswordHistory> allHistory = passwordHistoryRepository
            .findByUserIdOrderByCreatedAtDesc(userId);

        if (allHistory.size() > PASSWORD_HISTORY_LIMIT) {
            List<PasswordHistory> toDelete = allHistory.subList(
                PASSWORD_HISTORY_LIMIT, allHistory.size());
            passwordHistoryRepository.deleteAll(toDelete);
        }
    }

    private boolean containsCommonPatterns(String password) {
        String[] commonPatterns = {
            "123456", "password", "qwerty", "admin", "user"
        };

        String lowercasePassword = password.toLowerCase();
        return Arrays.stream(commonPatterns)
            .anyMatch(lowercasePassword::contains);
    }

    public PasswordStrength calculatePasswordStrength(String password) {
        int score = 0;

        if (password.length() >= 8) score++;
        if (password.length() >= 12) score++;
        if (password.matches(".*[A-Z].*")) score++;
        if (password.matches(".*[a-z].*")) score++;
        if (password.matches(".*\\d.*")) score++;
        if (password.matches(".*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?].*")) score++;
        if (!containsCommonPatterns(password)) score++;

        if (score <= 2) return PasswordStrength.WEAK;
        if (score <= 4) return PasswordStrength.MEDIUM;
        return PasswordStrength.STRONG;
    }
}

public enum PasswordStrength {
    WEAK, MEDIUM, STRONG
}

6.2 Security Headers và CORS Configuration

@Configuration
public class SecurityHeadersConfig {

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();

        configuration.setAllowedOriginPatterns(Arrays.asList(
            "https://*.myapp.com",
            "https://localhost:*",
            "http://localhost:*"
        ));

        configuration.setAllowedMethods(Arrays.asList(
            "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"
        ));

        configuration.setAllowedHeaders(Arrays.asList(
            "Authorization", "Content-Type", "X-Requested-With",
            "Accept", "Origin", "Access-Control-Request-Method",
            "Access-Control-Request-Headers", "X-Device-ID"
        ));

        configuration.setExposedHeaders(Arrays.asList(
            "Access-Control-Allow-Origin", "Access-Control-Allow-Credentials"
        ));

        configuration.setAllowCredentials(true);
        configuration.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", configuration);

        return source;
    }

    @Bean
    public WebMvcConfigurer webMvcConfigurer() {
        return new WebMvcConfigurer() {
            @Override
            public void addInterceptors(InterceptorRegistry registry) {
                registry.addInterceptor(new SecurityHeadersInterceptor());
            }
        };
    }
}

public class SecurityHeadersInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, 
                           HttpServletResponse response, 
                           Object handler) throws Exception {

        // Security headers
        response.setHeader("X-Content-Type-Options", "nosniff");
        response.setHeader("X-Frame-Options", "DENY");
        response.setHeader("X-XSS-Protection", "1; mode=block");
        response.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
        response.setHeader("Permissions-Policy", 
            "geolocation=(), microphone=(), camera=()");

        // HSTS header for HTTPS
        if (request.isSecure()) {
            response.setHeader("Strict-Transport-Security", 
                "max-age=31536000; includeSubDomains; preload");
        }

        return true;
    }
}

7. Exception Handling và Error Response

7.1 Security Exception Handling

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationEntryPoint.class);

    @Override
    public void commence(HttpServletRequest httpServletRequest,
                        HttpServletResponse httpServletResponse,
                        AuthenticationException e) throws IOException, ServletException {

        logger.error("Responding with unauthorized error. Message - {}", e.getMessage());

        httpServletResponse.setContentType("application/json;charset=UTF-8");
        httpServletResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        ErrorResponse errorResponse = ErrorResponse.builder()
            .timestamp(Instant.now())
            .status(HttpStatus.UNAUTHORIZED.value())
            .error("Unauthorized")
            .message("Authentication required")
            .path(httpServletRequest.getRequestURI())
            .build();

        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());

        httpServletResponse.getWriter().write(mapper.writeValueAsString(errorResponse));
    }
}

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    private static final Logger logger = LoggerFactory.getLogger(JwtAccessDeniedHandler.class);

    @Override
    public void handle(HttpServletRequest httpServletRequest,
                      HttpServletResponse httpServletResponse,
                      AccessDeniedException e) throws IOException, ServletException {

        logger.error("Responding with access denied error. Message - {}", e.getMessage());

        httpServletResponse.setContentType("application/json;charset=UTF-8");
        httpServletResponse.setStatus(HttpServletResponse.SC_FORBIDDEN);

        ErrorResponse errorResponse = ErrorResponse.builder()
            .timestamp(Instant.now())
            .status(HttpStatus.FORBIDDEN.value())
            .error("Forbidden")
            .message("Access denied")
            .path(httpServletRequest.getRequestURI())
            .build();

        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());

        httpServletResponse.getWriter().write(mapper.writeValueAsString(errorResponse));
    }
}

@ControllerAdvice
public class SecurityExceptionHandler {

    private static final Logger logger = LoggerFactory.getLogger(SecurityExceptionHandler.class);

    @ExceptionHandler(AuthenticationException.class)
    public ResponseEntity<ErrorResponse> handleAuthenticationException(
            AuthenticationException ex, HttpServletRequest request) {

        logger.error("Authentication error: {}", ex.getMessage());

        ErrorResponse errorResponse = ErrorResponse.builder()
            .timestamp(Instant.now())
            .status(HttpStatus.UNAUTHORIZED.value())
            .error("Authentication Failed")
            .message(ex.getMessage())
            .path(request.getRequestURI())
            .build();

        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse);
    }

    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ErrorResponse> handleAccessDeniedException(
            AccessDeniedException ex, HttpServletRequest request) {

        logger.error("Access denied: {}", ex.getMessage());

        ErrorResponse errorResponse = ErrorResponse.builder()
            .timestamp(Instant.now())
            .status(HttpStatus.FORBIDDEN.value())
            .error("Access Denied")
            .message("You don't have permission to access this resource")
            .path(request.getRequestURI())
            .build();

        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorResponse);
    }

    @ExceptionHandler(JwtException.class)
    public ResponseEntity<ErrorResponse> handleJwtException(
            JwtException ex, HttpServletRequest request) {

        logger.error("JWT error: {}", ex.getMessage());

        ErrorResponse errorResponse = ErrorResponse.builder()
            .timestamp(Instant.now())
            .status(HttpStatus.UNAUTHORIZED.value())
            .error("Invalid Token")
            .message("JWT token is invalid or expired")
            .path(request.getRequestURI())
            .build();

        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse);
    }
}

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ErrorResponse {
    private Instant timestamp;
    private int status;
    private String error;
    private String message;
    private String path;
}

8. Testing Security Configuration

8.1 Security Integration Tests

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestDatabase
@Testcontainers
class SecurityIntegrationTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Test
    void whenAccessProtectedEndpointWithoutToken_thenUnauthorized() {
        ResponseEntity<String> response = restTemplate.getForEntity(
            "/api/users/profile", String.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
    }

    @Test
    void whenLoginWithValidCredentials_thenReturnToken() {
        // Given
        createTestUser("testuser", "password123");

        LoginRequest loginRequest = new LoginRequest("testuser", "password123");

        // When
        ResponseEntity<AuthResponse> response = restTemplate.postForEntity(
            "/api/auth/login", loginRequest, AuthResponse.class);

        // Then
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody().getTokens().getAccessToken()).isNotNull();
    }

    @Test
    void whenAccessProtectedEndpointWithValidToken_thenSuccess() {
        // Given
        User user = createTestUser("testuser", "password123");
        String token = generateTokenForUser(user);

        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(token);
        HttpEntity<String> entity = new HttpEntity<>(headers);

        // When
        ResponseEntity<String> response = restTemplate.exchange(
            "/api/users/profile", HttpMethod.GET, entity, String.class);

        // Then
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
    }

    @Test
    void whenExceedLoginAttempts_thenBlocked() {
        // Given
        createTestUser("testuser", "password123");
        LoginRequest invalidRequest = new LoginRequest("testuser", "wrongpassword");

        // When - Make 5 failed attempts
        for (int i = 0; i < 5; i++) {
            ResponseEntity<ErrorResponse> response = restTemplate.postForEntity(
                "/api/auth/login", invalidRequest, ErrorResponse.class);
            assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
        }

        // Then - 6th attempt should be blocked
        ResponseEntity<ErrorResponse> response = restTemplate.postForEntity(
            "/api/auth/login", invalidRequest, ErrorResponse.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
        assertThat(response.getBody().getMessage()).contains("blocked");
    }

    private User createTestUser(String username, String password) {
        User user = User.builder()
            .username(username)
            .email(username + "@test.com")
            .password(passwordEncoder.encode(password))
            .enabled(true)
            .accountNonExpired(true)
            .accountNonLocked(true)
            .credentialsNonExpired(true)
            .build();

        return userRepository.save(user);
    }

    private String generateTokenForUser(User user) {
        UserPrincipal userPrincipal = UserPrincipal.create(user);
        JwtTokenProvider tokenProvider = new JwtTokenProvider();
        ReflectionTestUtils.setField(tokenProvider, "jwtSecret", "mySecretKey");
        ReflectionTestUtils.setField(tokenProvider, "accessTokenExpiration", 3600000L);

        return tokenProvider.generateTokens(userPrincipal).getAccessToken();
    }
}

8.2 JWT Token Tests

@ExtendWith(MockitoExtension.class)
class JwtTokenProviderTest {

    @InjectMocks
    private JwtTokenProvider jwtTokenProvider;

    @BeforeEach
    void setUp() {
        ReflectionTestUtils.setField(jwtTokenProvider, "jwtSecret", 
            Base64.getEncoder().encodeToString("mySecretKey".getBytes()));
        ReflectionTestUtils.setField(jwtTokenProvider, "accessTokenExpiration", 3600000L);
        ReflectionTestUtils.setField(jwtTokenProvider, "refreshTokenExpiration", 86400000L);
    }

    @Test
    void whenGenerateToken_thenReturnValidToken() {
        // Given
        UserDetails userDetails = createUserDetails("testuser");

        // When
        TokenResponse tokens = jwtTokenProvider.generateTokens(userDetails);

        // Then
        assertThat(tokens.getAccessToken()).isNotNull();
        assertThat(tokens.getRefreshToken()).isNotNull();
        assertThat(jwtTokenProvider.validateToken(tokens.getAccessToken())).isTrue();
    }

    @Test
    void whenExtractUsername_thenReturnCorrectUsername() {
        // Given
        UserDetails userDetails = createUserDetails("testuser");
        TokenResponse tokens = jwtTokenProvider.generateTokens(userDetails);

        // When
        String username = jwtTokenProvider.getUsernameFromToken(tokens.getAccessToken());

        // Then
        assertThat(username).isEqualTo("testuser");
    }

    @Test
    void whenTokenExpired_thenValidationFails() {
        // Given
        ReflectionTestUtils.setField(jwtTokenProvider, "accessTokenExpiration", 1L);
        UserDetails userDetails = createUserDetails("testuser");
        TokenResponse tokens = jwtTokenProvider.generateTokens(userDetails);

        // Wait for token to expire
        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }

        // When/Then
        assertThat(jwtTokenProvider.validateToken(tokens.getAccessToken())).isFalse();
    }

    private UserDetails createUserDetails(String username) {
        return User.builder()
            .username(username)
            .password("password")
            .authorities(List.of(new SimpleGrantedAuthority("ROLE_USER")))
            .build();
    }
}

9. Production Configuration

9.1 Security Properties

# application-prod.yml
app:
  jwt:
    secret: ${JWT_SECRET}
    access-token:
      expiration: 900000  # 15 minutes
    refresh-token:
      expiration: 604800000  # 7 days

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: ${JWT_ISSUER_URI}

  redis:
    host: ${REDIS_HOST}
    port: ${REDIS_PORT}
    password: ${REDIS_PASSWORD}
    ssl: true
    timeout: 2000ms
    lettuce:
      pool:
        max-active: 8
        max-idle: 8
        min-idle: 0

logging:
  level:
    org.springframework.security: INFO
    com.myapp.security: DEBUG

10. References

10.1 Official Documentation

10.2 Security Best Practices