使用 Spring Security 构建 OAuth 2.0 资源服务器
1、概览
本文将带你了解如何使用 Spring Security 构建 OAuth 2.0 资源服务器(使用 JWT 和 Opaque Token,这两种由 Spring Security 支持的 Bearer Token)。
2、背景介绍
2.1、JWT 和 Opaque Token 是什么?
JWT 或 JSON Web Token 是一种以广泛接受的 JSON 格式安全传输敏感信息的方式。其中包含的信息可能是关于用户的,也可能是关于 Token 本身的,例如其 expiry(有效期)和 issuer(签发者)。
Opaque Token 顾名思义,它所携带的信息是不透明的。Token 只是一个标识符,指向存储在授权服务器上的信息;它通过服务器端的自省(Introspection)进行验证。
2.2、资源服务器是什么?
在 OAuth 2.0 中,资源服务器是通过 OAuth Token 保护资源的应用。这些 Token 由授权服务器(通常是客户端应用)签发。资源服务器的工作是在向客户端提供资源之前验证 Token。
令牌的有效性由几个因素决定:
- 该令牌是否来自配置的授权服务器?
- 是否未过期?
- 该资源是否为预期受众(audience)提供服务?
- Token 是否具有访问所请求资源的必要权限?
来看一下 授权码模式 的顺序图,并观察所有相关的参与者:
正如在步骤 8 中看到的,当客户端应用调用资源服务器的 API 访问受保护的资源时,它首先会转到授权服务器,以验证请求的 Authorization: Bearer
Header 信息中包含的 Token,然后响应客户端。
本文的重点是第 9 步。
现在让进入代码实践部分。设置一个使用 Keycloak 的授权服务器、一个验证 JWT Token 的资源服务器、另一个验证 Opaque Token 的资源服务器,以及几个模拟客户端应用和验证响应的 JUnit 测试。
3、授权服务器
首先,创建一个授权服务器,也就是发放 Token 的服务器。
为此,我们直接在 Spring Boot 应用中嵌入 Keycloak。Keycloak 是一个开源身份和访问管理解决方案。由于本问的重点是资源服务器,这里不再深入。
嵌入式 Keycloak 服务器定义了两个客户端:fooClient
和 barClient
,分别对应我们的两个资源服务器应用。
4、资源服务器 - 使用 JWT
资源服务器由四个主要部分组成:
Model
:需要保护的资源API
:用于公开资源的 REST ControllerSecurity Configuration
:用于定义 API 公开的受保护资源的访问控制的类application.yml
:用于声明属性的配置文件,包括有关授权服务器的信息
4.1、Maven 依赖
添加 Spring Boot 的资源服务器支持 spring-boot-starter-oauth2-resource-server
依赖,该 Starter 默认包含了 Spring Security:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.7.5</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
<version>2.7.5</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.13.0</version>
</dependency>
除此之外,还添加了 Web 支持。
出于演示目的,这里使用 Apache 的 commons-lang3 库随机生成资源。
4.2、Model
使用 Foo
POJO 作为受保护资源:
public class Foo {
private long id;
private String name;
// 构造函数、get、set 方法省略
}
4.3. API
接着是 Rest Controller,可以对 Foo
进行操作:
@RestController
@RequestMapping(value = "/foos")
public class FooController {
@GetMapping(value = "/{id}")
public Foo findOne(@PathVariable Long id) {
return new Foo(Long.parseLong(randomNumeric(2)), randomAlphabetic(4));
}
@GetMapping
public List findAll() {
List fooList = new ArrayList();
fooList.add(new Foo(Long.parseLong(randomNumeric(2)), randomAlphabetic(4)));
fooList.add(new Foo(Long.parseLong(randomNumeric(2)), randomAlphabetic(4)));
fooList.add(new Foo(Long.parseLong(randomNumeric(2)), randomAlphabetic(4)));
return fooList;
}
@ResponseStatus(HttpStatus.CREATED)
@PostMapping
public void create(@RequestBody Foo newFoo) {
logger.info("Foo created");
}
}
如上,该 Controller 提供了 GET 所有 Foo
、按 ID GET 一个 Foo
和 POST 一个 Foo
的功能。
4.4、Security 配置
在配置类中定义资源的访问级别:
@Configuration
public class JWTSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeRequests(authz -> authz.antMatchers(HttpMethod.GET, "/foos/**")
.hasAuthority("SCOPE_read")
.antMatchers(HttpMethod.POST, "/foos")
.hasAuthority("SCOPE_write")
.anyRequest()
.authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt());
return http.build();
}
}
任何拥有 “具有 read
scope 的 Access Token” 的人都可以 GET Foo
。要 POST 一个新的 Foo
,其 Token 必须具有 write
scope。
还使用 oauth2ResourceServer()
DSL 添加对 jwt()
的调用,以指明服务器支持的令牌类型。
4.5、application.yml
在 application properties 中,除了常用的端口号和上下文路径外,还需要定义授权服务器的 issuer URI 路径,以便资源服务器发现其 Provider 配置:
server:
port: 8081
servlet:
context-path: /resource-server-jwt
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8083/auth/realms/baeldung
资源服务器使用这些信息来验证从客户端应用输入的 JWT Token,如序列图中的步骤 9 所示。
要使用 issuer-uri
属性进行验证,授权服务器必须启动并运行。否则,资源服务器将无法启动。
如果需要独立启动它,则可以提供 jwk-set-uri
属性来指向授权服务器的端点,以公开公钥:
jwk-set-uri: http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/certs
4.6、测试
创建 JUnit 测试。启动并运行授权服务器和资源服务器。
测试,是否能通过具有 read
scope 的 Token 从 resource-server-jwt
GET Foo
:
@Test
public void givenUserWithReadScope_whenGetFooResource_thenSuccess() {
String accessToken = obtainAccessToken("read");
Response response = RestAssured.given()
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.get("http://localhost:8081/resource-server-jwt/foos");
assertThat(response.as(List.class)).hasSizeGreaterThan(0);
}
在上述代码的第 3 行,从授权服务器获取了一个具有 read
scope 的 Access Token,涵盖了序列图中的第 1 步到第 7 步。
第 8 步由 RestAssured
的 get()
调用执行。第 9 步由资源服务器根据配置执行,对用户来说是透明的。
5、资源服务器 - 使用 Opaque Token
接着,来看看处理 Opaque Token 的资源服务器的相同组件。
5.1、Maven 依赖
要支持 Opaque Token,需要额外的 oauth2-oidc-sdk
依赖:
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>oauth2-oidc-sdk</artifactId>
<version>8.19</version>
<scope>runtime</scope>
</dependency>
5.2、Model 和 Controller
添加一个 Bar
资源:
public class Bar {
private long id;
private String name;
// 构造函数、get、set 省略
}
还有一个 BarController
,其端点与之前的 FooController
类似,用于提供对 Bar
的 GET 和 POST 操作。
5.3、application.yml
在 application.yml
中,需要添加与授权服务器自省端点相对应的 introspection-uri
。如前所述,这就是验证 Opaque Token 的方式:
server:
port: 8082
servlet:
context-path: /resource-server-opaque
spring:
security:
oauth2:
resourceserver:
opaque:
introspection-uri: http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/token/introspect
introspection-client-id: barClient
introspection-client-secret: barClientSecret
5.4、Security 配置
Bar
资源的访问级别也与 Foo
类似。该配置类还使用 oauth2ResourceServer()
DSL 调用 opaqueToken()
,以指示使用 Opaque Token 类型:
@Configuration
public class OpaqueSecurityConfig {
@Value("${spring.security.oauth2.resourceserver.opaque.introspection-uri}")
String introspectionUri;
@Value("${spring.security.oauth2.resourceserver.opaque.introspection-client-id}")
String clientId;
@Value("${spring.security.oauth2.resourceserver.opaque.introspection-client-secret}")
String clientSecret;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeRequests(authz -> authz.antMatchers(HttpMethod.GET, "/bars/**")
.hasAuthority("SCOPE_read")
.antMatchers(HttpMethod.POST, "/bars")
.hasAuthority("SCOPE_write")
.anyRequest()
.authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.opaqueToken
(token -> token.introspectionUri(this.introspectionUri)
.introspectionClientCredentials(this.clientId, this.clientSecret)));
return http.build();
}
}
还指定了授权服务器客户端对应的客户端凭证。之前在application.yml
中定义了这些凭证。
5.5、测试
和 JWT 一样,通过 Junit 来测试使用 Opaque Token 的资源服务器。
测试,具有 write
scope 的 Access Token 是否能将 Bar
POST 到 resource-server-opaque
:
@Test
public void givenUserWithWriteScope_whenPostNewBarResource_thenCreated() {
String accessToken = obtainAccessToken("read write");
Bar newBar = new Bar(Long.parseLong(randomNumeric(2)), randomAlphabetic(4));
Response response = RestAssured.given()
.contentType(ContentType.JSON)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.body(newBar)
.log()
.all()
.post("http://localhost:8082/resource-server-opaque/bars");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED.value());
}
如果响应的状态是 201 Created,意味着资源服务器成功地验证了 Opaque Token,并成功地创建了 Bar
资源。
6、总结
本文介绍了如何配置基于 Spring Security 的 Oauth2 资源服务器应用,以验证 JWT 和 Opaque Token。
Ref:https://www.baeldung.com/spring-security-oauth-resource-server