Authentication is one of the most important parts of any web application. For many years, the solution was cookies. However, handling authentication with multiple sources at once can be difficult. The best-known solutions to the authentication problem for APIs are OAuth2.0 and JWT or JSON Web Token.
What is a JSON Web Token?
Before we present the solution, let’s explain what exactly JWT is. JWT is an open standard (RFC 7519) that defines a compact and self-contained way for securely transferring information between pages of a JSON object. Due to its relatively small size, a JWT token can be sent via a URL, POST parameter, or inside an HTTP header. The JWT contains all the required information about the entity to avoid sending multiple requests to the database. The recipient of the JWT token also does not need to call the server to validate the token.
What can we use the JWT Token for
- Authentication: when a user successfully logs in with their credentials, the ID token is returned;
- Authorization: when a user successfully logs in, the application can request access to services or resources (e.g., APIs) on behalf of that user. To do this, it must pass an access token in each request, which may take the form of a JWT token. Single sign-on – SSO – uses JWTs extensively because of the low format overhead and the ability to be easily used across domains.
- Information exchange: JWT is a good way to securely transfer information between parties because we can sign them, which means we can be sure that the senders are who they say they are. In addition, the structure of a JWT token makes it possible to verify that the contents have not been tampered with.
Authentication with JWTs
It is time to configure Spring Security for stateless authentication using the JWT token. To customize Spring Security, we need a configuration class annotated with @EnableWebSecurity. Also, to simplify the customization process, the platform provides a WebSecurityConfigurerAdapter class. We will extend this adapter and override its two functions to:
1. configure an authentication manager with the appropriate provider.
2. Configure security for the appropriate addresses (public, private, authorization, e.t.c.).
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
}
@Override
protected void configure(HttpSecurity http) throws Exception {
}
}
In our sample application, we store user identities in the MongoDB database, in the users’ collection. These identities are mapped by the User entity. In turn, CRUD operations – are defined by UserRepo.
Now we accept the authentication request. We retrieve the correct identity from the database using the provided credentials. We verify it. To do this, we need an implementation of the UserDetailsService interface, defined as follows:
public interface UserDetailsService {
UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException;
}
Note that an object must be returned, implemented by the UserDetails interface. Our User entity implements it. We can find implementation details in the sample project repository.
Having configured the authentication manager, we now need to configure network security. We are implementing a REST API. We need stateless authentication using a JWT token. So we need to set the following options:
- enable CORS and disable CSRF;
- session management to stateless;
- exception handling for unauthorized requests;
- endpoint permissions;
- add JWT token filter.
An example implementation of the WebSecurityConfigurerAdapter interface looks like this:
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(username -> userRepo
.findByEmail(username)
.orElseThrow(
() -> new UsernameNotFoundException(
format("User: %s, not found", username)
)
)).passwordEncoder(new BCryptPasswordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http = http.cors().and().csrf().disable();
http = http
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and();
http = http
.exceptionHandling()
.authenticationEntryPoint(
(request, response, ex) -> {
logger.error("Unauthorized request - {}", ex.getMessage());
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, ex.getMessage());
}
)
.and();
http.authorizeRequests()
.antMatchers("/").permitAll()
.antMatchers(format("%s/**", restApiDocPath)).permitAll()
.antMatchers(format("%s/**", swaggerPath)).permitAll()
.antMatchers("/api/auth/**").permitAll()
.anyRequest().permitAll();
http.addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
}
Before implementing the login API function, we need to take care of one more step. We need access to the authentication manager. By default, it is not publicly available. We need to explicitly represent it as a bean in our SecurityConfig configuration class.
Why token refresh is needed
When a user successfully logs in, we send back an access token. Let’s say it has a validity of 15 minutes. During this time, it can be used by the user for authentication when sending various requests to our API.
Once the time expires and the token expires, the user must log in again with their username and password. Unfortunately, this does not provide the best user experience. On the other hand, increasing the expiration time of our access token can make our API less secure.
Refresh tokens can be a solution to the above problem. The basic idea is that we create two separate JWT tokens after a successful login. One is an access token that is valid for 15 minutes. The other is, for example, a refresh token, which expires every week.
How the refresh token works
The user saves both tokens. However, it only uses the access token for authentication when making requests. When the API determines that the access token expires, the user must perform a refresh.
To refresh the token, the user must call a separate endpoint. It is /refresh. If it is valid and has not expired, the user receives a new access token. This eliminates the need to re-enter the username and password.
Enabling login on multiple devices
Let’s assume, we want to provide an API that a user wants to access from multiple devices at once – phone, tablet, laptop.
We can solve this problem by storing a set of refresh tokens in the database (one refresh token per login). So that each device will not automatically log off when the next one logs in.
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
@Value("${devopsijwt.app.jwtRefreshExpirationMs}")
private Long refreshTokenDurationMs;
private final RefreshTokenRepository refreshTokenRepository;
private final UserRepo userRepository;
private final JwtTokenUtil jwtTokenUtil;
public Optional<RefreshToken> findByToken(String token) {
return refreshTokenRepository.findByToken(token);
}
@Transactional
public RefreshToken createRefreshToken(ObjectId userId) {
return userRepository.findById(userId).map
(user -> refreshTokenRepository.save(RefreshToken.builder()
.expiryDate(Instant.now().plusMillis(refreshTokenDurationMs))
.userId(user.getId())
.token(UUID.randomUUID().toString())
.build())
)
.orElseThrow(() -> new NotFoundException("User not found with id " + userId.toHexString()));
}
public RefreshToken verifyExpiration(RefreshToken token) {
if (token.getExpiryDate().compareTo(Instant.now()) < 0) {
refreshTokenRepository.delete(token);
throw new TokenRefreshException(token.getToken(), "Refresh token was expired. Please make a new signin request");
}
return token;
}
}
And now, we are ready to implement our login feature:
@RestController
@RequestMapping(path = "/api/auth")
@RequiredArgsConstructor
public class AuthApiController {
private final AuthenticationManager authenticationManager;
private final JwtTokenUtil jwtTokenUtil;
private final UserService userService;
private final RefreshTokenService refreshTokenService;
@PostMapping("/sign-in")
public ResponseEntity<JwtResponse> login(@RequestBody @Valid AuthRequest request) {
try {
Authentication authenticate = authenticationManager
.authenticate(new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword()));
User userDetails = (User) authenticate.getPrincipal();
RefreshToken refreshToken = refreshTokenService.createRefreshToken(userDetails.getId());
return ResponseEntity.ok()
.body(new JwtResponse(jwtTokenUtil.generateAccessToken(userDetails), refreshToken.getToken()));
} catch (BadCredentialsException ex) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
}
Here we verify the provided credentials using the authentication manager. If successful, we generate a JSON Web Token. Then, we return it as a response header along with the user identity information in the body of the answer, and the refresh token.
In this article, I’ve tried to demonstrate the configuration details. I hope someone finds them useful. You can find full code examples in the Git DevOpsi repository https://github.com/devopsipl/jwt_example.