Modern Java Engineering
Chapitre 12
12 - Sécurité Java
12 - Sécurité Java
Cours 12 : Sécurité Java
1. Spring Security
1.1 Architecture de Spring Security
Requête → FilterChainProxy → SecurityFilterChain → Controller
│
┌───────┴───────┐
│ Authentication │
│ Authorization │
│ CSRF │
│ CORS │
│ Session │
└───────────────┘
1.2 SecurityFilterChain (Spring Security 6+)
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/orders/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults());
return http.build();
}
}
1.3 Authentication Manager
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
1.4 UserDetailsService
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
User user = userRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return new org.springframework.security.core.userdetails.User(
user.getEmail(),
user.getPassword(),
user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority(role.getName()))
.toList()
);
}
}
1.5 Custom Authentication Provider
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
private final CustomUserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
UserDetails user = userDetailsService.loadUserByUsername(username);
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new BadCredentialsException("Invalid password");
}
return new UsernamePasswordAuthenticationToken(
user, null, user.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
}
2. OAuth2
2.1 Flux OAuth2 Authorization Code
Client (App) → Authorization Server (Keycloak)
│ │
│ 1. Login req │
│←─── 2. Auth code │
│ 3. Auth code │
│←─── 4. Token │
│ 5. Token → API │
▼ ▼
Resource Server (API) ← Valide le token
2.2 Configuration Resource Server
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {
@Bean
public SecurityFilterChain resourceServer(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/orders/**").hasAuthority("SCOPE_order:read")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
);
return http.build();
}
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder
.withJwkSetUri("http://localhost:8080/realms/orderhub/protocol/openid-connect/certs")
.build();
}
private JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
converter.setAuthoritiesClaimName("roles");
converter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
jwtConverter.setJwtGrantedAuthoritiesConverter(converter);
return jwtConverter;
}
}
2.3 Configuration OAuth2 Client
@Configuration
@EnableWebSecurity
public class OAuth2ClientConfig {
@Bean
public SecurityFilterChain oauth2Client(HttpSecurity http) throws Exception {
http
.oauth2Login(oauth2 -> oauth2
.loginPage("/oauth2/authorization/orderhub")
.defaultSuccessUrl("/dashboard", true)
)
.oauth2Client(Customizer.withDefaults());
return http.build();
}
}
// application.yml
spring:
security:
oauth2:
client:
registration:
orderhub:
client-id: orderhub-client
client-secret: ${CLIENT_SECRET}
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope: openid, profile, email, order:read
provider:
orderhub:
issuer-uri: http://localhost:8080/realms/orderhub
3. JWT (JSON Web Tokens)
3.1 Structure d'un JWT
header.payload.signature
Header:
{
"alg": "RS256",
"typ": "JWT",
"kid": "abc123"
}
Payload:
{
"sub": "user-id",
"iss": "http://localhost:8080/realms/orderhub",
"exp": 1700000000,
"iat": 1699913600,
"roles": ["USER", "ADMIN"],
"preferred_username": "johndoe",
"email": "john@example.com"
}
Signature: RSASHA256(base64(header) + "." + base64(payload), privateKey)
3.2 Vérification manuelle d'un JWT
@Component
public class JwtTokenValidator {
private final RSAPublicKey publicKey;
public JwtTokenValidator(@Value("${jwt.public-key}") String publicKeyPem) {
this.publicKey = loadPublicKey(publicKeyPem);
}
public Claims validateToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(publicKey)
.setAllowedClockSkewSeconds(60)
.build()
.parseClaimsJws(token)
.getBody();
}
public boolean hasRole(String token, String role) {
Claims claims = validateToken(token);
List<String> roles = claims.get("roles", List.class);
return roles.contains(role);
}
private RSAPublicKey loadPublicKey(String pem) {
try {
PEMParser parser = new PEMParser(new StringReader(pem));
SubjectPublicKeyInfo publicKeyInfo =
(SubjectPublicKeyInfo) parser.readObject();
JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
return (RSAPublicKey) converter.getPublicKey(publicKeyInfo);
} catch (IOException e) {
throw new RuntimeException("Failed to load public key", e);
}
}
}
3.3 Génération de JWT
@Component
public class JwtTokenProvider {
private final RSAPrivateKey privateKey;
public String generateAccessToken(User user) {
Instant now = Instant.now();
return Jwts.builder()
.setSubject(user.getId().toString())
.setIssuer("orderhub")
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(now.plus(30, ChronoUnit.MINUTES)))
.claim("email", user.getEmail())
.claim("roles", user.getRoles())
.signWith(privateKey, SignatureAlgorithm.RS256)
.compact();
}
public String generateRefreshToken(User user) {
Instant now = Instant.now();
return Jwts.builder()
.setSubject(user.getId().toString())
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(now.plus(7, ChronoUnit.DAYS)))
.signWith(privateKey, SignatureAlgorithm.RS256)
.compact();
}
}
4. Keycloak
4.1 Configuration Docker
version: '3.8'
services:
keycloak:
image: quay.io/keycloak/keycloak:23.0
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: password
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
ports:
- "8080:8080"
command: start-dev
4.2 Realm et client configuration
// Keycloak admin client pour configuration programmatique
@Component
public class KeycloakSetup {
private final Keycloak keycloak;
public void createRealm(String realmName) {
RealmRepresentation realm = new RealmRepresentation();
realm.setRealm(realmName);
realm.setEnabled(true);
realm.setSslRequired("external");
// Client
ClientRepresentation client = new ClientRepresentation();
client.setClientId("orderhub-client");
client.setEnabled(true);
client.setPublicClient(false);
client.setSecret("client-secret");
client.setProtocol("openid-connect");
realm.setClients(List.of(client));
keycloak.realms().create(realm);
}
public void createUser(String realm, String username, String password) {
UserRepresentation user = new UserRepresentation();
user.setUsername(username);
user.setEnabled(true);
user.setEmailVerified(true);
CredentialRepresentation cred = new CredentialRepresentation();
cred.setType(CredentialRepresentation.PASSWORD);
cred.setValue(password);
cred.setTemporary(false);
user.setCredentials(List.of(cred));
keycloak.realm(realm).users().create(user);
}
}
4.3 Spring Boot + Keycloak
// Intégration Spring Boot avec Keycloak
@Configuration
public class KeycloakConfig {
@Bean
public Keycloak keycloak(
@Value("${keycloak.server-url}") String serverUrl,
@Value("${keycloak.admin-username}") String username,
@Value("${keycloak.admin-password}") String password) {
return KeycloakBuilder.builder()
.serverUrl(serverUrl)
.realm("master")
.username(username)
.password(password)
.clientId("admin-cli")
.build();
}
}
5. Method Security
5.1 Configuration
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {}
5.2 @PreAuthorize
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or @orderSecurity.canReadOrder(#id, authentication)")
public Order getOrder(@PathVariable UUID id) {
return orderService.findById(id);
}
@PostMapping
@PreAuthorize("hasRole('USER')")
public Order createOrder(@RequestBody CreateOrderRequest request) {
return orderService.create(request);
}
@PutMapping("/{id}/cancel")
@PreAuthorize("hasRole('ADMIN') or @orderSecurity.isOwner(#id, authentication.name)")
public void cancelOrder(@PathVariable UUID id) {
orderService.cancel(id);
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public void deleteOrder(@PathVariable UUID id) {
orderService.delete(id);
}
}
5.3 Bean Security
@Component("orderSecurity")
public class OrderSecurity {
private final OrderRepository orderRepository;
public boolean canReadOrder(UUID orderId, Authentication authentication) {
return orderRepository.findById(orderId)
.map(order -> order.getCustomerId().value().toString()
.equals(authentication.getName()))
.orElse(false);
}
public boolean isOwner(UUID orderId, String username) {
return orderRepository.findById(orderId)
.map(order -> order.getCustomerId().value().toString().equals(username))
.orElse(false);
}
}
5.4 @Secured (legacy)
@Service
public class PaymentService {
@Secured("ROLE_ADMIN")
public void refundPayment(UUID paymentId) {
// Seulement pour les admins
}
}
5.5 @PostAuthorize
@Service
public class OrderService {
@PostAuthorize("returnObject.customerId.value.toString() == authentication.name")
public Order findById(UUID id) {
return orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
}
6. CSRF & CORS
6.1 CSRF
@Configuration
public class CsrfConfig {
@Bean
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
http
// API REST stateless -> pas de CSRF nécessaire
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
@Bean
public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception {
http
// Interface web -> CSRF nécessaire
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())
);
return http.build();
}
}
6.2 CORS
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of(
"https://app.orderhub.com",
"http://localhost:4200"
));
configuration.setAllowedMethods(List.of(
"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"
));
configuration.setAllowedHeaders(List.of(
"Authorization", "Content-Type", "X-Requested-With"
));
configuration.setExposedHeaders(List.of("X-Total-Count"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source =
new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
7. OWASP Top 10 pour Java
7.1 Les 10 risques principaux
- Injection (SQL, NoSQL, Command) : Utiliser JPA/paramètres préparés
- Broken Authentication : MFA, passwords forts, rate limiting
- Sensitive Data Exposure : TLS partout, encrypt au repos
- XXE : Désactiver les DOCTYPE dans les parsers XML
- Broken Access Control : Vérifier @PreAuthorize partout
- Security Misconfiguration : Spring Security defaults, CORS restrictif
- XSS : Encoder les sorties, Content-Security-Policy header
- Insecure Deserialization : Valider les entrées, éviter Java native serialization
- Using Components with Known Vulnerabilities : Dependabot, Snyk
- Insufficient Logging & Monitoring : Audit logs, metrics
7.2 Exemple : Protection contre les injections SQL
// MAUVAIS : injection SQL possible
@Query("SELECT * FROM orders WHERE customer_id = '" + customerId + "'")
List<Order> findByCustomerId(String customerId);
// BON : paramètres préparés
@Query("SELECT o FROM Order o WHERE o.customerId = :customerId")
List<Order> findByCustomerId(@Param("customerId") UUID customerId);
// MEILLEUR : Spring Data JPA
List<Order> findByCustomerId(UUID customerId);
7.3 Rate Limiting
@Component
public class RateLimitingFilter implements Filter {
private final Cache<String, Integer> requestCounts = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.MINUTES)
.build();
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String ip = httpRequest.getRemoteAddr();
int count = requestCounts.get(ip, k -> 0);
if (count > 100) { // 100 requêtes/minute max
((HttpServletResponse) response).setStatus(429);
return;
}
requestCounts.put(ip, count + 1);
chain.doFilter(request, response);
}
}
Points clés
- Spring Security filter chain : l'ordre des filtres est crucial
- OAuth2 avec Keycloak : solution SSO mature
- JWT : bien gérer expiration et signature
- @PreAuthorize : sécuriser au niveau méthode
- CSRF désactivé pour les API REST stateless
- CORS : ne pas mettre
allowedOrigins: * - OWASP Top 10 : connaître pour auditer