Securitate REST cu JWT folosind Java și Spring Security

Publicat: 2022-03-11

Securitate

Securitatea este inamicul confortului și invers. Această afirmație este valabilă pentru orice sistem, virtual sau real, de la intrarea fizică în casă până la platformele de web banking. Inginerii încearcă în mod constant să găsească echilibrul potrivit pentru cazul de utilizare dat, aplecându-se într-o parte sau alta. De obicei, atunci când apare o nouă amenințare, ne îndreptăm spre securitate și departe de comoditate. Apoi, vedem dacă putem recupera o oarecare comoditate pierdută fără a reduce prea mult securitatea. În plus, acest cerc vicios continuă pentru totdeauna.

tutorial de securitate de primăvară: ilustrație de securitate vs. comoditate

Securitatea este inamicul confortului și invers.
Tweet

Să încercăm să examinăm starea securității REST astăzi, folosind un tutorial simplu de securitate Spring pentru a o demonstra în acțiune.

Serviciile REST (care înseamnă Representational State Transfer) au început ca o abordare extrem de simplificată a Serviciilor Web, care avea specificații uriașe și formate greoaie, cum ar fi WSDL pentru descrierea serviciului sau SOAP pentru specificarea formatului mesajului. În REST, nu avem niciunul dintre acestea. Putem descrie serviciul REST într-un fișier text simplu și putem folosi orice format de mesaj dorim, cum ar fi JSON, XML sau chiar text simplu din nou. Abordarea simplificată a fost aplicată și pentru securitatea serviciilor REST; niciun standard definit nu impune un mod special de autentificare a utilizatorilor.

Deși serviciile REST nu au prea multe precizate, una importantă este lipsa de stat. Înseamnă că serverul nu păstrează nicio stare de client, cu sesiuni ca exemplu bun. Astfel, serverul răspunde la fiecare solicitare ca și cum ar fi prima făcută de client. Cu toate acestea, chiar și acum, multe implementări folosesc încă autentificarea bazată pe cookie-uri, care este moștenită din designul arhitectural standard al site-ului web. Abordarea fără stat a REST face cookie-urile de sesiune inadecvate din punct de vedere al securității, dar, cu toate acestea, sunt încă utilizate pe scară largă. Pe lângă ignorarea apatridiei cerute, abordarea simplificată a venit ca un compromis de securitate așteptat. În comparație cu standardul WS-Security folosit pentru Serviciile Web, este mult mai ușor să creați și să consumați servicii REST, prin urmare, confortul a trecut pe deasupra. Compensația este o securitate destul de subțire; deturnarea sesiunii și falsificarea cererilor între site-uri (XSRF) sunt cele mai frecvente probleme de securitate.

În încercarea de a scăpa de sesiunile client de pe server, au fost folosite ocazional alte metode, cum ar fi autentificarea HTTP Basic sau Digest. Ambele folosesc un antet de Authorization pentru a transmite acreditările utilizatorului, cu o anumită codificare (HTTP Basic) sau criptare (HTTP Digest). Bineînțeles, au avut aceleași defecte găsite pe site-uri web: HTTP Basic a trebuit să fie folosit peste HTTPS, deoarece numele de utilizator și parola sunt trimise în codificare base64 ușor reversibilă, iar HTTP Digest a forțat utilizarea hashingului MD5 învechit, care s-a dovedit a fi nesigur.

În cele din urmă, unele implementări au folosit token-uri arbitrare pentru a autentifica clienții. Această opțiune pare să fie cea mai bună pe care o avem, deocamdată. Dacă este implementat corespunzător, rezolvă toate problemele de securitate ale HTTP Basic, HTTP Digest sau cookie-uri de sesiune, este simplu de utilizat și urmează modelul apatrid.

