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

Aperçu

Nous avons implémenté une simple application de démonstration d'API Rest à l'aide de Spring Security et Spring Boot. La première moitié de l'article décrit l'implémentation autour de Spring Security, et la seconde moitié décrit l'implémentation du contrôleur et son code de test.

Le code source peut être trouvé sur rubytomato / demo-security-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, de mot de passe et de jeton CSRF est envoyée à l'API de connexion, et si l'authentification est possible, l'état HTTP 200, un cookie de session et un nouveau cookie de jeton CSRF seront renvoyés. L'authentification et l'autorisation ultérieures seront effectuées à l'aide de cookies de session.

À propos du code d'état HTTP

Les applications qui rendent des pages côté serveur peuvent rediriger (ou transférer) vers une page déterminée par un comportement spécifique (par exemple, retourner à la page d'origine après la connexion, passer à une page d'erreur lorsqu'une demande échoue). Cette application de démonstration ne se comporte pas comme ça, et on suppose que si une redirection est nécessaire, elle sera laissée au côté client.

comportement Code d'état HTTP Remarques
Lorsque la connexion est réussie 200 (Ok) Génération de session
Génération de jetons CSRF
Lorsque la connexion échoue 401 (Unauthorized) Mauvais mot de passe
Mauvaise adresse email
Lorsque la déconnexion est réussie 200 (Ok) Supprimer la session
Supprimer les cookies
Lorsque la déconnexion échoue 500 (Internal Server Error)
Lorsque l'API réussit 200 (Ok) Authentifié, autorisé et exécute l'API
Réponse le résultat de l'arrêt normal de l'API
Lorsque l'API échoue 400 (Bad Request)
404 (Not Found)
500 (Internal Server Error)
Authentifié, autorisé et exécute l'API
Réponse à la fin de l'API
Erreur d'authentification API 401 (Unauthorized) Lors de l'appel de l'API sans authentification
Y compris l'expiration de session, les jetons CSRF frauduleux, etc.
API non exécutée
Erreur d'autorisation API 403 (Forbidden) Lors de l'appel d'une API authentifiée mais non autorisée
API non exécutée

Conservation des informations utilisateur

Les informations utilisateur sont stockées dans la table USER avec des éléments tels que le nom d'utilisateur, le mot de passe haché, l'adresse e-mail (unique) et l'indicateur d'administrateur. L'indicateur admin (admin_flag) a un rôle pour l'application de l'utilisateur, 1 pour le rôle ADMIN et 0 pour le rôle USER. On suppose que certaines API effectuent une autorisation de rôle.

CREATE TABLE IF NOT EXISTS `user` (
  id BIGINT AUTO_INCREMENT,
  `name` VARCHAR(128) NOT NULL,
  password VARCHAR(256) NOT NULL,
  email VARCHAR(256) NOT NULL,
  admin_flag BOOLEAN NOT NULL DEFAULT FALSE,
  PRIMARY KEY (id),
  UNIQUE KEY (email)
)
ENGINE = INNODB,
CHARACTER SET = utf8mb4,
COLLATE utf8mb4_general_ci;

Les données utilisateur utilisées dans l'application de démonstration ressemblent à ceci: Le mot de passe est haché avec la méthode d'encode de la classe BCryptPasswordEncoder décrite plus loin.

> select * from user;
+----+----------+--------------------------------------------------------------+-----------------------+------------+
| id | name     | password                                                     | email                 | admin_flag |
+----+----------+--------------------------------------------------------------+-----------------------+------------+
|  1 | kamimura | $2a$10$yiIGwxNPWwJ3CZ0SGAq3i.atLYrQNhzTyep1ALi6dbax1b1R2Y.cG | [email protected] |          1 |
|  2 | sakuma   | $2a$10$9jo/FSVljst5xJjuw9eyoumx2iVCUA.uBkUKeBo748bUIaPjypbte | [email protected]   |          0 |
|  3 | yukinaga | $2a$10$1OXUbgiuuIi3SOO3t.jyZOEY66ELL03dRcGpAKWql8HBXOag4YZ8q | [email protected] |          0 |
+----+----------+--------------------------------------------------------------+-----------------------+------------+
3 rows in set (0.00 sec)

Bien que non implémenté dans cette application de démonstration, il est supposé que le mot de passe entré lors de l'enregistrement d'un nouvel utilisateur est haché avec BCryptPasswordEncoder.encode et rendu persistant.

PasswordEncoder encoder = new BCryptPasswordEncoder();

User user = User.of(usernme, encoder.encode(rawPassword), email);
userRepository.save(user);

Implémentation de la sécurité

Configuration de Spring Security

Implémentez la sécurité dans une classe qui hérite de WebSecurityConfigurerAdapter. Les différentes implémentations seront décrites séparément.

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            // AUTHORIZE
            .authorizeRequests()
                .mvcMatchers("/prelogin", "/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()
            // LOGOUT
            .logout()
                .logoutUrl("/logout")
                .invalidateHttpSession(true)
                .deleteCookies("JSESSIONID")
                .logoutSuccessHandler(logoutSuccessHandler())
                //.addLogoutHandler(new CookieClearingLogoutHandler())
            .and()
             // CSRF
            .csrf()
                //.disable()
                //.ignoringAntMatchers("/login")
                .csrfTokenRepository(new CookieCsrfTokenRepository())
            ;
    }

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

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

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

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

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

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

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

}

