[JAVA] Implémentez une API Rest simple avec Spring Security & JWT avec Spring Boot 2.0

Aperçu

Cet article est l'application de démonstration utilisée dans le post précédent "Implémentation d'une API Rest simple avec Spring Security avec Spring Boot 2.0.1". Est un article qui explique les changements lors de la prise en charge de JWT (Json Web Token). Il existe de nombreux articles détaillés sur les spécifications JWT, je ne les couvrirai donc pas ici.

Le code source peut être trouvé sur rubytomato / demo-security-jwt-spring2.

environnement

référence

Exigences de l'application de démonstration

Comment s'authentifier

Cette application de démonstration s'authentifie avec votre adresse e-mail et votre mot de passe. Plus précisément, une demande POST d'adresse e-mail et de mot de passe est adressée à l'API de connexion, et si l'authentification est possible, un statut HTTP 200 et un jeton JWT seront renvoyés. L'authentification et l'autorisation suivantes sont effectuées à l'aide du jeton JWT défini dans l'en-tête de la demande.

Mise en œuvre de la sécurité (où modifiée en raison de la prise en charge de JWT)

Ajouter une dépendance

J'ai choisi java-jwt parmi les bibliothèques qui gèrent plusieurs JWT.

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

Java-jwt facile à utiliser

À propos de la charge utile des jetons

Certains identificateurs de charge utile de jeton (réclamation) sont des noms de réclamation enregistrés. Les deux sont traités comme facultatifs, donc leur définition ou non dépend de l'application à utiliser.

id name description
jti JWT ID Identifiant unique JWT
aud Audience Utilisateurs JWT
iss Issuer Éditeur de JWT
sub Subject Corps principal de JWT.Valeur unique ou globalement unique dans le contexte de l'émetteur JWT
iat Issued At Heure d'émission JWT
nbf Not Before Heure de début de la période de validité JWT.Non disponible avant cette heure
exp Expiration Time Heure de fin de la période de validité JWT.Non disponible après cette heure

Génération de jetons

private static final Long EXPIRATION_TIME = 1000L * 60L * 10L;

public void build() {
     String secretKey = "secret";
     Date issuedAt = new Date(); 
     Date notBefore = new Date(issuedAt.getTime());
     Date expiresAt = new Date(issuedAt.getTime() + EXPIRATION_TIME);

     try {
         Algorithm algorithm = Algorithm.HMAC256(secretKey);
         String token = JWT.create()
             // registered claims
             //.withJWTId("jwtId")        //"jti" : JWT ID
             //.withAudience("audience")  //"aud" : Audience
             //.withIssuer("issuer")      //"iss" : Issuer
             .withSubject("test")         //"sub" : Subject
             .withIssuedAt(issuedAt)      //"iat" : Issued At
             .withNotBefore(notBefore)    //"nbf" : Not Before
             .withExpiresAt(expiresAt)    //"exp" : Expiration Time
             //private claims
             .withClaim("X-AUTHORITIES", "aaa")
             .withClaim("X-USERNAME", "bbb")
             .sign(algorithm);
         System.out.println("generate token : " + token);
     } catch (UnsupportedEncodingException e) {
         e.printStackTrace();
     }
}

Les jetons générés par ce paramètre sont les suivants.

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0IiwibmJmIjoxNTIzNTA2NzQwLCJYLUFVVEhPUklUSUVTIjoiYWFhIiwiZXhwIjoxNTIzNTA3MzQwLCJpYXQiOjE1MjM1MDY3NDAsIlgtVVNFUk5BTUUiOiJiYmIifQ.KLwUQcuNEt7m1HAC6ZzzGtRjZ3a2kvY11732aP9dyDY

Vous pouvez décoder les jetons sur le site JSON Web Tokens --jwt.io.

d.png

Vérification des jetons

