saverioriotto.it

Proteggere le API REST Spring Boot 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 Spring Boot con autenticazione JWT e Ruoli

In un precedente articolo, ti ho mostrato come realizzare un servizio di API in Spring Boot, ma niente a riguardo della sicurezza, che vedrai in questo articolo.

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

Prima di iniziare, qualora non lo sapessi, ti spiego brevemente cos’è un JWT (Json Web Token) e come questo viene utilizzato nelle applicazioni web.

Un token con lo standard JWT ha una rappresentazione JSON e può contenere informazioni personalizzate. La sua struttura è composta da un header, un payload (un set di informazioni chiamati Claims) e una signature. Un JWT infatti, può essere firmato tramite un algoritmo di cifratura come SHA256.

L’integrazione nelle applicazioni segue il flusso:

- Il client si autentica, attraverso ad esempio una API di login, con username e password.
- Il server controlla che l'utente esista (ad esempio su db) e la password sia corretta. Se è tutto ok, crea un token JWT con una firma tramite una secret key. A quel punto manda nella response della login (body o header) il token.
- Il client acquisisce il token, e per ogni chiamata REST che effettua al server, manda il token nell'header, solitamente nell'header con chiave Authorization usando le schema Bearer, poiché questo header evita problemi con CORS (Cross-Origin Resource Sharing).
- Il server, quando riceve una richiesta dal client, decodifica il token, esamina innanzitutto la signature per capire se è valida, successivamente analizza altri campi del payload come la data di scadenza del token, il ruolo dell'utente, etc. Se i check sono superati, il server mostrerà la response dell'API, altrimenti manderà un HTTP Status Response 403.

Fatta questa premessa procediamo all’implementazione vera e proprio.

Per questo tutorial prendiamo il codice utilizzato per il servizio REST creato nel precedente articolo e integriamo l’autenticazione.

Come primo step aggiungiamo le dipendenze che ci servono:

- spring-security-web
- spring-security-core
- spring-security-config
- jjwt
- spring-boot-starter-validation

Successivamente all’interno del file application.properties aggiungiamo due proprieta:

saverioriotto.app.jwtSecret= sRSecretKey
saverioriotto.app.jwtExpirationMs= 86400000

Creazione dei modelli/entità

Per questo tutorial prendiamo in considerazione tre tabelle nel database: utenti, ruoli e utente_ruoli per una relazione molti-a-molti ed un enum, RuoloEnum.java, con tre ruoli predefiniti

Nel package entities, creiamo Utente.java e Ruolo.java

@Entity
@Table(	name = "utenti",
        uniqueConstraints = {
                @UniqueConstraint(columnNames = "username"),
                @UniqueConstraint(columnNames = "email")
        })
public class Utente {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;
    @NotBlank
    @Size(max = 20)
    private String username;
    @NotBlank
    @Size(max = 50)
    @Email
    private String email;
    @NotBlank
    @Size(max = 120)
    private String password;
    @ManyToMany(fetch = FetchType.LAZY)
    @JoinTable(	name = "utente_ruoli",
            joinColumns = @JoinColumn(name = "id_utente"),
            inverseJoinColumns = @JoinColumn(name = "id_ruolo"))
    private Set roles = new HashSet<>();
    public Utente() {
    }
    public Utente(String username, String email, String password) {
        this.username = username;
        this.email = email;
        this.password = password;
    }

   //getter e setter
}
@Entity
@Table(name = "ruoli")
public class Ruolo {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Integer id;
    @Enumerated(EnumType.STRING)
    @Column(length = 20)
    private RuoloEnum name;
    public Ruolo() {
    }
    public Ruolo(RuoloEnum name) {
        this.name = name;
    }
 //getter e setter
public enum RuoloEnum {
    UTENTE,
    MODERATORE,
    ADMIN,
}

Implementazione repository

Ora, ogni modello sopra ha bisogno di un repository per la persistenza e l'accesso ai dati. Nel package repositories, creiamo due repository, UtenteRepository e RuoloRepository, che estendono JpaRepository per ereditare metodi di ricerca qualora servissero.

@Repository
public interface UtenteRepository extends JpaRepository<Utente, Long> {
    @Query(value = "SELECT * FROM utenti WHERE username LIKE %?1%", nativeQuery = true)
    Optional cercaPerUsername(String username);

    @Query(value = "SELECT * FROM utenti WHERE email LIKE %?1%", nativeQuery = true)
    Optional cercaPerEmail(String email);
}
@Repository
public interface RuoloRepository extends JpaRepository<Ruolo, Long> {
    @Query(value = "SELECT * FROM ruoli WHERE name LIKE %?1%", nativeQuery = true)
    Optional cercaPerNome(RuoloEnum name);
    @Query(value = "SELECT * FROM ruoli WHERE name LIKE %?1%", nativeQuery = true)
    List cercaPerNomeList(RuoloEnum name);
}

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, jwtSecret)
                .compact();
    }
    public String generateJwtToken(String username) {
        return Jwts.builder()
                .setSubject((username))
                .setIssuedAt(new Date())
                .setExpiration(new Date((new Date()).getTime() + jwtExpirationMs))
                .signWith(SignatureAlgorithm.HS512, jwtSecret)
                .compact();
    }
    public String getUserNameFromJwtToken(String token) {
        return Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token).getBody().getSubject();
    }
    public boolean validateJwtToken(String authToken) {
        try {
            Jwts.parser().setSigningKey(jwtSecret).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;
    }
}