Cu toate acestea, cu astfel de jetoane arbitrare, există puține standarde implicate. Fiecare furnizor de servicii a avut ideea lui despre ce să pună în token și cum să îl codifice sau să îl cripteze. Consumul de servicii de la diferiți furnizori a necesitat timp de configurare suplimentar, doar pentru a se adapta la formatul specific de token utilizat. Celelalte metode, pe de altă parte (cookie de sesiune, HTTP Basic și HTTP Digest) sunt bine cunoscute dezvoltatorilor și aproape toate browserele de pe toate dispozitivele funcționează cu ele de la cutie. Framework-urile și limbajele sunt pregătite pentru aceste metode, având funcții încorporate pentru a le gestiona fără probleme.

Autentificare JWT

JWT (prescurtat de la JSON Web Token) este standardizarea lipsă pentru utilizarea token-urilor pentru autentificarea pe web în general, nu numai pentru serviciile REST. În prezent, este în stare de proiect ca RFC 7519. Este robust și poate transporta o mulțime de informații, dar este încă simplu de utilizat, chiar dacă dimensiunea sa este relativ mică. Ca orice alt simbol, JWT poate fi folosit pentru a trece identitatea utilizatorilor autentificați între un furnizor de identitate și un furnizor de servicii (care nu sunt neapărat aceleași sisteme). De asemenea, poate transporta toate revendicările utilizatorului, cum ar fi datele de autorizare, astfel încât furnizorul de servicii nu trebuie să intre în baza de date sau sisteme externe pentru a verifica rolurile și permisiunile utilizatorului pentru fiecare cerere; acele date sunt extrase din token.

Iată cum este proiectată securitatea JWT să funcționeze:

Ilustrație JWT java flow

  • Clienții se conectează trimițându-și acreditările furnizorului de identitate.
  • Furnizorul de identitate verifică acreditările; dacă totul este OK, preia datele utilizatorului, generează un JWT care conține detaliile utilizatorului și permisiunile care vor fi folosite pentru a accesa serviciile și, de asemenea, setează expirarea JWT (care ar putea fi nelimitată).
  • Furnizorul de identitate semnează și, dacă este necesar, criptează JWT și îl trimite clientului ca răspuns la cererea inițială cu acreditări.
  • Clientul stochează JWT pentru o perioadă limitată sau nelimitată de timp, în funcție de expirarea stabilită de furnizorul de identitate.
  • Clientul trimite JWT stocat într-un antet de autorizare pentru fiecare cerere către furnizorul de servicii.
  • Pentru fiecare solicitare, furnizorul de servicii preia JWT din antetul Authorization și îl decriptează, dacă este necesar, validează semnătura, iar dacă totul este OK, extrage datele utilizatorului și permisiunile. Exclusiv pe baza acestor date și, din nou, fără a căuta detalii suplimentare în baza de date sau a contacta furnizorul de identitate, poate accepta sau respinge cererea clientului. Singura cerință este ca furnizorii de identitate și de servicii să aibă un acord privind criptarea, astfel încât serviciul să poată verifica semnătura sau chiar să decripteze identitatea criptată.

Acest flux permite o mare flexibilitate, păstrând totodată lucrurile în siguranță și ușor de dezvoltat. Prin utilizarea acestei abordări, este ușor să adăugați noi noduri de server la clusterul furnizorului de servicii, inițialându-le doar cu capacitatea de a verifica semnătura și de a decripta token-urile furnizându-le o cheie secretă partajată. Nu este necesară replicarea sesiunii, sincronizarea bazei de date sau comunicarea între noduri. ODIHNEȘTE-TE în toată gloria sa.

Principala diferență dintre JWT și alte jetoane arbitrare este standardizarea conținutului token-ului. O altă abordare recomandată este să trimiteți jetonul JWT în antetul de Authorization folosind schema Purtător. Conținutul antetului ar trebui să arate astfel:

 Authorization: Bearer <token>

Implementarea securității REST

Pentru ca serviciile REST să funcționeze conform așteptărilor, avem nevoie de o abordare de autorizare ușor diferită față de site-urile web clasice, cu mai multe pagini.

