Spring Security OAuth 2.0 + JWT 实战:从零搭建授权服务器、资源服务器与 OAuth Client

OAuth 2.0 如果只停留在授权码、Access Token、Refresh Token 等概念层面,很容易出现“流程看懂了,真正写代码还是不会”的情况。本文以 Spring Boot、Spring Security OAuth 与 JWT 为基础,完整搭建 Authorization Server、Resource Server 和 OAuth Client 三个应用,从数据库、JWT 非对称签名、授权码存储、Scope 与 Authority 权限控制,到 Password、Client Credentials、Authorization Code 三种流程、SSO 和远程资源调用,给出一套可以顺着代码真正跑通 OAuth 2.0 的工程实践。

先说明版本:这是一个经典 Spring Security OAuth 实战

本文重点是把 OAuth 2.0 的三个角色真正落到代码中,因此采用资料中的经典技术栈:

1
2
3
4
5
6
7
Java 8
Spring Boot 2.2.1.RELEASE
Spring Cloud Greenwich.SR4
Spring Security OAuth
MySQL
JWT
Maven

核心组件是:

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>

它可以非常直观地展示:

  • Authorization Server 如何签发 Token;
  • Resource Server 如何校验 Token;
  • OAuth Client 如何自动完成授权码流程;
  • JWT 如何进行非对称签名;
  • 用户权限如何映射到 Resource Server;
  • Authorization Code 如何存储和消费;
  • Client Credentials 与用户授权有什么区别;
  • OAuth 如何实现 SSO。

资料中的完整示例本身也是围绕 Authorization Server、Resource Server 和 Client 三个模块展开,并分别演示三种 Grant、方法级权限控制、SSO 以及 OAuth2RestTemplate 自动调用资源。

这套 API 主要适合学习经典 Spring Security OAuth 的实现模型。文章的重点是理解 OAuth 2.0 怎样真正落到 Spring Security 代码,而不是把旧 API 当成所有新项目的唯一技术选型。


最终要搭建什么

创建一个 Maven 父工程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
spring-security-oauth2-demo
├── pom.xml

├── oauth2-server
│ ├── Authorization Server
│ ├── 用户认证
│ ├── Client 管理
│ ├── Authorization Code
│ └── JWT Token 签发

├── oauth2-resource-server
│ ├── Resource Server
│ ├── JWT 公钥验签
│ └── API 权限控制

└── oauth2-client
├── OAuth Client
├── Authorization Code
├── SSO
└── OAuth2RestTemplate

三个应用分别监听:

1
2
3
4
Authorization Server : 8080
Resource Server : 8081
OAuth Client A : 8082
OAuth Client B : 8083

整体关系如下:

flowchart LR
    U[用户浏览器]

    C[OAuth Client<br/>8082 / 8083]

    AS[Authorization Server<br/>8080]

    RS[Resource Server<br/>8081]

    DB[(OAuth Database)]

    U --> C
    C -->|Authorization Request| AS
    AS --> DB

    AS -->|Authorization Code| C
    C -->|Code 换 Token| AS
    AS -->|JWT Access Token| C

    C -->|Bearer JWT| RS
    RS -->|Public Key Verify| RS

其中:

1
2
3
4
用户                = Resource Owner
oauth2-client = Client
oauth2-server = Authorization Server
oauth2-resource = Resource Server

OAuth 2.0 实战真正要完成的链路

最终我们需要打通:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
用户

OAuth Client

Authorization Server

Authorization Code

OAuth Client Backend

Access Token

Resource Server

业务 API

Authorization Code 流程:

sequenceDiagram
    autonumber
    actor U as 用户
    participant C as OAuth Client
    participant AS as Authorization Server
    participant RS as Resource Server

    U->>C: 请求安全页面
    C-->>U: 重定向 Authorization Server

    U->>AS: GET /oauth/authorize
    AS-->>U: 登录页面

    U->>AS: 用户名 + 密码
    AS-->>U: Consent 授权确认

    U->>AS: 同意授权
    AS-->>C: redirect_uri?code=xxx

    C->>AS: POST /oauth/token<br/>code + client_id + client_secret
    AS-->>C: access_token + refresh_token

    C->>RS: Authorization: Bearer token
    RS-->>C: Protected Resource

下面开始真正搭项目。


创建父 Maven 工程

根目录创建:

1
spring-security-oauth2-demo/

pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">

<modelVersion>4.0.0</modelVersion>

<groupId>com.example</groupId>
<artifactId>spring-security-oauth2-demo</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.1.RELEASE</version>
<relativePath/>
</parent>

<modules>
<module>oauth2-server</module>
<module>oauth2-resource-server</module>
<module>oauth2-client</module>
</modules>

<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>

<dependencies>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>

</dependencies>

<dependencyManagement>
<dependencies>

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Greenwich.SR4</version>
<type>pom</type>
<scope>import</scope>
</dependency>

</dependencies>
</dependencyManagement>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>

数据库准备

创建数据库

1
2
3
4
5
CREATE DATABASE oauth
DEFAULT CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;

USE oauth;

经典 Spring Security OAuth JDBC 模型会用到:

1
2
3
4
5
users
authorities
oauth_client_details
oauth_code
oauth_approvals

资料中的项目也是围绕这 5 张表保存用户、权限、Client、Authorization Code 与授权确认状态。JWT 本身不需要存入 Token 表,因为 Resource Server 可以利用公钥在本地验证 JWT。


users:用户账号

1
2
3
4
5
6
7
8
9
DROP TABLE IF EXISTS users;

