Block Image

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.
Oltre al framework, è necessario soprattutto scegliere il protocollo o lo standard da utilizzare per proteggere le API REST.

In questo articolo noi utilizzeremo JWT per la fasi di autenticazione e autorizzazione.
Inoltre, vedremo sia la configurazione di Spring Security per le versione uguali o successive a Spring Boot 2.7 (compresa la versione 3), e sia quella per le versioni precedenti alla 2.7.

Autenticazione e Autorizzazione

L'autenticazione è il processo che determina chi è l'utente e che privilegi ha; la fase di login fa parte del processo di autenticazione.

L'autorizzazione è il processo che verifica se l'utente può consumare o meno della API, in base a delle policy, come ad esempio il ruolo.

Nell'esempio che faremo, un utente con ruolo USER potrà consumare le API in lettura ma non in scrittura, mentre gli utenti con ruolo ADMIN potranno sia vedere la lista di utenti che crearne nuovi.

Cos'è JWT

JWT (JSON Web Token) è uno standard che permette la trasmissione di informazioni tra parti in un JSON codificato in base64Url.
Il token codifica tre parti (JSON) fondamentali, con questo ordine:

  1. l'header, che solitamente è composto da un campo alg che indica il tipo di algoritmo utilizzato per crittografare la firma (signature) e un campo typ che indica il tipo di token
{
  "alg": "HS256",
  "typ": "JWT"
}
  1. il payload che contiene informazioni riguardante l'utente, come username, ruoli, ma anche informazioni sul token, come la scadenza. Questi attributi vengono chiamati claims
{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}
  1. la terza parte è la firma (signature) che viene creata utilizzando l'header codificato, il payload codificato e una chiave segreta (secret). Ad esempio se si volesse utilizzare l'algortmo HMAC SHA256, la firma verrebbe creata in questa maniera:
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

Il token JWT quindi è formato da questi tre token in base64-URL, separati da un punto. Ecco un esempio di token JWT con i valori dei tre campi riportati nell'esempio:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Per altre informazioni su JWT riporto il sito https://jwt.io/introduction.