Paramètres d'autorisation

configuration


.authorizeRequests()
    .mvcMatchers("/prelogin", "/hello/**")
        .permitAll()
    .mvcMatchers("/user/**")
        .hasRole("USER")
    .mvcMatchers("/admin/**")
        .hasRole("ADMIN")
    .anyRequest()
        .authenticated()

Définissez l'autorisation API.

Gestion des exceptions d'authentification et d'autorisation

configuration


.exceptionHandling()
    .authenticationEntryPoint(authenticationEntryPoint())
    .accessDeniedHandler(accessDeniedHandler())

authenticationEntryPoint()

15.2.1 AuthenticationEntryPoint

Définissez le traitement lorsqu'un utilisateur non authentifié accède à une API qui nécessite une authentification.

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

Il n'utilise pas les classes d'implémentation par défaut ou standard fournies, mais implémente le processus de renvoi de l'état HTTP 401 et du message par défaut.

SimpleAuthenticationEntryPoint


public class SimpleAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException exception) throws IOException, ServletException {
        if (response.isCommitted()) {
            log.info("Response has already been committed.");
            return;
        }
        response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
    }

}

Le message par défaut ressemble à ceci: message est la valeur spécifiée pour le deuxième paramètre de la méthode sendError (HttpStatus.UNAUTHORIZED.getReasonPhrase).

HTTP/1.1 401
//réduction

{
  "timestamp" : "2018-04-08T21:13:24.918+0000",
  "status" : 401,
  "error" : "Unauthorized",
  "message" : "Unauthorized",
  "path" : "/app/memo/1"
}

standard implementations

AuthenticationException

Vous pourrez peut-être trouver la raison plus détaillée de l'exception dans une sous-classe de AuthenticationException.

accessDeniedHandler()

15.2.2 AccessDeniedHandler

Définit ce qui se passe lorsqu'un utilisateur accède à une ressource authentifiée mais sans licence.

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

Au lieu d'utiliser les classes d'implémentation par défaut ou standard fournies, implémentez un gestionnaire qui renvoie simplement l'état HTTP 403 et un message par défaut.

SimpleAccessDeniedHandler


public class SimpleAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException exception) throws IOException, ServletException {
        response.sendError(HttpStatus.FORBIDDEN.value(), HttpStatus.FORBIDDEN.getReasonPhrase());
    }

}

standard implementations

AccessDeniedException

Vous pouvez trouver la raison plus détaillée de l'exception dans la sous-classe AccessDeniedException.

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

15.4.1 Application Flow on Authentication Success and Failure

configuration


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

loginProcessingUrl()

Définissez la page de connexion et le nom du paramètre. Aucune authentification n'est requise pour accéder à cette page. (permitAll)

successHandler()

Définissez un gestionnaire qui implémente le traitement lorsque l'authentification est réussie.

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

Au lieu d'utiliser les classes d'implémentation par défaut ou standard fournies, implémentez un gestionnaire qui renvoie simplement l'état HTTP 200.

SimpleAuthenticationSuccessHandler


@Slf4j
public class SimpleAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

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

        response.setStatus(HttpStatus.OK.value());
        clearAuthenticationAttributes(request);
    }

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

standard implementations

failureHandler()

Définissez un gestionnaire qui implémente le traitement lorsque l'authentification échoue.

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

Au lieu d'utiliser les classes d'implémentation par défaut ou standard fournies, implémentez un gestionnaire qui renvoie simplement l'état HTTP 403 et un message par défaut.

SimpleAuthenticationFailureHandler


public class SimpleAuthenticationFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request,
                                        HttpServletResponse response,
                                        AuthenticationException exception) throws IOException, ServletException {
        response.sendError(HttpStatus.FORBIDDEN.value(), HttpStatus.FORBIDDEN.getReasonPhrase());
    }

}

standard implementations

Traitement à la déconnexion

configuration


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