CREATE TABLE users (
username VARCHAR(50) NOT NULL,
password VARCHAR(100) NOT NULL,
enabled TINYINT(1) NOT NULL,
PRIMARY KEY (username)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4;

authorities:用户权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
DROP TABLE IF EXISTS authorities;

CREATE TABLE authorities (
username VARCHAR(50) NOT NULL,
authority VARCHAR(50) NOT NULL,

UNIQUE KEY ix_auth_username (
username,
authority
),

CONSTRAINT fk_authorities_users
FOREIGN KEY (username)
REFERENCES users(username)

) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4;

oauth_client_details:OAuth Client

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
DROP TABLE IF EXISTS oauth_client_details;

CREATE TABLE oauth_client_details (

client_id VARCHAR(255) NOT NULL,

resource_ids VARCHAR(255) DEFAULT NULL,

client_secret VARCHAR(255) DEFAULT NULL,

scope VARCHAR(255) DEFAULT NULL,

authorized_grant_types VARCHAR(255) DEFAULT NULL,

web_server_redirect_uri VARCHAR(255) DEFAULT NULL,

authorities VARCHAR(255) DEFAULT NULL,

access_token_validity INT DEFAULT NULL,

refresh_token_validity INT DEFAULT NULL,

additional_information VARCHAR(4096) DEFAULT NULL,

autoapprove VARCHAR(255) DEFAULT NULL,

PRIMARY KEY (client_id)

) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4;

oauth_code:Authorization Code

1
2
3
4
5
6
7
8
9
10
DROP TABLE IF EXISTS oauth_code;

CREATE TABLE oauth_code (

code VARCHAR(255) DEFAULT NULL,

authentication BLOB

) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4;

Authorization Code 在这里存储。

例如:

1
XKkHGY

客户端拿到后,会使用:

1
2
grant_type=authorization_code
code=XKkHGY

兑换 Access Token。


oauth_approvals:授权确认记录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
DROP TABLE IF EXISTS oauth_approvals;

CREATE TABLE oauth_approvals (

userId VARCHAR(256) DEFAULT NULL,

clientId VARCHAR(256) DEFAULT NULL,

partnerKey VARCHAR(32) DEFAULT NULL,

scope VARCHAR(256) DEFAULT NULL,

status VARCHAR(10) DEFAULT NULL,

expiresAt DATETIME DEFAULT NULL,

lastModifiedAt DATETIME DEFAULT NULL

) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4;

搭建 Authorization Server

创建:

1
oauth2-server

目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
oauth2-server
├── pom.xml
└── src
└── main
├── java
│ └── com.example.oauth.server
│ ├── OAuth2ServerApplication.java
│ ├── OAuth2ServerConfiguration.java
│ ├── WebSecurityConfig.java
│ └── CustomTokenEnhancer.java

└── resources
├── application.yml
├── jwt.jks
└── templates
└── login.html

Authorization Server Maven 依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">

<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>com.example</groupId>
<artifactId>spring-security-oauth2-demo</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>

<artifactId>oauth2-server</artifactId>

<dependencies>

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>

</dependencies>

</project>

application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
server:
port: 8080

spring:
application:
name: oauth2-server

datasource:
url: jdbc:mysql://127.0.0.1:3306/oauth?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver

thymeleaf:
cache: false

启动类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.example.oauth.server;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class OAuth2ServerApplication {

public static void main(String[] args) {
SpringApplication.run(
OAuth2ServerApplication.class,
args
);
}
}

生成 JWT RSA 密钥

Authorization Server 要使用私钥:

1
Private Key

签名 JWT。

Resource Server 使用:

1
Public Key

验证 JWT。

关系是:

1
2
3
4
5
6
7
8
9
Authorization Server
private key
↓ sign

JWT
↓ verify

Resource Server
public key

生成 JKS

执行:

1
2
3
4
5
6
7
keytool \
-genkeypair \
-alias jwt \
-keyalg RSA \
-keysize 2048 \
-keystore jwt.jks \
-validity 3650

示例密码:

1
123456

将:

1
jwt.jks

复制到:

1
oauth2-server/src/main/resources/

查看并导出公钥

可以执行:

1
2
3
4
5
keytool -list -rfc \
--keystore jwt.jks \
| openssl x509 \
-inform pem \
-pubkey

得到类似:

1
2
3
4
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...
...
-----END PUBLIC KEY-----

保存到:

1
public.cert

后面 Resource Server 要使用。


Authorization Server 核心配置

@EnableAuthorizationServer

创建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
package com.example.oauth.server;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.approval.JdbcApprovalStore;
import org.springframework.security.oauth2.provider.code.AuthorizationCodeServices;
import org.springframework.security.oauth2.provider.code.JdbcAuthorizationCodeServices;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
import org.springframework.security.rsa.crypto.KeyStoreKeyFactory;

import javax.sql.DataSource;
import java.util.Arrays;

@Configuration
@EnableAuthorizationServer
public class OAuth2ServerConfiguration
extends AuthorizationServerConfigurerAdapter {

@Autowired
private DataSource dataSource;

@Autowired
private AuthenticationManager authenticationManager;

@Override
public void configure(
ClientDetailsServiceConfigurer clients
) throws Exception {

clients.jdbc(dataSource);
}

@Override
public void configure(
AuthorizationServerSecurityConfigurer security
) {

security
.checkTokenAccess("permitAll()")
.allowFormAuthenticationForClients()
.passwordEncoder(
NoOpPasswordEncoder.getInstance()
);
}

@Override
public void configure(
AuthorizationServerEndpointsConfigurer endpoints
) {

TokenEnhancerChain enhancerChain =
new TokenEnhancerChain();

enhancerChain.setTokenEnhancers(
Arrays.asList(
tokenEnhancer(),
jwtAccessTokenConverter()
)
);

endpoints
.approvalStore(approvalStore())
.authorizationCodeServices(
authorizationCodeServices()
)
.tokenStore(tokenStore())
.tokenEnhancer(enhancerChain)
.authenticationManager(
authenticationManager
);
}

@Bean
public AuthorizationCodeServices
authorizationCodeServices() {

return new JdbcAuthorizationCodeServices(
dataSource
);
}

@Bean
public JdbcApprovalStore approvalStore() {

return new JdbcApprovalStore(dataSource);
}

@Bean
public TokenStore tokenStore() {

return new JwtTokenStore(
jwtAccessTokenConverter()
);
}

@Bean
public TokenEnhancer tokenEnhancer() {

return new CustomTokenEnhancer();
}

@Bean
public JwtAccessTokenConverter
jwtAccessTokenConverter() {

KeyStoreKeyFactory factory =
new KeyStoreKeyFactory(
new ClassPathResource("jwt.jks"),
"123456".toCharArray()
);

JwtAccessTokenConverter converter =
new JwtAccessTokenConverter();

converter.setKeyPair(
factory.getKeyPair("jwt")
);

return converter;
}
}

这是整个 Authorization Server 最核心的配置。

资料里的 Authorization Server 也正是在这里完成 Client JDBC 存储、JWT TokenStore、RSA 签名、Authorization Code JDBC 存储和 ApprovalStore 配置。


clients.jdbc(dataSource) 是干什么的

1
clients.jdbc(dataSource);

意味着 Client 不再写死:

1
clients.inMemory()

而是查询:

1
oauth_client_details

因此以后新增 Client:

1
2
3
userservice1
userservice2
userservice3

不需要改 Java 代码。


TokenStore 为什么使用 JWT

1
2
3
4
5
6
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(
jwtAccessTokenConverter()
);
}