Flusso del JWT
  1. Il client si autentica, attraverso ad esempio una API di login, con username e password.
  2. 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.
  3. 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).
  4. 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 (magari l'API invocata può essere consumata solo da utenti con un certo ruolo), etc. Se i check sono superati, il server mostrerà la response dell'API, altrimenti manderà un HTTP Status Response 403.
Nota che i campi header e payload non sono crittografati, ma sono semplicemente codificati in base64Url! Chiunque venga in possesso del token, può decodificare questi due campi e vederne i valori. Per questo motivo il JWT non deve contenere dati sensibili.

In realtà il JWT si divide in due tipi: JWS e JWE.
Il JWS (JSON Web Signature) è il tipo di JWT di cui abbiamo parlato finora, dove il check avviene sulla signature.
Il JWE (JSON Web Encryption) è un tipo di JWT che viene utilizzato per scambiare dati criptati in JSON, quindi anche se venisse intercettato, i dati non sarebbe in chiaro. È utile utilizzarlo quando client e server devono scambiarsi dati sensibili.
Ovviamente, il client deve sapere come poter decriptare i dati, per poterli leggere.

È finalmente ora di scrivere codice!
Creeremo una app Spring Boot con classi Java che mapperanno tabelle USERS e ROLES utilizzando JPA.
Solo gli utenti registrati nella tabella USERS potranno consumare le API REST.
Come DB utilizzerebbe H2 in memory.

Primo passo: andare sul sito Spring Initializr

Questo sito creerà per noi uno scheletro di un'app Spring Boot con tutto quello che ci serve (basta cercare le dipendenze che ci servono nella sezione 'Dependencies'). Clicchiamo su 'ADD DEPENDENCIES' ed aggiungiamo le dipendenze riportate dall'immagine.

Block Image

Clicchiamo sul tasto Generate per scaricare lo zip del progetto.

Nel pom aggiungiamo anche la seguente dipendenza per utilizzare JWT:

<dependency>
   <groupId>com.nimbusds</groupId>
   <artifactId>nimbus-jose-jwt</artifactId>
   <version>9.31</version>
</dependency>

Usiamo questa libreria e non JJWT, che è più semplice da utilizzare, perché Spring Security OAuth2 la utilizza di default e perché è più completa, ad esempio si possono creare JWT sia di tipo JWS sia di tipo JWE.

Secondo passo: creiamo le entity User e Role

Nel subpackage model/entity creiamo le classi Java che mapperanno le tabelle USERS e ROLES:

@Entity
@Table(name = "USERS")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserEntity implements Serializable {

   @Id
   @GeneratedValue(strategy = AUTO)
   private Long id;

   @Column(unique = true, nullable = false)
   private String username;

   @Column(nullable = false)
   private String password;

    @ManyToMany(fetch = EAGER)
    private Collection<RoleEntity> roles = new ArrayList<>();
}
@Entity
@Table(name = "ROLES")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RoleEntity {

   @Id
   @GeneratedValue(strategy = AUTO)
   private Long id;
   
   @Column(unique = true, nullable = false)
   private String name;
}
  • Con @Entity indichiamo a JPA che questa classe Java mappa una tabella a DB.
  • Con @Table, indichiamo a JPA il nome della tabella.
  • Con @Id e @GeneratedValue indichiamo a JPA che l'attributo annotato è una primary key, che deve essere auto-generata.
  • Con @Column(unique = true, nullable = false) indichiamo a JPA che, quando genererà le tabelle a DB, dovrà creare anche i vincoli unique e not null per il campo annotato.
  • Con @ManyToMany indichiamo a JPA che USERS è in relazione N:N con ROLES (relazione unidirezionale, con fetch EAGER, cioè ogni volta che richiederemo un utente dalla tabella USERS, recupereremo a cascata anche tutti i suoi ruoli dalla tabella ROLES).
  • Le altre annotations sono della libreria Lombok, che permette la scrittura di codice più pulito.

Terzo passo: creiamo i repository per User e Role

All'interno del subpackage repo, creiamo le interfacce:

public interface UserJpaRepository extends JpaRepository<UserEntity, Long> {

   UserEntity findByUsername(String username);
}
public interface RoleJpaRepository extends JpaRepository<RoleEntity, Long> {

   RoleEntity findByName(String name);
}

Spring creerà per noi le implementazioni di queste due interfacce, con i metodi DAO findAll, findById, etc.

Quarto passo: creiamo i service per User e Role

All'interno del subpackage service, creiamo la classe:

@Service
@Transactional
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl implements UserService {

    private static final String USER_NOT_FOUND_MESSAGE = "User with username %s not found";

    private final UserJpaRepository userJpaRepository;
    private final RoleJpaRepository roleJpaRepository;
    private final PasswordEncoder passwordEncoder;
    
    @Override
    public UserEntity save(UserEntity user) {
        log.info("Saving user {} to the database", user.getUsername());
        user.setPassword(passwordEncoder.encode(user.getPassword()));
        return userJpaRepository.save(user);
    }


    @Override
    public UserEntity addRoleToUser(String username, String roleName) {
        log.info("Adding role {} to user {}", roleName, username);
        UserEntity userEntity = userJpaRepository.findByUsername(username);
        RoleEntity roleEntity = roleJpaRepository.findByName(roleName);
        userEntity.getRoles().add(roleEntity);
        return userEntity;
    }
    
    //findAll, findByUsername...
}
  1. Con @Service indichiamo a Spring che la classe è un bean con logica di business.
  2. Con @Transactional indichiamo a Spring che tutti i metodi della classe sono in transazione.
  3. @RequiredArgsConstructor e @Slf4j sono due annotations della libreria Lombok, che ci permettono di autogenerare un costruttore in base ai campi final, e creare un logger, rispettivamente.
  4. Il metodo save, oltre a richiamare banalmente il metodo save del repository, codifica la password prima di salvare a db. Creeremo successivamente un bean di tipo PasswordEncoder.
  5. Il metodo addRoleToUser permette di aggiungere un ruolo esistente ad un utente esistente. Non viene invocato il metodo save di UserJpaRepository in quanto userEntity è già una entity managed, essendo in transazione, e quindi tutte le sue modifiche dopo il findByUsername vengono salvare.

Omettiamo i metodi findAll, findByUsername e la classe RoleServiceImpl in quanto richiamano banalmente i metodi del repository (ma su GitHub trovate il codice completo).

Quinto passo: creiamo i servizi REST per User

All'interno del subpackage api, creiamo la classe UserResource:

@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Slf4j
public class UserResource {


   private final UserService userService;

   @GetMapping
   public ResponseEntity<List<UserEntity>> findAll() {
      return ResponseEntity.ok().body(userService.findAll());
   }

   @GetMapping("/{username}")
   public ResponseEntity<UserEntity> findByUsername(@PathVariable String username) {
      return ResponseEntity.ok().body(userService.findByUsername(username));
   }

   @PostMapping
   public ResponseEntity<UserEntity> save(@RequestBody UserEntity user) {
      UserEntity userEntity = userService.save(user);
      URI uri = URI.create(ServletUriComponentsBuilder.fromCurrentRequest().path("/{username}")
              .buildAndExpand(userEntity.getUsername()).toUriString());
      return ResponseEntity.created(uri).build();
   }


   @PostMapping("/{username}/addRoleToUser")
   public ResponseEntity<?> addRoleToUser(@PathVariable String username, @RequestBody RoleDTO request) {
      UserEntity userEntity = userService.addRoleToUser(username, request.getRoleName());
      return ResponseEntity.ok(userEntity);
   }
}

Banalmente, i metodo della classe non hanno logica di business ma richiamano semplicemente i metodi del service.

Sesto passo: creiamo il bean di tipo PasswordEncoder e inizializziamo le tabelle del DB con delle righe

All'interno della classe main SpringSecurityJwtApplication, aggiungiamo:

@SpringBootApplication
public class SpringSecurityJwtApplication {

   public static void main(String[] args) {
      SpringApplication.run(SpringSecurityJwtApplication.class, args);
   }

   @Bean
   PasswordEncoder passwordEncoder() {
      return new BCryptPasswordEncoder();
   }

   @Bean
   CommandLineRunner run(UserService userService, RoleService roleService) {
      return args -> {
         roleService.save(new RoleEntity(null, "ROLE_USER"));
         roleService.save(new RoleEntity(null, "ROLE_ADMIN"));

         userService.save(new UserEntity(null, "rossi", "1234", new ArrayList<>()));
         userService.save(new UserEntity(null, "bianchi", "1234", new ArrayList<>()));

         userService.addRoleToUser("rossi", "ROLE_USER");
         userService.addRoleToUser("bianchi", "ROLE_ADMIN");
         userService.addRoleToUser("bianchi", "ROLE_USER");
      };
   }

}

Ora siamo pronti per mettere in sicurezza le nostre API!

Settimo passo: creiamo una classe che estenda WebSecurityConfigurerAdapter (versioni precedenti a Spring Boot 2.7.0)

Il primo passo per personalizzare Spring Security è creare una classe che estenda WebSecurityConfigurerAdapter, in modo tale da sovrascrivere il comportamento di default.

@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {


   private final UserDetailsService userDetailsService;
   private final BCryptPasswordEncoder bCryptPasswordEncoder;

   //we want to check users from db
   @Override
   protected void configure(AuthenticationManagerBuilder auth) throws Exception {
      auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder);
   }

   @Override
   protected void configure(HttpSecurity http) throws Exception {
      http.csrf().disable()
              .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
              .and()
              .authorizeRequests().antMatchers(HttpMethod.POST, "/login/**").permitAll()
              .and()
              .authorizeRequests().antMatchers(HttpMethod.POST, "/users/**").hasAuthority("ROLE_ADMIN")
              .and()
              .authorizeRequests().anyRequest().authenticated()
              .and()
              .addFilter(new CustomAuthenticationFilter(super.authenticationManagerBean()))
              .addFilterBefore(new CustomAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class);

      http.headers().cacheControl();
   }
}