În loc să declanșeze procesul de autentificare prin redirecționarea către o pagină de autentificare atunci când un client solicită o resursă securizată, serverul REST autentifică toate cererile folosind datele disponibile în cererea însăși, tokenul JWT în acest caz. Dacă o astfel de autentificare eșuează, redirecționarea nu are sens. API-ul REST trimite pur și simplu un răspuns cod HTTP 401 (Neautorizat), iar clienții ar trebui să știe ce să facă; de exemplu, un browser va afișa un div dinamic pentru a permite utilizatorului să furnizeze numele de utilizator și parola.

Pe de altă parte, după o autentificare cu succes în site-uri web clasice, cu mai multe pagini, utilizatorul este redirecționat folosind codul HTTP 301 (Mutat permanent), de obicei către o pagină de pornire sau, și mai bine, către pagina pe care utilizatorul a solicitat-o ​​inițial și care a declanșat procesul de autentificare. Cu REST, din nou, acest lucru nu are sens. În schimb, am continua pur și simplu cu execuția cererii ca și cum resursa nu ar fi deloc securizată, returnăm codul HTTP 200 (OK) și corpul răspunsului așteptat.

Exemplu de securitate de primăvară

Spring REST Security cu JWT și Java

Acum, să vedem cum putem implementa API-ul REST bazat pe token JWT folosind Java și Spring, încercând în același timp să reutilizam comportamentul implicit Spring Security acolo unde putem.

După cum era de așteptat, cadrul Spring Security vine cu multe clase gata de plug-in care se ocupă de mecanismele de autorizare „vechi”: cookie-uri de sesiune, HTTP Basic și HTTP Digest. Cu toate acestea, îi lipsește suportul nativ pentru JWT și trebuie să ne murdărim mâinile pentru a-l face să funcționeze. Pentru o prezentare mai detaliată, ar trebui să consultați documentația oficială Spring Security.

Acum, să începem cu definiția obișnuită a filtrului Spring Security în 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>

Rețineți că numele filtrului Spring Security trebuie să fie exact springSecurityFilterChain pentru ca restul configurației Spring să funcționeze imediat.

Urmează declarația XML a fasolelor Spring legate de securitate. Pentru a simplifica XML-ul, vom seta spațiul de nume implicit la security adăugând xmlns="http://www.springframework.org/schema/security" la elementul XML rădăcină. Restul XML-ului arată astfel:

 <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) În această linie, activăm @PreFilter , @PreAuthorize , @PostFilter , @PostAuthorize pe orice boabe de primăvară din context.
  • (2) Definim punctele finale de conectare și înscriere pentru a omite securitatea; chiar și „anonim” ar trebui să poată face aceste două operațiuni.
  • (3) În continuare, definim lanțul de filtrare aplicat tuturor solicitărilor în timp ce adăugăm două configurații importante: referință la punctul de intrare și setăm crearea sesiunii la stateless (nu dorim ca sesiunea să fie creată din motive de securitate deoarece folosim token-uri pentru fiecare cerere) .
  • (4) Nu avem nevoie de protecție csrf , deoarece jetoanele noastre sunt imune la aceasta.
  • (5) În continuare, conectăm filtrul nostru special de autentificare în lanțul de filtre predefinit al Spring, chiar înainte de filtrul de autentificare din formular.
  • (6) Acest bean este declarația filtrului nostru de autentificare; deoarece extinde AbstractAuthenticationProcessingFilter de la Spring, trebuie să-l declarăm în XML pentru a-și conecta proprietățile (cablarea automată nu funcționează aici). Vom explica mai târziu ce face filtrul.
  • (7) Managerul de succes implicit al AbstractAuthenticationProcessingFilter nu este suficient de bun pentru scopuri REST, deoarece redirecționează utilizatorul către o pagină de succes; de aceea ne-am stabilit aici.
  • (8) Declarația furnizorului creată de authenticationManager este folosită de filtrul nostru pentru autentificarea utilizatorilor.

