Articolo originale: How to Set Up Java Spring Boot JWT Authorization and Authentication

Lo scorso mese, ho avuto la possibilità di implementare il sistema di autenticazione JWT per uno dei miei progetti. Ho lavorato precedentemente con JWT in Ruby on Rails, ma questa è stata la mia prima volta con Spring Boot.

In questo tutorial, proverò a spiegare cosa ho imparato e come l'ho applicato all'interno del mio progetto per condividere le mie esperienze, sperando che possano essere d'aiuto.

Inizieremo dando un rapido sguardo alla teoria dietro JWT e il suo funzionamento. Successivamente guarderemo insieme come implementarlo all'interno di una applicazione Spring Boot.

Fondamenti di JWT

JWT, o JSON Web Tokens (RFC 7519), è uno standard comunemente usato per rendere sicure le API REST. Nonostante sia una tecnologia relativamente giovane è diventata rapidamente popolare.

Nel processo di autenticazione JWT, il front end (il client) invia per primo alcune credenziali da utilizzare per l'autenticazione (nel nostro caso, username e password, perché stiamo lavorando su una app web).

Il server (nel nostro caso l'app con Spring Boot) poi, controlla le credenziali ricevute, se sono valide genera il JWT e lo restituisce.

Dopo questo passaggio, il client deve inviare questo il token nell'intestazione di Autorizzazione della richiesta, nel modulo “Bearer TOKEN”. Il back end controllerà la validità del token e autorizzerà o meno la richiesta. Il token potrebbe anche contenere informazioni sull'utente e le relative autorizzazioni in base al suo ruolo.

1

Implementazione

Adesso vediamo come implementare il meccanismo di login e salvataggio con JWT in una vera applicazione Spring Boot.

Dipendenze

Puoi vedere la lista di dipendenze Maven di cui abbiamo bisogno nel codice qui sotto. Nota che le principali dipendenze, come Spring Boot e Hibernate, non sono incluse nello screenshot.

2-1

Salvataggio Utenti

Inizieremo con la creazione dei controller per salvare gli utenti in modo sicuro e autenticarli in base ai loro username e password.

Abbiamo un'entità modello chiamata User. Questa è una semplice classe che mappa la tabella nel database chiamata USER. Puoi usare qualsiasi proprietà di cui hai bisogno a seconda della tua applicazione.

3-1

Abbiamo anche una semplice classe UserRepository per salvare gli utenti. Dobbiamo sovrascrivere il metodo findByUsername visto che lo useremo nell'autenticazione.

Creiamo un'interfaccia chiamandola UserRepository e estendiamola con JpaRepository. Questa ci servirà per la gestione degli utenti. Inoltre c'è bisogno di sovrascrivere il metodo findByUsername per l'autenticazione.

public interface UserRepository extends JpaRepository<User, String>{ 
    User findByUsername(String username); 
}

Non dovremmo mai memorizzare le password nel database in testo non crittografato, perché molti utenti tendono ad usare la stessa password per più siti.

Esistono diversi algoritmi di hash, ma il più usato è BCrypt ed è un metodo sicuro per criptare le password. Puoi controllare questo articolo per maggiori informazioni sull'argomento.

Per criptare le password definiamo un bean chiamato BCrypt all'interno della @SpringBootApplication e annotiamo la classe principale in questo modo:

@Bean public BCryptPasswordEncoder bCryptPasswordEncoder() {
    return new BCryptPasswordEncoder(); 
}

Chiameremo i metodi all'interno di questo bean quando avremo bisogno di criptare una password.

Abbiamo anche bisogno di un controller, UserController, per salvare gli utenti. Una volta creato lo annotiamo con @RestController e ne definiamo il corretto mapping.

Nella nostra applicazione, salveremo l'utente attraverso l'oggetto DTO ricevuto dal front end. Puoi anche passare un oggetto User attraverso il body sfruttando l'annotazione @RequestBody.

Dopo aver passato l'oggetto DTO, possiamo criptare il campo della password usando il suo getter passandolo al metodo che abbiamo definito nel bean BCrypt. Potresti farlo anche nel controller, ma è una pratica migliore farlo nella classe service.

@Transactional(rollbackFor = Exception.class) 
public String saveDto(UserDto userDto) { 
    userDto.setPassword(bCryptPasswordEncoder
           .encode(userDto.getPassword())); 
    return save(new User(userDto)).getId(); 
}

Filtro di Autenticazione

Abbiamo bisogno dell'autenticazione per confermare che l'utente sia davvero chi dice di essere. A questo scopo, useremo la classica coppia username/password.

Ecco i passaggi per l'autenticazione:

  1. Creare un filtro di autenticazione che estende UsernamePasswordAuthenticationFilter
  2. Creare una classe per la configurazione della security che estende WebSecurityConfigurerAdapter e applicare il filtro.

Ecco il codice per il nostro filtro di autenticazione – come puoi immaginare, i filtri sono il fulcro di Spring Security.

public class JWTAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private AuthenticationManager authenticationManager;

    public JWTAuthenticationFilter(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;

        setFilterProcessesUrl("/api/services/controller/user/login"); 
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest req,
                                                HttpServletResponse res) throws AuthenticationException {
        try {
            User creds = new ObjectMapper()
                    .readValue(req.getInputStream(), User.class);

            return authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(
                            creds.getUsername(),
                            creds.getPassword(),
                            new ArrayList<>())
            );
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest req,
                                            HttpServletResponse res,
                                            FilterChain chain,
                                            Authentication auth) throws IOException {
        String token = JWT.create()
                .withSubject(((User) auth.getPrincipal()).getUsername())
                .withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
                .sign(Algorithm.HMAC512(SECRET.getBytes()));

        String body = ((User) auth.getPrincipal()).getUsername() + " " + token;

        res.getWriter().write(body);
        res.getWriter().flush();
    }
}
JWTAuthenticationFilter.java hosted with ❤ by GitHub

Analizziamo ogni step di questo codice.

Questa classe estende UsernamePasswordAuthenticationFilter, che è la classe di default per l'autenticazione con password in Spring Security. L'abbiamo estesa per definire la nostra logica di autenticazione personalizzata.

Chiamiamo il metodo setFilterProcessesUrl nel nostro costruttore. Questo metodo imposta l'URL di login di default al parametro dato.

Se rimuovi questa riga, Spring Security crea un endpoint “/login” di default. Definisce l'endpoint per noi, motivo per cui non definiamo un endpoint di login esplicitamente nel nostro controller.

Dopo questa riga il nostro endpoint per il login sarà /api/services/controller/user/login. Puoi usare questa funzione essere coerente con i tuoi endpoint.

Sovrascriviamo i metodi attemptAuthentication e successfulAuthentication della classe UsernameAuthenticationFilter.

La funzione attemptAuthentication verrà eseguita quando l'utente tenta di effettuare il login nell'applicazione. Legge le credenziali, crea un utente POJO e controlla le credenziali da autenticare.

Passiamo username, password e una lista vuota. La lista vuota rappresenta le autorità (ruoli). In questo caso, la lasciamo vuota perché non abbiamo ancora alcun ruolo nella nostra applicazione.

Se l'autenticazione va a buon fine, il metodo successfulAuthentication viene eseguito. I valori dei parametri sono passati implicitamente da Spring Security.

Il metodo attemptAuthentication restituisce un oggetto Authentication che contiene le autorità passate nel frattempo.

Vogliamo restituire il token all'utente dopo la sua corretta autenticazione, quindi creiamo il token usando lo username, la chiave segreta e la data di scadenza. Definiamo quindi SECRET e EXPIRATION_DATE.

public class SecurityConstants {

  public static final String SECRET = "SECRET_KEY";
  public static final long EXPIRATION_TIME = 900_000; // 15 mins
  public static final String TOKEN_PREFIX = "Bearer ";
  public static final String HEADER_STRING = "Authorization";
  public static final String SIGN_UP_URL = "/api/services/controller/user";
}
SecurityConstants.java hosted with ❤ by GitHub

Abbiamo creato una classe che contiene le nostre costanti. Puoi definire qualsiasi chiave segreta tu voglia, ma una buona pratica è usare una chiave con una lunghezza uguale al tuo hash. In questo esempio, usiamo l'algoritmo HS256, quindi la chiave segreta è di 256 bit/32 caratteri.

La scadenza è impostata a 15 minuti, questo perché è considerata una buona abitudine per evitare che la nostra chiave sia soggetta ad attacchi di forza bruta. Il tempo è in millisecondi.

Abbiamo preparato il filtro di autenticazione, ma non è ancora attivo. Abbiamo anche bisogno di un filtro di autorizzazione, e li utilizzeremo entrambi attraverso una classe di configurazione.

Questo filtro controllerà l'esistenza e la validità del token nell'intestazione dell'autorizzazione. Specificheremo quali endpoint saranno soggetti a questo filtro nella classe di configurazione.

Filtro di autorizzazione

public class JWTAuthorizationFilter extends BasicAuthenticationFilter {

    public JWTAuthorizationFilter(AuthenticationManager authManager) {
        super(authManager);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                    HttpServletResponse res,
                                    FilterChain chain) throws IOException, ServletException {
        String header = req.getHeader(HEADER_STRING);

        if (header == null || !header.startsWith(TOKEN_PREFIX)) {
            chain.doFilter(req, res);
            return;
        }

        UsernamePasswordAuthenticationToken authentication = getAuthentication(req);

        SecurityContextHolder.getContext().setAuthentication(authentication);
        chain.doFilter(req, res);
    }

    // Reads the JWT from the Authorization header, and then uses JWT to validate the token
    private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
        String token = request.getHeader(HEADER_STRING);

        if (token != null) {
            // parse the token.
            String user = JWT.require(Algorithm.HMAC512(SECRET.getBytes()))
                    .build()
                    .verify(token.replace(TOKEN_PREFIX, ""))
                    .getSubject();

            if (user != null) {
                // new arraylist means authorities
                return new UsernamePasswordAuthenticationToken(user, null, new ArrayList<>());
            }

            return null;
        }

        return null;
    }
JWTAuthorizationFilter.java hosted with ❤ by GitHub

Il metodo doFilterInternal intercetta la richiesta e controlla l'intestazione di Autorizzazione. Se l'header non è presente o non inizia per "BEARER" procede con la catena di filtri.

Se l'intestazione è presente, verrà invocato il metodo getAuthentication, che verifica il JWT e se il token è valido restituisce un token di accesso che Spring userà internamente.

Questo nuovo token è salvato nel SecurityContext. Se hai bisogno di un tipo di autorizzazione in base al ruolo puoi passare questo token in Authorities.

I nostri filtri sono pronti e dobbiamo farli entrare in azione grazie all'aiuto di una classe di configurazione.

Configurazione

@EnableWebSecurity
public class WebSecurity extends WebSecurityConfigurerAdapter {

    private UserDetailsServiceImpl userDetailsService;
    private BCryptPasswordEncoder bCryptPasswordEncoder;

    public WebSecurity(UserDetailsServiceImpl userService, BCryptPasswordEncoder bCryptPasswordEncoder) {
        this.userDetailsService = userService;
        this.bCryptPasswordEncoder = bCryptPasswordEncoder;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and().authorizeRequests()
                .antMatchers(HttpMethod.POST, SIGN_UP_URL).permitAll()
                .anyRequest().authenticated()
                .and()
                .addFilter(new JWTAuthenticationFilter(authenticationManager()))
                .addFilter(new JWTAuthorizationFilter(authenticationManager()))
                // this disables session creation on Spring Security
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }

    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder);
    }

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

        CorsConfiguration corsConfiguration = new CorsConfiguration().applyPermitDefaultValues();
        source.registerCorsConfiguration("/**", corsConfiguration);

        return source;
    }
}
WebSecurity.java hosted with ❤ by GitHub

La classe è annotata con @EnableWebSecurity  ed estende WebSecurityConfigureAdapter per implementare la nostra logica per la sicurezza.

Sfruttiamo l'autowire del bean BCrypt che abbiamo definito precedentemente. Facciamo lo stesso con UserDetailsService per trovare l'account dell'utente.

Il metodo più importante è quello che accetta un oggetto HttpSecurity. Qui specifichiamo gli endpoint e i filtri che vogliamo applicare. Configuriamo il filtro CORS e permettiamo tutte le richieste POST al nostro URL di registrazione definito nella classe delle costanti.

Puoi aggiungere altri antMatchers per filtrare gli URL e i ruoli. Puoi leggere questa discussione su StackOverflow per vedere un esempio. L'altro metodo configura l'AuthenticationManager per usare l'oggetto codificatore per poter codificare le password durante il controllo delle credenziali.

Testing

Inviamo qualche richiesta per testare se funziona a dovere.

4

In questo esempio, abbiamo inviato una richiesta GET per accedere ad una risorsa protetta. Il nostro server risponde con un codice 403. È quello che ci aspettavamo poiché non abbiamo fornito il token nell'intestazione. Adesso, creiamo un utente:

5

Per la creazione di un utente abbiamo inviato una richiesta POST contenente i dati DTO User. Useremo questo utente per effettuare un login e ottenere il token.

6

Grande! Abbiamo il token. A questo punto lo useremo per poter accedere a risorse protette.

7

Abbiamo fornito il token nella chiave di Autorizzazione dell'intestazione e adesso abbiamo il permesso di accedere all'endpoint protetto.

Conclusione

In questo tutorial, ti ho guidato attraverso gli step che ho intrapreso per implementare l'autorizzazione JWT e l'autenticazione con password in Spring Boot. Inoltre abbiamo anche imparato come gestire un utente in modo sicuro.

Grazie per aver letto questo articolo – spero ti sia stato d'aiuto.