Analizziamo il metodo configure(AuthenticationManagerBuilder auth):

  • qui indichiamo a Spring che come UserDetailService, che contiene il metodo loadUserByUsername, deve essere usato un bean creato da noi. In particolare il bean creato da noi cercherà l'utente da db. Inoltre per codificare/decodificare la password deve essere usato il bean creato poc'anzi di tipo PasswordEncoder.

Analizziamo il metodo void configure(HttpSecurity http):

  • Con le prime due righe, disabilitiamo il controllo di default sugli attacchi CSRF e indichiamo a Spring Security che non deve creare una sessione per gli utenti che si autenticano (policy STATELESS).
  • Con authorizeRequests().antMatchers(HttpMethod.POST, "/login/**").permitAll() indichiamo a Spring Security che chiunque può consumare l'API /login con verbo POST.
  • Con authorizeRequests().antMatchers(HttpMethod.POST, "/users/**").hasAuthority("ROLE_ADMIN") indichiamo a Spring Security che solo gli utenti con ruolo ADMIN possono consumare le API /users/.. con verbo POST.
  • Con authorizeRequests().anyRequest().authenticated() indichiamo che tutte le altre richieste possono essere consumate se l'utente è autenticato.
  • Con addFilter(new CustomAuthenticationFilter(super.authenticationManagerBean())) aggiungiamo un filtro custom per la fase di autenticazione; la classe custom estende la classe UsernamePasswordAuthenticationFilter di Spring Security, quindi, viene utilizzata solo nella fase di login.
  • Con addFilterBefore(new CustomAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class) creiamo un filtro che viene utilizzato per ogni richiesta HTTP, prima del filtro di tipo UsernamePasswordAuthenticationFilter, ovvero viene richiamato prima della classe CustomAuthenticationFilter.

