saverioriotto.it

Proteggere le API REST di SpringBoot 3 con autenticazione JWT e Ruoli

La sicurezza è uno degli aspetti fondamentali dell'Informatica; Spring Security è un'ottima scelta per mettere in sicurezza un'applicazione se si utilizza già il framework Spring. In questo articolo utilizzeremo JWT per la fasi di autenticazione e autorizzazione.

Proteggere le API REST di SpringBoot 3 con autenticazione JWT e Ruoli

In un precedente articolo, ti ho mostrato come realizzare un servizio di API in SpringBoot 2 con sicurezza tramite JWT e ruoli.

Vedrai come mettere in sicurezza un’applicazione web Spring Boot utilizzando invece SpringBoot 3 e lo standard JWT per l’autenticazione e l’autorizzazione delle API REST.

Per questo tutorial prendiamo il codice utilizzato per il servizio REST creato nel precedente articolo e modifichiamo solo le parti di compatibilità con la versione 3.

Come primo step aggiorniamo la versione di springboot e java nelle dipendenze e proprietà del pom.xml:

<parent>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-parent</artifactId>
	<version>3.2.3</version>
	<relativePath/> <!-- lookup parent from repository -->
</parent>
.
.
<properties>
	<java.version>17</java.version>
</properties>

Successivamente all’interno del file application.properties aggiorniamo le proprietà:

saverioriotto.app.jwtSecret= 357638792F423F4428472B4B6250655368566D597133743677397A24432646298472B4B6250655368566D597133743677397A2443264629
saverioriotto.app.jwtExpirationMs= 86400000

La classe JwtUtils

Rappresenta la classe di utilità per la gestione del token JWT.

@Component
public class JwtUtils {
    private static final Logger logger = Logger.getLogger(JwtUtils.class.getName());
    @Value("${saverioriotto.app.jwtSecret}")
    private String jwtSecret;
    @Value("${saverioriotto.app.jwtExpirationMs}")
    private int jwtExpirationMs;
    public String generateJwtToken(Authentication authentication) {
        DettagliUtente userPrincipal = (DettagliUtente) authentication.getPrincipal();
        return Jwts.builder()
                .setSubject((userPrincipal.getUsername()))
                .setIssuedAt(new Date())
                .setExpiration(new Date((new Date()).getTime() + jwtExpirationMs))
                .signWith(SignatureAlgorithm.HS512, getSignKey())
                .compact();
    }
    public String generateJwtToken(String username) {
        return Jwts.builder()
                .setSubject((username))
                .setIssuedAt(new Date())
                .setExpiration(new Date((new Date()).getTime() + jwtExpirationMs))
                .signWith(SignatureAlgorithm.HS512, getSignKey())
                .compact();
    }
    public String getUserNameFromJwtToken(String token) {
        return Jwts.parser().setSigningKey(getSignKey()).parseClaimsJws(token).getBody().getSubject();
    }
    public boolean validateJwtToken(String authToken) {
        try {
            Jwts.parser().setSigningKey(getSignKey()).parseClaimsJws(authToken);
            return true;
        } catch (MalformedJwtException e) {
            logger.log(Level.SEVERE,"Invalid JWT token: {}", e.getMessage());
        } catch (ExpiredJwtException e) {
            logger.log(Level.SEVERE,"JWT token is expired: {}", e.getMessage());
        } catch (UnsupportedJwtException e) {
            logger.log(Level.SEVERE,"JWT token is unsupported: {}", e.getMessage());
        } catch (IllegalArgumentException e) {
            logger.log(Level.SEVERE,"JWT claims string is empty: {}", e.getMessage());
        }
        return false;
    }