logoutUrl()

Définissez la page de déconnexion.

logoutSuccessHandler()

Définissez un gestionnaire qui implémente le traitement lorsque la déconnexion se termine normalement. Puisqu'il existe une classe d'implémentation standard HttpStatusReturningLogoutSuccessHandler de Spring Security qui ne renvoie que l'état HTTP, j'ai utilisé ceci. La destruction de session et la suppression des cookies effectuées à la déconnexion sont effectuées par configuration, donc l'implémentation n'est pas requise.

5.5.2 LogoutSuccessHandler

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

standard implementations

addLogoutHandler()

Je ne l'ai pas utilisé dans cette application de démonstration, mais vous pouvez ajouter un gestionnaire à exécuter lorsque la déconnexion est terminée.

standard implementations

CSRF

Par défaut, la protection CSRF est activée et contient le jeton CSRF dans HttpSession. Étant donné que l'API de connexion est également soumise aux mesures CSRF, un jeton CSRF est requis lors de la connexion, mais si vous souhaitez le rendre inutile pour l'API de connexion, spécifiez l'URL dans ignororingAntMatchers.

configuration


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

Si vous souhaitez désactiver les contre-mesures CSRF, ajoutez disable.

configuration


.csrf()
    .disable()

csrfTokenRepository

Nous avons utilisé la classe d'implémentation standard CookieCsrfTokenRepository qui contient les jetons CSRF dans les cookies.

standard implementations

HEADER

Par défaut, le contrôle du cache est désactivé et les en-têtes suivants sont définis. Nous avons laissé les valeurs par défaut pour cette application de démonstration car elles n'ont pas besoin d'être personnalisées.

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

Si vous souhaitez désactiver le contrôle du cache

configuration


.headers()
    .cacheControl()
        .disable()

Si vous souhaitez également désactiver d'autres options

configuration


.headers()
    .cacheControl()
        .disable()
    .frameOptions()
        .disable()
    .xssProtection()
        .disable()
    .contentTypeOptions()
        .disable()

Si vous souhaitez ajouter un en-tête

.headers()
    .addHeaderWriter(new StaticHeadersWriter("X-TEST-STATIC-HEADER", "dummy_value"))

Configuration du processus d'authentification

@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth,
    @Qualifier("simpleUserDetailsService") UserDetailsService userDetailsService,
    PasswordEncoder passwordEncoder) throws Exception {

    auth.eraseCredentials(true)
        .userDetailsService(userDetailsService)
        .passwordEncoder(passwordEncoder);

}

UserDetailsService

9.2.2 The UserDetailsService 10.2 UserDetailsService Implementations

L'interface UserDetailsService définit une seule méthode, loadUserByUsername. La classe qui implémente cette interface doit remplacer loadUserByUsername et renvoyer toute classe d'informations d'identification qui implémente l'interface UserDetails.

Dans cette application de démonstration, les informations utilisateur sont stockées dans la table USER de la base de données, recherchez donc la table USER à l'aide de UserRepository comme indiqué dans l'exemple ci-dessous, et si un utilisateur est trouvé, générez une instance de la classe d'informations d'authentification SimpleLoginUser qui implémente l'interface UserDetails. Et reviens.

@Service("simpleUserDetailsService")
public class SimpleUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public SimpleUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(final String email) {
        //Rechercher des entités utilisateur dans la base de données par e-mail
        return userRepository.findByEmail(email)
                .map(SimpleLoginUser::new)
                .orElseThrow(() -> new UsernameNotFoundException("user not found"));
    }
}

UserDetails

Certains des détails de l'utilisateur sont extraits ci-dessous car l'explication sur la page de référence est facile à comprendre.

9.2.2 The UserDetailsService

UserDetails is a core interface in Spring Security. It represents a principal, but in an extensible and application-specific way. Think of UserDetails as the adapter between your own user database and what Spring Security needs inside the SecurityContextHolder.

Inherit User, qui implémente UserDetails, et implémente la classe d'informations d'identification spécifiques à l'application SimpleLoginUser. Si vous disposez des informations requises par les exigences de votre application, définissez-les dans les champs de cette classe. Cet exemple définit une instance de l'entité User.

SimpleLoginUser


public class SimpleLoginUser extends org.springframework.security.core.userdetails.User {

    //Entité utilisateur
    private com.example.demo.entity.User user;

    public User getUser() {
        return user;
    }

    public SimpleLoginUser(User user) {
        super(user.getName(), user.getPassword(), determineRoles(user.getAdmin()));
        this.user = user;
    }