采用 JWT 后:

1
Resource Server

不需要每次请求:

1
Authorization Server

查询 Token。

而是:

1
2
3
4
5
JWT

public key

local verify

完成本地验签。


自定义 JWT Claims

资料中专门使用 TokenEnhancer 把用户信息放入 JWT。

创建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package com.example.oauth.server;

import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;

import java.util.HashMap;
import java.util.Map;

public class CustomTokenEnhancer
implements TokenEnhancer {

@Override
public OAuth2AccessToken enhance(
OAuth2AccessToken accessToken,
OAuth2Authentication authentication) {

Authentication userAuthentication =
authentication.getUserAuthentication();

if (userAuthentication == null) {
return accessToken;
}

Map<String, Object> additional =
new HashMap<>();

additional.put(
"username",
userAuthentication.getName()
);

additional.put(
"authorities",
userAuthentication.getAuthorities()
);

((DefaultOAuth2AccessToken) accessToken)
.setAdditionalInformation(additional);

return accessToken;
}
}

最终 JWT Payload 会包含类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"user_name": "writer",
"scope": [
"FOO"
],
"username": "writer",
"authorities": [
{
"authority": "READ"
},
{
"authority": "WRITE"
}
],
"client_id": "userservice1"
}

生产环境不要把整个:

1
UserDetails

对象塞进 Token。

这里为了方便演示,只增加:

1
2
username
authorities

配置用户认证

Authorization Server 不仅负责 OAuth Client,还需要完成:

1
Resource Owner

的认证。

创建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
package com.example.oauth.server;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

import javax.sql.DataSource;

@Configuration
public class WebSecurityConfig
extends WebSecurityConfigurerAdapter {

@Autowired
private DataSource dataSource;

@Bean
@Override
public AuthenticationManager
authenticationManagerBean()
throws Exception {

return super.authenticationManagerBean();
}

@Override
protected void configure(
AuthenticationManagerBuilder auth
) throws Exception {

auth
.jdbcAuthentication()
.dataSource(dataSource)
.passwordEncoder(
new BCryptPasswordEncoder()
);
}

@Override
protected void configure(
HttpSecurity http
) throws Exception {

http
.authorizeRequests()
.antMatchers(
"/login",
"/oauth/authorize"
)
.permitAll()
.anyRequest()
.authenticated()

.and()

.formLogin()
.loginPage("/login")
.permitAll();
}
}

注意:

1
AuthenticationManager

非常重要。

因为 Password Grant:

1
grant_type=password

需要使用它验证:

1
2
username
password

创建登录页面

放到:

1
src/main/resources/templates/login.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<head>
<meta charset="UTF-8"/>
<title>OAuth Login</title>
</head>

<body>

<h1>OAuth 2.0 Login</h1>

<div th:if="${param.error}">
Username or password error.
</div>

<form method="post"
th:action="@{/login}">

<div>
<label>Username</label>

<input type="text"
name="username"
value="reader"/>
</div>

<div>
<label>Password</label>

<input type="password"
name="password"
value="reader"/>
</div>

<button type="submit">
Login
</button>

</form>

</body>

</html>

还需要配置 View Controller:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.example.oauth.server;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class MvcConfiguration
implements WebMvcConfigurer {

@Override
public void addViewControllers(
ViewControllerRegistry registry
) {

registry
.addViewController("/login")
.setViewName("login");
}
}

Authorization Server 到这里完成。


创建 Resource Server

项目:

1
oauth2-resource-server

目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
oauth2-resource-server
├── pom.xml
└── src/main
├── java
│ └── com.example.oauth.resource
│ ├── ResourceServerApplication.java
│ ├── ResourceServerConfiguration.java
│ ├── HelloController.java
│ └── UserController.java

└── resources
├── application.yml
└── public.cert

Maven 依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">

<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>com.example</groupId>
<artifactId>spring-security-oauth2-demo</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>

<artifactId>oauth2-resource-server</artifactId>

<dependencies>

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

</dependencies>

</project>

application.yml

1
2
3
4
5
6
server:
port: 8081

spring:
application:
name: oauth2-resource-server

启动类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.example.oauth.resource;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ResourceServerApplication {

public static void main(String[] args) {

SpringApplication.run(
ResourceServerApplication.class,
args
);
}
}

Resource Server 核心配置

创建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
package com.example.oauth.resource;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
import org.springframework.util.FileCopyUtils;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(
prePostEnabled = true
)
public class ResourceServerConfiguration
extends ResourceServerConfigurerAdapter {

@Override
public void configure(
ResourceServerSecurityConfigurer resources
) {

resources
.resourceId("userservice")
.tokenStore(tokenStore());
}

@Bean
public TokenStore tokenStore() {

return new JwtTokenStore(
jwtAccessTokenConverter()
);
}

@Bean
public JwtAccessTokenConverter
jwtAccessTokenConverter() {

JwtAccessTokenConverter converter =
new JwtAccessTokenConverter();

Resource resource =
new ClassPathResource(
"public.cert"
);

try {

String publicKey =
new String(
FileCopyUtils
.copyToByteArray(
resource.getInputStream()
),
StandardCharsets.UTF_8
);

converter.setVerifierKey(
publicKey
);

} catch (IOException e) {

throw new IllegalStateException(
"Load public key failed",
e
);
}

return converter;
}

@Override
public void configure(
HttpSecurity http
) throws Exception {

http
.authorizeRequests()
.antMatchers("/user/**")
.authenticated()
.anyRequest()
.permitAll();
}
}

Resource Server 不需要私钥。

只需要:

1
public.cert

因为:

1
2
3
4
5
6
7
8
9
Authorization Server
Private Key

Sign JWT

Resource Server
Public Key

Verify JWT

创建匿名接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.example.oauth.resource;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {

@GetMapping("/hello")
public String hello() {

return "Hello OAuth 2.0";
}
}

直接访问:

1
http://localhost:8081/hello

应该返回:

1
Hello OAuth 2.0

不需要 Token。


创建需要权限的 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package com.example.oauth.resource;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.authentication.OAuth2AuthenticationDetails;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/user")
public class UserController {

@Autowired
private TokenStore tokenStore;

@PreAuthorize(
"hasAuthority('READ') or hasAuthority('WRITE')"
)
@GetMapping("/name")
public String name(
OAuth2Authentication authentication) {

return authentication.getName();
}

@PreAuthorize(
"hasAuthority('READ') or hasAuthority('WRITE')"
)
@GetMapping
public OAuth2Authentication read(
OAuth2Authentication authentication) {

return authentication;
}

@PreAuthorize(
"hasAuthority('WRITE')"
)
@PostMapping
public Object write(
OAuth2Authentication authentication) {

OAuth2AuthenticationDetails details =
(OAuth2AuthenticationDetails)
authentication.getDetails();

OAuth2AccessToken token =
tokenStore.readAccessToken(
details.getTokenValue()
);

return token
.getAdditionalInformation();
}
}

