Sécurité REST avec JWT utilisant Java et Spring Security
Publié: 2022-03-11Sécurité
La sécurité est l'ennemie de la commodité, et vice versa. Cette affirmation est vraie pour tout système, virtuel ou réel, de l'entrée physique de la maison aux plateformes bancaires en ligne. Les ingénieurs essaient constamment de trouver le bon équilibre pour le cas d'utilisation donné, penchant d'un côté ou de l'autre. Habituellement, lorsqu'une nouvelle menace apparaît, nous nous dirigeons vers la sécurité et nous nous éloignons de la commodité. Ensuite, nous voyons si nous pouvons récupérer un peu de commodité perdue sans trop réduire la sécurité. De plus, ce cercle vicieux s'éternise.
Essayons d'examiner l'état de la sécurité REST aujourd'hui, en utilisant un didacticiel de sécurité Spring simple pour le démontrer en action.
Les services REST (qui signifie Representational State Transfer) ont commencé comme une approche extrêmement simplifiée des services Web qui avaient d'énormes spécifications et des formats encombrants, tels que WSDL pour décrire le service ou SOAP pour spécifier le format du message. Dans REST, nous n'en avons aucun. Nous pouvons décrire le service REST dans un fichier texte brut et utiliser n'importe quel format de message que nous voulons, comme JSON, XML ou même encore du texte brut. L'approche simplifiée a également été appliquée à la sécurité des services REST ; aucune norme définie n'impose une manière particulière d'authentifier les utilisateurs.
Bien que les services REST n'aient pas beaucoup de spécifications, un élément important est le manque d'état. Cela signifie que le serveur ne conserve aucun état client, les sessions en étant un bon exemple. Ainsi, le serveur répond à chaque requête comme s'il s'agissait de la première requête du client. Cependant, même maintenant, de nombreuses implémentations utilisent encore l'authentification basée sur les cookies, qui est héritée de la conception architecturale standard des sites Web. L'approche sans état de REST rend les cookies de session inappropriés du point de vue de la sécurité, mais néanmoins, ils sont encore largement utilisés. En plus d'ignorer l'apatridie requise, l'approche simplifiée est venue comme un compromis de sécurité attendu. Par rapport à la norme WS-Security utilisée pour les services Web, il est beaucoup plus facile de créer et de consommer des services REST, d'où la commodité qui a explosé. Le compromis est une sécurité assez mince; le détournement de session et la falsification de requêtes intersites (XSRF) sont les problèmes de sécurité les plus courants.
En essayant de se débarrasser des sessions client du serveur, d'autres méthodes ont été utilisées occasionnellement, telles que l'authentification HTTP de base ou Digest. Les deux utilisent un en-tête d' Authorization pour transmettre les informations d'identification de l'utilisateur, avec un codage (HTTP Basic) ou un cryptage (HTTP Digest) ajouté. Bien sûr, ils comportaient les mêmes failles que celles trouvées sur les sites Web : HTTP Basic devait être utilisé sur HTTPS puisque le nom d'utilisateur et le mot de passe sont envoyés dans un codage base64 facilement réversible, et HTTP Digest a forcé l'utilisation d'un hachage MD5 obsolète qui s'est avéré non sécurisé.
Enfin, certaines implémentations utilisaient des jetons arbitraires pour authentifier les clients. Cette option semble être la meilleure que nous ayons pour le moment. S'il est correctement implémenté, il résout tous les problèmes de sécurité de HTTP Basic, HTTP Digest ou des cookies de session, il est simple à utiliser et suit le modèle sans état.
Cependant, avec de tels jetons arbitraires, il y a peu de normes impliquées. Chaque fournisseur de services avait sa propre idée de ce qu'il fallait mettre dans le jeton et de la manière de l'encoder ou de le chiffrer. La consommation de services de différents fournisseurs nécessitait un temps de configuration supplémentaire, juste pour s'adapter au format de jeton spécifique utilisé. Les autres méthodes, en revanche (cookie de session, HTTP Basic et HTTP Digest) sont bien connues des développeurs, et presque tous les navigateurs sur tous les appareils fonctionnent avec eux. Les frameworks et les langages sont prêts pour ces méthodes, ayant des fonctions intégrées pour gérer chacun de manière transparente.
Authentification JWT
JWT (abrégé de JSON Web Token) est la normalisation manquante pour l'utilisation de jetons pour s'authentifier sur le Web en général, pas seulement pour les services REST. Actuellement, il est à l'état de projet en tant que RFC 7519. Il est robuste et peut contenir beaucoup d'informations, mais reste simple à utiliser même si sa taille est relativement petite. Comme tout autre jeton, JWT peut être utilisé pour transmettre l'identité d'utilisateurs authentifiés entre un fournisseur d'identité et un fournisseur de services (qui ne sont pas nécessairement les mêmes systèmes). Il peut également contenir toutes les demandes de l'utilisateur, telles que les données d'autorisation, de sorte que le fournisseur de services n'a pas besoin d'accéder à la base de données ou aux systèmes externes pour vérifier les rôles et les autorisations des utilisateurs pour chaque demande ; ces données sont extraites du jeton.
Voici comment la sécurité JWT est conçue pour fonctionner :
- Les clients se connectent en envoyant leurs identifiants au fournisseur d'identité.
- Le fournisseur d'identité vérifie les informations d'identification ; si tout va bien, il récupère les données de l'utilisateur, génère un JWT contenant les détails de l'utilisateur et les autorisations qui seront utilisées pour accéder aux services, et il définit également l'expiration sur le JWT (qui peut être illimitée).
- Le fournisseur d'identité signe et, si nécessaire, chiffre le JWT et l'envoie au client en réponse à la demande initiale avec les informations d'identification.
- Le client stocke le JWT pendant une durée limitée ou illimitée, en fonction de l'expiration définie par le fournisseur d'identité.
- Le client envoie le JWT stocké dans un en-tête d'autorisation pour chaque demande au fournisseur de services.
- Pour chaque requête, le fournisseur de services prend le JWT de l'en-tête d'
Authorizationet le déchiffre, si nécessaire, valide la signature et, si tout est OK, extrait les données et les autorisations de l'utilisateur. Sur la base de ces données uniquement, et encore une fois sans rechercher plus de détails dans la base de données ou contacter le fournisseur d'identité, il peut accepter ou refuser la demande du client. La seule exigence est que les fournisseurs d'identité et de services aient un accord sur le cryptage afin que le service puisse vérifier la signature ou même décrypter quelle identité a été cryptée.
Ce flux permet une grande flexibilité tout en gardant les choses sécurisées et faciles à développer. En utilisant cette approche, il est facile d'ajouter de nouveaux nœuds de serveur au cluster du fournisseur de services, en les initialisant avec uniquement la possibilité de vérifier la signature et de déchiffrer les jetons en leur fournissant une clé secrète partagée. Aucune réplication de session, synchronisation de base de données ou communication inter-nœuds n'est requise. REPOS dans toute sa splendeur.
La principale différence entre JWT et d'autres jetons arbitraires est la standardisation du contenu du jeton. Une autre approche recommandée consiste à envoyer le jeton JWT dans l'en-tête d' Authorization à l'aide du schéma Bearer. Le contenu de l'en-tête devrait ressembler à ceci :
Authorization: Bearer <token>Implémentation de la sécurité REST
Pour que les services REST fonctionnent comme prévu, nous avons besoin d'une approche d'autorisation légèrement différente de celle des sites Web classiques à plusieurs pages.
Au lieu de déclencher le processus d'authentification en redirigeant vers une page de connexion lorsqu'un client demande une ressource sécurisée, le serveur REST authentifie toutes les requêtes à l'aide des données disponibles dans la requête elle-même, le jeton JWT dans ce cas. Si une telle authentification échoue, la redirection n'a aucun sens. L'API REST envoie simplement une réponse de code HTTP 401 (non autorisé) et les clients doivent savoir quoi faire ; par exemple, un navigateur affichera une div dynamique pour permettre à l'utilisateur de fournir le nom d'utilisateur et le mot de passe.
D'autre part, après une authentification réussie dans les sites Web classiques à plusieurs pages, l'utilisateur est redirigé à l'aide du code HTTP 301 (Déplacement permanent), généralement vers une page d'accueil ou, mieux encore, vers la page initialement demandée par l'utilisateur qui a déclenché le processus d'authentification. Avec REST, encore une fois, cela n'a aucun sens. Au lieu de cela, nous continuerions simplement l'exécution de la requête comme si la ressource n'était pas sécurisée du tout, retournons le code HTTP 200 (OK) et le corps de réponse attendu.
Exemple de sécurité Spring
Voyons maintenant comment implémenter l'API REST basée sur le jeton JWT en utilisant Java et Spring, tout en essayant de réutiliser le comportement par défaut de Spring Security lorsque nous le pouvons.
Comme prévu, le framework Spring Security est livré avec de nombreuses classes prêtes à l'emploi qui traitent des «anciens» mécanismes d'autorisation: cookies de session, HTTP Basic et HTTP Digest. Cependant, il manque le support natif de JWT, et nous devons nous salir les mains pour le faire fonctionner. Pour un aperçu plus détaillé, vous devriez consulter la documentation officielle de Spring Security.
Commençons maintenant avec la définition habituelle du filtre Spring Security dans 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> Notez que le nom du filtre Spring Security doit être exactement springSecurityFilterChain pour que le reste de la configuration Spring fonctionne immédiatement.
Vient ensuite la déclaration XML des beans Spring liés à la sécurité. Afin de simplifier le XML, nous allons définir l'espace de noms par défaut sur security en ajoutant xmlns="http://www.springframework.org/schema/security" à l'élément XML racine. Le reste du XML ressemble à ceci :
<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) Dans cette ligne, nous activons les annotations
@PreFilter,@PreAuthorize,@PostFilter,@PostAuthorizesur tous les beans de printemps dans le contexte. - (2) Nous définissons les points de terminaison de connexion et d'inscription pour ignorer la sécurité ; même "anonyme" devrait pouvoir faire ces deux opérations.
- (3) Ensuite, nous définissons la chaîne de filtrage appliquée à toutes les requêtes tout en ajoutant deux configurations importantes : la référence du point d'entrée et la définition de la création de session sur
stateless(nous ne voulons pas que la session soit créée à des fins de sécurité car nous utilisons des jetons pour chaque requête) . - (4) Nous n'avons pas besoin de la protection
csrfcar nos jetons y sont immunisés. - (5) Ensuite, nous insérons notre filtre d'authentification spécial dans la chaîne de filtres prédéfinie de Spring, juste avant le filtre de connexion au formulaire.
- (6) Ce bean est la déclaration de notre filtre d'authentification ; puisqu'il étend
AbstractAuthenticationProcessingFilterde Spring, nous devons le déclarer en XML pour câbler ses propriétés (la connexion automatique ne fonctionne pas ici). Nous expliquerons plus tard ce que fait le filtre. - (7) Le gestionnaire de réussite par défaut d'
AbstractAuthenticationProcessingFiltern'est pas assez bon à des fins REST car il redirige l'utilisateur vers une page de réussite ; c'est pourquoi nous avons établi le nôtre ici. - (8) La déclaration du fournisseur créée par
authenticationManagerest utilisée par notre filtre pour authentifier les utilisateurs.
Voyons maintenant comment nous implémentons les classes spécifiques déclarées dans le XML ci-dessus. Notez que Spring les câblera pour nous. On commence par les plus simples.

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"); } }Comme expliqué ci-dessus, cette classe renvoie simplement le code HTTP 401 (non autorisé) lorsque l'authentification échoue, remplaçant la redirection par défaut de 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 } } Ce remplacement simple supprime le comportement par défaut d'une authentification réussie (redirection vers l'accueil ou toute autre page demandée par l'utilisateur). Si vous vous demandez pourquoi nous n'avons pas besoin de remplacer le AuthenticationFailureHandler , c'est parce que l'implémentation par défaut ne redirigera nulle part si son URL de redirection n'est pas définie, nous évitons donc simplement de définir l'URL, ce qui est suffisant.
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); } } Cette classe est le point d'entrée de notre processus d'authentification JWT ; le filtre extrait le jeton JWT des en-têtes de requête et délègue l'authentification au AuthenticationManager injecté. Si le jeton n'est pas trouvé, une exception est levée qui arrête le traitement de la demande. Nous avons également besoin d'un remplacement pour une authentification réussie car le flux Spring par défaut arrêterait la chaîne de filtrage et procéderait à une redirection. Gardez à l'esprit que nous avons besoin que la chaîne s'exécute pleinement, y compris la génération de la réponse, comme expliqué ci-dessus.
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); } } Dans cette classe, nous utilisons le AuthenticationManager par défaut de Spring, mais nous l'injectons avec notre propre AuthenticationProvider qui effectue le processus d'authentification proprement dit. Pour implémenter cela, nous étendons le AbstractUserDetailsAuthenticationProvider , qui nous oblige uniquement à renvoyer UserDetails en fonction de la demande d'authentification, dans notre cas, le jeton JWT enveloppé dans la classe JwtAuthenticationToken . Si le jeton n'est pas valide, nous levons une exception. Cependant, s'il est valide et que le déchiffrement par JwtUtil réussit, nous extrayons les détails de l'utilisateur (nous verrons exactement comment dans la classe JwtUtil ), sans accéder du tout à la base de données. Toutes les informations sur l'utilisateur, y compris ses rôles, sont contenues dans le jeton lui-même.
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(); } } Enfin, la classe JwtUtil est chargée d'analyser le jeton dans l'objet User et de générer le jeton à partir de l'objet User . C'est simple car il utilise la bibliothèque jjwt pour effectuer tout le travail JWT. Dans notre exemple, nous stockons simplement le nom d'utilisateur, l'ID utilisateur et les rôles d'utilisateur dans le jeton. Nous pourrions également stocker plus de choses arbitraires et ajouter plus de fonctionnalités de sécurité, telles que l'expiration du jeton. L'analyse du jeton est utilisée dans AuthenticationProvider , comme indiqué ci-dessus. La méthode generateToken() est appelée à partir des services REST de connexion et d'inscription, qui ne sont pas sécurisés et ne déclencheront aucun contrôle de sécurité ni ne nécessiteront la présence d'un jeton dans la requête. Au final, il génère le jeton qui sera renvoyé aux clients, en fonction de l'utilisateur.
Conclusion
Bien que les anciennes approches de sécurité standardisées (cookie de session, HTTP Basic et HTTP Digest) fonctionnent également avec les services REST, elles présentent toutes des problèmes qu'il serait bon d'éviter en utilisant une meilleure norme. JWT arrive juste à temps pour sauver la situation, et surtout, il est sur le point de devenir une norme IETF.
La principale force de JWT est de gérer l'authentification des utilisateurs de manière sans état, et donc évolutive, tout en gardant tout sécurisé avec des normes de cryptographie à jour. Le stockage des revendications (rôles d'utilisateur et autorisations) dans le jeton lui-même crée d'énormes avantages dans les architectures de systèmes distribués où le serveur qui émet la demande n'a pas accès à la source de données d'authentification.