Settimo passo: creiamo una classe che configuri Spring Security (versioni uguali o successive a Spring Boot 2.7.0, compreso Spring Boot 3)

Dalla versione 2.7.0 di Spring Boot, la classe WebSecurityConfigurerAdapter è stata deprecata, per cui la configurazione di Spring Security deve essere leggermente adattata in questo modo:

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {


    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
        return authConfig.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http, AuthenticationManager authenticationManager) throws Exception {
        http
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeHttpRequests((authz) -> authz
                        .requestMatchers(HttpMethod.POST, "/login/**").permitAll()
                        .requestMatchers(HttpMethod.POST, "/users/**").hasAuthority("ROLE_ADMIN")
                        .anyRequest().authenticated()
                )
                .addFilter(new CustomAuthenticationFilter(authenticationManager))
                .addFilterBefore(new CustomAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class)
                .headers().cacheControl();

        return http.build();
    }
}

Come possiamo notare, rispetto alla versione precedente, non abbiamo bisogno di associare i bean di UserDetailService e PasswordEncoder all'AuthenticationManagerBuilder in quanto viene fatto automaticamente da Spring.
Non abbiamo nemmeno più bisogno di annotare la classe con @EnableWebSecurity. Ci basta annotarla con una semplice @Configuration.

Non analizziamo nel dettaglio i vari metodi utilizzati (come sessionManagement) poiché i concetti sono gli stessi del paragrafo precedente.

Comunque, se volete sapere di più su come sostituire la vecchia configurazione di Spring con la nuova in base alle vostre esigenze, vi rimando alla doc ufficiale:
spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter.

Nota: Se si utilizza Spring Boot 2.7, sostituire il metodo requestMatchers con antMatchers

Ottavo passo: creiamo il bean di tipo UserDetailService

Invece di creare una nuova classe che implementa l'interfaccia UserDetailService, facciamo implementare quest'ultima direttamente alla classe UserServiceImpl:

public class UserServiceImpl implements UserService, UserDetailsService {
...
@Transactional(readOnly = true)
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity user = userJpaRepository.findByUsername(username);
        if(user == null) {
            String message = String.format(USER_NOT_FOUND_MESSAGE, username);
            log.error(message);
            throw new UsernameNotFoundException(message);
        } else {
            log.debug("User found in the database: {}", username);
            Collection<SimpleGrantedAuthority> authorities = new ArrayList<>();
            user.getRoles().forEach(role -> {
                authorities.add(new SimpleGrantedAuthority(role.getName()));
            });
            return new User(user.getUsername(), user.getPassword(), authorities);
        }
    }
    ...

Il metodo loadUserByUsername semplicemente cerca l'utente con username in input, sul DB.
Se esiste, trasforma i ruoli RoleEntity in SimpleGrantedAuthority, che è la classe di default di Spring Security per la gestione dei ruoli ed infine restituisce una istanza di tipo User, che è una classe di Spring Security che implementa UserDetails.
Se non esiste l'utente, viene lanciata una eccezione di tipo UsernameNotFoundException.

Nono passo: creiamo il filtro per la fase di autenticazione

Questo filtro viene utilizzato nella fase di login. Richiama automaticamente UserDetailsService.loadUserByUsername, e se l'utente esiste, crea e restituisce due token JWT: uno è l'access token, utilizzato per autorizzare l'utente, l'altro è il refresh token, utilizzato dal client per acquisire un nuovo access token senza dover effettuare nuovamente il login.

