MFormations
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

  1. Injection (SQL, NoSQL, Command) : Utiliser JPA/paramètres préparés
  2. Broken Authentication : MFA, passwords forts, rate limiting
  3. Sensitive Data Exposure : TLS partout, encrypt au repos
  4. XXE : Désactiver les DOCTYPE dans les parsers XML
  5. Broken Access Control : Vérifier @PreAuthorize partout
  6. Security Misconfiguration : Spring Security defaults, CORS restrictif
  7. XSS : Encoder les sorties, Content-Security-Policy header
  8. Insecure Deserialization : Valider les entrées, éviter Java native serialization
  9. Using Components with Known Vulnerabilities : Dependabot, Snyk
  10. 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