Jannarong Wadthong
Posted on April 15, 2020
Since version 5.2, Spring has introduced a new library, OAuth 2.0 Resource Sever, handling JWT so that we no longer need to manually add a Filter to extract claims from JWT token and verify the token.
What is a Resource server?
Resource server provides protected resources. It communicates with its Authorization server to validate a request to access a protected resource. Typically the endpoints of a resource server are protected based on the Oauth2 scopes and user roles.
Please refer to this for more details.
Example Token
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOi8vbXkubWljcm9zZXJ2aWNlLmNvbS8iLCJzdWIiOiJzdWJqZWN0Iiwic2NvcGUiOlsicmVhZCJdLCJleHAiOjQ3NDA1NDczODcsImp0aSI6ImM4YWEyZjc3LTY2NjYtNDdmNy1iNTZlLTQyNGUxYzFlMThjYiIsImlhdCI6MTU4Njk0NzM4N30.PmHnPwf7M1vTPxskfzPgVvJhhJ9azLgoqTA6C4EsTSU
When you decode it from jwt.io, you find that the JWT structure consists of 3 parts: Header, Payload, Signature.
Header
It usually contains two fields:
- A type of token, type, JWT
- The signing algorithm, alg, HS256
{
"typ": "JWT",
"alg": "HS256"
}
Payload
The payload contains a set of claims. e.g. iss (issuer), exp (expiration time), sub (subject)
{
"iss": "http://my.microservice.com/",
"sub": "subject",
"scope": [
"read"
],
"exp": 4740547387,
"jti": "c8aa2f77-6666-47f7-b56e-424e1c1e18cb",
"iat": 1586947387
}
And the claim that is going be used to authorise our endpoints is scope: read.
According to this, Spring OAuth 2 Resource Server, by default, looks for the clam names: scope and scp, as they are well-known claims for authorisation. If you are going use a custom claim name, you can see the example at the end of this post.
Example Project
We're going to use Spring Initializr to generate Spring Boot project from scratch.
Here is the dependencies inside build.gradle file:
plugins {
id 'org.springframework.boot' version '2.2.6.RELEASE'
id 'io.spring.dependency-management' version '1.0.9.RELEASE'
id 'java'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
}
}
test {
useJUnitPlatform()
}
As you can see, we use Spring Boot version 2.2.6.RELEASE. The spring-boot-starter-oauth2-resource-server includes spring-security-oauth2-jose version 5.2.5.RELEASE containing nimbus-jose-jwt library to support JWT decoding.
Controller
We have created 2 endpoints:
- "/" endpoint - accepts HTTP GET method and expects HTTP Header with 'Authorization: Bearer (JWT Token)'
- "/message" endpoints accepts 2 HTTP methods: GET and POST
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class Controller {
@GetMapping("/")
public String index(@AuthenticationPrincipal Jwt jwt) {
return String.format("Hello, %s!", jwt.getSubject());
}
@GetMapping("/message")
public String message() {
return "secret message";
}
@PostMapping("/message")
public String createMessage(@RequestBody String message) {
return String.format("Message was created. Content: %s", message);
}
}
Configuration
-
We define the security rules to the /message endpoint. The message endpoint will check if
- the request has the authority read for GET method
- the request has the authority write for POST method
We also tell Spring that we are going use OAuth2 Resource Sever with JSON Web Token (JWT).
-
We disable
- Session Management – this will prevent the creation of session cookies
- HTTP Basic Authentication
- Default Spring login page
- CSRF .
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer;
@EnableWebSecurity
public class OAuth2ResourceServerSecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.formLogin(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.authorizeRequests(authorize -> authorize
.mvcMatchers(HttpMethod.GET, "/messages/**").hasAuthority("SCOPE_read")
.mvcMatchers(HttpMethod.POST, "/messages/**").hasAuthority("SCOPE_write")
.anyRequest().authenticated()
)
.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
.sessionManagement(sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
;
}
}
Note that this configuration file is expressed as DSL. Unlike the traditional approach with builder chaining, we can use Java 8 lamda to express the configurations.
application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: https://login.domain.com/xxx/keys # JSON Web Key URI to use to verify the JWT token.
Expected Results
You’ll get HTTP 403 message when you call secured endpoint with an invalid claims. For example, you sent a token with read scope but the endpoints expect write scope.
You’ll get HTTP 401 message when the JWT authorisation fails. For example,
- the token is not recognised by the issuer.
- the token has expired.
- the token is invalid structure.
- etc.
Testing
Request message endpoint using HTTP GET. The token contains read scope
GET http://localhost:8080/message
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOi8vbXkubWljcm9zZXJ2aWNlLmNvbS8iLCJzdWIiOiJzdWJqZWN0Iiwic2NvcGUiOlsicmVhZCJdLCJleHAiOjQ3NDA1NDczODcsImp0aSI6ImM4YWEyZjc3LTY2NjYtNDdmNy1iNTZlLTQyNGUxYzFlMThjYiIsImlhdCI6MTU4Njk0NzM4N30.PmHnPwf7M1vTPxskfzPgVvJhhJ9azLgoqTA6C4EsTSU
HTTP/1.1 200
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: text/plain;charset=UTF-8
Content-Length: 33
Date: Wed, 15 Apr 2020 16:12:03 GMT
secret message
Response code: 200; Time: 1261ms; Content length: 337bytes
Request message endpoint using HTTP POST. The token contains read scope. But it expects write scope.
POST http://localhost:8080/message
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOi8vbXkubWljcm9zZXJ2aWNlLmNvbS8iLCJzdWIiOiJzdWJqZWN0Iiwic2NvcGUiOlsicmVhZCJdLCJleHAiOjQ3NDA1NDczODcsImp0aSI6ImM4YWEyZjc3LTY2NjYtNDdmNy1iNTZlLTQyNGUxYzFlMThjYiIsImlhdCI6MTU4Njk0NzM4N30.PmHnPwf7M1vTPxskfzPgVvJhhJ9azLgoqTA6C4EsTSU
HTTP/1.1 403
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;charset=UTF-8
Transfer-Encoding: chunked
Date: Wed, 15 Apr 2020 20:12:00 GMT
{
"timestamp": "2019-04-15T12:27:25.020+0000",
"status": 403,
"error": "Forbidden",
"message": "Access Denied",
"path": "/message"
}
Response code: 403; Time: 28ms; Content length: 125 byte
Custom claim
What if our JWT does not contain the well-known claims(scope, scp) for authorisation?
We'll use claim name: roles as example.
Token with claim: roles
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOi8vbXkubWljcm9zZXJ2aWNlLmNvbS8iLCJzdWIiOiJzdWJqZWN0Iiwicm9sZXMiOlsic3R1ZGVudCJdLCJleHAiOjQ3NDA1NzAxMjMsImp0aSI6IjM3OWVhNzYxLTNlNTAtNDM2Mi04ZTEyLWQwNzIzNDZhN2JlMSIsImlhdCI6MTU4Njk3MDEyM30.qJCgYrMb17D6Y6MWKrpsaTLBmWKZXvc4wTGsZg2YGGY
Payload
{
"iss": "http://my.microservice.com/",
"sub": "subject",
"roles": [
"student"
],
"exp": 4740570123,
"jti": "379ea761-3e50-4362-8e12-d072346a7be1",
"iat": 1586970123
}
This section is going to illustrate on how to modify a the Default JWT Converter.
We'll modify our existing configuration file as follows:
package com.example.resourcesever;
import org.springframework.core.convert.converter.Converter;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import java.util.Collection;
@EnableWebSecurity
public class OAuth2ResourceServerSecurityCustomConfiguration extends WebSecurityConfigurerAdapter {
private static final String AUTHORITY_PREFIX = "ROLE_";
private static final String CLAIM_ROLES = "roles";
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.formLogin(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.authorizeRequests(authorize -> authorize
.mvcMatchers(HttpMethod.GET, "/messages/**").hasAuthority("ROLE_student")
.mvcMatchers(HttpMethod.POST, "/messages/**").hasAuthority("ROLE_admin")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2ResourceServer ->
oauth2ResourceServer
.jwt(jwt ->
jwt.jwtAuthenticationConverter(getJwtAuthenticationConverter()))
)
.sessionManagement(sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
;
}
private Converter<Jwt, AbstractAuthenticationToken> getJwtAuthenticationConverter() {
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(getJwtGrantedAuthoritiesConverter());
return jwtAuthenticationConverter;
}
private Converter<Jwt, Collection<GrantedAuthority>> getJwtGrantedAuthoritiesConverter() {
JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
converter.setAuthorityPrefix(AUTHORITY_PREFIX);
converter.setAuthoritiesClaimName(CLAIM_ROLES);
return converter;
}
}
From above, first, we tell Spring that we want to use claim name roles instead of scope or scp.
- Only student role will pass the GET method authorisation.
- Only admin role will pass the POST method authorisation.
Second, we want to set authority prefix with ROLE_ instead of SCOPE_
We can modify it further... We can use hasRole instead of hasAuthority.
package com.example.resourcesever;
import org.springframework.core.convert.converter.Converter;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import java.util.Collection;
@EnableWebSecurity
public class OAuth2ResourceServerSecurityCustomConfiguration extends WebSecurityConfigurerAdapter {
private static final String AUTHORITY_PREFIX = "ROLE_";
private static final String CLAIM_ROLES = "roles";
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.formLogin(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.authorizeRequests(authorize -> authorize
.mvcMatchers(HttpMethod.GET, "/messages/**").hasRole("student")
.mvcMatchers(HttpMethod.POST, "/messages/**").hasRole("admin")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2ResourceServer ->
oauth2ResourceServer
.jwt(jwt ->
jwt.jwtAuthenticationConverter(getJwtAuthenticationConverter()))
)
.sessionManagement(sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
;
}
private Converter<Jwt, AbstractAuthenticationToken> getJwtAuthenticationConverter() {
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(getJwtGrantedAuthoritiesConverter());
return jwtAuthenticationConverter;
}
private Converter<Jwt, Collection<GrantedAuthority>> getJwtGrantedAuthoritiesConverter() {
JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
converter.setAuthorityPrefix(AUTHORITY_PREFIX);
converter.setAuthoritiesClaimName(CLAIM_ROLES);
return converter;
}
}
Notice that I provide only the role name(admin, student) without the prefix ROLE_ to the hasRole() method, because the implementation of hasRole() does not expect us to put the prefix ROLE_, it will do for us.
Testing with Custom claim: roles
Request message endpoint using HTTP GET. The token contains student role
GET http://localhost:8080/message
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOi8vbXkubWljcm9zZXJ2aWNlLmNvbS8iLCJzdWIiOiJzdWJqZWN0Iiwicm9sZXMiOlsic3R1ZGVudCJdLCJleHAiOjQ3NDA1NzAxMjMsImp0aSI6IjM3OWVhNzYxLTNlNTAtNDM2Mi04ZTEyLWQwNzIzNDZhN2JlMSIsImlhdCI6MTU4Njk3MDEyM30.qJCgYrMb17D6Y6MWKrpsaTLBmWKZXvc4wTGsZg2YGGY
HTTP/1.1 200
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: text/plain;charset=UTF-8
Content-Length: 33
Date: Wed, 15 Apr 2020 17:11:01 GMT
secret message
Response code: 200; Time: 1261ms; Content length: 337bytes
Request message endpoint using HTTP POST. The token contains student role. But it expects admin role.
POST http://localhost:8080/message
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOi8vbXkubWljcm9zZXJ2aWNlLmNvbS8iLCJzdWIiOiJzdWJqZWN0Iiwicm9sZXMiOlsic3R1ZGVudCJdLCJleHAiOjQ3NDA1NzAxMjMsImp0aSI6IjM3OWVhNzYxLTNlNTAtNDM2Mi04ZTEyLWQwNzIzNDZhN2JlMSIsImlhdCI6MTU4Njk3MDEyM30.qJCgYrMb17D6Y6MWKrpsaTLBmWKZXvc4wTGsZg2YGGY
HTTP/1.1 403
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;charset=UTF-8
Transfer-Encoding: chunked
Date: Wed, 15 Apr 2020 22:10:05 GMT
{
"timestamp": "2019-04-15T12:27:25.020+0000",
"status": 403,
"error": "Forbidden",
"message": "Access Denied",
"path": "/message"
}
Response code: 403; Time: 28ms; Content length: 125 byte
Conclusion
The OAuth 2 Resource Sever library provide us a minimal configuration. We do not need to write a filter anymore.
Posted on April 15, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.