Anche il refresh token ha una data di scadenza, ma ovviamente è maggiore rispetto a quella dell'access token.
@Slf4j
@RequiredArgsConstructor
public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private static final String BAD_CREDENTIAL_MESSAGE = "Authentication failed for username: %s and password: %s";

    private final AuthenticationManager authenticationManager;

    @SneakyThrows
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {

        String username = null;
        String password = null;
        try {
            ObjectMapper objectMapper = new ObjectMapper();
            Map<String, String> map = objectMapper.readValue(request.getInputStream(), Map.class);
            username = map.get("username");
            password = map.get("password");
            log.debug("Login with username: {}", username);
            return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
        }
        catch (AuthenticationException e) {
            log.error(String.format(BAD_CREDENTIAL_MESSAGE, username, password), e);
            throw e;
        }
        catch (Exception e) {
            response.setStatus(INTERNAL_SERVER_ERROR.value());
            Map<String, String> error = new HashMap<>();
            error.put("errorMessage", e.getMessage());
            response.setContentType(APPLICATION_JSON_VALUE);
            new ObjectMapper().writeValue(response.getOutputStream(), error);
            throw new RuntimeException(String.format("Error in attemptAuthentication with username %s and password %s", username, password), e);
        }
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
                                            Authentication authentication) throws IOException, ServletException {
        User user = (User)authentication.getPrincipal();
        String accessToken = JwtUtil.createAccessToken(user.getUsername(), request.getRequestURL().toString(),
                user.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList()));
        String refreshToken = JwtUtil.createRefreshToken(user.getUsername());
        response.addHeader("access_token", accessToken);
        response.addHeader("refresh_token", refreshToken);
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        ObjectMapper mapper = new ObjectMapper();
        Map<String, String> error = new HashMap<>();
        error.put("errorMessage", "Bad credentials");
        response.setContentType(APPLICATION_JSON_VALUE);
        mapper.writeValue(response.getOutputStream(), error);
    }
}

Analizziamo il codice:

  • il metodo attemptAuthentication viene invocato nella fase di login, prende username e password dalla RequestBody e richiama authenticationManager.authenticate, che a sua volta chiama UserDetailService per controllare che lo user sia presente nel database, e poi controlla che la password decodificata dell'istanza User (creata da UserDetailService) corrisponda a quella data in input. Se i check sono superati, viene richiamato il metodo successfulAuthentication, altrimenti unsuccessfulAuthentication.
  • Il metodo successfulAuthentication crea l'access token e il refresh token e li aggiunge all'header di risposta della chiamata /login.
  • Il metodo unsuccessfulAuthentication viene invocato quando attemptAuthentication lancia una eccezione di tipo AuthenticationException. La sovrascrittura di questo metodo, per i nostri scopi, è opzionale. Noi lo utilizziamo per restituire 401 e un messaggio di errore nella Response Body.

La classe JwtUtil è una classe di utility che creeremo per creare e validare il token JWT.

Decimo passo: creiamo il filtro per la fase di autorizzazione

Questo filtro leggerà e convaliderà il token dato in input dal client nell'header con chiave Authorization.
Più nello specifico, se il token è valido, valorizza le informazioni dell'utente loggato, come username e ruoli, all'interno del contesto di SecurityContextHolder.

@Slf4j
public class CustomAuthorizationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        String token = null;
        if(request.getServletPath().equals("/login") || request.getServletPath().equals("/refreshToken")) {
            filterChain.doFilter(request, response);
        } else {
            String authorizationHeader = request.getHeader(AUTHORIZATION);
            if(authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
                try {
                    token = authorizationHeader.substring("Bearer ".length());
                    UsernamePasswordAuthenticationToken authenticationToken = JwtUtil.parseToken(token);
                    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
                    filterChain.doFilter(request, response);
                }
                catch (Exception e) {
                    log.error(String.format("Error auth token: %s", token), e);
                    response.setStatus(FORBIDDEN.value());
                    Map<String, String> error = new HashMap<>();
                    error.put("errorMessage", e.getMessage());
                    response.setContentType(APPLICATION_JSON_VALUE);
                    new ObjectMapper().writeValue(response.getOutputStream(), error);
                }
            } else {
                filterChain.doFilter(request, response);
            }
        }
    }
}

Undicesimo passo: creiamo la classe JwtUtil

public abstract class JwtUtil {