    private static final List<GrantedAuthority> USER_ROLES = AuthorityUtils.createAuthorityList("ROLE_USER");
    private static final List<GrantedAuthority> ADMIN_ROLES = AuthorityUtils.createAuthorityList("ROLE_ADMIN", "ROLE_USER");

    private static List<GrantedAuthority> determineRoles(boolean isAdmin) {
        return isAdmin ? ADMIN_ROLES : USER_ROLES;
    }
}

Password Encoder

10.3 Password Encoding

Le mot de passe a été codé à l'aide de la classe d'implémentation standard BCryptPasswordEncoder. Il existe plusieurs autres classes d'implémentation standard, mais vous pouvez également implémenter l'interface PasswordEncoder pour créer l'encodeur dont vous avez besoin s'il ne répond pas aux exigences de votre application.

PasswordEncoder


@Bean
PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

standard implementations

DelegatingPasswordEncoder

Il s'agit d'un encodeur implémenté à partir de Spring Security 5.0. Dans le passé, une fois que l'algorithme de codage était décidé, il y avait le problème qu'il était difficile de changer l'algorithme adopté plus tard.

Cet encodeur délègue le processus à la classe d'encodage existante comme le nom de classe le suggère, mais ajoute l'ID d'algorithme au début de la valeur de hachage encodée. Par défaut, BCryptPasswordEncoder est utilisé, donc {bcrpt} indiquant que Bcrypt est ajouté à la valeur de hachage comme indiqué dans l'exemple ci-dessous.

DelegatingPasswordEncoder recherche cet ID pour déterminer la classe de codage à utiliser.

PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
String password = encoder.encode("dummy_password");
System.out.println(password);
// {bcrypt}$2a$10$7qLNvQ7CZ80.VcZGtfe2QuMk7NlWP8ktJyEZoqToo1L7.zi9dIy76

Contrôleur à mettre en œuvre

39.5 Spring MVC and CSRF Integration

Dans l'argument du contrôleur

@GetMapping
public String greeting(@AuthenticationPrincipal(expression = "user") User user, CsrfToken csrfToken) {
    log.debug("token : {}", csrfToken.getToken());
    log.debug("access user : {}", user.toString());

}

API de pré-connexion

Une API qui renvoie le jeton CSRF requis par l'API de connexion.

method path body content type request body
GET /prelogin
@RestController
@RequestMapping(path = "prelogin")
public class PreLoginController {

    @GetMapping
    public String preLogin(HttpServletRequest request) {
        DefaultCsrfToken token = (DefaultCsrfToken) request.getAttribute("_csrf");
        if (token == null) {
            throw new RuntimeException("could not get a token.");
        }
        return token.getToken();
    }

}

API de connexion / déconnexion

Aucune implémentation n'est requise car elle est activée dans les paramètres de configuration de la sécurité.

method path body content type request body
POST /login application/x-www-form-urlencoded email={email}
pass={password}
_csrf={CSRF-TOKEN}
POST /logout

API qui ne nécessite pas d'authentification

C'est une API à laquelle tout le monde peut accéder sans authentification ni autorisation.

method path body content type request body
GET /hello
GET /hello/{message}
POST /hello application/x-www-form-urlencoded message={message}

HelloController


@RestController
@RequestMapping(path = "hello")
@Slf4j
public class HelloController {

    @GetMapping
    public String greeting() {
        return "hello world";
    }

    @GetMapping(path = "{message}")
    public String greeting(@PathVariable(name = "message") String message) {
        return "hello " + message;
    }

    @PostMapping
    public String postGreeting(@RequestParam(name = "message") String message) {
        return "hello " + message;
    }

}

API qui nécessite une authentification et ne nécessite pas d'autorisation

Il s'agit d'une API accessible par tout utilisateur authentifié.

method path body content type request body
GET /memo/1
GET /memo/list
@RestController
@RequestMapping(path = "memo")
public class MemoController {

    private final MemoService service;

    public MemoController(MemoService service) {
        this.service = service;
    }

