Since version 5.2, Spring has introduced a new library, OAuth 2.0 Resource 小号ever, 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
它通常包含两个字段:
- 一种令牌,类型,智威汤逊签名算法阿尔格, 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
}
用来授权我们的端点的主张是范围:读。
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.
这是build.gradle文件中的依赖项:
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()
}
如您所见,我们使用Spring Boot版本2.2.6.RELEASE。 spring-boot-starter-oauth2-resource-server包含spring-security-oauth2-jose版本5.2.5.RELEASE,其中包含nimbus-jose-jwt库以支持JWT解码。
Controller
我们创建了2个端点:
- “ /”终结点-接受HTTP GET方法并期望带有“ Authorization:Bearer(JWT Token)”的HTTP标头"/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
- 我们为/ message端点定义安全规则。 消息端点将检查是否请求具有权限读用于GET方法请求具有权限写对于POST方法我们还告诉Spring我们将使用带有JSON Web令牌(JWT)的OAuth2资源服务器。我们禁用会话管理–这将阻止创建会话cookieHTTP基本认证默认的Spring登录页面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
当您调用带有无效声明的安全终结点时,您会收到HTTP 403消息。 例如,您发送了一个读为范围但端点期望写 范围.
当JWT授权失败时,您会收到HTTP 401消息。 例如,
- 令牌未被发行者识别。令牌已过期。令牌是无效的结构。等等
Testing
使用HTTP请求消息端点得到。 令牌包含读范围
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
使用HTTP请求消息端点开机自检。 令牌包含读范围。 但它期望写范围。
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
如果我们的JWT不包含众所周知的声明怎么办?范围,scp)进行授权?
我们将使用索赔名称:角色例如。
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 JWŤ Converter.
我们将修改现有的配置文件,如下所示:
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;
}
}
从上面开始,首先,我们告诉Spring我们要使用声明名称角色代替范围要么scp。
- 只要学生角色将通过GET方法授权。只要管理员角色将通过POST方法授权。
其次,我们要设置权限前缀角色_代替范围_
我们可以进一步修改。。。我们可以使用hasRole代替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
使用HTTP请求消息端点得到。 令牌包含学生角色
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
使用HTTP请求消息端点开机自检。 令牌包含学生角色。 但它期望管理员角色。
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
OAuth 2资源服务器库为我们提供了最低限度的配置。 我们不再需要编写过滤器了。