public void verify() {
    String secretKey = "secret";
    String token = "";

    try {
        Algorithm algorithm = Algorithm.HMAC256(secretKey);
        JWTVerifier verifier = JWT.require(algorithm).build();

        DecodedJWT jwt = verifier.verify(token);

        // registered claims
        String subject = jwt.getSubject();
        Date issuedAt = jwt.getIssuedAt();
        Date notBefore = jwt.getNotBefore();
        Date expiresAt = jwt.getExpiresAt();
        System.out.println("subject : [" + subject + "] issuedAt : [" + issuedAt.toString() + "] notBefore : [" + notBefore.toString() + "] expiresAt : [" + expiresAt.toString() + "]");
        // subject : [test] issuedAt : [Thu Apr 12 13:19:00 JST 2018] notBefore : [Thu Apr 12 13:19:00 JST 2018] expiresAt : [Thu Apr 12 13:29:00 JST 2018]

        // private claims
        String authorities = jwt.getClaim("X-AUTHORITIES").asString();
        String username = jwt.getClaim("X-USERNAME").asString();
        System.out.println("private claim  X-AUTHORITIES : [" + authorities + "] X-USERNAME : [" + username + "]");
        // private claim  X-AUTHORITIES : [aaa] X-USERNAME : [bbb]

    } catch (UnsupportedEncodingException | JWTVerificationException e) {
        e.printStackTrace();
    }
}

Exceptions pouvant survenir lors de la vérification

Les exceptions qui ne sont pas exhaustives mais qui sont susceptibles de se produire (susceptibles de se produire) sont: Les deux exceptions sont des sous-classes de JWTVerificationException.

exception description
SignatureVerificationException Lorsque la clé secrète est différente, etc.
AlgorithmMismatchException Lorsque l'algorithme de signature est différent, etc.
JWTDecodeException Par exemple, si le jeton a été falsifié
TokenExpiredException Si le jeton a expiré
InvalidClaimException Avant de commencer à utiliser des jetons, etc.

Configuration de Spring Security

Affiche les pièces modifiées pour JWT.

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserRepository userRepository;

    // ★1
    @Value("${security.secret-key:secret}")
    private String secretKey = "secret";

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // @formatter:off
        http
            // AUTHORIZE
            .authorizeRequests()
                .mvcMatchers("/hello/**")
                    .permitAll()
                .mvcMatchers("/user/**")
                    .hasRole("USER")
                .mvcMatchers("/admin/**")
                    .hasRole("ADMIN")
                .anyRequest()
                    .authenticated()
            .and()
            // EXCEPTION
            .exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint())
                .accessDeniedHandler(accessDeniedHandler())
            .and()
            // LOGIN
            .formLogin()
                .loginProcessingUrl("/login").permitAll()
                    .usernameParameter("email")
                    .passwordParameter("pass")
                .successHandler(authenticationSuccessHandler())
                .failureHandler(authenticationFailureHandler())
            .and()
            // ★2 LOGOUT
            .logout()
                .logoutUrl("/logout")
                .logoutSuccessHandler(logoutSuccessHandler())
            .and()
            // ★3 CSRF
            .csrf()
                .disable()
            // ★4 AUTHORIZE
            .addFilterBefore(tokenFilter(), UsernamePasswordAuthenticationFilter.class)
            // ★5 SESSION
            .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            ;
        // @formatter:on
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.eraseCredentials(true)
                .userDetailsService(simpleUserDetailsService())
                .passwordEncoder(passwordEncoder());
    }

    @Bean("simpleUserDetailsService")
    UserDetailsService simpleUserDetailsService() {
        return new SimpleUserDetailsService(userRepository);
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    GenericFilterBean tokenFilter() {
        return new SimpleTokenFilter(userRepository, secretKey);
    }

    AuthenticationEntryPoint authenticationEntryPoint() {
        return new SimpleAuthenticationEntryPoint();
    }

    AccessDeniedHandler accessDeniedHandler() {
        return new SimpleAccessDeniedHandler();
    }

    AuthenticationSuccessHandler authenticationSuccessHandler() {
        return new SimpleAuthenticationSuccessHandler(secretKey);
    }

    AuthenticationFailureHandler authenticationFailureHandler() {
        return new SimpleAuthenticationFailureHandler();
    }

    LogoutSuccessHandler logoutSuccessHandler() {
        return new HttpStatusReturningLogoutSuccessHandler();
    }

}

Authentification et traitement en cas de succès / échec

Il n'y a eu aucun changement dans les paramètres d'authentification.

configuration


.formLogin()
    .loginProcessingUrl("/login").permitAll()
        .usernameParameter("email")
        .passwordParameter("pass")
    .successHandler(authenticationSuccessHandler())
    .failureHandler(authenticationFailureHandler())

successHandler()

