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
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.
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>
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 |
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.
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();
}
}
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. |
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();
}
}
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);
}
}
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()));
});
}
}
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())
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)
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.
** 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
** 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
** Pour les utilisateurs avec le rôle USER **
> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/user"
HTTP/1.1 200
** 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