Acum să vedem cum implementăm clasele specifice declarate în XML de mai sus. Rețineți că Spring le va conecta pentru noi. Începem cu cele mai simple.

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"); } }

După cum sa explicat mai sus, această clasă returnează doar codul HTTP 401 (Neautorizat) atunci când autentificarea eșuează, suprascriind redirecționarea implicită a 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 } }

Această înlocuire simplă elimină comportamentul implicit al unei autentificări reușite (redirecționarea către acasă sau la orice altă pagină solicitată de utilizator). Dacă vă întrebați de ce nu trebuie să suprascriem AuthenticationFailureHandler , aceasta se datorează faptului că implementarea implicită nu va redirecționa nicăieri dacă URL-ul său de redirecționare nu este setat, așa că evităm doar setarea URL-ului, care este suficient de bun.

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); } }

Această clasă este punctul de intrare al procesului nostru de autentificare JWT; filtrul extrage jetonul JWT din antetele cererii și deleagă autentificarea la AuthenticationManager injectat. Dacă jetonul nu este găsit, este lansată o excepție care oprește procesarea cererii. De asemenea, avem nevoie de o modificare pentru autentificarea cu succes, deoarece fluxul Spring implicit ar opri lanțul de filtre și ar continua cu o redirecționare. Rețineți că avem nevoie ca lanțul să se execute complet, inclusiv generarea răspunsului, așa cum s-a explicat mai sus.

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); } }

În această clasă, folosim AuthenticationManager implicit al Spring, dar îl injectăm cu propriul nostru AuthenticationProvider care face procesul de autentificare real. Pentru a implementa acest lucru, extindem AbstractUserDetailsAuthenticationProvider , care ne cere doar să returnăm UserDetails pe baza cererii de autentificare, în cazul nostru, jetonul JWT împachetat în clasa JwtAuthenticationToken . Dacă simbolul nu este valid, lansăm o excepție. Totuși, dacă este validă și decriptarea de către JwtUtil are succes, extragem detaliile utilizatorului (vom vedea exact cum în clasa JwtUtil ), fără a accesa deloc baza de date. Toate informațiile despre utilizator, inclusiv rolurile acestuia, sunt conținute în simbolul însuși.

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(); } }

În cele din urmă, clasa JwtUtil este responsabilă de analizarea jetonului în obiectul User și generarea jetonului din obiectul User . Este simplu, deoarece folosește biblioteca jjwt pentru a face toată munca JWT. În exemplul nostru, pur și simplu stocăm numele de utilizator, ID-ul utilizatorului și rolurile utilizatorului în token. De asemenea, am putea stoca mai multe lucruri arbitrare și am putea adăuga mai multe funcții de securitate, cum ar fi expirarea simbolului. Analiza simbolului este utilizată în AuthenticationProvider , așa cum se arată mai sus. Metoda generateToken() este apelată de la serviciile REST de conectare și înscriere, care sunt nesecurizate și nu vor declanșa nicio verificare de securitate și nu vor necesita prezentarea unui token în cerere. În final, generează token-ul care va fi returnat clienților, în funcție de utilizator.

Concluzie

Deși abordările vechi, standardizate de securitate (cookie de sesiune, HTTP Basic și HTTP Digest) vor funcționa și cu serviciile REST, toate au probleme pe care ar fi bine să le evitați folosind un standard mai bun. JWT sosește exact la timp pentru a salva situația și, cel mai important, este foarte aproape de a deveni un standard IETF.

Principalul punct forte al JWT este gestionarea autentificării utilizatorilor într-un mod fără stat și, prin urmare, scalabil, menținând în același timp totul în siguranță cu standarde de criptografie actualizate. Stocarea revendicărilor (rolurile utilizatorului și permisiunile) în token-ul în sine creează beneficii uriașe în arhitecturile de sistem distribuite în care serverul care emite cererea nu are acces la sursa de date de autentificare.