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
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.
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 |
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é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();
}
}
configuration
.authorizeRequests()
.mvcMatchers("/prelogin", "/hello/**")
.permitAll()
.mvcMatchers("/user/**")
.hasRole("USER")
.mvcMatchers("/admin/**")
.hasRole("ADMIN")
.anyRequest()
.authenticated()
Définissez l'autorisation API.
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()
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.
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
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.
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"))
@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.
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
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
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());
}
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();
}
}
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 |
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;
}
}
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());
}
}
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
}
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();
}
}
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.
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
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}
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
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
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
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
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
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.
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).
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) {
}
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());
}
}
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());
}
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