This article is the demo application used in the previous post "Implementing a simple Rest API with Spring Security with Spring Boot 2.0.1". Is an article that explains the changes when making JWT (Json Web Token) compatible. There are many detailed articles on the JWT specifications, so I won't cover them here.
The source code can be found at rubytomato / demo-security-jwt-spring2.
environment
reference
This demo application authenticates with your email address and password. Specifically, a POST request for an email address and password is made to the login API, and if authentication is possible, an HTTP status 200 and JWT token will be returned. Subsequent authentication and authorization are performed by the JWT token set in the request header.
I chose java-jwt from the libraries that handle multiple JWTs.
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.3.0</version>
</dependency>
Some token payload (claim) identifiers are Registered Claim Names. Both are treated as optional, so whether to set them depends on the application to be used.
id | name | description |
---|---|---|
jti | JWT ID | JWT unique identifier |
aud | Audience | JWT users |
iss | Issuer | JWT issuer |
sub | Subject | JWT subject.Unique or globally unique value within the context of the JWT issuer |
iat | Issued At | JWT issuance time |
nbf | Not Before | JWT validity period start time.Not available before this time |
exp | Expiration Time | End time of JWT validity period.Not available after this time |
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();
}
}
The token generated by this setting is as follows.
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0IiwibmJmIjoxNTIzNTA2NzQwLCJYLUFVVEhPUklUSUVTIjoiYWFhIiwiZXhwIjoxNTIzNTA3MzQwLCJpYXQiOjE1MjM1MDY3NDAsIlgtVVNFUk5BTUUiOiJiYmIifQ.KLwUQcuNEt7m1HAC6ZzzGtRjZ3a2kvY11732aP9dyDY
You can decode the tokens at the 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();
}
}
The exceptions that are not exhaustive but are likely to occur (prone to occur) are: Both exceptions are subclasses of JWTVerificationException.
exception | description |
---|---|
SignatureVerificationException | When the secret key is different, etc. |
AlgorithmMismatchException | When the signature algorithm is different, etc. |
JWTDecodeException | For example, if the token has been tampered with |
TokenExpiredException | If the token has expired |
InvalidClaimException | Before starting to use the token, etc. |
Shows the changed parts for 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();
}
}
There was no change in the settings around authentication.
configuration
.formLogin()
.loginProcessingUrl("/login").permitAll()
.usernameParameter("email")
.passwordParameter("pass")
.successHandler(authenticationSuccessHandler())
.failureHandler(authenticationFailureHandler())
successHandler()
Before the change, it was a handler that only returned HTTP status 200, but it has been changed to generate a token from the authentication information and set it in the response header.
AuthenticationSuccessHandler authenticationSuccessHandler() {
return new SimpleAuthenticationSuccessHandler(secretKey);
}
Since the information set in the token payload (claim) can be decoded by anyone, select the information that can be exposed to the outside. In this example, the user ID is set in the subject, and at the time of authorization, the user ID is acquired from the token and the user is searched.
String token = JWT.create()
.withIssuedAt(issuedAt) //JWT issuance time
.withNotBefore(notBefore) //JWT expiration start time
.withExpiresAt(expiresAt) //JWT expiration date
.withSubject(loginUser.getUser().getId().toString()) //Unique or globally unique value within the context of the JWT subject, JWT issuer
.sign(this.algorithm);
The generateToken method generates a token from the credentials, and the setToken method sets the token in the Authorization header.
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);
}
}
We have implemented a new filter that validates and authorizes tokens. Set this filter to be executed before the filter (UsernamePasswordAuthenticationFilter) that authenticates by user name and password.
configuration
.addFilterBefore(tokenFilter(), UsernamePasswordAuthenticationFilter.class)
SimpleTokenFilter
GenericFilterBean tokenFilter() {
return new SimpleTokenFilter(userRepository, secretKey);
}
Get the token from the request header "Authorization" and validate it. In addition to checking whether the token has been tampered with, the verification also checks the set expiration date. If the token is normal, the user ID for searching the user information is obtained from the payload and the user is searched.
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()));
});
}
}
The only settings at logout are the URL and handler. I wanted to invalidate the token issued at the time of logout, but as far as I checked, it seems that the token cannot be invalidated, so the handler only returns the HTTP status.
configuration
.logout()
.logoutUrl("/logout")
//.invalidateHttpSession(true)
//.deleteCookies("JSESSIONID")
.logoutSuccessHandler(logoutSuccessHandler())
When dealing with invalidation on the server side, there seems to be a method of holding the token of the logged out user for a certain period of time (until the token expiration date), and not authorizing if it matches during the authorization process. ..
CSRF
I don't use CSRF, so I disabled it.
configuration
.csrf()
.disable()
//.ignoringAntMatchers("/login")
//.csrfTokenRepository(new CookieCsrfTokenRepository())
SessionManagement
Since session management is not performed, I set it to stateless. With this setting, Spring Security will not use HttpSession.
configuration
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
By changing to JWT, session cookies and CSRF tokens are no longer required. Instead, set the JWT token received after authentication in the request header.
** For a valid account **
If the authentication is successful, the token will be set in the Authorization header as shown in the example below.
> 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
** For invalid accounts **
Wrong email address, wrong password, etc.
> curl -i -X POST "http://localhost:9000/app/login" -d "[email protected]" -d "pass=hogehoge"
HTTP/1.1 401
** If you specify a valid token **
> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/memo/1"
HTTP/1.1 200
** If no token is specified **
> curl -i "http://localhost:9000/app/memo/1"
HTTP/1.1 401
** If the token is tampered with **
> curl -i -H "Authorization: Bearer {invalid_token}" "http://localhost:9000/app/memo/1"
HTTP/1.1 401
** If the token has expired **
> curl -i -H "Authorization: Bearer {invalid_token}" "http://localhost:9000/app/memo/1"
HTTP/1.1 401
** For users with the USER role **
> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/user"
HTTP/1.1 200
** For users with the ADMIN role **
> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/admin"
HTTP/1.1 200
** For users who do not have the ADMIN role **
If the token is successful but the user does not have the required role, an error will occur.
> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/admin"
HTTP/1.1 403
Recommended Posts