REST Security con JWT utilizzando Java e Spring Security
Pubblicato: 2022-03-11Sicurezza
La sicurezza è nemica della convenienza e viceversa. Questa affermazione vale per qualsiasi sistema, virtuale o reale, dall'ingresso fisico della casa alle piattaforme di web banking. Gli ingegneri cercano costantemente di trovare il giusto equilibrio per il caso d'uso dato, sporgendosi da una parte o dall'altra. Di solito, quando appare una nuova minaccia, ci muoviamo verso la sicurezza e ci allontaniamo dalla comodità. Quindi, vediamo se possiamo recuperare un po 'di convenienza persa senza ridurre troppo la sicurezza. Inoltre, questo circolo vizioso va avanti per sempre.
Proviamo a esaminare lo stato della sicurezza REST oggi, utilizzando un semplice tutorial sulla sicurezza di Spring per dimostrarlo in azione.
I servizi REST (che sta per Representational State Transfer) sono nati come un approccio estremamente semplificato ai servizi Web che avevano specifiche enormi e formati ingombranti, come WSDL per descrivere il servizio o SOAP per specificare il formato del messaggio. In REST, non abbiamo nessuno di quelli. Possiamo descrivere il servizio REST in un file di testo normale e utilizzare qualsiasi formato di messaggio che desideriamo, come JSON, XML o persino testo normale. L'approccio semplificato è stato applicato anche alla sicurezza dei servizi REST; nessuno standard definito impone un modo particolare per autenticare gli utenti.
Sebbene i servizi REST non abbiano molto specificato, un aspetto importante è la mancanza di stato. Significa che il server non mantiene alcuno stato del client, con le sessioni come buon esempio. Pertanto, il server risponde a ogni richiesta come se fosse la prima che il client ha fatto. Tuttavia, anche ora, molte implementazioni utilizzano ancora l'autenticazione basata su cookie, che è ereditata dalla progettazione architettonica standard del sito Web. L'approccio stateless di REST rende i cookie di sessione inappropriati dal punto di vista della sicurezza, ma sono comunque ampiamente utilizzati. Oltre a ignorare l'apolidia richiesta, l'approccio semplificato è arrivato come un previsto compromesso di sicurezza. Rispetto allo standard WS-Security utilizzato per i servizi Web, è molto più semplice creare e utilizzare servizi REST, quindi la comodità è andata alle stelle. Il compromesso è una sicurezza piuttosto ridotta; il dirottamento della sessione e la falsificazione delle richieste tra siti (XSRF) sono i problemi di sicurezza più comuni.
Nel tentativo di eliminare le sessioni client dal server, sono stati utilizzati occasionalmente altri metodi, come l'autenticazione HTTP di base o Digest. Entrambi utilizzano un'intestazione di Authorization per trasmettere le credenziali dell'utente, con l'aggiunta di alcune codifiche (HTTP Basic) o crittografia (HTTP Digest). Naturalmente, presentavano gli stessi difetti riscontrati nei siti Web: HTTP Basic doveva essere utilizzato su HTTPS poiché nome utente e password venivano inviati con una codifica Base64 facilmente reversibile e HTTP Digest obbligava l'uso di un hash MD5 obsoleto che si è dimostrato insicuro.
Infine, alcune implementazioni utilizzavano token arbitrari per autenticare i client. Questa opzione sembra essere la migliore che abbiamo, per ora. Se implementato correttamente, risolve tutti i problemi di sicurezza di HTTP Basic, HTTP Digest o cookie di sessione, è semplice da usare e segue lo schema stateless.
Tuttavia, con tali token arbitrari, sono coinvolti pochi standard. Ogni fornitore di servizi aveva la sua idea su cosa inserire nel token e come codificarlo o crittografarlo. Il consumo di servizi di fornitori diversi richiedeva tempi di configurazione aggiuntivi, solo per adattarsi al formato di token specifico utilizzato. Gli altri metodi, invece (cookie di sessione, HTTP Basic e HTTP Digest) sono ben noti agli sviluppatori e quasi tutti i browser su tutti i dispositivi funzionano con loro immediatamente. Framework e linguaggi sono pronti per questi metodi, con funzioni integrate per gestirli senza problemi.
Autenticazione JWT
JWT (abbreviato da JSON Web Token) è la standardizzazione mancante per l'utilizzo dei token per l'autenticazione sul Web in generale, non solo per i servizi REST. Attualmente è in bozza come RFC 7519. È robusto e può contenere molte informazioni, ma è comunque semplice da usare anche se le sue dimensioni sono relativamente ridotte. Come qualsiasi altro token, JWT può essere utilizzato per trasferire l'identità di utenti autenticati tra un provider di identità e un provider di servizi (che non sono necessariamente gli stessi sistemi). Può anche contenere tutte le richieste dell'utente, come i dati di autorizzazione, in modo che il fornitore di servizi non abbia bisogno di entrare nel database o nei sistemi esterni per verificare i ruoli e le autorizzazioni dell'utente per ogni richiesta; quei dati vengono estratti dal token.
Ecco come è progettata la sicurezza JWT per funzionare:
- I client accedono inviando le proprie credenziali al provider di identità.
- Il provider di identità verifica le credenziali; se tutto è a posto, recupera i dati dell'utente, genera un JWT contenente i dettagli dell'utente e le autorizzazioni che verranno utilizzate per accedere ai servizi e imposta anche la scadenza sul JWT (che potrebbe essere illimitata).
- Il provider di identità firma e, se necessario, crittografa il JWT e lo invia al client come risposta alla richiesta iniziale con le credenziali.
- Il cliente archivia il JWT per un periodo di tempo limitato o illimitato, a seconda della scadenza impostata dal provider di identità.
- Il client invia il JWT memorizzato in un'intestazione di autorizzazione per ogni richiesta al fornitore di servizi.
- Per ogni richiesta, il fornitore di servizi preleva il JWT dall'intestazione di
Authorizatione lo decrittografa, se necessario, convalida la firma e, se tutto è a posto, estrae i dati e le autorizzazioni dell'utente. Basandosi esclusivamente su questi dati, e ancora senza cercare ulteriori dettagli nel database o contattare il provider di identità, può accettare o rifiutare la richiesta del cliente. L'unico requisito è che l'identità e i fornitori di servizi abbiano un accordo sulla crittografia in modo che il servizio possa verificare la firma o addirittura decifrare quale identità è stata crittografata.
Questo flusso consente una grande flessibilità pur mantenendo le cose sicure e facili da sviluppare. Utilizzando questo approccio, è facile aggiungere nuovi nodi server al cluster del provider di servizi, inizializzandoli solo con la possibilità di verificare la firma e decrittografare i token fornendo loro una chiave segreta condivisa. Non è richiesta la replica della sessione, la sincronizzazione del database o la comunicazione tra nodi. RIPOSA nella sua piena gloria.
La principale differenza tra JWT e altri token arbitrari è la standardizzazione del contenuto del token. Un altro approccio consigliato consiste nell'inviare il token JWT nell'intestazione Authorization utilizzando lo schema Bearer. Il contenuto dell'intestazione dovrebbe essere simile a questo:
Authorization: Bearer <token>Implementazione della sicurezza REST
Affinché i servizi REST funzionino come previsto, è necessario un approccio di autorizzazione leggermente diverso rispetto ai classici siti Web a più pagine.
Invece di attivare il processo di autenticazione reindirizzando a una pagina di accesso quando un client richiede una risorsa protetta, il server REST autentica tutte le richieste utilizzando i dati disponibili nella richiesta stessa, in questo caso il token JWT. Se tale autenticazione fallisce, il reindirizzamento non ha senso. L'API REST invia semplicemente una risposta con codice HTTP 401 (non autorizzato) e i client dovrebbero sapere cosa fare; ad esempio, un browser mostrerà un div dinamico per consentire all'utente di fornire il nome utente e la password.
D'altra parte, dopo una corretta autenticazione nei classici siti multipagina, l'utente viene reindirizzato utilizzando il codice HTTP 301 (Spostato in modo permanente), solitamente ad una home page o, meglio ancora, alla pagina inizialmente richiesta dall'utente che ha attivato il processo di autenticazione. Con REST, ancora una volta questo non ha senso. Invece continueremmo semplicemente con l'esecuzione della richiesta come se la risorsa non fosse affatto protetta, restituiremmo il codice HTTP 200 (OK) e il corpo della risposta previsto.
Esempio di sicurezza primaverile
Ora, vediamo come possiamo implementare l'API REST basata su token JWT utilizzando Java e Spring, mentre proviamo a riutilizzare il comportamento predefinito di Spring Security dove possibile.
Come previsto, il framework Spring Security viene fornito con molte classi pronte per il plug-in che si occupano dei "vecchi" meccanismi di autorizzazione: cookie di sessione, HTTP Basic e HTTP Digest. Tuttavia, manca il supporto nativo per JWT e dobbiamo sporcarci le mani per farlo funzionare. Per una panoramica più dettagliata, dovresti consultare la documentazione ufficiale di Spring Security.
Ora, iniziamo con la consueta definizione del filtro Spring Security in web.xml :
<filter> <filter-name>springSecurityFilterChain</filter-name> <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class> </filter> <filter-mapping> <filter-name>springSecurityFilterChain</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> Si noti che il nome del filtro Spring Security deve essere esattamente springSecurityFilterChain affinché il resto della configurazione di Spring funzioni immediatamente.
Segue la dichiarazione XML dei bean Spring relativi alla sicurezza. Per semplificare l'XML, imposteremo lo spazio dei nomi predefinito su security aggiungendo xmlns="http://www.springframework.org/schema/security" all'elemento XML radice. Il resto dell'XML è simile a questo:
<global-method-security pre-post-annotations="enabled" /> (1) <http pattern="/api/login" security="none"/> (2) <http pattern="/api/signup" security="none"/> <http pattern="/api/**" entry-point-ref="restAuthenticationEntryPoint" create-session="stateless"> (3) <csrf disabled="true"/> (4) <custom-filter before="FORM_LOGIN_FILTER" ref="jwtAuthenticationFilter"/> (5) </http> <beans:bean class="com.toptal.travelplanner.security.JwtAuthenticationFilter"> (6) <beans:property name="authenticationManager" ref="authenticationManager" /> <beans:property name="authenticationSuccessHandler" ref="jwtAuthenticationSuccessHandler" /> (7) </beans:bean> <authentication-manager alias="authenticationManager"> <authentication-provider ref="jwtAuthenticationProvider" /> (8) </authentication-manager>- (1) In questa riga, attiviamo le
@PreFilter,@PreAuthorize,@PostFilter,@PostAuthorizesu qualsiasi spring bean nel contesto. - (2) Definiamo gli endpoint di accesso e registrazione per saltare la sicurezza; anche "anonimo" dovrebbe essere in grado di fare queste due operazioni.
- (3) Successivamente, definiamo la catena di filtri applicata a tutte le richieste aggiungendo due importanti configurazioni: Riferimento al punto di ingresso e impostando la creazione della sessione su
stateless(non vogliamo che la sessione venga creata per motivi di sicurezza poiché utilizziamo i token per ogni richiesta) . - (4) Non abbiamo bisogno della protezione
csrfperché i nostri token ne sono immuni. - (5) Successivamente, inseriamo il nostro filtro di autenticazione speciale all'interno della catena di filtri predefinita di Spring, appena prima del filtro di accesso del modulo.
- (6) Questo bean è la dichiarazione del nostro filtro di autenticazione; poiché sta estendendo
AbstractAuthenticationProcessingFilterdi Spring, dobbiamo dichiararlo in XML per collegare le sue proprietà (il cablaggio automatico non funziona qui). Spiegheremo più avanti cosa fa il filtro. - (7) Il gestore di successo predefinito di
AbstractAuthenticationProcessingFilternon è abbastanza buono per scopi REST perché reindirizza l'utente a una pagina di successo; ecco perché abbiamo impostato il nostro qui. - (8) La dichiarazione del provider creata dall'AuthenticationManager viene utilizzata dal nostro filtro per
authenticationManagergli utenti.
Ora vediamo come implementiamo le classi specifiche dichiarate nell'XML sopra. Nota che la primavera li collegherà per noi. Partiamo da quelli più semplici.