现在有三个接口:

API 权限
GET /user/name READ 或 WRITE
GET /user READ 或 WRITE
POST /user WRITE

初始化测试用户

用户密码使用:

1
BCryptPasswordEncoder

所以不能直接插入:

1
reader

需要 BCrypt。

为了方便测试,可以临时执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {

BCryptPasswordEncoder encoder =
new BCryptPasswordEncoder();

System.out.println(
encoder.encode("reader")
);

System.out.println(
encoder.encode("writer")
);
}

然后插入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
INSERT INTO users (
username,
password,
enabled
)
VALUES (
'reader',
'$2a$10$替换为reader的BCrypt结果',
1
);

INSERT INTO users (
username,
password,
enabled
)
VALUES (
'writer',
'$2a$10$替换为writer的BCrypt结果',
1
);

初始化权限

创建:

1
reader

只有:

1
READ

创建:

1
writer

拥有:

1
2
READ
WRITE

SQL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
INSERT INTO authorities (
username,
authority
)
VALUES (
'reader',
'READ'
);

INSERT INTO authorities (
username,
authority
)
VALUES (
'writer',
'READ'
);

INSERT INTO authorities (
username,
authority
)
VALUES (
'writer',
'WRITE'
);

这里特意将 writer 拆成两条 Authority。

因为 Spring Security 的:

1
hasAuthority("WRITE")

匹配的是单个 Authority。

如果数据库里写:

1
READ,WRITE

作为一整条字符串,那么它实际上可能被理解成:

1
authority = "READ,WRITE"

而不是两个 Authority。


初始化三个 OAuth Client

我们创建三个 Client:

1
2
3
userservice1
userservice2
userservice3

分别测试:

1
2
3
Password Grant
Client Credentials Grant
Authorization Code Grant

Password Client

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
INSERT INTO oauth_client_details (
client_id,
resource_ids,
client_secret,
scope,
authorized_grant_types,
web_server_redirect_uri,
authorities,
access_token_validity,
refresh_token_validity,
additional_information,
autoapprove
)
VALUES (
'userservice1',
'userservice',
'1234',
'FOO',
'password,refresh_token',
NULL,
'READ,WRITE',
7200,
2592000,
NULL,
'true'
);

Client Credentials Client

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
INSERT INTO oauth_client_details (
client_id,
resource_ids,
client_secret,
scope,
authorized_grant_types,
web_server_redirect_uri,
authorities,
access_token_validity,
refresh_token_validity,
additional_information,
autoapprove
)
VALUES (
'userservice2',
'userservice',
'1234',
'FOO',
'client_credentials',
NULL,
'READ,WRITE',
7200,
NULL,
NULL,
'true'
);

Authorization Code Client

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
INSERT INTO oauth_client_details (
client_id,
resource_ids,
client_secret,
scope,
authorized_grant_types,
web_server_redirect_uri,
authorities,
access_token_validity,
refresh_token_validity,
additional_information,
autoapprove
)
VALUES (
'userservice3',
'userservice',
'1234',
'FOO',
'authorization_code,refresh_token',
'http://localhost:8082/ui/login',
'READ,WRITE',
7200,
2592000,
NULL,
'false'
);

资料中的实验也是通过三个 Client 分别演示 Password、Client Credentials 和 Authorization Code,并让它们指向同一个 userservice Resource ID。


启动服务

先启动:

1
oauth2-server

确认:

1
http://localhost:8080

再启动:

1
oauth2-resource-server

确认:

1
http://localhost:8081/hello

返回:

1
Hello OAuth 2.0

实战一:Password Grant

虽然这个模式适合用来理解历史 OAuth 流程,但这里主要把它当做学习 Spring Security OAuth Token Endpoint 的实验。

请求:

1
2
3
4
5
6
7
curl -X POST \
"http://localhost:8080/oauth/token" \
-d "grant_type=password" \
-d "client_id=userservice1" \
-d "client_secret=1234" \
-d "username=writer" \
-d "password=writer"

完整参数:

1
2
3
4
5
grant_type=password
client_id=userservice1
client_secret=1234
username=writer
password=writer

Authorization Server 会经历:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
client_id

ClientDetailsService

client_secret

Client Authentication

username/password

AuthenticationManager

Authentication

TokenEnhancer

JWT

Private Key Sign

返回类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "bearer",
"refresh_token": "eyJhbGciOiJSUzI1NiIs...",
"expires_in": 7199,
"scope": "FOO",
"username": "writer",
"authorities": [
{
"authority": "READ"
},
{
"authority": "WRITE"
}
]
}

使用 Token 调 Resource Server

拿到:

1
ACCESS_TOKEN

然后:

1
2
3
curl \
-H "Authorization: Bearer $ACCESS_TOKEN" \
http://localhost:8081/user/name

返回:

1
writer

请求:

1
2
3
curl \
-H "Authorization: Bearer $ACCESS_TOKEN" \
http://localhost:8081/user

可以看到:

1
2
3
4
OAuth2Authentication
authorities
client_id
userAuthentication

测试 WRITE 权限

请求:

1
2
3
curl -X POST \
-H "Authorization: Bearer $ACCESS_TOKEN" \
http://localhost:8081/user

writer 应该成功。

返回可能类似:

1
2
3
4
5
6
7
8
9
10
11
{
"username": "writer",
"authorities": [
{
"authority": "READ"
},
{
"authority": "WRITE"
}
]
}

使用 reader Token 测试 403

获取 reader Token:

1
2
3
4
5
6
7
curl -X POST \
"http://localhost:8080/oauth/token" \
-d "grant_type=password" \
-d "client_id=userservice1" \
-d "client_secret=1234" \
-d "username=reader" \
-d "password=reader"

然后:

1
2
3
curl -X POST \
-H "Authorization: Bearer $READER_TOKEN" \
http://localhost:8081/user

因为:

1
reader = READ

而 API 要求:

1
2
3
@PreAuthorize(
"hasAuthority('WRITE')"
)

所以应该返回:

1
403 Forbidden

注意这个区别:

1
2
3
4
5
6
7
8
401

