Security is a critical aspect of any application, especially when exposing APIs to external consumers. Spring Security provides a robust, highly customizable authentication and authorization framework that integrates seamlessly with Spring Boot applications. In this comprehensive guide, we'll explore how to implement enterprise-grade security for your REST APIs.
Understanding Security Fundamentals
Before diving into implementation, it's crucial to understand the core concepts that form the foundation of application security.
Authentication vs. Authorization
Understanding the difference between these concepts is fundamental:
- Authentication: Verifying who a user is (identity verification)
- Authorization: Determining what a user can access (permission validation)
Think of authentication as showing your ID at an airport checkpoint, while authorization is determining which areas of the airport you can access based on your ticket type.
Spring Security Architecture
Spring Security operates through a series of filters that intercept HTTP requests. The core components include:
- SecurityContext: Stores authentication information for the current thread
- Authentication: Represents the user's credentials and authorities
- AuthenticationManager: Coordinates authentication providers
- SecurityFilterChain: Defines security configuration for different URL patterns
Setting Up Spring Security
First, add the Spring Security dependency to your project:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
For Gradle:
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
Basic Security Configuration
Here's a comprehensive security configuration that demonstrates key concepts:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
private final UserDetailsService userDetailsService;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtRequestFilter jwtRequestFilter;
public SecurityConfig(UserDetailsService userDetailsService,
JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
JwtRequestFilter jwtRequestFilter) {
this.userDetailsService = userDetailsService;
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
this.jwtRequestFilter = jwtRequestFilter;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/moderator/**").hasAnyRole("ADMIN", "MODERATOR")
.requestMatchers("/api/user/**").hasAnyRole("ADMIN", "MODERATOR", "USER")
.anyRequest().authenticated()
)
.exceptionHandling(ex -> ex.authenticationEntryPoint(jwtAuthenticationEntryPoint))
.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
@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());
return authProvider;
}
}
User Management and Custom UserDetailsService
Create a custom UserDetailsService
to load user-specific data:
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String username;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private boolean enabled = true;
@Column(nullable = false)
private boolean accountNonExpired = true;
@Column(nullable = false)
private boolean accountNonLocked = true;
@Column(nullable = false)
private boolean credentialsNonExpired = true;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<Role> roles = new HashSet<>();
// Constructors, getters, and setters
}
@Entity
@Table(name = "roles")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
@Column(length = 20)
private RoleName name;
// Constructors, getters, and setters
}
public enum RoleName {
ROLE_USER,
ROLE_MODERATOR,
ROLE_ADMIN
}
Custom UserDetailsService
implementation:
@Service
@Transactional
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() ->
new UsernameNotFoundException("User not found: " + username));
return UserPrincipal.create(user);
}
public UserDetails loadUserById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() ->
new UsernameNotFoundException("User not found with id: " + id));
return UserPrincipal.create(user);
}
}
public class UserPrincipal implements UserDetails {
private Long id;
private String username;
private String email;
private String password;
private Collection<? extends GrantedAuthority> authorities;
public UserPrincipal(Long id, String username, String email, String password,
Collection<? extends GrantedAuthority> authorities) {
this.id = id;
this.username = username;
this.email = email;
this.password = password;
this.authorities = authorities;
}
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
);
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
// Getters for id, email, etc.
}
Implementing JWT Authentication
JSON Web Tokens (JWT) provide a stateless way to handle authentication, making them ideal for REST APIs and microservices architectures.
JWT Utility Class
@Component
public class JwtTokenUtil {
private static final Logger logger = LoggerFactory.getLogger(JwtTokenUtil.class);
@Value("${app.jwtSecret}")
private String jwtSecret;
@Value("${app.jwtExpirationInMs}")
private int jwtExpirationInMs;
public String generateToken(UserDetails userDetails) {
Date expiryDate = new Date(System.currentTimeMillis() + jwtExpirationInMs);
return Jwts.builder()
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date())
.setExpiration(expiryDate)
.signWith(SignatureAlgorithm.HS512, jwtSecret)
.compact();
}
public String generateTokenFromUsername(String username) {
Date expiryDate = new Date(System.currentTimeMillis() + jwtExpirationInMs);
return Jwts.builder()
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(expiryDate)
.signWith(SignatureAlgorithm.HS512, jwtSecret)
.compact();
}
public String getUsernameFromToken(String token) {
Claims claims = Jwts.parser()
.setSigningKey(jwtSecret)
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
public boolean validateJwtToken(String authToken) {
try {
Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(authToken);
return true;
} catch (SignatureException e) {
logger.error("Invalid JWT signature: {}", e.getMessage());
} catch (MalformedJwtException e) {
logger.error("Invalid JWT token: {}", e.getMessage());
} catch (ExpiredJwtException e) {
logger.error("JWT token is expired: {}", e.getMessage());
} catch (UnsupportedJwtException e) {
logger.error("JWT token is unsupported: {}", e.getMessage());
} catch (IllegalArgumentException e) {
logger.error("JWT claims string is empty: {}", e.getMessage());
}
return false;
}
}
JWT Request Filter
@Component
public class JwtRequestFilter extends OncePerRequestFilter {
private static final Logger logger = LoggerFactory.getLogger(JwtRequestFilter.class);
private final JwtTokenUtil jwtTokenUtil;
private final CustomUserDetailsService userDetailsService;
public JwtRequestFilter(JwtTokenUtil jwtTokenUtil,
CustomUserDetailsService userDetailsService) {
this.jwtTokenUtil = jwtTokenUtil;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
final String requestTokenHeader = request.getHeader("Authorization");
String username = null;
String jwtToken = null;
if (requestTokenHeader != null && requestTokenHeader.startsWith("Bearer ")) {
jwtToken = requestTokenHeader.substring(7);
try {
username = jwtTokenUtil.getUsernameFromToken(jwtToken);
} catch (IllegalArgumentException e) {
logger.warn("Unable to get JWT Token");
} catch (ExpiredJwtException e) {
logger.warn("JWT Token has expired");
}
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtTokenUtil.validateJwtToken(jwtToken)) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
usernamePasswordAuthenticationToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
}
filterChain.doFilter(request, response);
}
}
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationEntryPoint.class);
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
logger.error("Unauthorized error: {}", authException.getMessage());
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
final String body = "{\"error\":\"Unauthorized\",\"message\":\"" +
authException.getMessage() + "\",\"path\":\"" +
request.getServletPath() + "\"}";
response.getWriter().write(body);
}
}
Authentication Controller
@RestController
@RequestMapping("/api/auth")
@Validated
public class AuthController {
private static final Logger logger = LoggerFactory.getLogger(AuthController.class);
private final AuthenticationManager authenticationManager;
private final UserService userService;
private final JwtTokenUtil jwtTokenUtil;
private final PasswordEncoder passwordEncoder;
public AuthController(AuthenticationManager authenticationManager,
UserService userService,
JwtTokenUtil jwtTokenUtil,
PasswordEncoder passwordEncoder) {
this.authenticationManager = authenticationManager;
this.userService = userService;
this.jwtTokenUtil = jwtTokenUtil;
this.passwordEncoder = passwordEncoder;
}
@PostMapping("/signin")
public ResponseEntity<?> authenticateUser(@Valid @RequestBody LoginRequest loginRequest) {
try {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
loginRequest.getUsername(),
loginRequest.getPassword()
)
);
SecurityContextHolder.getContext().setAuthentication(authentication);
String jwt = jwtTokenUtil.generateToken((UserDetails) authentication.getPrincipal());
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
List<String> roles = userPrincipal.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
return ResponseEntity.ok(new JwtResponse(
jwt,
userPrincipal.getId(),
userPrincipal.getUsername(),
userPrincipal.getEmail(),
roles
));
} catch (BadCredentialsException e) {
logger.warn("Authentication failed for user: {}", loginRequest.getUsername());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new MessageResponse("Invalid username or password"));
}
}
@PostMapping("/signup")
public ResponseEntity<?> registerUser(@Valid @RequestBody SignUpRequest signUpRequest) {
if (userService.existsByUsername(signUpRequest.getUsername())) {
return ResponseEntity.badRequest()
.body(new MessageResponse("Username is already taken!"));
}
if (userService.existsByEmail(signUpRequest.getEmail())) {
return ResponseEntity.badRequest()
.body(new MessageResponse("Email is already in use!"));
}
// Create new user account
User user = new User();
user.setUsername(signUpRequest.getUsername());
user.setEmail(signUpRequest.getEmail());
user.setPassword(passwordEncoder.encode(signUpRequest.getPassword()));
Set<String> strRoles = signUpRequest.getRole();
Set<Role> roles = new HashSet<>();
if (strRoles == null) {
Role userRole = userService.findRoleByName(RoleName.ROLE_USER)
.orElseThrow(() -> new RuntimeException("User Role not found."));
roles.add(userRole);
} else {
strRoles.forEach(role -> {
switch (role) {
case "admin":
Role adminRole = userService.findRoleByName(RoleName.ROLE_ADMIN)
.orElseThrow(() -> new RuntimeException("Admin Role not found."));
roles.add(adminRole);
break;
case "mod":
Role modRole = userService.findRoleByName(RoleName.ROLE_MODERATOR)
.orElseThrow(() -> new RuntimeException("Moderator Role not found."));
roles.add(modRole);
break;
default:
Role userRole = userService.findRoleByName(RoleName.ROLE_USER)
.orElseThrow(() -> new RuntimeException("User Role not found."));
roles.add(userRole);
}
});
}
user.setRoles(roles);
userService.save(user);
return ResponseEntity.ok(new MessageResponse("User registered successfully!"));
}
@PostMapping("/refresh")
public ResponseEntity<?> refreshToken(HttpServletRequest request) {
String headerAuth = request.getHeader("Authorization");
if (headerAuth != null && headerAuth.startsWith("Bearer ")) {
String refreshToken = headerAuth.substring(7);
if (jwtTokenUtil.validateJwtToken(refreshToken)) {
String username = jwtTokenUtil.getUsernameFromToken(refreshToken);
String newToken = jwtTokenUtil.generateTokenFromUsername(username);
return ResponseEntity.ok(new TokenRefreshResponse(newToken, refreshToken));
}
}
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new MessageResponse("Refresh token is not valid!"));
}
}
Role-Based Access Control
Spring Security makes it easy to implement fine-grained role-based permissions:
Controller-Level Security
@RestController
@RequestMapping("/api/admin")
@PreAuthorize("hasRole('ADMIN')")
public class AdminController {
private final UserService userService;
private final ReportService reportService;
public AdminController(UserService userService, ReportService reportService) {
this.userService = userService;
this.reportService = reportService;
}
@GetMapping("/users")
public ResponseEntity<List<UserResponse>> getAllUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<User> users = userService.findAll(PageRequest.of(page, size));
List<UserResponse> userResponses = users.getContent().stream()
.map(UserResponse::from)
.collect(Collectors.toList());
return ResponseEntity.ok(userResponses);
}
@PutMapping("/users/{userId}/roles")
public ResponseEntity<?> updateUserRoles(
@PathVariable Long userId,
@RequestBody @Valid UpdateRolesRequest request) {
userService.updateUserRoles(userId, request.getRoles());
return ResponseEntity.ok(new MessageResponse("User roles updated successfully"));
}
@DeleteMapping("/users/{userId}")
public ResponseEntity<?> deleteUser(@PathVariable Long userId) {
userService.deleteUser(userId);
return ResponseEntity.ok(new MessageResponse("User deleted successfully"));
}
}
Method-Level Security
@Service
public class DocumentService {
private final DocumentRepository documentRepository;
public DocumentService(DocumentRepository documentRepository) {
this.documentRepository = documentRepository;
}
@PreAuthorize("hasRole('ADMIN') or hasRole('MODERATOR')")
public List<Document> getAllDocuments() {
return documentRepository.findAll();
}
@PreAuthorize("hasRole('ADMIN') or #document.author.username == authentication.principal.username")
public Document updateDocument(@Param("document") Document document) {
return documentRepository.save(document);
}
@PreAuthorize("hasRole('ADMIN') or @documentService.isOwner(#documentId, authentication.principal.username)")
public void deleteDocument(Long documentId) {
documentRepository.deleteById(documentId);
}
@PostAuthorize("hasRole('ADMIN') or returnObject.author.username == authentication.principal.username")
public Document getDocument(Long documentId) {
return documentRepository.findById(documentId)
.orElseThrow(() -> new DocumentNotFoundException("Document not found"));
}
public boolean isOwner(Long documentId, String username) {
return documentRepository.findById(documentId)
.map(doc -> doc.getAuthor().getUsername().equals(username))
.orElse(false);
}
}
Advanced Security Features
Rate Limiting
Implement rate limiting to protect against abuse:
@Component
public class RateLimitingFilter implements Filter {
private final Map<String, UserRateLimit> userRateLimits = new ConcurrentHashMap<>();
private final int maxRequestsPerMinute = 100;
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String clientIP = getClientIP(httpRequest);
UserRateLimit rateLimit = userRateLimits.computeIfAbsent(clientIP,
k -> new UserRateLimit());
if (rateLimit.isRateLimitExceeded(maxRequestsPerMinute)) {
httpResponse.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
httpResponse.getWriter().write("{\"error\":\"Rate limit exceeded\"}");
return;
}
chain.doFilter(request, response);
}
private String getClientIP(HttpServletRequest request) {
String xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
return xForwardedFor.split(",")[0].trim();
}
return request.getRemoteAddr();
}
private static class UserRateLimit {
private final Queue<Long> requestTimes = new ConcurrentLinkedQueue<>();
public boolean isRateLimitExceeded(int maxRequests) {
long currentTime = System.currentTimeMillis();
long oneMinuteAgo = currentTime - 60000;
// Remove old requests
while (!requestTimes.isEmpty() && requestTimes.peek() < oneMinuteAgo) {
requestTimes.poll();
}
if (requestTimes.size() >= maxRequests) {
return true;
}
requestTimes.offer(currentTime);
return false;
}
}
}
Input Validation and Sanitization
@RestController
@RequestMapping("/api/users")
@Validated
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@PostMapping
public ResponseEntity<?> createUser(@Valid @RequestBody CreateUserRequest request) {
// Request validation happens automatically via @Valid
User user = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(UserResponse.from(user));
}
@GetMapping("/{userId}")
public ResponseEntity<UserResponse> getUser(
@PathVariable @Min(1) Long userId) {
User user = userService.findById(userId);
return ResponseEntity.ok(UserResponse.from(user));
}
}
public class CreateUserRequest {
@NotBlank(message = "Username is required")
@Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters")
@Pattern(regexp = "^[a-zA-Z0-9._-]+$", message = "Username contains invalid characters")
private String username;
@NotBlank(message = "Email is required")
@Email(message = "Email should be valid")
@Size(max = 100, message = "Email must not exceed 100 characters")
private String email;
@NotBlank(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters long")
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]",
message = "Password must contain at least one uppercase letter, one lowercase letter, one digit, and one special character")
private String password;
// Getters and setters
}
CORS Configuration
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList("https://*.mydomain.com", "http://localhost:3000"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
}
Security Best Practices
1. Use HTTPS
Always encrypt data in transit. Never transmit sensitive information over HTTP.
# application.yml
server:
ssl:
key-store: classpath:keystore.p12
key-store-password: password
key-store-type: PKCS12
key-alias: tomcat
enabled: true
port: 8443
2. Secure Password Storage
Always hash passwords with a strong algorithm:
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// BCrypt with strength 12 (2^12 = 4096 rounds)
return new BCryptPasswordEncoder(12);
}
}
3. Implement Proper Session Management
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.maximumSessions(1)
.maxSessionsPreventsLogin(false)
.sessionRegistry(sessionRegistry())
.and()
.sessionFixation().migrateSession()
.invalidSessionUrl("/login?expired")
);
return http.build();
}
4. Security Headers
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers(headers -> headers
.frameOptions().deny()
.contentTypeOptions().and()
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(31536000)
.includeSubdomains(true)
)
.and()
);
return http.build();
}
5. Audit Logging
@Component
public class SecurityAuditListener {
private static final Logger auditLogger = LoggerFactory.getLogger("AUDIT");
@EventListener
public void handleAuthenticationSuccess(AuthenticationSuccessEvent event) {
String username = event.getAuthentication().getName();
auditLogger.info("AUTHENTICATION_SUCCESS: User '{}' successfully authenticated", username);
}
@EventListener
public void handleAuthenticationFailure(AbstractAuthenticationFailureEvent event) {
String username = event.getAuthentication().getName();
auditLogger.warn("AUTHENTICATION_FAILURE: Failed authentication attempt for user '{}'", username);
}
@EventListener
public void handleAuthorizationFailure(AuthorizationDeniedEvent event) {
Authentication auth = event.getAuthentication();
auditLogger.warn("AUTHORIZATION_DENIED: User '{}' denied access to resource",
auth != null ? auth.getName() : "anonymous");
}
}
Error Handling and Security
@ControllerAdvice
public class SecurityExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(SecurityExceptionHandler.class);
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDenied(
AccessDeniedException ex, HttpServletRequest request) {
logger.warn("Access denied for request: {} {}", request.getMethod(), request.getRequestURI());
ErrorResponse error = new ErrorResponse(
"ACCESS_DENIED",
"You don't have permission to access this resource",
request.getRequestURI()
);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);
}
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ErrorResponse> handleAuthentication(
AuthenticationException ex, HttpServletRequest request) {
logger.warn("Authentication failed for request: {} {}", request.getMethod(), request.getRequestURI());
ErrorResponse error = new ErrorResponse(
"AUTHENTICATION_FAILED",
"Authentication credentials are invalid",
request.getRequestURI()
);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
}
@ExceptionHandler(JwtException.class)
public ResponseEntity<ErrorResponse> handleJwtException(
JwtException ex, HttpServletRequest request) {
logger.warn("JWT validation failed: {}", ex.getMessage());
ErrorResponse error = new ErrorResponse(
"INVALID_TOKEN",
"The provided token is invalid or expired",
request.getRequestURI()
);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
}
}
Testing Security
@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(locations = "classpath:application-test.properties")
class SecurityIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Test
void shouldAllowAccessToPublicEndpoint() throws Exception {
mockMvc.perform(get("/api/public/health"))
.andExpect(status().isOk());
}
@Test
void shouldDenyAccessToProtectedEndpointWithoutToken() throws Exception {
mockMvc.perform(get("/api/user/profile"))
.andExpect(status().isUnauthorized());
}
@Test
void shouldAllowAccessToProtectedEndpointWithValidToken() throws Exception {
String token = jwtTokenUtil.generateTokenFromUsername("testuser");
mockMvc.perform(get("/api/user/profile")
.header("Authorization", "Bearer " + token))
.andExpected(status().isOk());
}
@Test
void shouldDenyAccessToAdminEndpointForRegularUser() throws Exception {
String userToken = jwtTokenUtil.generateTokenFromUsername("regularuser");
mockMvc.perform(get("/api/admin/users")
.header("Authorization", "Bearer " + userToken))
.andExpect(status().isForbidden());
}
}
Conclusion
Implementing robust security for REST APIs requires a comprehensive approach that covers authentication, authorization, input validation, and protection against common vulnerabilities. Spring Security provides a powerful and flexible framework that can be customized to meet the specific needs of your application.
Key takeaways for secure API development:
- Layer your security: Implement security at multiple levels (network, application, data)
- Follow the principle of least privilege: Grant only the minimum permissions required
- Validate everything: Never trust user input
- Keep dependencies updated: Regularly update dependencies to patch security vulnerabilities
- Monitor and log: Implement comprehensive logging and monitoring for security events
- Test your security: Include security testing in your development process
Remember that security is not a one-time implementation but an ongoing process that requires continuous attention and updates as new threats emerge. Stay informed about security best practices and regularly review and update your security measures.
By following these practices and leveraging Spring Security's capabilities, you can build robust, secure APIs that protect your users' data and maintain their trust in your application.