I am learning how to use JWT to build a Stateless backend API, This is a dummy learning project. I am using Spring Boot's OAuth 2 resource server.
Since I just have a single backend, there is no need for public keys, I just have one secret key generated with openssl and appended to the application.properties file.
The documentation has not been good to say the least and it has taken a long time to figure out basic things.
I have managed to successfully log the users through a basic UsernamePasswordAuthentication and then issued a token that for testing purposes should expire in 2 minutes.
However, it is still valid many times for up to 10 minutes after the expiry, Why is this ?
Here is the code:
@Configuration
@EnableWebSecurity(debug = true)
@EnableMethodSecurity
public class SecurityConfig {
@Autowired
private MuserDetailsService muserDetailsService;
private JwtPropHolder jwtPropHolder;
public SecurityConfig(JwtPropHolder jwtPropHolder) {
this.jwtPropHolder = jwtPropHolder;
}
@Bean
public SecurityFilterChain createSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(configurer -> {
configurer.jwt(jwtConfigurer -> {
jwtConfigurer.jwtAuthenticationConverter(getMyJwtAuthenticationConverter());
});
})
.build();
}
@Bean
public PasswordEncoder createPasswordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
@Bean
public JwtDecoder createJwtDecoder() {
NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withSecretKey(jwtPropHolder.getSecretKey()).build();
OAuth2TokenValidator<Jwt> withClockSkwe = new DelegatingOAuth2TokenValidator<>(
new JwtTimestampValidator(Duration.ofSeconds(0))
);
jwtDecoder.setJwtValidator(withClockSkwe);
return jwtDecoder;
}
@Bean
public AuthenticationManager getAuthenticationManager(JwtDecoder jwtDecoder) {
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setPasswordEncoder(NoOpPasswordEncoder.getInstance());
daoAuthenticationProvider.setUserDetailsService(muserDetailsService);
JwtAuthenticationProvider jwtAuthenticationProvider = new JwtAuthenticationProvider(createJwtDecoder());
return new ProviderManager(
daoAuthenticationProvider,
jwtAuthenticationProvider
);
}
@Bean
public Converter<Jwt, AbstractAuthenticationToken> getMyJwtAuthenticationConverter() {
return new MyJwtConverter();
}
@Bean
public RoleHierarchy roleHierarchy() {
RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
roleHierarchy.setHierarchy("ROLE_MYTHICAL_USER > ROLE_GRANDPARENT > ROLE_PARENT > ROLE_CHILD");
return roleHierarchy;
}
@Bean
public MethodSecurityExpressionHandler getMethodSecurityExpressionHandler() {
DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
expressionHandler.setRoleHierarchy(roleHierarchy());
return expressionHandler;
}
public JwtAuthenticationConverter oldJwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
//This line throws the class cast exception but then
grantedAuthoritiesConverter.setAuthoritiesClaimName("authorities");
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return jwtAuthenticationConverter;
}
}
@Configuration
@Getter
@Setter
@ConfigurationProperties(prefix = "jwt")
public class JwtPropHolder {
private String key;
private String algorithm;
private Integer randomByteSize;
public SecretKey getSecretKey() {
return new SecretKeySpec(key.getBytes(), algorithm);
}
}
The class that generates the token:
@Service
public class JwsService {
@Autowired
private JwtPropHolder jwtPropHolder;
private JWSObject jwsObject;
private String jwsSerialised;
@Autowired
private ObjectMapper objectMapper;
public void createJws(Map<String, Object> claims) throws JOSEException {
jwsObject = new JWSObject(
new JWSHeader(JWSAlgorithm.parse(jwtPropHolder.getAlgorithm())),
new Payload(claims)
);
JWSSigner signer = new MACSigner(jwtPropHolder.getSecretKey());
jwsObject.sign(signer);
jwsSerialised = jwsObject.serialize();
}
public void createJwsFromClaimsSet(Map<String, Object> claims) throws JOSEException {
JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder();
if (claims.containsKey("sub")){
builder.claim("sub", claims.get("sub"));
}
if (claims.containsKey("iss")){
builder.claim("iss", claims.get("iss"));
}
if (claims.containsKey("exp")){
builder.claim("exp", claims.get("exp"));
}
if (claims.containsKey("iat")) {
builder.claim("iat", claims.get("iat"));
}
builder.claim("authorities", claims.get("authorities"));
}
/**
* Returns the string token
* @return
*/
public String getJwsSerialised() {
return jwsSerialised;
}
/**
* Returns the whole JWSObject
* @return
*/
public JWSObject getJwsObject() {
return jwsObject;
}
}
The class that does the login and returns the token:
@RestController
@Slf4j
public class AuthenticationApi {
private AuthenticationManager authenticationManager;
private JwsService jwsService;
public AuthenticationApi(AuthenticationManager authenticationManager, JwsService jwsService) {
this.authenticationManager = authenticationManager;
this.jwsService = jwsService;
}
@PostMapping("/login")
public LoginResponse login(@RequestBody LoginRequest loginRequest) throws JOSEException {
Authentication authentication = UsernamePasswordAuthenticationToken.unauthenticated(
loginRequest.username(),
loginRequest.password()
);
Authentication authenticated = authenticationManager.authenticate(authentication);
/**
* I can't just pass a date,
* And all time related things need to be in numeric seconds format as per JWT spec,
* So I am converting LocalDateTime and then working with it as its more accurate,
* And does not have that one hour discrepency
* First I will create the LocalDateTimes, so I can send these back to the client,
* Then I will create the seconds version and send it in the JWT:
*/
LocalDateTime now = LocalDateTime.now();
LocalDateTime expiry = LocalDateTime.now().plus(2, ChronoUnit.MINUTES);
Long nowInSeconds = now.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
Long expiryInSeconds = expiry.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
Map<String, Object> claims = new HashMap<>();
claims.put("authorities", authenticated.getAuthorities());
claims.put("name", authenticated.getName());
claims.put("exp", expiryInSeconds);
claims.put("iat", nowInSeconds);
jwsService.createJws(claims);
return new LoginResponse(
jwsService.getJwsSerialised(),
jwsService.getJwsObject(),
authenticated,
now,
expiry
);
}
}
Since i am using LocalDateTime, i am not getting the one hour difference, but it just does not expire ? I looked through the source code and I can't seem to find any code that validates the expiry date
Using ZonedDateTime / OffsetDateTime instead of LocalDateTime or instant solves the problem. The Instant class goes one hour behind and does not take into account the daylight saving time in Europe/London from where I am doing this.
This should not be taken into account when working with timestamps, but for some reason it was not working when i was creating timestamps out of the Instant class. But it works with ZonedDateTime and OffsetDateTime
@PostMapping("/login")
public LoginResponse login(@RequestBody LoginRequest loginRequest) throws JOSEException {
Authentication authentication = UsernamePasswordAuthenticationToken.unauthenticated(
loginRequest.username(),
loginRequest.password()
);
Authentication authenticated = authenticationManager.authenticate(authentication);
OffsetDateTime now = OffsetDateTime.now();
OffsetDateTime expiry = OffsetDateTime.now().plus(2, ChronoUnit.MINUTES);
Long nowInSeconds = now.toEpochSecond();
Long expiryInSeconds = expiry.toEpochSecond();
Map<String, Object> claims = new HashMap<>();
claims.put("authorities", authenticated.getAuthorities());
claims.put("sub", authenticated.getName());
claims.put("exp", expiryInSeconds);
claims.put("iat", nowInSeconds);
jwsService.createJws(claims);
return new LoginResponse(
jwsService.getJwsSerialised(),
jwsService.getJwsObject(),
authenticated,
now,
expiry
);
}