Token 无效 / 没有认证

403

Token 合法
但权限不够

实战二:Client Credentials

Client Credentials 没有最终用户。

关系是:

1
2
3
4
5
6
7
Client

Authorization Server

Access Token

Resource Server

请求:

1
2
3
curl -X POST \
-u userservice2:1234 \
"http://localhost:8080/oauth/token?grant_type=client_credentials"

或者由于前面打开了:

1
allowFormAuthenticationForClients()

也可以:

1
2
3
4
5
curl -X POST \
"http://localhost:8080/oauth/token" \
-d "grant_type=client_credentials" \
-d "client_id=userservice2" \
-d "client_secret=1234"

返回:

1
2
3
4
5
6
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "bearer",
"expires_in": 7199,
"scope": "FOO"
}

这里通常不会出现:

1
2
3
username
userDetails
refresh_token

原因非常简单。

Client Credentials 表达的是:

1
userservice2

自身身份。

并不存在:

1
2
3
4
reader
writer
张三
李四

这样的最终用户。

资料中的实验也特别指出,Client Credentials 没有用户概念,因此不会产生用于避免用户重新登录的 Refresh Token,也无法注入用户相关的额外 Claims。


Client Credentials 在微服务中的意义

例如:

1
2
3
Order Service

Inventory Service

Order Service 可以注册成:

1
client_id = order-service

Scope:

1
inventory.read

获得 Token:

1
2
sub/client_id = order-service
scope = inventory.read

然后:

1
2
GET /inventory/10001
Authorization: Bearer xxx

这就是典型的:

1
2
Service-to-Service
Machine-to-Machine

认证授权。


实战三:Authorization Code

这是最值得真正跑一遍的流程。

访问:

1
2
3
4
http://localhost:8080/oauth/authorize
?response_type=code
&client_id=userservice3
&redirect_uri=http://localhost:8082/ui/login

浏览器完整地址:

1
http://localhost:8080/oauth/authorize?response_type=code&client_id=userservice3&redirect_uri=http://localhost:8082/ui/login

此时会跳转到:

1
/login

使用 reader 登录

输入:

1
2
username = reader
password = reader

然后出现授权确认:

1
2
3
4
5
6
7
Do you authorize userservice3
to access your protected resources?

scope.FOO

Approve
Deny

因为数据库:

1
autoapprove = false

所以会显示 Consent 页面。


同意授权

用户批准:

1
FOO

后,Authorization Server 创建:

1
Authorization Code

同时:

1
oauth_code

中会出现数据。

然后浏览器被重定向:

1
http://localhost:8082/ui/login?code=XXXXXX

例如:

1
http://localhost:8082/ui/login?code=XKkHGY

使用 Code 换 Access Token

1
2
3
4
5
6
7
curl -X POST \
"http://localhost:8080/oauth/token" \
-d "grant_type=authorization_code" \
-d "client_id=userservice3" \
-d "client_secret=1234" \
-d "code=XKkHGY" \
-d "redirect_uri=http://localhost:8082/ui/login"

返回:

1
2
3
4
5
6
7
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "bearer",
"refresh_token": "eyJhbGciOiJSUzI1NiIs...",
"expires_in": 7199,
"scope": "FOO"
}

因为登录用户:

1
reader

所以最终权限也是 reader 权限。

这体现了一个很重要的模型:

1
2
3
4
5
OAuth Client 能申请的权限

当前用户拥有的权限

最终 Token Authority

Client 自己拥有:

1
2
READ
WRITE

并不代表:

1
reader

突然获得:

1
WRITE

权限。


Authorization Code 为什么只能使用一次

再次拿:

1
XKkHGY

请求:

1
2
3
4
5
6
7
curl -X POST \
"http://localhost:8080/oauth/token" \
-d "grant_type=authorization_code" \
-d "client_id=userservice3" \
-d "client_secret=1234" \
-d "code=XKkHGY" \
-d "redirect_uri=http://localhost:8082/ui/login"

应该失败。

Authorization Code 是:

1
2
3
短生命周期
一次性
临时凭据

而不是 Access Token。

这也是为什么:

1
2
3
Authorization Code

Access Token

使用 Refresh Token

如果响应中:

1
2
3
{
"refresh_token": "xxx"
}

可以:

1
2
3
4
5
6
curl -X POST \
"http://localhost:8080/oauth/token" \
-d "grant_type=refresh_token" \
-d "client_id=userservice3" \
-d "client_secret=1234" \
-d "refresh_token=$REFRESH_TOKEN"

得到新的:

1
access_token

客户端不需要再次让用户输入密码。


JWT 本地验签到底是怎么工作的

Authorization Server 使用:

1
jwt.jks

里面的:

1
Private Key

签名:

1
2
Header
Payload

得到:

1
Signature

JWT:

1
xxxxx.yyyyy.zzzzz

Resource Server 收到:

1
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

之后:

1
2
3
4
5
6
7
8
9
10
11
12
13
JWT

JwtTokenStore

JwtAccessTokenConverter

public.cert

Verify Signature

OAuth2Authentication

SecurityContext

因此不需要:

1
2
3
Resource Server
↓ 每次请求
Authorization Server

做远程校验。


如果不用 JWT 怎么办

资料还总结了两个典型方法。

共享 TokenStore

1
2
3
4
5
6
7
Authorization Server

Redis

Resource Server

Redis

例如:

1
2
RedisTokenStore
JdbcTokenStore

Resource Server 查询共享状态。

RemoteTokenServices

Resource Server 请求:

1
/oauth/check_token

例如:

1
2
3
4
curl -X POST \
-u userservice1:1234 \
-d "token=$ACCESS_TOKEN" \
http://localhost:8080/oauth/check_token

Authorization Server 返回:

1
2
3
4
5
6
7
8
{
"active": true,
"user_name": "writer",
"client_id": "userservice1",
"scope": [
"FOO"
]
}

三种方案可以理解成:

1
2
3
4
5
6
7
8
JWT
-> 本地验证

Redis/JDBC TokenStore
-> 共享状态验证

RemoteTokenServices
-> 远程验证

搭建真正的 OAuth Client

到现在为止,我们一直:

1
Postman / curl

手动完成 OAuth 流程。

接下来创建:

1
oauth2-client

让 Spring 自动完成:

1
2
3
4
5
6
Authorization Code
获取 Token
保存 Token
Bearer Token
远程调用
SSO

