Module 8 - Spring Security and Application Protection
By the end of this module, you will:
Security is not optional. It must be built into the application from the start, not added as an afterthought.
Spring Security is the de-facto standard for securing Spring applications:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
flowchart LR
subgraph Request["HTTP Request"]
R[Client Request]
end
subgraph Filters["Security Filter Chain"]
F1[Authentication Filter]
F2[Authorization Filter]
F3[Exception Filter]
end
subgraph Core["Security Core"]
AM[Authentication Manager]
SM[Security Context]
end
subgraph App["Application"]
C[Controller]
end
R --> F1
F1 --> AM
AM --> SM
F1 --> F2
F2 --> F3
F3 --> C
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth // (#1:Define access rules)
.requestMatchers("/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form // (#2:Enable form login)
.loginPage("/login")
.defaultSuccessUrl("/dashboard")
.permitAll()
)
.logout(logout -> logout // (#3:Configure logout)
.logoutSuccessUrl("/login?logout")
.permitAll()
);
return http.build();
}
}
@Service
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) // (#1:Find user)
.orElseThrow(() ->
new UsernameNotFoundException("User not found: " + username));
return org.springframework.security.core.userdetails.User // (#2:Build UserDetails)
.withUsername(user.getUsername())
.password(user.getPassword())
.roles(user.getRoles().toArray(new String[0]))
.build();
}
}
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // (#1:BCrypt is recommended)
}
}
@Service
public class UserService {
private final PasswordEncoder passwordEncoder;
public User createUser(String username, String rawPassword) {
User user = new User();
user.setUsername(username);
user.setPassword(passwordEncoder.encode(rawPassword)); // (#2:Hash password)
return userRepository.save(user);
}
}
Never store plain text passwords! Always use a strong hashing algorithm like BCrypt.
@Configuration
@EnableMethodSecurity // (#1:Enable method security)
public class MethodSecurityConfig {}
@Service
public class ProductService {
@PreAuthorize("hasRole('ADMIN')") // (#2:Check before execution)
public void deleteProduct(Long id) {
productRepository.deleteById(id);
}
@PreAuthorize("hasRole('USER') and #userId == authentication.principal.id") // (#3:Custom SpEL)
public List<Order> getOrdersForUser(Long userId) {
return orderRepository.findByUserId(userId);
}
@PostAuthorize("returnObject.owner == authentication.name") // (#4:Check after execution)
public Document getDocument(Long id) {
return documentRepository.findById(id).orElseThrow();
}
}
JSON Web Tokens (JWT) are ideal for stateless REST API authentication:
@Component
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String jwtSecret;
@Value("${jwt.expiration}")
private long jwtExpiration;
public String generateToken(Authentication auth) {
UserDetails user = (UserDetails) auth.getPrincipal();
Date now = new Date();
Date expiryDate = new Date(now.getTime() + jwtExpiration);
return Jwts.builder() // (#1:Build JWT)
.setSubject(user.getUsername())
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(SignatureAlgorithm.HS512, jwtSecret) // (#2:Sign with secret)
.compact();
}
}
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String token = getJwtFromRequest(request); // (#1:Extract token)
if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) {
String username = tokenProvider.getUsernameFromToken(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken auth = // (#2:Create auth token)
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth); // (#3:Set in context)
}
filterChain.doFilter(request, response);
}
}
@Configuration
@EnableWebSecurity
public class JwtSecurityConfig {
private final JwtAuthenticationFilter jwtFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // (#1:Disable CSRF for stateless API)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // (#2:No sessions)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtFilter, // (#3:Add JWT filter)
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
@Configuration
@EnableWebSecurity
public class OAuth2Config {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
)
.oauth2Login(oauth -> oauth // (#1:Enable OAuth2 login)
.loginPage("/login")
.defaultSuccessUrl("/dashboard")
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService) // (#2:Custom user service)
)
);
return http.build();
}
}
# application.properties
spring.security.oauth2.client.registration.google.client-id=your-client-id
spring.security.oauth2.client.registration.google.client-secret=your-secret
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of( // (#1:Allowed origins)
"https://frontend.example.com"
));
configuration.setAllowedMethods(List.of( // (#2:Allowed methods)
"GET", "POST", "PUT", "DELETE", "OPTIONS"
));
configuration.setAllowedHeaders(List.of("*")); // (#3:Allowed headers)
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source =
new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
}
Cross-Site Request Forgery protection is enabled by default:
@Configuration
public class CsrfConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) // (#1:Cookie-based token)
.ignoringRequestMatchers("/api/webhooks/**") // (#2:Exclude webhooks)
);
return http.build();
}
}
CSRF is important for browser-based apps. For stateless REST APIs with JWT, CSRF can be disabled since tokens already prevent cross-site attacks.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers(headers -> headers
.contentSecurityPolicy(csp -> csp // (#1:CSP header)
.policyDirectives("default-src 'self'; script-src 'self'")
)
.frameOptions(frame -> frame.deny()) // (#2:Prevent clickjacking)
.xssProtection(xss -> xss.disable()) // (#3:Modern browsers don't need this)
.contentTypeOptions(Customizer.withDefaults()) // (#4:Prevent MIME sniffing)
.httpStrictTransportSecurity(hsts -> hsts // (#5:Force HTTPS)
.maxAgeInSeconds(31536000)
.includeSubDomains(true)
)
);
return http.build();
}
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping
public ResponseEntity<User> createUser(
@Valid @RequestBody UserRequest request) { // (#1:Validate input)
return ResponseEntity.ok(userService.create(request));
}
}
public class UserRequest {
@NotBlank(message = "Username is required") // (#2:Validation annotations)
@Size(min = 3, max = 50)
@Pattern(regexp = "^[a-zA-Z0-9_]+$")
private String username;
@NotBlank
@Email(message = "Invalid email format")
private String email;
@NotBlank
@Size(min = 8, message = "Password must be at least 8 characters")
private String password;
}
// VULNERABLE - Never do this!
@Query(value = "SELECT * FROM users WHERE name = '" + name + "'", nativeQuery = true)
List<User> findByNameUnsafe(String name);
// SAFE - Use parameterized queries
@Query("SELECT u FROM User u WHERE u.name = :name") // (#1:Named parameter)
List<User> findByName(@Param("name") String name);
// SAFE - Use Spring Data methods
List<User> findByUsername(String username); // (#2:Spring Data magic)
// SAFE - Use Criteria API
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<User> query = cb.createQuery(User.class);
Root<User> root = query.from(User.class);
query.where(cb.equal(root.get("name"), name)); // (#3:Type-safe criteria)
Always use parameterized queries! Never concatenate user input into SQL.
th:text (escapes by default)<!-- Thymeleaf - Safe (escapes HTML) -->
<p th:text="${userInput}"></p>
<!-- Thymeleaf - UNSAFE (raw HTML) -->
<p th:utext="${userInput}"></p>
<!-- Use utext ONLY for trusted, sanitized content -->
@Component
public class RateLimitingFilter extends OncePerRequestFilter {
private final RateLimiter rateLimiter = RateLimiter.create(10.0); // (#1:10 requests/second)
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (!rateLimiter.tryAcquire()) { // (#2:Check rate limit)
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.getWriter().write("Rate limit exceeded");
return;
}
filterChain.doFilter(request, response);
}
}
Consider using Redis for distributed rate limiting across multiple instances
@Component
public class SecurityAuditListener {
private static final Logger auditLog = LoggerFactory.getLogger("SECURITY_AUDIT");
@EventListener
public void onAuthSuccess(AuthenticationSuccessEvent event) { // (#1:Log success)
auditLog.info("LOGIN_SUCCESS user={} ip={}",
event.getAuthentication().getName(),
getClientIp());
}
@EventListener
public void onAuthFailure(AuthenticationFailureBadCredentialsEvent event) { // (#2:Log failure)
auditLog.warn("LOGIN_FAILURE user={} ip={}",
event.getAuthentication().getName(),
getClientIp());
}
@EventListener
public void onAccessDenied(AuthorizationDeniedEvent event) { // (#3:Log access denied)
auditLog.warn("ACCESS_DENIED user={} resource={}",
event.getAuthentication().getName(),
event.getSource());
}
}
// BAD - Hardcoded secret
private static final String SECRET = "my-super-secret-key";
// GOOD - Environment variable
@Value("${jwt.secret}")
private String jwtSecret;
@SpringBootTest
@AutoConfigureMockMvc
class SecurityTests {
@Autowired
private MockMvc mockMvc;
@Test
void unauthenticatedAccessShouldBeRejected() throws Exception {
mockMvc.perform(get("/api/users")) // (#1:Test without auth)
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "ADMIN") // (#2:Mock authenticated user)
void adminShouldAccessAdminEndpoint() throws Exception {
mockMvc.perform(get("/api/admin/users"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(roles = "USER")
void userShouldNotAccessAdminEndpoint() throws Exception {
mockMvc.perform(get("/api/admin/users")) // (#3:Test authorization)
.andExpect(status().isForbidden());
}
}
| Vulnerability | Prevention |
|---|---|
| Injection | Parameterized queries, input validation |
| Broken Authentication | Strong passwords, MFA, session management |
| Sensitive Data Exposure | Encryption at rest and in transit |
| XXE | Disable external entities in XML parsers |
| Broken Access Control | Role-based authorization, principle of least privilege |
| Security Misconfiguration | Secure defaults, remove debug features |
In this module, you learned:
Next Module: API Documentation with OpenAPI/Swagger