原文地址:http://www.svlada.com/jwt-token-authentication-with-spring-boot/
Table of contents:
Introduction
This article will guide you on how you can implement JWT authentication with Spring Boot.
We will cover the following two scenarios:
- Ajax Authentication
- JWT Token Authentication
PRE-requisites
Please check out the sample code/project from the following GitHub repository: https://github.com/svlada/springboot-security-jwt before going further reading the article.
This project is using H2 in-memory database to store sample user information. To make things easier I have created data fixtures and configured Spring Boot to automatically load them on the application startup (/jwt-demo/src/main/resources/data.sql
).
Overall project structure is shown below:
+---main
| +---java
| | ---com
| | ---svlada
| | +---common
| | +---entity
| | +---profile
| | | ---endpoint
| | +---security
| | | +---auth
| | | | +---ajax
| | | | ---jwt
| | | | +---extractor
| | | | ---verifier
| | | +---config
| | | +---endpoint
| | | +---exceptions
| | | ---model
| | | ---token
| | ---user
| | +---repository
| | ---service
| ---resources
| +---static
| ---templates
Ajax authentication
When we talk about Ajax authentication we usually refer to process where user is supplying credentials through JSON payload that is sent as a part of XMLHttpRequest.
In the first part of this tutorial Ajax authentication is implemented by following standard patterns found in the Spring Security framework.
Following is the list of components that we'll implement:
AjaxLoginProcessingFilter
AjaxAuthenticationProvider
AjaxAwareAuthenticationSuccessHandler
AjaxAwareAuthenticationFailureHandler
RestAuthenticationEntryPoint
WebSecurityConfig
Before we get to the details of the implementation, let's look at the request/response authentication flow.
Ajax authentication request example
The Authentication API allows user to pass in credentials in order to receive authentication token.
In our example, client initiates authentication process by invoking Authentication API endpoint (/api/auth/login
).
Raw HTTP request:
POST /api/auth/login HTTP/1.1
Host: localhost:9966
X-Requested-With: XMLHttpRequest
Content-Type: application/json
Cache-Control: no-cache
{
"username": "svlada@gmail.com",
"password": "test1234"
}
CURL:
curl -X POST -H "X-Requested-With: XMLHttpRequest" -H "Content-Type: application/json" -H "Cache-Control: no-cache" -d '{
"username": "svlada@gmail.com",
"password": "test1234"
}' "http://localhost:9966/api/auth/login"
Ajax authentication response example
If client supplied credentials are valid, Authentication API will respond with the HTTP response including the following details:
- HTTP status "200 OK"
- Signed JWT Access and Refresh tokens are included in the response body
JWT Access token - used to authenticate against protected API resources. It must be set in X-Authorization
header.
JWT Refresh token - used to acquire new Access Token. Token refresh is handled by the following API endpoint: /api/auth/token
.
Raw HTTP Response:
{
"token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJzdmxhZGFAZ21haWwuY29tIiwic2NvcGVzIjpbIlJPTEVfQURNSU4iLCJST0xFX1BSRU1JVU1fTUVNQkVSIl0sImlzcyI6Imh0dHA6Ly9zdmxhZGEuY29tIiwiaWF0IjoxNDcyMDMzMzA4LCJleHAiOjE0NzIwMzQyMDh9.41rxtplFRw55ffqcw1Fhy2pnxggssdWUU8CDOherC0Kw4sgt3-rw_mPSWSgQgsR0NLndFcMPh7LSQt5mkYqROQ",
"refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJzdmxhZGFAZ21haWwuY29tIiwic2NvcGVzIjpbIlJPTEVfUkVGUkVTSF9UT0tFTiJdLCJpc3MiOiJodHRwOi8vc3ZsYWRhLmNvbSIsImp0aSI6IjkwYWZlNzhjLTFkMmUtNDg2OS1hNzdlLTFkNzU0YjYwZTBjZSIsImlhdCI6MTQ3MjAzMzMwOCwiZXhwIjoxNDcyMDM2OTA4fQ.SEEG60YRznBB2O7Gn_5X6YbRmyB3ml4hnpSOxqkwQUFtqA6MZo7_n2Am2QhTJBJA1Ygv74F2IxiLv0urxGLQjg"
}
JWT Access Token
JWT Access token can be used for authentication and authorization:
- Authentication is performed by verifying JWT Access Token signature. If signature proves to be valid, access to requested API resource is granted.
- Authorization is done by looking up privileges in the scope attribute of JWT Access token.
Decoded JWT Access token has three parts: Header, Claims and Signature as shown below:
Header
{
"alg": "HS512"
}
Claims
{
"sub": "svlada@gmail.com",
"scopes": [
"ROLE_ADMIN",
"ROLE_PREMIUM_MEMBER"
],
"iss": "http://svlada.com",
"iat": 1472033308,
"exp": 1472034208
}
Signature (base64 encoded)
41rxtplFRw55ffqcw1Fhy2pnxggssdWUU8CDOherC0Kw4sgt3-rw_mPSWSgQgsR0NLndFcMPh7LSQt5mkYqROQ
JWT Refresh Token
Refresh token is long-lived token used to request new Access tokens. It's expiration time is greater than expiration time of Access token.
In this tutorial we'll use jti
claim to maintain list of blacklisted or revoked tokens. JWT ID(jti
) claim is defined by RFC7519 with purpose to uniquely identify individual Refresh token.
Decoded Refresh token has three parts: Header, Claims and Signature as shown below:
Header
{
"alg": "HS512"
}
Claims
{
"sub": "svlada@gmail.com",
"scopes": [
"ROLE_REFRESH_TOKEN"
],
"iss": "http://svlada.com",
"jti": "90afe78c-1d2e-4869-a77e-1d754b60e0ce",
"iat": 1472033308,
"exp": 1472036908
}
Signature (base64 encoded)
SEEG60YRznBB2O7Gn_5X6YbRmyB3ml4hnpSOxqkwQUFtqA6MZo7_n2Am2QhTJBJA1Ygv74F2IxiLv0urxGLQjg
AjaxLoginProcessingFilter
First step is to extend AbstractAuthenticationProcessingFilter
in order to provide custom processing of Ajax authentication requests.
De-serialization and basic validation of the incoming JSON payload is done in the AjaxLoginProcessingFilter#attemptAuthentication
method. Upon successful validation of the JSON payload authentication logic is delegated to AjaxAuthenticationProvider class.
In case of a successful authentication AjaxLoginProcessingFilter#successfulAuthentication
method is invoked.
In case of failure authentication AjaxLoginProcessingFilter#unsuccessfulAuthentication
method is invoked.
public class AjaxLoginProcessingFilter extends AbstractAuthenticationProcessingFilter {
private static Logger logger = LoggerFactory.getLogger(AjaxLoginProcessingFilter.class);
private final AuthenticationSuccessHandler successHandler;
private final AuthenticationFailureHandler failureHandler;
private final ObjectMapper objectMapper;
public AjaxLoginProcessingFilter(String defaultProcessUrl, AuthenticationSuccessHandler successHandler,
AuthenticationFailureHandler failureHandler, ObjectMapper mapper) {
super(defaultProcessUrl);
this.successHandler = successHandler;
this.failureHandler = failureHandler;
this.objectMapper = mapper;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException, IOException, ServletException {
if (!HttpMethod.POST.name().equals(request.getMethod()) || !WebUtil.isAjax(request)) {
if(logger.isDebugEnabled()) {
logger.debug("Authentication method not supported. Request method: " + request.getMethod());
}
throw new AuthMethodNotSupportedException("Authentication method not supported");
}
LoginRequest loginRequest = objectMapper.readValue(request.getReader(), LoginRequest.class);
if (StringUtils.isBlank(loginRequest.getUsername()) || StringUtils.isBlank(loginRequest.getPassword())) {
throw new AuthenticationServiceException("Username or Password not provided");
}
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword());
return this.getAuthenticationManager().authenticate(token);
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
successHandler.onAuthenticationSuccess(request, response, authResult);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed) throws IOException, ServletException {
SecurityContextHolder.clearContext();
failureHandler.onAuthenticationFailure(request, response, failed);
}
}
AjaxAuthenticationProvider
Responsibility of the AjaxAuthenticationProvider class is to:
- Verify user credentials against database, LDAP or some other system which holds the user data
- If
username
andpassword
do not match the record in the database authentication exception is thrown - Create UserContext and populate it with user data you need (in our case just
username
anduser privileges
) - Upon successful authentication delegate creation of JWT Token to
AjaxAwareAuthenticationSuccessHandler
@Component
public class AjaxAuthenticationProvider implements AuthenticationProvider {
private final BCryptPasswordEncoder encoder;
private final DatabaseUserService userService;
@Autowired
public AjaxAuthenticationProvider(final DatabaseUserService userService, final BCryptPasswordEncoder encoder) {
this.userService = userService;
this.encoder = encoder;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.notNull(authentication, "No authentication data provided");
String username = (String) authentication.getPrincipal();
String password = (String) authentication.getCredentials();
User user = userService.getByUsername(username).orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
if (!encoder.matches(password, user.getPassword())) {
throw new BadCredentialsException("Authentication Failed. Username or Password not valid.");
}
if (user.getRoles() == null) throw new InsufficientAuthenticationException("User has no roles assigned");
List<GrantedAuthority> authorities = user.getRoles().stream()
.map(authority -> new SimpleGrantedAuthority(authority.getRole().authority()))
.collect(Collectors.toList());
UserContext userContext = UserContext.create(user.getUsername(), authorities);
return new UsernamePasswordAuthenticationToken(userContext, null, userContext.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}
}
AjaxAwareAuthenticationSuccessHandler
We'll implement AuthenticationSuccessHandler interface that is called when client has been successfully authenticated.
AjaxAwareAuthenticationSuccessHandler class is our custom implementation of AuthenticationSuccessHandler interface. Responsibility of this class is to add JSON payload containing JWT Access and Refresh tokens into the HTTP response body.
@Component
public class AjaxAwareAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final ObjectMapper mapper;
private final JwtTokenFactory tokenFactory;
@Autowired
public AjaxAwareAuthenticationSuccessHandler(final ObjectMapper mapper, final JwtTokenFactory tokenFactory) {
this.mapper = mapper;
this.tokenFactory = tokenFactory;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
UserContext userContext = (UserContext) authentication.getPrincipal();
JwtToken accessToken = tokenFactory.createAccessJwtToken(userContext);
JwtToken refreshToken = tokenFactory.createRefreshToken(userContext);
Map<String, String> tokenMap = new HashMap<String, String>();
tokenMap.put("token", accessToken.getToken());
tokenMap.put("refreshToken", refreshToken.getToken());
response.setStatus(HttpStatus.OK.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
mapper.writeValue(response.getWriter(), tokenMap);
clearAuthenticationAttributes(request);
}
/**
* Removes temporary authentication-related data which may have been stored
* in the session during the authentication process..
*
*/
protected final void clearAuthenticationAttributes(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return;
}
session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
}
}
Let's focus for a moment on how JWT Access token is created. In this tutorial we are using Java JWT library created by Stormpath.
Make sure that JJWT
dependency is included in your pom.xml
.
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>${jjwt.version}</version>
</dependency>
We have created factory class (JwtTokenFactory
) to decouple token creation logic.
Method JwtTokenFactory#createAccessJwtToken
creates signed JWT Access token.
Method JwtTokenFactory#createRefreshToken
creates signed JWT Refresh token.
@Component
public class JwtTokenFactory {
private final JwtSettings settings;
@Autowired
public JwtTokenFactory(JwtSettings settings) {
this.settings = settings;
}
/**
* Factory method for issuing new JWT Tokens.
*
* @param username
* @param roles
* @return
*/
public AccessJwtToken createAccessJwtToken(UserContext userContext) {
if (StringUtils.isBlank(userContext.getUsername()))
throw new IllegalArgumentException("Cannot create JWT Token without username");
if (userContext.getAuthorities() == null || userContext.getAuthorities().isEmpty())
throw new IllegalArgumentException("User doesn't have any privileges");
Claims claims = Jwts.claims().setSubject(userContext.getUsername());
claims.put("scopes", userContext.getAuthorities().stream().map(s -> s.toString()).collect(Collectors.toList()));
DateTime currentTime = new DateTime();
String token = Jwts.builder()
.setClaims(claims)
.setIssuer(settings.getTokenIssuer())
.setIssuedAt(currentTime.toDate())
.setExpiration(currentTime.plusMinutes(settings.getTokenExpirationTime()).toDate())
.signWith(SignatureAlgorithm.HS512, settings.getTokenSigningKey())
.compact();
return new AccessJwtToken(token, claims);
}
public JwtToken createRefreshToken(UserContext userContext) {
if (StringUtils.isBlank(userContext.getUsername())) {
throw new IllegalArgumentException("Cannot create JWT Token without username");
}
DateTime currentTime = new DateTime();
Claims claims = Jwts.claims().setSubject(userContext.getUsername());
claims.put("scopes", Arrays.asList(Scopes.REFRESH_TOKEN.authority()));
String token = Jwts.builder