La classe AuthTokenFilter

Rappresenta il filtro di autorizzazione che viene attivato automaticamente quando viene ricevuta una richiesta Http esterna.

public class AuthTokenFilter extends OncePerRequestFilter {
    @Autowired
    private JwtUtils jwtUtils;
    @Autowired
    private DettagliUtenteService userDetailsService;
    private static final Logger logger = Logger.getLogger(AuthTokenFilter.class.getName());
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        try {
            String jwt = parseJwt(request);
            if (jwt != null && jwtUtils.validateJwtToken(jwt)) {
                String username = jwtUtils.getUserNameFromJwtToken(jwt);
                DettagliUtente userDetails = userDetailsService.loadUserByUsername(username);
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception e) {
            logger.log(Level.SEVERE, "Cannot set user authentication: {}", e);
        }
        filterChain.doFilter(request, response);
    }
    private String parseJwt(HttpServletRequest request) {
        String headerAuth = request.getHeader("Authorization");
        if (StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) {
            return headerAuth.substring(7, headerAuth.length());
        }
        return null;
    }
}

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().and().csrf().disable()  .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                .authorizeRequests().antMatchers("/api/auth/**").permitAll()
                .antMatchers("/api/test/**").permitAll()
                .antMatchers("/api/libro/**").permitAll()
                .anyRequest().authenticated();
        http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring().antMatchers("/images/**", "/js/**", "/webjars/**");
    }
    @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().and().csrf().disable();
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

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

.addFilterBefore(new .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.

.antMatchers("/api/auth/**").permitAll()
.antMatchers("/api/test/**").permitAll()
.antMatchers("/api/libro/**").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.

N.B. Dalla versione 5.7.0-M2 di Spring Security, che trovi in Spring Boot 2.7.x, la classe WebSecurityConfigurerAdapter che implementa http security è stata deprecata.

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

User Avatar
Antonino Lascala
 Ho aggiunto al sistema di autenticazione la parte web con thymeleaft e riesco correttamente ad inserite utenti con ruoli associati. Effettuo correttamente il login ricevendo il token di autenticazione ma quando provo ad aprire una pagina ad accesso limitato ( Es. .antMatchers("/utenti").hasRole("ADMIN") ) ricevo come risposta accesso negato. Devo ripassare il token tramite Thymeleaft? Come posso procedere? Grazie
2 mesi fa
User Avatar
saverio
 Ciao Antonino, ti da accesso negato probabilmente perché non stai passando il token JWT, generato al login, ad ogni richiesta protetta all’interno dell’header come Bearer token.
2 mesi fa
User Avatar
Antonino
  io nella richiesta inglobo il jwt nell'header. Ti riporto di seguito il mio postmap login che dovrebbe aprire la pagina ruoli: @PostMapping("/login") public String authenticateUser(HttpServletResponse response,HttpServletRequest request,@ModelAttribute("username") String username,@ModelAttribute("password") String password) { Authentication authentication = authManager.authenticate( new UsernamePasswordAuthenticationToken(username, password)); 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)); */ String targetUrl = "/ruoli"; return UriComponentsBuilder.fromUriString(targetUrl).queryParam("Authorization", "Bearer "+jwt).build().toUriString(); }
2 mesi fa
User Avatar
saverio
 La parte UriComponentsBuilder è errata. Ma tralasciando questo non serve chiamare il servizio ruoli in quanto al login hai già i ruoli dell'utente quindi basterebbe solo ritornarli come oggetto quindi il codice diventa : @PostMapping("/login") public String authenticateUser(HttpServletResponse response,HttpServletRequest request,@ModelAttribute("username") String username,@ModelAttribute("password") String password) { Authentication authentication = authManager.authenticate( new UsernamePasswordAuthenticationToken(username, password)); 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(roles); }
2 mesi fa
User Avatar
Antonino
  scusa mi sono espresso male e il codice è forviante. Io voglio aprire da Login una nuova scheda html e lo stesso dovrò fare per navigare via controller da una pagina all'altra. Ho provato a sostituire UriComponentsBuilder con ( voglio aprire la pagina ruoli o eventualmente altre ) response.addHeader("Authorization", "Bearer "+jwt); try { response.sendRedirect("/ruoli"); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } con questa modifica ricevo errore 403 . Ho verificato che il jwt è valido utilizzando postmap. Dove sbaglio nel comporre l'url per aprire un'altra pagina html? Grazie
2 mesi fa
User Avatar
saverio
 Allora dovresti modificare il return con return "redirect:/ruoli?token= "+jwt; e nella parte dei ruoli recuperare il jwt dal parametro token e darlo in pasto al metodo di verifica/lettura dei ruoli. N.B. Non è un metodo sicuro però per imparare a comprendere l'utilizzo va bene.
2 mesi fa
User Avatar
Antonino
  Scusa Saverio ma non ti seguo, ho provato ad impostare il return come hai scritto tu ma mi da errore 500. Se interrogo il controller con postman o con ajax ricevo come risposta la nuova pagina (se mi scrivi in privato il tuo indirizzo email ti mando gli screenshot), se lo faccio dal controller ricevo errore 403, come se il security non riuscisse a leggere il jwt che aggiungo come header al response.Qual'è la procedura corretta per passare da una pagina all'altra?
2 mesi fa
User Avatar
saverio
 il problema è più complesso di quanto sembra, Magari scrivimi su [email protected] e metti anche gli screen dell'errore dato dal 500 così vedo il tutto nel dettaglio.
2 mesi fa