目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
oauth2-client
├── pom.xml
└── src/main
├── java
│ └── com.example.oauth.client
│ ├── OAuthClientApplication.java
│ ├── WebMvcConfig.java
│ ├── WebSecurityConfig.java
│ ├── OAuthClientConfig.java
│ └── DemoController.java

└── resources
├── application.yml
└── templates
├── index.html
└── securedPage.html

Client Maven 依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">

<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>com.example</groupId>
<artifactId>spring-security-oauth2-demo</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>

<artifactId>oauth2-client</artifactId>

<dependencies>

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

</dependencies>

</project>

Client application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
server:
port: 8082

servlet:
context-path: /ui

security:
oauth2:

client:
client-id: userservice3
client-secret: 1234

access-token-uri:
http://localhost:8080/oauth/token

user-authorization-uri:
http://localhost:8080/oauth/authorize

scope:
- FOO

resource:
jwt:
key-value: |
-----BEGIN PUBLIC KEY-----
把 public.cert 中的 RSA 公钥放在这里
-----END PUBLIC KEY-----

spring:
thymeleaf:
cache: false

资料中特别提到,本地运行 Client 和 Authorization Server 时,为 Client 增加独立的 context-path 可以避免 Cookie 相互干扰导致 CSRF 防护被触发,这类问题日志默认又不明显,是一个实际排错时很容易踩的坑。


Client 启动类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.example.oauth.client;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class OAuthClientApplication {

public static void main(String[] args) {

SpringApplication.run(
OAuthClientApplication.class,
args
);
}
}

MVC 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package com.example.oauth.client;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.context.request.RequestContextListener;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfig
implements WebMvcConfigurer {

@Bean
public RequestContextListener
requestContextListener() {

return new RequestContextListener();
}

@Override
public void addViewControllers(
ViewControllerRegistry registry
) {

registry
.addViewController("/")
.setViewName("forward:/index");

registry
.addViewController("/index");
}
}

Client Security

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.example.oauth.client;

import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@Order(200)
public class WebSecurityConfig
extends WebSecurityConfigurerAdapter {

@Override
protected void configure(
HttpSecurity http
) throws Exception {

http
.authorizeRequests()

.antMatchers(
"/",
"/index",
"/login**"
)
.permitAll()

.anyRequest()
.authenticated();
}
}

开启 OAuth2 SSO

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.example.oauth.client;

import org.springframework.boot.autoconfigure.security.oauth2.client.EnableOAuth2Sso;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.client.OAuth2ClientContext;
import org.springframework.security.oauth2.client.OAuth2RestTemplate;
import org.springframework.security.oauth2.client.resource.OAuth2ProtectedResourceDetails;

@Configuration
@EnableOAuth2Sso
public class OAuthClientConfig {

@Bean
public OAuth2RestTemplate
oauth2RestTemplate(
OAuth2ClientContext context,
OAuth2ProtectedResourceDetails details) {

return new OAuth2RestTemplate(
details,
context
);
}
}

@EnableOAuth2Sso 会帮助 Client 建立 OAuth SSO 流程。


创建安全页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package com.example.oauth.client;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.client.OAuth2RestTemplate;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.ModelAndView;

@Controller
public class DemoController {

@Autowired
private OAuth2RestTemplate restTemplate;

@GetMapping("/securedPage")
public ModelAndView securedPage(
OAuth2Authentication authentication) {

return new ModelAndView(
"securedPage"
)
.addObject(
"authentication",
authentication
);
}

@ResponseBody
@GetMapping("/remoteCall")
public String remoteCall() {

ResponseEntity<String> response =
restTemplate.getForEntity(
"http://localhost:8081/user/name",
String.class
);

return response.getBody();
}
}

这里有两个功能。


securedPage

1
GET /securedPage

必须登录才能访问。

显示:

1
2
当前用户
当前 Authority

remoteCall

1
GET /remoteCall

内部使用:

1
OAuth2RestTemplate

自动完成:

1
2
3
4
5
获取 Access Token

添加 Authorization Header

调用 Resource Server

不需要手写:

1
2
3
4
request.setHeader(
"Authorization",
"Bearer " + accessToken
);

创建首页

templates/index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8"/>
<title>OAuth Client</title>
</head>

<body>

<h1>Spring Security OAuth Client</h1>

<a href="securedPage">
Login
</a>

<br/>
<br/>

<a href="remoteCall">
Call Resource Server
</a>

</body>

</html>

创建安全页面

templates/securedPage.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<!DOCTYPE html>

<html xmlns:th="http://www.thymeleaf.org">

<head>
<meta charset="UTF-8"/>
<title>Secured Page</title>
</head>

<body>

<h1>Secured Page</h1>

<p>
Welcome:
<span
th:text="${authentication.name}">
</span>
</p>

<p>
Authorities:
<span
th:text="${authentication.authorities}">
</span>
</p>

</body>

</html>

运行 Authorization Code Client

启动:

1
2
3
oauth2-server      :8080
oauth2-resource :8081
oauth2-client :8082

浏览器打开:

1
http://localhost:8082/ui/securedPage

此时不是直接访问成功。

而是发生:

1
2
3
4
5
8082

8080 /oauth/authorize

8080 /login

登录:

1
2
reader
reader

批准授权。

然后:

1
2
3
4
5
Authorization Server

Authorization Code

http://localhost:8082/ui/login?code=xxx

Client 自动:

1
2
3
4
5
Code

/oauth/token

Access Token

最终回到:

1
/securedPage

显示:

1
2
Welcome reader
Authorities [READ]

背后的 302 跳转

实际发生的大致链路是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
http://localhost:8082/ui/securedPage


http://localhost:8082/ui/login


http://localhost:8080/oauth/authorize
?client_id=userservice3
&redirect_uri=http://localhost:8082/ui/login
...


http://localhost:8082/ui/login
?code=XXXXXX
&state=XXXXXX


http://localhost:8082/ui/securedPage

你平时看到的:

1
“登录一下就回来了”

背后实际发生的是多次:

1
302 Redirect

资料中的 SSO 演示也直接展示了这组跳转链路。


测试自动调用 Resource Server

访问:

1
http://localhost:8082/ui/remoteCall

代码只有:

1
2
3
4
restTemplate.getForEntity(
"http://localhost:8081/user/name",
String.class
);

但背后:

1
2
3
4
5
6
7
8
9
OAuth2RestTemplate

OAuth2ClientContext

Access Token

Authorization: Bearer xxx

Resource Server

返回:

1
reader

这时整个 OAuth Client 流程已经真正跑通。


测试 SSO

现在复制一套 Client。

或者简单地:

1
把 Client 端口从 8082 改成 8083