Avant la modification, c'était un gestionnaire qui renvoyait uniquement l'état HTTP 200, mais il a été modifié pour générer un jeton à partir des informations d'identification et le définir dans l'en-tête de réponse.

AuthenticationSuccessHandler authenticationSuccessHandler() {
    return new SimpleAuthenticationSuccessHandler(secretKey);
}

Les informations définies dans la charge utile du jeton (revendication) peuvent être décodées par n'importe qui, alors sélectionnez les informations qui peuvent être exposées à l'extérieur. Dans cet exemple, l'ID utilisateur est défini dans l'objet et, au moment de l'autorisation, l'ID utilisateur est acquis à partir du jeton et l'utilisateur est recherché.

String token = JWT.create()
        .withIssuedAt(issuedAt)        //Heure d'émission JWT
        .withNotBefore(notBefore)      //Heure de début d'expiration JWT
        .withExpiresAt(expiresAt)      //Heure de fin de la date d'expiration JWT
        .withSubject(loginUser.getUser().getId().toString()) //Valeur unique ou globalement unique dans le contexte de l'entité JWT, émetteur JWT
        .sign(this.algorithm);

La méthode generateToken génère un jeton à partir des informations d'identification et la méthode setToken définit le jeton dans l'en-tête Authorization.

SimpleAuthenticationSuccessHandler


@Slf4j
public class SimpleAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    final private Algorithm algorithm;

    public SimpleAuthenticationSuccessHandler(String secretKey) {
        Objects.requireNonNull(secretKey, "secret key must be not null");
        try {
            this.algorithm = Algorithm.HMAC256(secretKey);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication auth) throws IOException, ServletException {
        if (response.isCommitted()) {
            log.info("Response has already been committed.");
            return;
        }
        setToken(response, generateToken(auth));
        response.setStatus(HttpStatus.OK.value());
        clearAuthenticationAttributes(request);
    }

    private static final Long EXPIRATION_TIME = 1000L * 60L * 10L;

    private String generateToken(Authentication auth) {
        SimpleLoginUser loginUser = (SimpleLoginUser) auth.getPrincipal();
        Date issuedAt = new Date();
        Date notBefore = new Date(issuedAt.getTime());
        Date expiresAt = new Date(issuedAt.getTime() + EXPIRATION_TIME);
        String token = JWT.create()
                .withIssuedAt(issuedAt)
                .withNotBefore(notBefore)
                .withExpiresAt(expiresAt)
                .withSubject(loginUser.getUser().getId().toString())
                .sign(this.algorithm);
        log.debug("generate token : {}", token);
        return token;
    }

    private void setToken(HttpServletResponse response, String token) {
        response.setHeader("Authorization", String.format("Bearer %s", token));
    }

    /**
     * Removes temporary authentication-related data which may have been stored in the
     * session during the authentication process.
     */
    private void clearAuthenticationAttributes(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session == null) {
            return;
        }
        session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
    }

}

Vérification et autorisation des jetons

Nous avons implémenté un nouveau filtre qui valide et autorise les jetons. Ce filtre est défini pour être exécuté avant le filtre qui s'authentifie par nom d'utilisateur / mot de passe (UsernamePasswordAuthenticationFilter).

configuration


.addFilterBefore(tokenFilter(), UsernamePasswordAuthenticationFilter.class)

SimpleTokenFilter

GenericFilterBean tokenFilter() {
    return new SimpleTokenFilter(userRepository, secretKey);
}

Obtenez et validez le jeton à partir de l'en-tête de la demande «Autorisation». En plus de vérifier si le jeton a été falsifié, la vérification vérifie également la date d'expiration définie. Si le jeton est normal, l'ID utilisateur pour rechercher les informations utilisateur est obtenu à partir de la charge utile et l'utilisateur est recherché.

SimpleTokenFilter


@Slf4j
public class SimpleTokenFilter extends GenericFilterBean {

    final private UserRepository userRepository;
    final private Algorithm algorithm;