    @GetMapping(path = "{id}", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public ResponseEntity<Memo> id(@PathVariable(value = "id") Long id) {
        Optional<Memo> memo = service.findById(id);
        return memo.map(ResponseEntity::ok)
                .orElseGet(() -> ResponseEntity.notFound().build());
    }

    @GetMapping(path = "list", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public ResponseEntity<List<Memo>> list(Pageable page) {
        Page<Memo> memos = service.findAll(page);
        return ResponseEntity.ok(memos.getContent());
    }

}

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

Une API accessible si l'utilisateur authentifié a le rôle USER.

method path body content type request body
GET /user
GET /user/echo/{message}
POST /user/echo application/json {"{key}": "{value}"}
@RestController
@RequestMapping(path = "user")
public class UserController {

    @GetMapping
    public String greeting(@AuthenticationPrincipal(expression = "user") User user) {
        return "hello " + user.getName();
    }

    @GetMapping(path = "echo/{message}")
    public String getEcho(@PathVariable(name = "message") String message) {
        return message.toUpperCase();
    }

    @PostMapping(path = "echo", consumes = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public String postEcho(@RequestBody Map<String, String> message) {
        return message.toString();
    }

}

Vous pouvez prendre l'objet d'authentification de l'utilisateur authentifié comme argument de la méthode du gestionnaire.

public String greeting(@AuthenticationPrincipal SimpleLoginUser loginUser) {
    User user = loginUser.getUser();

    //réduction
}

Vous pouvez obtenir l'objet directement en spécifiant la méthode getter de l'objet d'authentification (par exemple, user pour getUser) dans expression.

public String greeting(@AuthenticationPrincipal(expression = "user") User user) {

    //réduction
}

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

Une API accessible si l'utilisateur authentifié a le rôle ADMIN.

method path body content type request body
GET /admin
GET /admin/{username}
GET /admin/echo/{message}
POST /admin/echo application/json {"{key}": "{value}"}
@RestController
@RequestMapping(path = "admin")
public class AdminController {

    private final UserService userService;

    public AdminController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping
    public String greeting(@AuthenticationPrincipal(expression = "user") User user) {
         return "hello admin " + user.getName();
    }

    @GetMapping(path = "{name}")
    public String greeting(@AuthenticationPrincipal(expression = "user") User user, @PathVariable(name = "name") String name) {
         return userService.findByName(name).map(u -> "hello " + u.getName()).orElse("unknown user");
    }

    @GetMapping(path = "echo/{message}")
    public String getEcho(@PathVariable(name = "message") String message) {
        return message.toUpperCase();
    }

    @PostMapping(path = "echo", consumes = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public String postEcho(@RequestBody Map<String, String> message) {
        return message.toString();
    }

}

Vérification du fonctionnement de l'API

J'ai vérifié le fonctionnement de l'API avec la commande curl. Le cookie de résultat d'authentification est enregistré dans un fichier texte avec l'option -c, et le fichier texte est spécifié avec l'option -b lors de l'envoi.

API qui ne nécessite pas d'authentification

Pour les utilisateurs non authentifiés

> curl -i "http://localhost:9000/app/hello/world"

HTTP/1.1 200

Même les API qui ne nécessitent pas d'authentification ont besoin d'un jeton CSRF dans le cookie de jeton CSRF et d'un en-tête x-xsrf-token au POST si elles sont soumises à CSRF.

NG


> curl -i -X POST "http://localhost:9000/app/hello" -d "message=WORLD"

HTTP/1.1 401

OK


> curl -i -b cookie.txt -X POST "http://localhost:9000/app/hello" -d "message=WORLD" -d "_csrf={CSRF-TOKEN}"

HTTP/1.1 200

Pour les utilisateurs authentifiés

> curl -i -b cookie.txt "http://localhost:9000/app/hello/world"

HTTP/1.1 200
> curl -i -b cookie.txt -X POST "http://localhost:9000/app/hello" -d "message=WORLD" -d "_csrf={CSRF-TOKEN}"

HTTP/1.1 200

API de pré-connexion

Une API qui renvoie le CSRF-TOKEN requis lors de la connexion.

prelogin


> curl -i -c cookie.txt "http://localhost:9000/app/prelogin"

HTTP/1.1 200

{CSRF-TOKEN}

API de connexion

Tout d'abord, accédez à l'API de pré-connexion et obtenez le CSRF-TOKEN à utiliser lors de la connexion.

Pour un compte valide

login


> curl -i -b cookie.txt -c cookie.txt -X POST "http://localhost:9000/app/login" -d "[email protected]" -d "pass=iWKw06pvj" -d "_csrf={CSRF-TOKEN}"

HTTP/1.1 200

À propos, les contenus suivants sont écrits dans cookie.txt.

> type cookie.txt

# Netscape HTTP Cookie File
# http://curl.haxx.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

#HttpOnly_localhost     FALSE   /app    FALSE   0       XSRF-TOKEN      d10bdddd-d66d-4cfb-9417-fcdb9a3d4d71
#HttpOnly_localhost     FALSE   /app    FALSE   0       JSESSIONID      99096C52A9CCDC52ED4A15BCB0079CB5

En cas de compte invalide (mauvaise adresse e-mail, mauvais mot de passe, etc.)

login


> curl -i -b cookie.txt -c cookie.txt -X POST "http://localhost:9000/app/login" -d "[email protected]" -d "pass=hogehoge" -d "_csrf={CSRF-TOKEN}"

HTTP/1.1 401

API accessible aux utilisateurs authentifiés

Pour les utilisateurs authentifiés

> curl -i -b cookie.txt "http://localhost:9000/app/memo/1"

HTTP/1.1 200

Pour les utilisateurs non authentifiés

> curl -i "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 -b cookie.txt "http://localhost:9000/app/user"

HTTP/1.1 200

Si le jeton CSRF ne peut pas être spécifié pour le corps de la requête, spécifiez-le dans l'en-tête.

> curl -i -b cookie.txt -H "Content-Type:application/json" -H "x-xsrf-token:{CSRF-TOKEN}" -X POST "http://localhost:9000/app/user/echo" -d "{\"message\": \"abc\"}"

HTTP/1.1 200

Pour les utilisateurs non authentifiés

> curl -i "http://localhost:9000/app/user"

HTTP/1.1 401

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

Pour les utilisateurs avec le rôle ADMIN

> curl -i -b cookie.txt "http://localhost:9000/app/admin"

HTTP/1.1 200
> curl -i -b cookie.txt -H "Content-Type:application/json" -H "x-xsrf-token:{CSRF-TOKEN}" -X POST "http://localhost:9000/app/admin/echo" -d "{\"message\": \"abc\"}"

HTTP/1.1 200

Pour les jetons CSRF non valides

> curl -i -b cookie.txt -H "Content-Type:application/json" -H "x-xsrf-token:{INVALID-CSRF-TOKEN}" -X POST "http://localhost:9000/app/admin/echo" -d "{\"message\": \"abc\"}"

HTTP/1.1 403

Pour les utilisateurs qui n'ont pas le rôle ADMIN (Connectez-vous en tant qu'utilisateur qui n'a pas le rôle ADMIN avant de vérifier.)

> curl -i -b cookie.txt "http://localhost:9000/app/admin"

HTTP/1.1 403

Pour les utilisateurs non authentifiés

> curl -i "http://localhost:9000/app/admin"

HTTP/1.1 401

API de déconnexion

Pour les utilisateurs authentifiés

logout


> curl -i -b cookie.txt -H "x-xsrf-token:{CSRF-TOKEN}" -X POST "http://localhost:9000/app/logout"

HTTP/1.1 200

Pour les utilisateurs non authentifiés

logout


> curl -i -H "x-xsrf-token:{CSRF-TOKEN}" -X POST "http://localhost:9000/app/logout"

HTTP/1.1 401

Pour les jetons CSRF non valides

logout


> curl -i -b cookie.txt -H "x-xsrf-token:{INVALID-CSRF-TOKEN}" -X POST "http://localhost:9000/app/logout"

HTTP/1.1 403

Description du code de test

Cette section décrit l'unité et le test d'intégration de la classe de contrôleur de l'application à l'aide de Spring Security.

Test du contrôleur

Dans cet article, le test utilisant l'annotation MockMvcTest est considéré comme un test unitaire. La partie déroutante du test unitaire est que l'authentification de base est activée, mais le comportement personnalisé par SecurityConfig n'est pas reflété. Les paramètres du code de test changent selon que le test unitaire teste également la partie liée à la sécurité (contenu défini dans la classe SecurityConfig).

Désactiver les fonctionnalités de Spring Security

Si vous souhaitez tester sans tenir compte de la partie sécurité, vous pouvez désactiver la fonction Spring Security en spécifiant false dans l'attribut sécurisé de l'annotation WebMvcTest.

@RunWith(SpringRunner.class)
@WebMvcTest(value = UserController.class, secure = false)
public class UserControllerTests {

    //Code de test

}

Avec ce paramètre, les API qui nécessitent une authentification peuvent être testées sans authentification.

@Test
public void getEcho() throws Exception {
    RequestBuilder builder = MockMvcRequestBuilders.get("/user/echo/{message}", "abc")
        .accept(MediaType.TEXT_PLAIN);

    mvc.perform(builder)
        .andExpect(status().isOk())
        .andExpect(content().contentType(contentTypeText))
        .andExpect(content().string("ABC"))
        .andDo(print());
}

Cependant, si la méthode du gestionnaire prend l'objet d'authentification de l'utilisateur comme argument, elle ne peut pas être testée car l'objet d'authentification ne peut pas être injecté.

Exemple de prise d'un objet d'authentification comme argument


@GetMapping
public String greeting(@AuthenticationPrincipal(expression = "user") User user) {

}

Activer certaines fonctionnalités de Spring Security

Il s'agit de l'état par défaut (secure = true). La partie authentification est activée, mais la partie autorisation n'est pas valide car les paramètres SecurityConfig ne sont pas reflétés. Par exemple, même si vous limitez l'accès par rôle, vous pouvez accéder à n'importe quel rôle du test.

@RunWith(SpringRunner.class)
@WebMvcTest(value = UserController.class, secure = true)
public class UserControllerTests {

    //Code de test

}

Si la méthode de gestion du contrôleur testé prend l'objet d'authentification de l'utilisateur comme argument, vous pouvez spécifier un objet d'authentification factice avec with (user (...)). J'essaie d'enregistrer le jeton CSRF dans un cookie dans SecurityConfig, mais le cookie XSRF-TOKEN n'existe pas car il n'est pas reflété dans le test.

@Test
public void greeting() throws Exception {
    //Créer un objet d'authentification factice
    User user = new User(1L, "test_user", "pass", "[email protected]", true);
    SimpleLoginUser loginUser = new SimpleLoginUser(user);

    RequestBuilder builder = MockMvcRequestBuilders.get("/user")
        .with(user(loginUser))
        .accept(MediaType.TEXT_PLAIN);

    mvc.perform(builder)
        .andExpect(status().isOk())
        .andExpect(authenticated().withUsername("test_user").withRoles("USER", "ADMIN"))
        .andExpect(content().contentType(contentTypeText))
        .andExpect(content().string("hello aaaa"))
        //.andExpect(cookie().exists("XSRF-TOKEN"))
        .andExpect(forwardedUrl(null))
        .andExpect(redirectedUrl(null))
        .andDo(print());
    }

Si la méthode de gestionnaire ne prend pas un objet d'authentification comme argument dans une API qui nécessite une authentification, il vous suffit de spécifier l'annotation WithMockUser.

@WithMockUser(roles = "USER")
@Test
public void getEcho() throws Exception {
    RequestBuilder builder = MockMvcRequestBuilders.get("/user/echo/{message}", "abc")
        .accept(MediaType.TEXT_PLAIN);

    mvc.perform(builder)
        .andExpect(status().isOk())
        .andExpect(content().contentType(contentTypeText))
        .andExpect(content().string("ABC"))
        .andDo(print());
}

Si vous n'ajoutez pas l'annotation WithMockUser, l'état HTTP 401 sera renvoyé car il est dans un état non authentifié.

@Test
public void getEcho_401() throws Exception {
    RequestBuilder builder = MockMvcRequestBuilders.get("/user/echo/{message}", "abc");

    mvc.perform(builder)
        .andExpect(status().is(HttpStatus.UNAUTHORIZED.value()))
        .andDo(print());
}

Cependant, le test suivant échouera car le paramètre d'autorisation n'est pas activé. L'API testée n'est accessible que par un utilisateur avec le rôle USER, mais elle est également accessible par un utilisateur avec le rôle ADMIN.

@WithMockUser(roles = "ADMIN")
@Test
public void getEcho_403() throws Exception {
    RequestBuilder builder = MockMvcRequestBuilders.get("/user/echo/{message}", "abc");

    mvc.perform(builder)
        .andExpect(status().is(HttpStatus.FORBIDDEN.value()))
        .andDo(print());
}

Spécifiez with (csrf ()) pour les API qui nécessitent un jeton CSRF, comme la méthode POST. Si vous souhaitez utiliser un jeton CSRF non valide, utilisez with (csrf (). UseInvalidToken ()).

@WithMockUser(roles = "USER")
@Test
public void postEcho() throws Exception {
    RequestBuilder builder = MockMvcRequestBuilders.post("/user/echo")
        .contentType(MediaType.APPLICATION_JSON_UTF8)
        .content("{\"message\": \"hello world\"}")
        .with(csrf())
        .accept(MediaType.APPLICATION_JSON_UTF8_VALUE);

    mvc.perform(builder)
        .andExpect(status().isOk())
        .andExpect(content().contentType(contentTypeJson))
        .andExpect(content().string("{message=hello world}"))
        .andDo(print());
}

Veuillez noter que les API qui ne nécessitent pas d'authentification nécessitent une authentification. L'API testée est définie dans SecurityConfig comme une API qui ne nécessite pas d'authentification, mais le test échouera sans l'annotation WithMockUser.

@RunWith(SpringRunner.class)
@WebMvcTest(value = HelloController.class)
public class HelloControllerTests {

    @Autowired
    private MockMvc mvc;

    final private MediaType contentTypeText = new MediaType(MediaType.TEXT_PLAIN.getType(),
            MediaType.TEXT_PLAIN.getSubtype(), Charset.forName("utf8"));

    @WithMockUser
    @Test
    public void greeting() throws Exception {
        RequestBuilder builder = MockMvcRequestBuilders.get("/hello")
            .accept(MediaType.TEXT_PLAIN);

        mvc.perform(builder)
            .andExpect(status().isOk())
            .andExpect(content().contentType(contentTypeText))
            .andExpect(content().string("hello world"))
            .andDo(print());
    }

}

Refléter les paramètres SecurityConfig

Si les exigences du test unitaire incluent également ce que vous avez défini dans SpringConfig, importez la classe SecurityConfig. Jusque-là, le test sera conduit dans presque les mêmes conditions que le test d'intégration, il peut donc être préférable d'utiliser le test d'intégration en fonction du niveau de détail du test.

L'importation de SecurityConfig nécessite une instance de la classe qui implémente l'interface UserDetailsService, nous préparons donc une instance simulée avec MockBean.

@RunWith(SpringRunner.class)
@WebMvcTest(value = UserController.class)
@Import(value = {SecurityConfig.class})
public class UserControllerTests {

    @Autowired
    private MockMvc mvc;

    @MockBean(name = "simpleUserDetailsService")
    private UserDetailsService userDetailsService;

    //Code de test

}

Le test d'autorisation qui a échoué dans «Activer certaines fonctionnalités de Spring Security» ci-dessus réussira également. L'état HTTP 403 sera renvoyé pour les utilisateurs qui n'ont pas de rôle accessible.

@WithMockUser(roles = "ADMIN")
@Test
public void getEcho_403() throws Exception {
    RequestBuilder builder = MockMvcRequestBuilders.get("/user/echo/{message}", "abc");

    mvc.perform(builder)
        .andExpect(status().is(HttpStatus.FORBIDDEN.value()))
        .andDo(print());
}

Test d'intégration du contrôleur

Dans cet article, le test utilisant l'annotation SpringBootTest est appelé test d'intégration. Dans le test d'intégration, les paramètres de la classe SecurityConfig sont activés par défaut.

Dans le test unitaire, MockMvc a été câblé automatiquement, mais il semble qu'il doit être construit avec la méthode avec l'annotation Avant, comme indiqué dans le code ci-dessous.

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class UserControllerIntegrationTests {

    @Autowired
    private WebApplicationContext context;

    private MockMvc mvc;

    @Before
    public void setup() {
        mvc = MockMvcBuilders
            .webAppContextSetup(context)
            .apply(springSecurity())
            .build();
    }

    //Code de test

}

Le code de test est presque le même que le code de test unitaire pour «Reflect Security Config settings».

@WithMockUser(roles = "USER")
@Test
public void getEcho() throws Exception {
    RequestBuilder builder = MockMvcRequestBuilders.get("/user/echo/{message}", "abc")
        .accept(MediaType.TEXT_PLAIN);

    mvc.perform(builder)
        .andExpect(status().isOk())
        .andExpect(content().contentType(contentTypeText))
        .andExpect(content().string("ABC"))
        .andDo(print());
}

Recommended Posts

Implémentez une API Rest simple avec Spring Security avec Spring Boot 2.0
Implémentez une API Rest simple avec Spring Security & JWT avec Spring Boot 2.0
Implémentez un serveur API Web REST simple avec Spring Boot + MySQL
Implémenter l'API REST avec Spring Boot
Créez un site de démonstration simple avec Spring Security avec Spring Boot 2.1
Implémenter l'API REST avec Spring Boot et JPA (Application Layer)
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
Implémenter GraphQL 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)
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
[Débutant] Essayez d'écrire l'API REST pour l'application Todo avec Spring Boot
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 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
Spring Boot avec les paramètres du filtre de sécurité Spring et les points addictifs
[JUnit 5] Ecrivez un test de validation avec Spring Boot! [Test de paramétrage]
Présentez swagger-ui à l'API REST implémentée dans Spring Boot
J'ai écrit un test avec Spring Boot + JUnit 5 maintenant
Générer un code à barres avec Spring Boot
Hello World avec Spring Boot
Démarrez avec Spring Boot
Exécutez LIFF avec Spring Boot
Connexion SNS avec Spring Boot
Téléchargement de fichiers avec Spring Boot
Spring Boot commençant par copie
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
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
Gérez l'API de date et d'heure Java 8 avec Thymeleaf avec Spring Boot
Une histoire remplie des bases de Spring Boot (résolu)
Essayez d'utiliser l'API de recherche de code postal avec Spring Boot
J'ai créé un système d'exemple MVC simple à l'aide de 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.
Découvrons comment recevoir avec Request Body avec l'API REST de Spring Boot
Mise en œuvre de la fonction d'authentification avec Spring Security ②