再启动一个实例。

数据库为 Client 增加第二个回调地址的实际项目中需要单独注册;为了理解 SSO,这里重点观察 Authorization Server 的登录状态。

先登录:

1
http://localhost:8082/ui/securedPage

已经在:

1
Authorization Server

建立 Session。

然后访问:

1
http://localhost:8083/ui/securedPage

第二个 Client 仍然会发起:

1
Authorization Request

但是到:

1
Authorization Server

以后发现:

1
用户已经登录

因此不需要再次输入用户名密码。

这就是 SSO 的关键:

1
2
3
4
5
6
7
8
9
不是两个 Client 共享自己的 Session

而是:

两个 Client

同一个 Authorization Server

Authorization Server 已有认证 Session

SSO 的完整模型

sequenceDiagram
    actor U as 用户

    participant A as Client A
    participant B as Client B
    participant AS as Authorization Server

    U->>A: 访问 Client A

    A-->>U: Redirect AS

    U->>AS: Login

    AS->>AS: 建立 AS Session

    AS-->>A: Authorization Code

    A->>AS: Code 换 Token

    AS-->>A: Access Token

    Note over U,A: Client A 登录成功

    U->>B: 访问 Client B

    B-->>U: Redirect AS

    U->>AS: Authorization Request

    AS->>AS: 已有 Login Session

    AS-->>B: Authorization Code

    B->>AS: Code 换 Token

    AS-->>B: Access Token

    Note over U,B: 不需要重新输入密码

权限控制到底发生在哪

OAuth 项目中最容易混在一起的东西是:

1
2
3
4
Client
Scope
Authority
Resource

可以拆成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
client_id

这个 Client 是谁

scope

这个 Client 被允许访问什么范围

authority

当前主体在 Spring Security 中有什么权限

resource_id

Token 面向哪个 Resource Server

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Client:
userservice3

Scope:
FOO

User:
reader

Authority:
READ

Resource:
userservice

最终:

1
2
3
4
5
6
7
reader
+
userservice3
+
FOO
+
READ

共同构成授权上下文。


Spring Security 方法级权限

Resource Server:

1
2
3
4
5
6
7
8
9
@PreAuthorize(
"hasAuthority('READ') or hasAuthority('WRITE')"
)
@GetMapping("/name")
public String name(
OAuth2Authentication authentication) {

return authentication.getName();
}

以及:

1
2
3
4
5
6
7
8
9
@PreAuthorize(
"hasAuthority('WRITE')"
)
@PostMapping
public Object write(
OAuth2Authentication authentication) {

// ...
}

执行过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
HTTP Request

Bearer Token

Resource Server Filter

JWT Verify

OAuth2Authentication

SecurityContext

@PreAuthorize

Controller

因此:

1
JWT 验证成功

不等于:

1
一定能访问 API

还需要:

1
Authorization

真实业务里还必须做对象级权限

OAuth Token 有:

1
order.read

也不能这样:

1
2
3
4
5
6
7
8
@GetMapping("/orders/{id}")
public Order get(
@PathVariable Long id) {

return orderRepository
.findById(id)
.orElseThrow();
}

否则:

1
商家 A

只要猜到:

1
商家 B 的 orderId

就可能读到 B 的订单。

正确模型应该是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@GetMapping("/orders/{id}")
public Order getOrder(
@PathVariable Long id,
OAuth2Authentication authentication) {

String merchantId =
authentication.getName();

return orderRepository
.findByIdAndMerchantId(
id,
merchantId
)
.orElseThrow();
}

或者 SQL:

1
2
3
4
SELECT *
FROM orders
WHERE id = :orderId
AND merchant_id = :currentMerchantId;

这正是 OAuth 实践中常见的水平越权问题:网关或 Resource Server 即使已经校验 Token,也不能替代业务数据的归属校验。


完整请求调用链

整个系统跑通以后:

sequenceDiagram
    autonumber

    actor U as User

    participant C as OAuth Client

    participant AS as Authorization Server

    participant DB as OAuth DB

    participant RS as Resource Server

    U->>C: GET /securedPage

    C-->>U: 302 /oauth/authorize

    U->>AS: Authorization Request

    AS->>DB: 查询 client_id

    DB-->>AS: ClientDetails

    AS-->>U: Login

    U->>AS: username/password

    AS->>DB: 查询 User + Authority

    DB-->>AS: reader + READ

    AS-->>U: Consent

    U->>AS: Approve

    AS->>DB: 保存 Approval

    AS->>DB: 保存 Authorization Code

    AS-->>C: code

    C->>AS: code + client authentication

    AS->>DB: 查询并消费 Code

    AS->>AS: 创建 JWT

    AS->>AS: RSA Private Key Sign

    AS-->>C: Access Token

    C->>RS: Bearer JWT

    RS->>RS: Public Key Verify

    RS->>RS: 创建 OAuth2Authentication

    RS->>RS: @PreAuthorize

    RS-->>C: Protected Resource

    C-->>U: Response

这张图基本就是整个经典 Spring Security OAuth 2.0 实现的核心。


常见问题:401 和 403 怎么区分

401 Unauthorized

常见原因:

1
2
3
4
5
6
没有 Token
Token 格式错误
Token 过期
JWT 签名错误
公钥不正确
Resource ID 不匹配

排查:

1
2
3
4
5
6
7
8
9
Authorization Header

Bearer Token

JWT Signature

exp

Resource ID

403 Forbidden

常见原因:

1
2
Token 合法
但 Authority 不够

例如:

1
2
3
@PreAuthorize(
"hasAuthority('WRITE')"
)

当前 Token:

1
READ

所以:

1
403

常见问题:JWT 校验失败

如果出现:

1
Cannot convert access token to JSON

或者:

1
Invalid token

检查:

1
2
Authorization Server 的 private key
Resource Server 的 public key

是否来自同一个 KeyPair。

模型必须是:

1
2
3
4
5
6
7
private key A

sign

public key A

verify

而不是:

1
2
3
4
private key A


public key B

常见问题:Client Secret 不对

如果:

1
invalid_client

检查:

1
oauth_client_details.client_secret

和:

1
client_secret

是否一致。

本文为了复现实验使用:

1
NoOpPasswordEncoder

所以数据库直接:

1
1234

生产环境不要这么处理。


常见问题:Authorization Code 兑换失败

如果出现:

1
invalid_grant

检查:

1
2
3
4
code 是否已经使用
code 是否过期
redirect_uri 是否一致
Client 是否一致

尤其是:

1
redirect_uri