    public SimpleTokenFilter(UserRepository userRepository, String secretKey) {
        Objects.requireNonNull(secretKey, "secret key must be not null");
        this.userRepository = userRepository;
        try {
            this.algorithm = Algorithm.HMAC256(secretKey);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        String token = resolveToken(request);
        if (token == null) {
            filterChain.doFilter(request, response);
            return;
        }

        try {
            authentication(verifyToken(token));
        } catch (JWTVerificationException e) {
            log.error("verify token error", e);
            SecurityContextHolder.clearContext();
            ((HttpServletResponse) response).sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
        }
        filterChain.doFilter(request, response);
    }

    private String resolveToken(ServletRequest request) {
        String token = ((HttpServletRequest) request).getHeader("Authorization");
        if (token == null || !token.startsWith("Bearer ")) {
            return null;
        }
        return token.substring(7);
    }

    private DecodedJWT verifyToken(String token) {
        JWTVerifier verifier = JWT.require(algorithm).build();
        return verifier.verify(token);
    }

    private void authentication(DecodedJWT jwt) {
        Long userId = Long.valueOf(jwt.getSubject());
        userRepository.findById(userId).ifPresent(user -> {
            SimpleLoginUser simpleLoginUser = new SimpleLoginUser(user);
            SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(simpleLoginUser, null, simpleLoginUser.getAuthorities()));
        });
    }

}

Traitement à la déconnexion

Seuls l'URL et le gestionnaire sont définis lors de la déconnexion. Je voulais invalider le jeton émis au moment de la déconnexion, mais pour autant que je sache, il semble que le jeton ne puisse pas être invalidé, donc le gestionnaire ne renvoie que l'état HTTP.

configuration


.logout()
    .logoutUrl("/logout")
    //.invalidateHttpSession(true)
    //.deleteCookies("JSESSIONID")
    .logoutSuccessHandler(logoutSuccessHandler())

Invalidation de jeton

Lors du traitement de l'invalidation côté serveur, il semble exister une méthode permettant de conserver le jeton de l'utilisateur déconnecté pendant un certain temps (jusqu'à la date d'expiration du jeton) et de ne pas autoriser s'ils correspondent pendant le processus d'autorisation. ..

CSRF

Je n'utilise pas CSRF, donc je l'ai désactivé.

configuration


.csrf()
    .disable()
    //.ignoringAntMatchers("/login")
    //.csrfTokenRepository(new CookieCsrfTokenRepository())

SessionManagement

Je l'ai défini sur apatride car il ne gère pas les sessions. Avec ce paramètre, Spring Security n'utilisera pas HttpSession.

configuration


.sessionManagement()
    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

Vérification du fonctionnement de l'API

En passant à JWT, ni les cookies de session ni les jetons CSRF ne sont requis. Au lieu de cela, définissez le jeton JWT reçu après l'authentification dans l'en-tête de la demande.

API de connexion

** Pour un compte valide **

Si l'authentification réussit, le jeton sera défini dans l'en-tête Authorization, comme indiqué dans l'exemple ci-dessous.

> curl -i -X POST "http://localhost:9000/app/login" -d "[email protected]" -d "pass=iWKw06pvj"

HTTP/1.1 200
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwibmJmIjoxNTIzNTExNjY5LCJleHAiOjE1MjM1MTIyNjksImlhdCI6MTUyMzUxMTY2OX0.E6HZShowNPUvNj84dYRHMyZROxIvYjsEP7e29
_QLXic
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 0
Date: Thu, 12 Apr 2018 05:41:09 GMT

** Pour un compte invalide **

Mauvaise adresse e-mail, mauvais mot de passe, etc.

> curl -i -X POST "http://localhost:9000/app/login" -d "[email protected]" -d "pass=hogehoge"

HTTP/1.1 401

API accessible aux utilisateurs authentifiés

** Si vous spécifiez un jeton valide **

> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/memo/1"

HTTP/1.1 200

** Si aucun jeton n'est spécifié **

> curl -i "http://localhost:9000/app/memo/1"

HTTP/1.1 401

** Si le jeton est falsifié **

> curl -i -H "Authorization: Bearer {invalid_token}" "http://localhost:9000/app/memo/1"

HTTP/1.1 401

** Si le jeton a expiré **

> curl -i -H "Authorization: Bearer {invalid_token}" "http://localhost:9000/app/memo/1"

HTTP/1.1 401

API qui nécessitent une authentification et des rôles USER

** Pour les utilisateurs avec le rôle USER **

> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/user"

HTTP/1.1 200

API nécessitant une authentification et un rôle ADMIN

** Pour les utilisateurs avec le rôle ADMIN **

> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/admin"

HTTP/1.1 200