RestAuthenticationEntryPoint.java
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { // This is invoked when user tries to access a secured REST resource without supplying any credentials // We should just send a 401 Unauthorized response because there is no 'login page' to redirect to response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); } }Come spiegato sopra, questa classe restituisce semplicemente il codice HTTP 401 (non autorizzato) quando l'autenticazione fallisce, sovrascrivendo il reindirizzamento predefinito di Spring.
JwtAuthenticationSuccessHandler.java
public class JwtAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { // We do not need to do anything extra on REST authentication success, because there is no page to redirect to } } Questo semplice override rimuove il comportamento predefinito di un'autenticazione riuscita (reindirizzamento alla home oa qualsiasi altra pagina richiesta dall'utente). Se ti stai chiedendo perché non è necessario sovrascrivere AuthenticationFailureHandler , è perché l'implementazione predefinita non reindirizzerà da nessuna parte se il suo URL di reindirizzamento non è impostato, quindi evitiamo semplicemente di impostare l'URL, che è abbastanza buono.
JwtAuthenticationFilter.java
public class JwtAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public JwtAuthenticationFilter() { super("/**"); } @Override protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) { return true; } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { String header = request.getHeader("Authorization"); if (header == null || !header.startsWith("Bearer ")) { throw new JwtTokenMissingException("No JWT token found in request headers"); } String authToken = header.substring(7); JwtAuthenticationToken authRequest = new JwtAuthenticationToken(authToken); return getAuthenticationManager().authenticate(authRequest); } @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { super.successfulAuthentication(request, response, chain, authResult); // As this authentication is in HTTP header, after success we need to continue the request normally // and return the response as if the resource was not secured at all chain.doFilter(request, response); } } Questa classe è il punto di ingresso del nostro processo di autenticazione JWT; il filtro estrae il token JWT dalle intestazioni della richiesta e delega l' AuthenticationManager all'AuthenticationManager inserito. Se il token non viene trovato, viene generata un'eccezione che interrompe l'elaborazione della richiesta. Abbiamo anche bisogno di un override per l'autenticazione riuscita perché il flusso Spring predefinito arresterebbe la catena di filtri e procederebbe con un reindirizzamento. Tieni presente che abbiamo bisogno che la catena venga eseguita completamente, inclusa la generazione della risposta, come spiegato sopra.
JwtAuthenticationProvider.java
public class JwtAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { @Autowired private JwtUtil jwtUtil; @Override public boolean supports(Class<?> authentication) { return (JwtAuthenticationToken.class.isAssignableFrom(authentication)); } @Override protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { } @Override protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { JwtAuthenticationToken jwtAuthenticationToken = (JwtAuthenticationToken) authentication; String token = jwtAuthenticationToken.getToken(); User parsedUser = jwtUtil.parseToken(token); if (parsedUser == null) { throw new JwtTokenMalformedException("JWT token is not valid"); } List<GrantedAuthority> authorityList = AuthorityUtils.commaSeparatedStringToAuthorityList(parsedUser.getRole()); return new AuthenticatedUser(parsedUser.getId(), parsedUser.getUsername(), token, authorityList); } } In questa classe, utilizziamo l' AuthenticationManager predefinito di Spring, ma lo iniettiamo con il nostro AuthenticationProvider che esegue il processo di autenticazione effettivo. Per implementare ciò, estendiamo AbstractUserDetailsAuthenticationProvider , che ci richiede solo di restituire UserDetails in base alla richiesta di autenticazione, nel nostro caso, il token JWT racchiuso nella classe JwtAuthenticationToken . Se il token non è valido, viene generata un'eccezione. Tuttavia, se è valido e la decrittazione da parte di JwtUtil ha esito positivo, estraiamo i dettagli dell'utente (vedremo esattamente come nella classe JwtUtil ), senza accedere affatto al database. Tutte le informazioni sull'utente, compresi i suoi ruoli, sono contenute nel token stesso.
JwtUtil.java
public class JwtUtil { @Value("${jwt.secret}") private String secret; /** * Tries to parse specified String as a JWT token. If successful, returns User object with username, id and role prefilled (extracted from token). * If unsuccessful (token is invalid or not containing all required user properties), simply returns null. * * @param token the JWT token to parse * @return the User object extracted from specified token or null if a token is invalid. */ public User parseToken(String token) { try { Claims body = Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); User u = new User(); u.setUsername(body.getSubject()); u.setId(Long.parseLong((String) body.get("userId"))); u.setRole((String) body.get("role")); return u; } catch (JwtException | ClassCastException e) { return null; } } /** * Generates a JWT token containing username as subject, and userId and role as additional claims. These properties are taken from the specified * User object. Tokens validity is infinite. * * @param u the user for which the token will be generated * @return the JWT token */ public String generateToken(User u) { Claims claims = Jwts.claims().setSubject(u.getUsername()); claims.put("userId", u.getId() + ""); claims.put("role", u.getRole()); return Jwts.builder() .setClaims(claims) .signWith(SignatureAlgorithm.HS512, secret) .compact(); } } Infine, la classe JwtUtil è responsabile dell'analisi del token nell'oggetto User e della generazione del token dall'oggetto User . È semplice poiché utilizza la libreria jjwt per eseguire tutto il lavoro JWT. Nel nostro esempio, memorizziamo semplicemente il nome utente, l'ID utente e i ruoli utente nel token. Potremmo anche archiviare elementi più arbitrari e aggiungere più funzionalità di sicurezza, come la scadenza del token. L'analisi del token viene utilizzata in AuthenticationProvider come mostrato sopra. Il metodo generateToken() viene chiamato dai servizi REST di accesso e registrazione, che non sono protetti e non attiveranno alcun controllo di sicurezza né richiederanno la presenza di un token nella richiesta. Alla fine, genera il token che verrà restituito ai client, in base all'utente.
Conclusione
Sebbene i vecchi approcci di sicurezza standardizzati (cookie di sessione, HTTP Basic e HTTP Digest) funzionino anche con i servizi REST, presentano tutti problemi che sarebbe opportuno evitare utilizzando uno standard migliore. JWT arriva giusto in tempo per salvare la situazione e, soprattutto, è molto vicino a diventare uno standard IETF.
Il principale punto di forza di JWT è gestire l'autenticazione degli utenti in modo stateless e quindi scalabile, mantenendo tutto al sicuro con standard di crittografia aggiornati. L'archiviazione delle attestazioni (ruoli utente e autorizzazioni) nel token stesso crea enormi vantaggi nelle architetture di sistema distribuito in cui il server che invia la richiesta non ha accesso all'origine dati di autenticazione.