Authorization Request:

1
http://localhost:8082/ui/login

Token Request 也应该:

1
http://localhost:8082/ui/login

常见问题:为什么 Client Credentials 没有用户

因为:

1
grant_type=client_credentials

认证的是:

1
Client

不是:

1
Resource Owner

模型:

1
2
3
4
5
Order Service

Authorization Server

Inventory Token

而不是:

1
2
3
4
5
张三

Order Service

Inventory Service

所以:

1
getUserAuthentication()

可能就是:

1
null

这也是前面的:

1
CustomTokenEnhancer

必须先判断:

1
if (userAuthentication == null)

的原因。


常见问题:为什么 JWT 登出比较麻烦

JWT 的优势:

1
Resource Server 本地验证

但这也是它的代价。

假设:

1
Access Token 有效期 2 小时

用户:

1
2
10:00 登录
10:10 管理员禁用账号

Token 可能仍然写着:

1
exp = 12:00

如果 Resource Server 只本地验签:

1
2
Token signature valid
Token not expired

那么它并不知道:

1
账号已经在 10:10 被禁用

这就是:

1
JWT

与:

1
中心化 Session / Opaque Token

之间最典型的设计取舍。


实战项目中建议增加的安全措施

虽然本文重点是把代码跑通,但真实系统还应该至少增加:

1
2
3
4
5
6
7
8
9
10
11
12
HTTPS
Authorization Code 一次性
Redirect URI 严格匹配
state
PKCE
Access Token 短有效期
Refresh Token 管理
Client Secret 安全存储
JWT Claims 最小化
Object-Level Authorization
Token Revocation
安全审计

尤其不要把:

1
client_secret

放到:

1
2
3
4
5
JavaScript
SPA
Mobile App APK
Git
前端配置文件

里面。


从 Demo 演进到真实微服务架构

当系统只有:

1
Resource Server A

的时候,每个 Resource Server 自己验证 JWT 很简单。

但如果变成:

1
2
3
4
5
6
API Gateway
Order Service
Product Service
Payment Service
User Service
Inventory Service

通常会演进为:

flowchart LR
    C[Client]

    G[API Gateway]

    AS[Authorization Server]

    O[Order Service]
    P[Product Service]
    I[Inventory Service]

    C -->|Access Token| G

    G -->|Token Verify| AS

    G --> O
    G --> P
    G --> I

Gateway 做:

1
2
3
4
5
6
Token 基础验证
Client 校验
Scope 校验
路由
限流
日志

业务服务继续做:

1
2
3
Resource Ownership
Tenant Permission
Business Authorization

OAuth 2.0/JWT 微服务参考架构资料同样将 IDP、Gateway、BFF 和领域服务分层,并让 Gateway 负责 Token 校验和粗粒度权限判断,后端服务利用可信身份上下文继续完成领域逻辑。


实战项目结构回顾

最终工程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
spring-security-oauth2-demo

├── pom.xml

├── oauth2-server
│ ├── OAuth2ServerApplication
│ ├── OAuth2ServerConfiguration
│ ├── WebSecurityConfig
│ ├── MvcConfiguration
│ ├── CustomTokenEnhancer
│ ├── jwt.jks
│ └── login.html

├── oauth2-resource-server
│ ├── ResourceServerApplication
│ ├── ResourceServerConfiguration
│ ├── HelloController
│ ├── UserController
│ └── public.cert

└── oauth2-client
├── OAuthClientApplication
├── WebMvcConfig
├── WebSecurityConfig
├── OAuthClientConfig
├── DemoController
├── index.html
└── securedPage.html

建议按照这个顺序亲手验证

如果真的准备把这套 Demo 跑起来,不建议三个项目一把启动然后看结果。

最好按下面的顺序来。

第一阶段:

1
Authorization Server

先测试:

1
/oauth/token

能不能成功产生 Token。

第二阶段:

1
JWT

把 Token 解开,检查:

1
2
3
4
5
client_id
user_name
scope
authorities
exp

第三阶段:

1
Resource Server

分别测试:

1
2
3
GET /hello
GET /user
POST /user

确认:

1
2
3
4
anonymous
401
403
200

分别在什么情况下出现。

第四阶段:

1
2
3
Password Grant
Client Credentials
Authorization Code

手动把三种流程跑一遍。

第五阶段:

1
OAuth Client

再让:

1
OAuth2RestTemplate

自动完成同样的工作。

第六阶段:

1
2
8082
8083

同时启动两个 Client,观察 SSO。

这样你会发现 OAuth 2.0 不再是一堆:

1
2
3
4
code
token
scope
grant_type

而是一条非常清晰的执行链。


总结

这套实战最有价值的地方,并不是“记住几个 Spring Security OAuth 配置类”,而是可以真正观察 OAuth 2.0 中各个组件如何协作。

Authorization Server 负责:

1
2
3
4
5
6
7
用户认证
Client Authentication
Authorization
Authorization Code
Token
Refresh Token
JWT Sign

Resource Server 负责:

1
2
3
4
5
Bearer Token
JWT Verify
Authentication
Authority
Protected API

OAuth Client 负责:

1
2
3
4
5
6
Authorization Request
Redirect
接收 Authorization Code
交换 Access Token
维护授权上下文
调用 Resource Server

而数据库则承担:

1
2
3
4
5
User
Authority
Client
Authorization Code
Approval

当我们把代码全部串起来以后,Authorization Code 流程实际上就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
用户访问 Client

Client 跳转 Authorization Server

用户认证

用户授权

Authorization Code

Client 后端换 Access Token

Resource Server 校验 Token

Spring Security 权限判断

业务资源授权

Client Credentials 又把其中的“用户”拿掉,变成:

1
2
3
4
5
6
7
Service

Authorization Server

Machine Access Token

Another Service

JWT 则解决:

1
2
3
Authorization Server 签发

Resource Server 本地验证

真正掌握了这三个角色和这几条链路以后,再去学习 API Gateway、微服务服务间鉴权、OIDC、SSO、PKCE、Token Exchange,甚至迁移到新的 Spring Authorization Server,都会容易很多。因为框架的 API 会更新,但 OAuth 的角色、授权关系和 Token 流转边界不会因为一个注解被废弃就突然改变。


Spring Security OAuth 2.0 + JWT 实战:从零搭建授权服务器、资源服务器与 OAuth Client
https://allendericdalexander.github.io/2026/08/17/archtect/idp/Spring-Security-OAuth-2-0 -JWT/
作者
AtLuoFu
发布于
2026年8月17日
许可协议