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
}
@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