    private static final int expireHourToken = 24;
    private static final int expireHourRefreshToken = 72;

    private static final String SECRET = "FBA898697394CDBC534E7ED86A97AA59F627FE6B309E0A21EEC6C9B130E0369C";


    public static String createAccessToken(String username, String issuer, List<String> roles) {
        try {
            JWTClaimsSet claims = new JWTClaimsSet.Builder()
                    .subject(username)
                    .issuer(issuer)
                    .claim("roles", roles)
                    .expirationTime(Date.from(Instant.now().plusSeconds(expireHourToken * 3600)))
                    .issueTime(new Date())
                    .build();

            Payload payload = new Payload(claims.toJSONObject());

            JWSObject jwsObject = new JWSObject(new JWSHeader(JWSAlgorithm.HS256),
                    payload);

            jwsObject.sign(new MACSigner(SECRET));
            return jwsObject.serialize();
        }
        catch (JOSEException e) {
            throw new RuntimeException("Error to create JWT", e);
        }
    }

    public static String createRefreshToken(String username) {
        //like createAccessToken method, but without issuer, roles...
    }

    public static UsernamePasswordAuthenticationToken parseToken(String token) throws JOSEException, ParseException,
            BadJOSEException {

        byte[] secretKey = SECRET.getBytes();
        SignedJWT signedJWT = SignedJWT.parse(token);
        signedJWT.verify(new MACVerifier(secretKey));
        ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();

        JWSKeySelector<SecurityContext> keySelector = new JWSVerificationKeySelector<>(JWSAlgorithm.HS256,
                new ImmutableSecret<>(secretKey));
        jwtProcessor.setJWSKeySelector(keySelector);
        jwtProcessor.process(signedJWT, null);
        JWTClaimsSet claims = signedJWT.getJWTClaimsSet();
        String username = claims.getSubject();
        var roles = (List<String>) claims.getClaim("roles");
        var authorities = roles == null ? null : roles.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
        return new UsernamePasswordAuthenticationToken(username, null, authorities);
    }
}
  • Il metodo createAccessToken crea un token con payload contenente: subject (username), roles, issuer (chi ha richiesto il JWT), expirationTime impostato a 24 ore e issueTime, cioè quando è stato creato il token.
    La signature è criptata con l'algoritmo HS256 e una secretKey aes-256-cfb, generata dal sito https://asecuritysite.com/encryption/keygen.
  • Il metodo parseToken ha il compito di verificare la signature del token e di effettuare eventuali check sul payload, come la data di scadenza del token. Se i check danno esito positivo, viene restituita una istanza di UsernamePasswordAuthenticationToken che contiene username e ruoli associati all'utente. Questa istanza verrà poi aggiunta al contesto di Spring Security, con SecurityContextHolder.getContext().setAuthentication(authenticationToken).

Finito! Provamo l'applicativo

Nel file application.properties aggiungiamo queste 2 property:

server.servlet.context-path=/api
spring.jpa.show-sql=true

in modo tale da avere come context-root /api e i log sql di hibernate.

Eseguiamo la classe main da un IDE oppure eseguiamo da terminale il comando:
mvnw spring-boot:run
dalla root del progetto.

Da un client REST come Postaman o cURL effettuiamo una chiamata REST con verbo POST a:
http://localhost:8080/api/login
e con Request Body:

{
    "username": "rossi",
    "password": "1111"
}

Avremo come risposta una status 401 e come body:

{
    "errorMessage": "Bad credentials"
}

in quanto la password è sbagliata: quella corretta è 1234.
Facciamo la stessa richiesta con la password corretta, avremo in risposta un 200 OK, con response body vuoto e come response header, i valori di access_token e refresh_token, oltre agli altri headers di default.

Ora effettuiamo la chiamata in GET:
http://localhost:8080/api/users/
avremo una risposta 403 come status e come body:

{
    "timestamp": "2021-08-22T13:38:22.166+00:00",
    "status": 403,
    "error": "Forbidden",
    "path": "/api/users/"
}

in quanto abbiamo fatto la richiesta senza inserire il token JWT.
Facciamo la stessa richiesta inserendo nell'header della request, la chiave Authorization e con valore l'access_token ricevuto dalla richiesta di login, preceduta dalla parola Bearer e uno spazio:

cURL:

curl 'http://localhost:8080/api/users/' -H \
'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOlwvXC9sb2NhbGhvc3Q6ODA4MFwvYXBpXC9sb2dpbiIsInN1YiI6InJvc3NpIiwiZXhwIjoxNjI5NzI1Nzc2LCJpYXQiOjE2Mjk2MzkzNzYsInJvbGVzIjpbIlJPTEVfVVNFUiJdfQ.odGsLpcQwiYkQPT9XxoyzveaSUx3Qcp4p-VheapqsbU'

avremo come risposta un 200 OK con la lista di users.

Ora proviamo ad effettuare una chiamata POST:
http://localhost:8080/api/users/rossi/addRoleToUser
con lo stesso access token e con request body:

{
    "roleName": "ROLE_ADMIN"
}

vogliamo in pratica che l'utente rossi acquisisca anche il ruolo di ADMIN. Ma essendo che la richiesta viene fatta dallo stesso utente rossi, che ha ruolo USER, la richiesta fallisce in quanto solo un ADMIN ha il permesso di fare delle richieste POST alla risorsa /user; avremo infatti come risposta:

{
    "timestamp": "2021-08-22T13:51:55.291+00:00",
    "status": 403,
    "error": "Forbidden",
    "path": "/api/users/rossi/addRoleToUser"
}

Per aggiungere il ruolo di ADMIN all'utente rossi, dobbiamo loggarci con l'utenza bianchi, che ha il ruolo di ADMIN, e acquisire l'access token:

curl -i -X POST 'http://localhost:8080/api/login' -H 'Content-Type: application/json' -d '{
    "username": "bianchi",
    "password": "1234"
}'
HTTP/1.1 200
access_token: eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOlwvXC9sb2NhbGhvc3Q6ODA4MFwvYXBpXC9sb2dpbiIsInN1YiI6ImJpYW5jaGkiLCJleHAiOjE2Mjk3MjczODAsImlhdCI6MTYyOTY0MDk4MCwicm9sZXMiOlsiUk9MRV9BRE1JTiIsIlJPTEVfVVNFUiJdfQ.F1S3sYRDcVUst90ggsY7Dwsc1FkjLZTve8fwgrmc0Zo
refresh_token: eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2Mjk5MDAxODAsInN1YiI6ImJpYW5jaGkifQ.LIY-RzzdAIyYSLfWkkVhDPJuazGcwDiEMyXu_hRQO0s
...

Ora effettuiamo la richiesta precedenza con il nuovo token:

curl -i -X POST 'http://localhost:8080/api/users/rossi/addRoleToUser' \
> -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOlwvXC9sb2NhbGhvc3Q6ODA4MFwvYXBpXC9sb2dpbiIsInN1YiI6ImJpYW5jaGkiLCJleHAiOjE2Mjk3MjcwMjksImlhdCI6MTYyOTY0MDYyOSwicm9sZXMiOlsiUk9MRV9BRE1JTiIsIlJPTEVfVVNFUiJdfQ.jtarIuzC5WPF0TlwTT0DFcQlRoPaDILiOiEES2HTnTU' \
> -H 'Content-Type: application/json' \
> -d '{
>     "roleName": "ROLE_ADMIN"
> }'
HTTP/1.1 200 
...

{"id":3,"username":"rossi","password":"$2a$10$mXCq0fVafTlSihho7ZSA0ugWQ.F1h8bEijFgbIB4YJEb1IsuaErmC","roles":[{"id":1,"name":"ROLE_USER"},{"id":2,"name":"ROLE_ADMIN"},{"id":2,"name":"ROLE_ADMIN"}]}

Ora siamo riusciti ad aggiungere il ruolo ADMIN all'utente rossi!

Conclusioni

Abbiamo dato una rapida panoramica a JWT e abbiamo integrato facilmente questo standard in Spring Security.

Naturalmente, come ogni tecnologia, il JWT ha aspetti positivi, come il fatto di creare una policy di sicurezza stateless, ma anche aspetti negativi, come il fatto di non poter tenere traccia degli utenti loggati (che comporta, ad esempio, l'impossibilità di gestire il logout lato server), non essendo memorizzati da nessuna parte. Ma per entrare nel dettaglio di questi aspetti, si consiglia di leggere altri articoli in rete.

Trovate il progetto completo, compreso di metodo per creare il refresh token, sul mio GitHub: Spring Security JWT.

Articoli su Spring: Spring

Libri consigliati su Spring: