Token authentication con Spring Security

In questo post vediamo come implementare nei nostri REST service l’autenticazione basata su token sfruttando Spring Security.

Implementeremo due soluzioni: la prima, più semplice, utilizzerà un token generato randomicamente; la seconda invece supporterà lo standard JWT (JSON Web Tokens).

Ovviamente, il progetto di riferimento rimane la nostra fedele SuperRubricaREST!

Nota Per la stesura di questo post ho preso ispirazione da diversi tutorial online, e in particolare dal post di Jérôme Loisel pubblicato sul blog di OctoPerf, da cui ho ripreso la struttura di alcune classi.

Le dipendenze

Come al solito, partiamo aggiungendo i pacchetti necessari nel nostro pom.xml. Innanzitutto avremo bisogno di Spring Security:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>

Inoltre, se decidiamo di sfruttare JWT, avremo bisogno di una libreria per la generazione e validazione dei token. Ce ne sono diverse in circolazione; per questo tutorial utilizzerò quella mantenuta da Auth0:

<dependency>
  <groupId>com.auth0</groupId>
  <artifactId>java-jwt</artifactId>
  <version>3.4.0</version>
</dependency>

Gestione degli utenti

A questo punto dobbiamo aggiungere qualche componente in più al progetto. Partiamo con la classe User che mapperà le informazioni associate agli utenti:

@Data
@NoArgsConstructor
@Document(collection = "users")
public class User {
    @Id
    private String id;
    private String username;
    private String password;
    private String token;
}

Oltre alle credenziali, dobbiamo prevedere un campo token che utilizzeremo per registrare l’autenticazione dell’utente.

La persistenza dei dati, come al solito, la deleghiamo a Spring Data:

public interface UserRepository extends MongoRepository<User, String> {
    Optional<User> findByUsername(String username);

    Optional<User> findByToken(String token);
}

Introduciamo inoltre una nuova interfaccia, UserAuthenticationService:

public interface UserAuthenticationService {
    String login(String username, String password) throws BadCredentialsException;

    User authenticateByToken(String token) throws AuthenticationException;

    void logout(String username);
}

Ci servirà per definire le operazioni principali legate all’autenticazione e all’autorizzazione delle richieste inviate al REST service.

Tra poco andremo ad implementare questa interfaccia in due modi: inizialmente l’authentication token sarà costituito da un semplice UUID dopodiché introdurremo il supporto allo standard JWT.

Prima però occupiamoci di Spring Security…

Configuriamo Spring Security

Per implementare l’autenticazione basata su token in un progetto Spring dobbiamo frugare un po’ tra componenti di Spring Security che solitamente rimangono nascosti.

In particolare avremo bisogno d’implementare una nostra RedirectStrategy, un authentication filter e un custom AuthenticationProvider.

Partiamo dalle cose semplici:

public class NoRedirectStrategy implements RedirectStrategy {
    @Override
    public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url)  {
    }
}

Solitamente, l’autenticazione tramite form prevede, in caso di credenziali errate o di errori lato server, il redirect alla pagina di login (o quantomeno ad una pagina di errore).

Nel caso di REST API, invece, il server dovrà semplicemente restituire un errore 401, Unauthorized. Di conseguenza il metodo sendRedirect non dovrà fare nulla…

Tutte le richieste da autorizzare dovranno essere processate da un apposito authentication filter:

public class TokenAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    private static final String AUTHORIZATION = "Authorization";
    private static final String BEARER = "Bearer";

    @Override
    public Authentication attemptAuthentication(
            HttpServletRequest request,
            HttpServletResponse response) {
        String token = Optional.ofNullable(request.getHeader(AUTHORIZATION))
                .map(v -> v.replace(BEARER, "").trim())
                .orElseThrow(() -> new BadCredentialsException("Missing authentication token."));

        Authentication auth = new UsernamePasswordAuthenticationToken(token, token);
        return getAuthenticationManager().authenticate(auth);
    }

    // ...
}

Il metodo attemptAuthentication, ereditato dalla classe di supporto AbstractAuthenticationProcessingFilter, implementa una logica piuttosto semplice: andiamo ad estrarre dall’header della richiesta la voce Authorization e creiamo un oggetto UsernamePasswordAuthenticationToken valorizzandolo con il token ricevuto.

A questo punto la palla passa all’authentication provider:

@Component
public class TokenAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
    @Autowired
    private UserAuthenticationService userAuthenticationService;

    @Override
    protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) {
        Object token = authentication.getCredentials();
        return Optional
                .ofNullable(token)
                .flatMap(t ->
                        Optional.of(userAuthenticationService.authenticateByToken(String.valueOf(t)))
                                .map(u -> User.builder()
                                        .username(u.getUsername())
                                        .password(u.getPassword())
                                        .roles("user")
                                        .build()))
                .orElseThrow(() -> new BadCredentialsException("Invalid authentication token=" + token));
    }

    // ...
}

Anche in questo caso andiamo ad estendere una classe di supporto fornita da Spring Security, AbstractUserDetailsAuthenticationProvider, e ridefiniamo la logica del metodo retrieveUser.

Ottenuto il token dall’oggetto Authentication, andiamo a sfruttare lo UserAuthenticationService, visto precedentemente, per recuperare i dati relativi all’utente che sta effettuando la richiesta. Se l’utente risulterà autenticato, Spring Security darà l’autorizzazione a procedere con la richiesta, altrimenti otterremo un errore HTTP 401.

Ed ora mettiamo ogni cosa al suo posto:

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    private static final RequestMatcher PUBLIC_URLS = new OrRequestMatcher(
            new AntPathRequestMatcher("/register"),
            new AntPathRequestMatcher("/login")
    );
    private static final RequestMatcher PROTECTED_URLS = new NegatedRequestMatcher(PUBLIC_URLS);

    @Autowired
    private TokenAuthenticationProvider authenticationProvider;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) {
        auth.authenticationProvider(authenticationProvider);
    }

    @Override
    public void configure(WebSecurity web) {
        web.ignoring().requestMatchers(PUBLIC_URLS);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .sessionManagement().sessionCreationPolicy(STATELESS)
                .and()
                .exceptionHandling()
                .defaultAuthenticationEntryPointFor(forbiddenEntryPoint(), PROTECTED_URLS)
                .and()
                .authenticationProvider(authenticationProvider)
                .addFilterBefore(restAuthenticationFilter(), AnonymousAuthenticationFilter.class)
                .authorizeRequests()
                .anyRequest()
                .authenticated()
                .and()
                .csrf().disable()
                .formLogin().disable()
                .httpBasic().disable()
                .logout().disable();
    }

    @Bean
    TokenAuthenticationFilter restAuthenticationFilter() throws Exception {
        TokenAuthenticationFilter filter = new TokenAuthenticationFilter(PROTECTED_URLS);
        filter.setAuthenticationManager(authenticationManager());
        filter.setAuthenticationSuccessHandler(successHandler());
        return filter;
    }

    @Bean
    SimpleUrlAuthenticationSuccessHandler successHandler() {
        SimpleUrlAuthenticationSuccessHandler successHandler = new SimpleUrlAuthenticationSuccessHandler();
        successHandler.setRedirectStrategy(new NoRedirectStrategy());
        return successHandler;
    }

    @Bean
    FilterRegistrationBean disableAutoRegistration(TokenAuthenticationFilter filter) {
        FilterRegistrationBean registration = new FilterRegistrationBean(filter);
        registration.setEnabled(false);
        return registration;
    }

    @Bean
    AuthenticationEntryPoint forbiddenEntryPoint() {
        return new HttpStatusEntryPoint(FORBIDDEN);
    }
}

Un bel po’ di roba…

Esaminiamo qualche passaggio interessante:

  • Usiamo la costante PUBLIC_URLS e il suo negato, PROTECTED_URLS, per definire le rotte pubbliche da quelle che richiedono autorizzazione. Configuriamo Spring Security affinché ignori le prime e processi le seconde.
  • In caso di errore in fase di autenticazione definiamo un forbiddenEntryPoint che si limiterà a restituire uno status HTTP 403.
  • L’elaborazione delle richieste e l’effettiva autenticazione avverrà tramite il restAuthenticationFilter che andremo a posizionare nella Spring Security Filter Chain appena prima del AnonymousAuthenticationFilter.

Gestione dei token

Ora che abbiamo messo a punto la parte più polposa della nostra configurazione, torniamo ad esaminare la gestione dei token.

Autenticazione tramite UUID token

Iniziamo con la soluzione più “semplice”: scegliamo di utilizzare come authentication token un classico UUID generato randomicamente.

Per fare ciò andiamo ad implementare i metodi previsti da UserAuthenticationService in questo modo:

@Service
public class UUIDAuthenticationService implements UserAuthenticationService {
    @Autowired
    private UserService userService;

    @Override
    public String login(String username, String password) {
        return userService.getByUsername(username)
                .filter(u -> u.getPassword().equals(password))
                .map(u -> {
                    u.setToken(UUID.randomUUID().toString());
                    userService.save(u);
                    return u.getToken();
                })
                .orElseThrow(() -> new BadCredentialsException("Invalid username or password."));
    }

    @Override
    public User authenticateByToken(String token) {
        return userService.getByToken(token)
                .orElseThrow(() -> new BadCredentialsException("Token not found."));
    }

    @Override
    public void logout(String username) {
        userService.getByUsername(username)
                .ifPresent(u -> {
                    u.setToken(null);
                    userService.save(u);
                });
    }
}

Una rapida carrellata sui metodi della classe UUIDAuthenticationService:

  • In login verifichiamo la presenza dello username nel nostro DB e a fare un check sulla password immessa (ovviamente, una password opportunamente criptata è caldamente consigliata). Se le credenziali sono corrette associamo all’utente un nuovo token e lo restituiamo per le future richieste al servizio.
  • Il metodo authenticateByToken va semplicemente a recuperare l’utente associato al token fornito. Se l’utente non esiste, viene lanciata un’eccezione.
  • Infine logout ha la funzione di rimuovere (se presente) il token associato ad un determinato utente, invalidando le successive richieste al servizio.

Siamo pronti per fare un test di login… o quasi.

Abbiamo bisogno di un Controller che gestisca le richieste agli endpoint pubblici:

@RestController
public class PublicEndpointsController {
    @Autowired
    private UserAuthenticationService authenticationService;

    @PostMapping("/login")
    public Object login(
            @RequestParam("username") String username,
            @RequestParam("password") String password) {
        try {
            return authenticationService
                    .login(username, password);
        } catch (BadCredentialsException e) {
            return ResponseEntity.status(UNAUTHORIZED).body(e.getMessage());
        }
    }

    // ...
}

Sfoderiamo Postman (o il client HTTP che preferite) e procediamo con l’autenticazione. Se le credenziali sono valide otterremo come risposta un token simile a questo:

51961324-7ad0-4f45-85bc-4be851b9e8c5

Possiamo utilizzarlo nell’header Authorization per accedere alle risorse che richiedono autorizzazione, ad esempio:

Authorization Bearer 51961324-7ad0-4f45-85bc-4be851b9e8c5

Autenticazione tramite JWT

Lo standard JWT, formalmente RFC 7519, è un metodo per la generazione di token di accesso a servizi web, basato su JSON.

Un token JWT contiene al suo interno informazioni relative all’utente a cui è associato; queste informazioni possono riguardare dati personali e privilegi d’accesso al servizio.

Il server genera e firma il token tramite una chiave privata, così da poter validare le informazioni ed estrarle nel momento in cui le riceve dal client.

Ecco l’implementazione dell’interfaccia UserAuthenticationService basata su JWT:

@Service
public class JWTAuthenticationService implements UserAuthenticationService {
    @Autowired
    private JWTService jwtService;
    @Autowired
    private UserService userService;

    @Override
    public String login(String username, String password) throws BadCredentialsException {
        return userService
                .getByUsername(username)
                .filter(user -> Objects.equals(password, user.getPassword()))
                .map(user -> jwtService.create(username))
                .orElseThrow(() -> new BadCredentialsException("Invalid username or password."));
    }

    @Override
    public User authenticateByToken(String token) {
        try {
            Object username = jwtService.verify(token).get("username");
            return Optional.ofNullable(username)
                    .flatMap(name -> userService.getByUsername(String.valueOf(name)))
                    .orElseThrow(() -> new UsernameNotFoundException("User '" + username + "' not found."));
        } catch (TokenVerificationException e) {
            throw new BadCredentialsException("Invalid JWT token.", e);
        }
    }

    @Override
    public void logout(String username) {
        // ...
    }
}

Qualche osservazione:

  • In questo caso il login si limita a generare un token JWT e a restituirlo al client senza salvare la stringa nel DB; questo perché tutte le informazioni necessaria a recuperare l’utente (username) sono contenute nel token e non servirà ricorrere ad un getByToken.
  • Il metodo authenticateByToken verifica la validità del token ed estrae lo username da utilizzare per il recupero dell’utente.
  • Il logout lato server è un’operazione “problematica” se decidiamo di utilizzare JWT, in quanto non esiste un modo diretto per disabilitare i token rilasciati ad un utente. L’approccio comune è quello di invalidare (eliminare) il token lato client.

Le operazioni di generazione, validazione e parsing del token sono delegate alla classe JWTService (per brevità vi rimando al repository GitHub per il codice).

Se andiamo ad effettuare il login, in questo caso otterremo un token in questo formato:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1MzI1MzU1ODUsImlhdCI6MTUzMjUwNTU4NSwidXNlcm5hbWUiOiJhbGRvIn0.sMJ8gbxes41Al1xi11WFidMuXvzl95ntzIpFcWIiIJA

… che potremo utilizzare nell’header delle successive richieste, in modo analogo a quanto visto in precedenza con lo UUID .

In conclusione

Implementare l’autenticazione token-based con Spring Security richiede sicuramente qualche sforzo in più rispetto ad una semplice configurazione in stile Spring Boot; inoltre è necessaria una comprensione leggermente più profonda di alcune logiche e componenti interne al framework.

Per il codice completo delle classi utilizzate in questo tutorial, come al solito vi rimando al repository GitHub del progetto SuperRubricaREST! (link in fondo al post).

A presto,

David