====== Spring Simple JWT Authentication ====== - Add dependencies in build.gradle, we use auth0 library here. (compile "com.auth0:java-jwt:3.4.0") - Create our Authentication Token (JwtAuthToken extends AbstractAuthenticationToken) - Create our Authentication Filter (JwtAuthFilter extends AbstractAuthenticationProcessingFilter) - Create our Authentication Provider (JwtAuthenticationProvider implements AuthenticationProvider) - Config WebMvcConfig extends WebSecurityConfigurerAdapter, override ''protected void configure(AuthenticationManagerBuilder auth) throws Exception {...}'', and ''protected void configure(HttpSecurity http) throws Exception {...}'' to add our Authentication Provider , and Authentication Filter in spring authentication chain. ===== Add dependencies in build.gradle ===== Just add ''compile "com.auth0:java-jwt:3.4.0"'' in the dependencies section. There are other library like ''compile group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1''', but in this example we use the one from Auth0. dependencies { ... compile "com.auth0:java-jwt:3.4.0" ===== Create our Authentication Token ===== Here is out token, which store ''username'', the type of token ''isAccessToke'' (Access/Refresh), the token expire date time ''expireDate'', and the ''GrantedAuthority'' in the parent class. public class JwtAuthToken extends AbstractAuthenticationToken { private String username; private Date expireDate; private boolean isAccessToke; public JwtAuthToken(String username, Date expireDate, boolean isAccessToke, Collection authorities) { super(authorities); this.username = username; this.expireDate = expireDate; this.isAccessToke = isAccessToke; } @Override public Object getCredentials() { return null; } @Override public Object getPrincipal() { return this.username; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public Date getExpireDate() { return expireDate; } public void setExpireDate(Date expireDate) { this.expireDate = expireDate; } public boolean isAccessToke() { return isAccessToke; } public void setAccessToke(boolean accessToke) { isAccessToke = accessToke; } } ===== Create our Authentication Filter ===== To make the code look better, we first setup some constants in a class. public class SecurityConstants { public static final String SECRET = "SecretKeyToGenJWTs"; public static final long ACCESS_TOKEN_EXPIRATION_TIME = 900 * 1000; // 15-min public static final long REFRESH_TOKEN_EXPIRATION_TIME = 7200 * 1000; // 2-hrs public static final String TOKEN_PREFIX = "Bearer "; public static final String ACCESS_HEADER_STRING = "token"; public static final String REFRESH_HEADER_STRING = "token-refresh"; public static final String ROLE = "ROLE"; } When a request come, if the URL matches the given pattern, this filter's ''attemptAuthentication(...)'' will be called. It should prepare the authentication token, and call the authentication manager with the token to attempt the authentication. It can return a fully authenticated token if success, null if the filter do not know what to do and let the next filter to figure out, or throws an AuthenticationException (classes that extends it) on error. On successfully authentication, the call-back method ''successfulAuthentication(...){}'' will be called, or if authentication failed, ''unsuccessfulAuthentication(...){}'' will be called if a type of AuthenticationException has been throw. After successfully authenticated, we need to setup the security context holder by putting a security context into it. This part is simple, because the call-back has already provided us the authenticated token. For JWT auth, we need to do it basically on every request. SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication(authResult); SecurityContextHolder.setContext(context); You might also see that we put new access, and refresh token on the response header on receiving JWT refresh token. Normally, the front-end will use the access token to access our site, but it will expire. When it expired, the back-end will give the front-end an 401 error, the front-end should be smart enough to retry the connection with the refresh token. Upon receiving the responses with the access, and refresh tokens, the front-end should update its values (by using interceptor in Angular, for example). ''Full code:'' public class JwtAuthFilter extends AbstractAuthenticationProcessingFilter { public JwtAuthFilter(RequestMatcher matcher, AuthenticationManager authenticationManager) { super(matcher); this.setAuthenticationManager(authenticationManager); } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException { JwtAuthToken jwtAuthToken = null; try { String token = request.getHeader(SecurityConstants.ACCESS_HEADER_STRING); if (token != null && token.startsWith(SecurityConstants.TOKEN_PREFIX)) { jwtAuthToken = getJwtAuthToken(token, true); } else { String refreshToken = request.getHeader(SecurityConstants.REFRESH_HEADER_STRING); if (refreshToken != null && refreshToken.startsWith(SecurityConstants.TOKEN_PREFIX)) { jwtAuthToken = getJwtAuthToken(refreshToken, false); } } if (jwtAuthToken != null) return getAuthenticationManager().authenticate(jwtAuthToken); } catch (Exception e) { throw new BadCredentialsException("Error on jwt attemptAuthentication"); } return null; } private JwtAuthToken getJwtAuthToken(String token, boolean isAccessToken) { JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC512(SecurityConstants.SECRET.getBytes())).build(); DecodedJWT decodedJWT = jwtVerifier.verify(token.replace(SecurityConstants.TOKEN_PREFIX, "")); String user = decodedJWT.getSubject(); Date expireDate = decodedJWT.getExpiresAt(); List grantedAuthorities = new ArrayList<>(); grantedAuthorities.add(new SimpleGrantedAuthority(decodedJWT.getClaim(SecurityConstants.ROLE).asString())); if (user != null) { JwtAuthToken unAuthToken = new JwtAuthToken(user, expireDate, isAccessToken, grantedAuthorities); return unAuthToken; } return null; } @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication(authResult); SecurityContextHolder.setContext(context); JwtAuthToken jwtAuthToken = (JwtAuthToken) authResult; if (!jwtAuthToken.isAccessToke()) { String role = jwtAuthToken.getAuthorities().iterator().next().getAuthority(); String token = JWT.create() .withSubject(jwtAuthToken.getUsername()) .withExpiresAt(new Date(System.currentTimeMillis() + SecurityConstants.ACCESS_TOKEN_EXPIRATION_TIME)) .withClaim(SecurityConstants.ROLE, role) .sign(HMAC512(SecurityConstants.SECRET.getBytes())); String refreshToken = JWT.create() .withSubject(jwtAuthToken.getUsername()) .withExpiresAt(new Date(System.currentTimeMillis() + SecurityConstants.REFRESH_TOKEN_EXPIRATION_TIME)) .withClaim(SecurityConstants.ROLE, role) .sign(HMAC512(SecurityConstants.SECRET.getBytes())); response.addHeader(SecurityConstants.ACCESS_HEADER_STRING, SecurityConstants.TOKEN_PREFIX + token); response.addHeader(SecurityConstants.REFRESH_HEADER_STRING, SecurityConstants.TOKEN_PREFIX + refreshToken); } chain.doFilter(request, response); } } ===== Create our Authentication Provider ===== Here we extends AuthenticationProvider. Tell it we support JwtAuthToken as Authentication token, and do the auth here. In our example, we actually has done the authentication in the filter. If you would like, you can move that part into the public Authentication authenticate(Authentication authentication) throws AuthenticationException {...} method here. The better way to do it is to create two type of tokens. One call RawJwtToken, and one call JwtToken, and put the create the raw one in the filter, and the real one in the authentication provider, and do the token conversion in the authentication provider. We are a bit lazy here without doing it. Full code: @Component public class JwtAuthenticationProvider implements AuthenticationProvider { @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { JwtAuthToken jwtAuthToken = (JwtAuthToken) authentication; Date now = new Date(); if (jwtAuthToken.getExpireDate().before(now)){ throw new CredentialsExpiredException("Token Expired"); } JwtAuthToken authenticatedToken = new JwtAuthToken(jwtAuthToken.getUsername(), jwtAuthToken.getExpireDate(), jwtAuthToken.isAccessToke(), jwtAuthToken.getAuthorities()); authenticatedToken.setAuthenticated(true); return authenticatedToken; } @Override public boolean supports(Class authentication) { return JwtAuthToken.class.isAssignableFrom(authentication); } } ===== Config WebMvcConfig extends WebSecurityConfigurerAdapter ===== We need to config the spring security as usual. The following two lines are the key points for JWT config. For the jwt auth filter, we need to tell the filter which paths need to be authenticated, and which paths need to be skipped. Here, ''/login'', and ''/error'' are skipped, and everything else need to be authenticated. auth.authenticationProvider(jwtAuthenticationProvider); addFilterBefore(new JwtAuthFilter(new SkipPathRequestMatcher(jwtSkipPaths, "/**"), this.authenticationManager()), UsernamePasswordAuthenticationFilter.class) The full config: @Configuration public class WebMvcConfig extends WebSecurityConfigurerAdapter { private UserDetailsServiceImpl userDetailsService; private BCryptPasswordEncoder bCryptPasswordEncoder; @Autowired private JwtAuthenticationProvider jwtAuthenticationProvider; public WebMvcConfig(UserDetailsServiceImpl userDetailsService, BCryptPasswordEncoder bCryptPasswordEncoder) { this.userDetailsService = userDetailsService; this.bCryptPasswordEncoder = bCryptPasswordEncoder; } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder); auth.authenticationProvider(jwtAuthenticationProvider); } @Override protected void configure(HttpSecurity http) throws Exception { List jwtSkipPaths = new ArrayList<>(); jwtSkipPaths.add("/login"); jwtSkipPaths.add("/error"); http.cors().and().csrf().disable().authorizeRequests() .anyRequest().authenticated() .and() .formLogin() .and() .addFilterBefore(new AjaxAuthFilter(this.authenticationManager()), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(new JwtAuthFilter(new SkipPathRequestMatcher(jwtSkipPaths, "/**"), this.authenticationManager()), UsernamePasswordAuthenticationFilter.class) .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); } } ===== Testing ===== - Get the token by using other login method, such as Ajax auth. - Use the access token - Use the refresh token if the access token expired. ==== Get the tokens ==== (base) D:\>curl --noproxy "*" -v http://127.0.0.1:8080/login -d "username=user&password=password" * Trying 127.0.0.1... * TCP_NODELAY set * Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0) > POST /login HTTP/1.1 > Host: 127.0.0.1:8080 > User-Agent: curl/7.61.0 > Accept: */* > Content-Length: 31 > Content-Type: application/x-www-form-urlencoded > * upload completely sent off: 31 out of 31 bytes < HTTP/1.1 200 < Vary: Origin < Vary: Access-Control-Request-Method < Vary: Access-Control-Request-Headers < token: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJST0xFIjoiVVNFUiIsInN1YiI6InVzZXIiLCJleHAiOjE1OTIyNzQ0MDZ9.eD5JKRveeyQAn03EikZurdlGfn35Op--F7Czw6qcN8FvHv80qRUQZoE_JUdBqJrXzrgx2LU54eZZ-2o5S9DGpQ < token-refresh: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJST0xFIjoiVVNFUiIsInN1YiI6InVzZXIiLCJleHAiOjE1OTIyODA3MDZ9.I43CCtTTmjvqakUollzaRf0b5DCHt5LlJ-if_0Ci4JXHWJEQ-L_TyJ1pqZHTotKFXk3OMn3NZnY9AQUuyQTgkQ < 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: Tue, 16 Jun 2020 02:11:47 GMT < * Connection #0 to host 127.0.0.1 left intact ==== Use the access token ==== (base) D:\>curl --noproxy "*" -v http://127.0.0.1:8080/hello -H "token: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJST0xFIjoiVVNFUiIsInN1YiI6InVzZXIiLCJleHAiOjE1OTIyNzQ0ODV9.a-Qb1Put98e9rQw1E_Am0ybJkrH1BYgX4MuiFam8CoLZej_1RWQhWpfmXdp4WnIprW9GFUOPmm3S6BHJwZQc9A" * Trying 127.0.0.1... * TCP_NODELAY set * Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0) > GET /hello HTTP/1.1 > Host: 127.0.0.1:8080 > User-Agent: curl/7.61.0 > Accept: */* > token: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJST0xFIjoiVVNFUiIsInN1YiI6InVzZXIiLCJleHAiOjE1OTIyNzQ0ODV9.a-Qb1Put98e9rQw1E_Am0ybJkrH1BYgX4MuiFam8CoLZej_1RWQhWpfmXdp4WnIprW9GFUOPmm3S6BHJwZQc9A > < HTTP/1.1 200 < Vary: Origin < Vary: Access-Control-Request-Method < Vary: Access-Control-Request-Headers < 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-Type: application/json < Transfer-Encoding: chunked < Date: Tue, 16 Jun 2020 02:13:33 GMT < {"response":"Hello"}* Connection #0 to host 127.0.0.1 left intact ==== Use the refresh token ==== (base) D:\>curl --noproxy "*" -v http://127.0.0.1:8080/hello -H "token-refresh: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJST0xFIjoiVVNFUiIsInN1YiI6InVzZXIiLCJleHAiOjE1OTIyODA3ODV9.uGdHsQVLDJaakJLru6wAIVw74ymYaULnVtTerfg_FSquJZoY0-71tkcy4gYGogoR6CUM5K1IxrZ-Jjme0KZ36Q" * Trying 127.0.0.1... * TCP_NODELAY set * Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0) > GET /hello HTTP/1.1 > Host: 127.0.0.1:8080 > User-Agent: curl/7.61.0 > Accept: */* > token-refresh: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJST0xFIjoiVVNFUiIsInN1YiI6InVzZXIiLCJleHAiOjE1OTIyODA3ODV9.uGdHsQVLDJaakJLru6wAIVw74ymYaULnVtTerfg_FSquJZoY0-71tkcy4gYGogoR6CUM5K1IxrZ-Jjme0KZ36Q > < HTTP/1.1 200 < Vary: Origin < Vary: Access-Control-Request-Method < Vary: Access-Control-Request-Headers < token: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJST0xFIjoiVVNFUiIsInN1YiI6InVzZXIiLCJleHAiOjE1OTIyNzQ1ODd9._laxbwGZkLl5RE4EZ1eJOs8d0dB3ci0uhU1aL8-2sSL6fdQdMFvk4qAvwfSlrhdQXG3TTMoW74K7d8AsJY35GA < token-refresh: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJST0xFIjoiVVNFUiIsInN1YiI6InVzZXIiLCJleHAiOjE1OTIyODA4ODd9.BcF13z9OZ1O1VnzXuJi3M95MfFGcczP2n4n7sjtek1H0EaU5ifowODeqXAQhoeVG4MtANzsNm9WW9z0_CS6fwA < 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-Type: application/json < Transfer-Encoding: chunked < Date: Tue, 16 Jun 2020 02:14:47 GMT < {"response":"Hello"}* Connection #0 to host 127.0.0.1 left intact