    private Key getSignKey() {
        byte[] keyBytes = Decoders.BASE64.decode(jwtSecret);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

La classe WebSecurityConfig (configurazione)

Per implementare Spring Security con JWT dobbiamo prendere in considerazione alcune osservazioni.

Di default, Spring Security utilizza un sistema di generazione cookie che vengono scambiati ad ogni richiesta client-server, e registra un utente autenticato tra le sessioni attive di Spring in un oggetto chiamato Principal. Questo, insieme alla filter chain (catena dei filtri di sicurezza) di default, permette a Spring di verificare l’autenticazione dell’utente controllando le informazioni tra le sessioni attive.

Per la nostra implementazione con JWT vogliamo:

- escludere i cookie
- creare noi le sessioni
- modificare il comportamento della filter chain per validare il token

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig {
    @Bean
    public AuthTokenFilter authenticationJwtTokenFilter() {
        return new AuthTokenFilter();
    }
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http.cors(cors-> cors.disable()).csrf(csrf-> csrf.disable())
                .authorizeRequests()
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/test/**").permitAll()
                .requestMatchers("/api/libro/**").permitAll()
                .requestMatchers("/swagger-ui/**").permitAll()
                .requestMatchers("/v3/api-docs/**").permitAll()
                .anyRequest().authenticated();
        http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring().requestMatchers("/images/**", "/js/**", "/webjars/**","/swagger-ui/**");
    }
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration)
            throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }
}

La classe WebSecurityConfig qui sopra rappresenta la nostra configurazione per Spring Security.

Cerchiamo di capire meglio quanto scritto. SecurityConfig viene annotata con @Configuration affinché venga trattata come tale da Spring.

Occorre definire il Bean filterChain, nell’esempio:

http.cors(cors-> cors.disable()).csrf(csrf-> csrf.disable())

rispettivamente per disabilitare CORS e CSRF e utilizzare una politica stateless delle sessioni.

http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);

per aggiungere una nuova istanza del filtro AuthorizationFilter creato all’interno della filter chain (catena dei filtri) di Spring Security, subito prima del filtro UsernamePasswordAuthenticationFilter.

.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/test/**").permitAll()
.requestMatchers("/api/libro/**").permitAll()
.requestMatchers("/swagger-ui/**").permitAll()
.requestMatchers("/v3/api-docs/**").permitAll()

Riguarda le API che vogliamo esporre pubblicamente, e che quindi non devono essere autenticate, come il login.

Cosi facendo, abbiamo configurato correttamente Spring Security per gestire un’autenticazione con JWT.

Aggiornare i packages che iniziano con 'javax' con 'jakarta'

Poiché Java EE è stato modificato in Jakarta EE, Spring Boot 3.0 è stato aggiornato anche da Java EE alle API Jakarta EE per tutte le dipendenze. Ove possibile, sono state scelte le dipendenze compatibili con Jakarta EE 10. Pertanto, i nomi dei pacchetti che iniziano con “javax” devono essere modificati di conseguenza in “jakarta”. Ad esempio, alcuni dei pacchetti comunemente utilizzati verranno modificati come mostrato di seguito:

javax.persistence.*  -> jakarta.persistence.*
javax.validation.*    -> jakarta.validation.*
javax.servlet.*         -> jakarta.servlet.*
javax.annotation.*  -> jakarta.annotation.*
javax.transaction.   -> jakarta.transaction. 

Payload di Login e Registrazione utente

public class Credenziali {
    @NotBlank
    private String username;

    @NotBlank
    private String password;

   // getter e setter
}
public class Registrazione {
    @NotBlank
    @Size(min = 3, max = 20)
    private String username;

    @NotBlank
    @Size(max = 50)
    @Email
    private String email;

    private Set ruoli;

    @NotBlank
    @Size(min = 6, max = 40)
    private String password;

  //getter e setter
}

Implementazioni del controller di autenticazione e registrazione

In questa classe troviamo l'implementazione dei metodi che corrispondono agli endpoint per la gestione dell’autenticazione.

@CrossOrigin(origins = "*", maxAge = 3600)
@RestController
@RequestMapping("/api/auth")
public class AuthController{

    @Autowired
    private UtenteRepository userRepo;
    @Autowired private JwtUtils jwtUtil;
    @Autowired private AuthenticationManager authManager;
    @Autowired private PasswordEncoder passwordEncoder;
    @Autowired private RuoloRepository roleRepository;

    @PostMapping("/login")
    public ResponseEntity authenticateUser(@Valid @RequestBody Credenziali loginRequest) {
        Authentication authentication = authManager.authenticate(
                new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword()));
        SecurityContextHolder.getContext().setAuthentication(authentication);
        String jwt = jwtUtil.generateJwtToken(authentication);

        DettagliUtente userDetails = (DettagliUtente) authentication.getPrincipal();
        List roles = userDetails.getAuthorities().stream()
                .map(item -> item.getAuthority())
                .collect(Collectors.toList());
        return ResponseEntity.ok(new JwtResponse(jwt,
                userDetails.getId(),
                userDetails.getUsername(),
                userDetails.getEmail(),
                roles));
    }

    @PostMapping("/registrazione")
    @PreAuthorize("hasAuthority('ADMIN')")
    public ResponseEntity registerUser(@Valid @RequestBody Registrazione signUpRequest) {

        Optional username = userRepo.cercaPerUsername(signUpRequest.getUsername());
        if (username.isPresent()) {
            return ResponseEntity
                    .badRequest()
                    .body(new MessageResponse("Error: Username già presente!"));
        }

        Optional email = userRepo.cercaPerEmail(signUpRequest.getEmail());
        if (email.isPresent()) {
            return ResponseEntity
                    .badRequest()
                    .body(new MessageResponse("Error: Email già presente!"));
        }
        Utente user = new Utente(signUpRequest.getUsername(),
                signUpRequest.getEmail(),
                passwordEncoder.encode(signUpRequest.getPassword()));
        Set strRoles = signUpRequest.getRuoli();

        Set roles = new HashSet<>();
        if (strRoles == null) {
            Ruolo userRole = roleRepository.cercaPerNome(RuoloEnum.UTENTE)
                    .orElseThrow(() -> new RuntimeException("Error: Role "+RuoloEnum.UTENTE+" is not found"));
            roles.add(userRole);
        } else {
            strRoles.forEach(role -> {
                switch (role) {
                    case "ADMIN":
                        Ruolo adminRole = roleRepository.cercaPerNome(RuoloEnum.ADMIN)
                                .orElseThrow(() -> new RuntimeException("Error: Role "+RuoloEnum.ADMIN+" is not found"));
                        roles.add(adminRole);
                        break;
                    case "MODERATORE":
                        Ruolo modRole = roleRepository.cercaPerNome(RuoloEnum.MODERATORE)
                                .orElseThrow(() -> new RuntimeException("Error: Role "+RuoloEnum.MODERATORE+" is not found"));
                        roles.add(modRole);
                        break;
                    default:
                        Ruolo userRole = roleRepository.cercaPerNome(RuoloEnum.UTENTE)
                                .orElseThrow(() -> new RuntimeException("Error: Role "+RuoloEnum.UTENTE+" is not found"));
                        roles.add(userRole);
                }
            });
        }
        user.setRoles(roles);
        userRepo.save(user);
        return ResponseEntity.ok(new MessageResponse("Utente registrato correttamente!"));
    }
}

/api/auth si aspetta di avere un oggetto con username e password di tipo Credenziali come body della richiesta e se l’utente è presente sul DB risponderà con un oggetto con i dati dell’utente, jwt token e ruoli di appartenenza. Il token JWT va passato, ad ogni richiesta protetta all’interno dell’header, come Bearer token.

In alcune richieste troviamo l’annotazione @PreAuthorize("hasAuthority('ADMIN')") che serve ad abilitare i metodi solo per quei ruoli indicati.

Arrivato a questo punto, avrai configurato correttamente Spring Security per la gestione di un’autenticazione con JWT creando un’API per il login dell’utente, contribuendo cosi alla sicurezza delle API della tua applicazione Spring Boot.

Il codice completo del tutorial lo trovi su github.




Commenti
* Obbligatorio