** Pour les utilisateurs qui n'ont pas le rôle ADMIN **

Si le jeton réussit mais que l'utilisateur n'a pas le rôle requis, une erreur se produit.

> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/admin"

HTTP/1.1 403

Recommended Posts

Implémentez une API Rest simple avec Spring Security & JWT avec Spring Boot 2.0
Implémentez une API Rest simple avec Spring Security avec Spring Boot 2.0
Implémentez un serveur API Web REST simple avec Spring Boot + MySQL
Créez un site de démonstration simple avec Spring Security avec Spring Boot 2.1
Implémenter l'API REST avec Spring Boot
Implémenter l'API REST avec Spring Boot et JPA (couche d'infrastructure)
Faisons une API simple avec EC2 + RDS + Spring boot ①
Implémenter l'API REST avec Spring Boot et JPA (Domain Layer Edition)
J'ai créé un formulaire de recherche simple avec Spring Boot + GitHub Search API.
Créez une application de recherche simple avec Spring Boot
Créer un serveur API Web avec Spring Boot
Hello World (API REST) avec Apache Camel + Spring Boot 2
[Spring Boot] Obtenez des informations utilisateur avec l'API Rest (débutant)
Personnalisez la réponse aux erreurs de l'API REST avec Spring Boot (Partie 2)
Un mémorandum lors de la création d'un service REST avec Spring Boot
Personnalisez la réponse aux erreurs de l'API REST avec Spring Boot (Partie 1)
Spring avec Kotorin - 4 Conception d'API REST
[Débutant] Essayez d'écrire l'API REST pour l'application Todo avec Spring Boot
Créez un lot à la demande simple avec Spring Batch
Implémenter CRUD avec Spring Boot + Thymeleaf + MySQL
Implémenter la fonction de pagination avec Spring Boot + Thymeleaf
Obtenez une authentification BASIC avec Spring Boot + Spring Security
Créez un site Web avec Spring Boot + Gradle (jdk1.8.x)
Hash des mots de passe avec Spring Boot + Spring Security (avec sel, avec étirement)
Essayez l'authentification LDAP avec Spring Security (Spring Boot) + OpenLDAP
Essayez d'implémenter la fonction de connexion avec Spring Boot
[Introduction à Spring Boot] Fonction d'authentification avec Spring Security
Créer un environnement de développement Spring Boot avec docker
Créez un serveur Spring Cloud Config en toute sécurité avec Spring Boot 2.0
Télécharger avec Spring Boot
Mappez automatiquement DTO aux entités avec l'API Spring Boot
Mémo d'utilisation de Spring Security: coopération avec Spring MVC et Boot
[Compatible JUnit 5] Ecrire un test en utilisant JUnit 5 avec Spring boot 2.2, 2.3
Présentez swagger-ui à l'API REST implémentée dans Spring Boot
Générer un code à barres avec Spring Boot
Hello World avec Spring Boot
Gérez l'API de date et d'heure Java 8 avec Thymeleaf avec Spring Boot
Démarrez avec Spring Boot
Bonjour tout le monde avec Spring Boot!
Une histoire remplie des bases de Spring Boot (résolu)
Exécutez LIFF avec Spring Boot
Connexion SNS avec Spring Boot
Fonction de connexion avec Spring Security
Spring Boot à partir de Docker
Hello World avec Spring Boot
Définir des cookies avec Spring Boot
Test de l'API REST avec REST Assured
Utiliser Spring JDBC avec Spring Boot
Avec Spring Boot, hachez le mot de passe et utilisez l'enregistrement des membres et la sécurité Spring pour implémenter la fonction de connexion.
Ajouter un module avec Spring Boot
Premiers pas avec Spring Boot
Lier l'API avec Spring + Vue.js
Essayez d'utiliser Spring Boot Security
Créer un micro service avec Spring Boot
Envoyer du courrier avec Spring Boot
Découvrons comment recevoir avec Request Body avec l'API REST de Spring Boot
J'ai créé un système d'exemple MVC simple à l'aide de Spring Boot
Créons une application Web de gestion de livres avec Spring Boot part1
Une erreur 404 se produit lors du test de l'authentification par formulaire avec Spring Security
J'ai essayé d'implémenter un client OAuth avec Spring Boot / Security (connexion LINE)
Créons une application Web de gestion de livres avec Spring Boot part3