基于Ruoyi-Cloud-Plus重构黑马项目-学成在线

基于Ruoyi-Cloud-Plus重构黑马项目-学成在线

一、系统介绍

毕设:基于主流微服务技术栈的在线教育系统的设计与实现

前端仓库:https://github.com/Xiamu-ssr/Dragon-Edu-Vue3
后端仓库:https://github.com/Xiamu-ssr/Dragon-Edu

感谢来自”疯狂的狮子”开源精神,RuoYi 微服务Plus版本:

文档地址: plus-doc

二、系统架构图

三、参考教程

主要以视频录制的方式展示。
包含:服务器选购,环境初始化,本地开发,部署系统,功能测试,性能测试,代码解析,架构探讨。

b站-木子dn

四、演示图例

机构端

运营端

用户端

开发端

微服务OAuth 2.1认证授权可行性方案(Spring Security 6)

微服务OAuth 2.1认证授权可行性方案(Spring Security 6)

一、背景

Oauth2停止维护,基于OAuth 2.1OpenID Connect 1.0Spring Authorization Server模块独立于SpringCloud

本文开发环境如下:

Version
Java 17
SpringCloud 2023.0.0
SpringBoot 3.2.1
Spring Authorization Server 1.2.1
Spring Security 6.2.1
mysql 8.2.0

https://spring.io/projects/spring-security#learn
https://spring.io/projects/spring-authorization-server#learn

二、微服务架构介绍

一个认证服务器(也是一个微服务),专门用于颁发JWT。
一个网关(也是一个微服务),用于白名单判断和JWT校验。
若干微服务。

本文的关键在于以下几点:

  • 搭建认证服务器
  • 网关白名单判断
  • 网关验证JWT
  • 认证服务器如何共享公钥,让其余微服务有JWT自校验的能力。

三、认证服务器

这里是官方文档https://spring.io/projects/spring-authorization-server#learn
基本上跟着Getting Started写完就可以。

1. 数据库创建

新建一个数据库xc_users
然后执行jar里自带的三个sql

这一步官方并没有给出,大概因为可以使用内存存储,在简单demo省去了持久化。不建立数据库可能也是可行的,我没试过。

2. 新建模块

新建一个auth模块,作为认证服务器。

3. 导入依赖和配置

language-xml
1
2
3
4
5
6
7
8
9
10
11
12
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>
language-yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
server:
servlet:
context-path: /auth
port: 63070
spring:
application:
name: auth-service
profiles:
active: dev
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.101.65:3306/xc_users?serverTimezone=UTC&userUnicode=true&useSSL=false&
username: root
password: 1009

4. 安全认证配置类

language-java
1
2
3
4
@Configuration
@EnableWebSecurity
public class AuthServerSecurityConfig {
}

里面包含诸多内容,有来自Spring Security的,也有来自的Spring Authorization Server的。

  1. UserDetailsService 的实例,用于检索用户进行身份验证。
language-java
1
2
3
4
5
6
7
8
9
10
@Bean
public UserDetailsService userDetailsService() {

UserDetails userDetails = User
.withUsername("lisi")
.password("456")
.roles("read")
.build();
return new InMemoryUserDetailsManager(userDetails);
}
  1. 密码编码器(可选,本文不用)
language-java
1
2
3
4
5
6
7
8
    @Bean
public PasswordEncoder passwordEncoder() {

// 密码为明文方式
return NoOpPasswordEncoder.getInstance();
// 或使用 BCryptPasswordEncoder
// return new BCryptPasswordEncoder();
}
  1. 协议端点的 Spring Security 过滤器链
language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
throws Exception {

OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0
http

// Redirect to the login page when not authenticated from the
// authorization endpoint
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
)
// Accept access tokens for User Info and/or Client Registration
.oauth2ResourceServer((resourceServer) -> resourceServer
.jwt(Customizer.withDefaults()));

return http.build();
}
  1. 用于身份验证的 Spring Security 过滤器链。
    至于哪些要校验身份,哪些不用,根据自己需求写。
language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Bean
@Order(2)
public SecurityFilterChain defaultFilterChain(HttpSecurity http) throws Exception {

http
.authorizeHttpRequests((authorize) ->
authorize
.requestMatchers(new AntPathRequestMatcher("/actuator/**")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/login")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/oauth2/**")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/**/*.html")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/**/*.json")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/auth/**")).permitAll()
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
);

return http.build();
}
  1. 自定义验证转化器(可选)
language-java
1
2
3
4
5
6
7
private JwtAuthenticationConverter jwtAuthenticationConverter() {

JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
// 此处可以添加自定义逻辑来提取JWT中的权限等信息
// jwtConverter.setJwtGrantedAuthoritiesConverter(...);
return jwtConverter;
}
  1. 用于管理客户端的 RegisteredClientRepository 实例
language-java
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
	@Bean
public RegisteredClientRepository registeredClientRepository() {

RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("XcWebApp")
// .clientSecret("{noop}XcWebApp")
.clientSecret("XcWebApp")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.redirectUri("http://www.51xuecheng.cn")
// .postLogoutRedirectUri("http://localhost:63070/login?logout")
.scope("all")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.scope("message.read")
.scope("message.write")
.scope("read")
.scope("write")
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofHours(2)) // 设置访问令牌的有效期
.refreshTokenTimeToLive(Duration.ofDays(3)) // 设置刷新令牌的有效期
.reuseRefreshTokens(true) // 是否重用刷新令牌
.build())
.build();

return new InMemoryRegisteredClientRepository(registeredClient);
}
  1. 用于对访问令牌进行签名的实例
language-java
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
@Bean
public JWKSource<SecurityContext> jwkSource() {

KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
JWKSet jwkSet = new JWKSet(rsaKey);
return new ImmutableJWKSet<>(jwkSet);
}
private static KeyPair generateRsaKey() {

KeyPair keyPair;
try {

KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
keyPair = keyPairGenerator.generateKeyPair();
}
catch (Exception ex) {

throw new IllegalStateException(ex);
}
return keyPair;
}
  1. 用于解码签名访问令牌的JwtDecoder 实例
language-java
1
2
3
4
5
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {

return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
  1. 用于配置Spring Authorization ServerAuthorizationServerSettings 实例
language-java
1
2
3
4
5
@Bean
public AuthorizationServerSettings authorizationServerSettings() {

return AuthorizationServerSettings.builder().build();
}

这里可以设置各种端点的路径,默认路径点开builder()即可看到,如下

language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static Builder builder() {

return new Builder()
.authorizationEndpoint("/oauth2/authorize")
.deviceAuthorizationEndpoint("/oauth2/device_authorization")
.deviceVerificationEndpoint("/oauth2/device_verification")
.tokenEndpoint("/oauth2/token")
.jwkSetEndpoint("/oauth2/jwks")
.tokenRevocationEndpoint("/oauth2/revoke")
.tokenIntrospectionEndpoint("/oauth2/introspect")
.oidcClientRegistrationEndpoint("/connect/register")
.oidcUserInfoEndpoint("/userinfo")
.oidcLogoutEndpoint("/connect/logout");
}

这里我必须吐槽一下,qnmd /.well-known/jwks.json,浪费我一下午。获取公钥信息的端点现在已经替换成了/oauth2/jwks。

四、认证服务器测试

基本上跟着Getting Started走就行。只不过端点的变动相较于Oauth2很大,还有使用方法上不同。

在配置RegisteredClient的时候,我们设置了三种GrantType,这里只演示两种AUTHORIZATION_CODECLIENT_CREDENTIALS

1. AUTHORIZATION_CODE(授权码模式)

1. 获取授权码

用浏览器打开以下网址,

language-txt
1
http://localhost:63070/auth/oauth2/authorize?client_id=XcWebApp&response_type=code&scope=all&redirect_uri=http://www.51xuecheng.cn

对应oauth2/authorize端点,后面的参数和当时设置RegisteredClient 保持对应就行。response_type一定是code
进入到登陆表单,输入lisi - 456登陆。

选择all,同意请求。

url被重定向到http://www.51xuecheng.cn,并携带一个code,这就是授权码。

language-txt
1
http://www.51xuecheng.cn/?code=9AexK_KFH1m3GiNBKsc0FU2KkedM2h_6yR-aKF-wPnpQT5USKLTqoZiSkHC3GUvt-56_ky-E3Mv5LbMeH9uyd-S1UV6kfJO6znqAcCAF43Yo4ifxTAQ8opoPJTjLIRUC

2. 获取JWT

使用apifox演示,postmanidea-http都可以。
localhost:63070/auth服务的/oauth2/token端点发送Post请求,同时需要携带认证信息。
认证信息可以如图所填的方法,也可以放到Header中,具体做法是将客户端ID和客户端密码用冒号(:)连接成一个字符串,进行Base64编码放入HTTP请求的Authorization头部中,前缀为Basic 。比如
Authorization: Basic bXlDbGllbnRJZDpteUNsaWVudFNlY3JldA==

得到JWT

2. CLIENT_CREDENTIALS(客户端凭证模式)

不需要授权码,直接向localhost:63070/auth服务的/oauth2/token端点发送Post请求,同时需要携带认证信息。

五、Gateway

至于gateway基础搭建步骤和gateway管理的若干微服务本文不做指导。

相较于auth模块(也就是Authorization Server),gateway的角色是Resource Server

1. 引入依赖

language-xml
1
2
3
4
5
6
7
8
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

2. 添加白名单文件

resource下添加security-whitelist.properties文件。
写入以下内容

language-properties
1
2
3
/auth/**=????
/content/open/**=??????????
/media/open/**=??????????

3. 全局过滤器

在全局过滤器中,加载白名单,然后对请求进行判断。

language-java
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
@Component
@Slf4j
public class GatewayAuthFilter implements GlobalFilter, Ordered {


//白名单
private static List<String> whitelist = null;

static {

//加载白名单
try (
InputStream resourceAsStream = GatewayAuthFilter.class.getResourceAsStream("/security-whitelist.properties");
) {

Properties properties = new Properties();
properties.load(resourceAsStream);
Set<String> strings = properties.stringPropertyNames();
whitelist= new ArrayList<>(strings);

} catch (Exception e) {

log.error("加载/security-whitelist.properties出错:{}",e.getMessage());
e.printStackTrace();
}
}


@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {

String requestUrl = exchange.getRequest().getPath().value();
log.info("请求={}",requestUrl);
AntPathMatcher pathMatcher = new AntPathMatcher();
//白名单放行
for (String url : whitelist) {

if (pathMatcher.match(url, requestUrl)) {

return chain.filter(exchange);
}
}
}

private Mono<Void> buildReturnMono(String error, ServerWebExchange exchange) {

ServerHttpResponse response = exchange.getResponse();
String jsonString = JSON.toJSONString(new RestErrorResponse(error));
byte[] bits = jsonString.getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = response.bufferFactory().wrap(bits);
response.setStatusCode(HttpStatus.UNAUTHORIZED);
response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
return response.writeWith(Mono.just(buffer));
}

@Override
public int getOrder() {

return 0;
}
}

4. 获取远程JWKS

yml配置中添加jwk-set-uri属性。

language-yml
1
2
3
4
5
6
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: http://localhost:63070/auth/oauth2/jwks

新建配置类,自动注入JwtDecoder

language-java
1
2
3
4
5
6
7
8
9
10
11
12
@Configuration
public class JwtDecoderConfig {

@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
String jwkSetUri;
@Bean
public JwtDecoder jwtDecoderLocal() {

return NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
}
}

5. 校验JWT

在全局过滤器中补全逻辑。

language-java
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
@Component
@Slf4j
public class GatewayAuthFilter implements GlobalFilter, Ordered {


@Lazy
@Autowired
private JwtDecoder jwtDecoderLocal;

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {

String requestUrl = exchange.getRequest().getPath().value();
log.info("请求={}",requestUrl);
AntPathMatcher pathMatcher = new AntPathMatcher();
//白名单放行
for (String url : whitelist) {

if (pathMatcher.match(url, requestUrl)) {

return chain.filter(exchange);
}
}

//检查token是否存在
String token = getToken(exchange);
log.info("token={}",token);
if (StringUtils.isBlank(token)) {

return buildReturnMono("没有携带Token,没有认证",exchange);
}
// return chain.filter(exchange);
try {

Jwt jwt = jwtDecoderLocal.decode(token);
// 如果没有抛出异常,则表示JWT有效

// 此时,您可以根据需要进一步检查JWT的声明
log.info("token有效期至:{}", formatInstantTime(jwt.getExpiresAt()));
return chain.filter(exchange);
} catch (JwtValidationException e) {

log.info("token验证失败:{}",e.getMessage());
return buildReturnMono("认证token无效",exchange);
}
}

/**
* 从请求头Authorization中获取token
*/
private String getToken(ServerWebExchange exchange) {

String tokenStr = exchange.getRequest().getHeaders().getFirst("Authorization");
if (StringUtils.isBlank(tokenStr)) {

return null;
}
String token = tokenStr.split(" ")[1];
if (StringUtils.isBlank(token)) {

return null;
}
return token;
}

/**
* 格式化Instant时间
*
* @param expiresAt 在到期
* @return {@link String}
*/
public String formatInstantTime(Instant expiresAt) {

// 将Instant转换为系统默认时区的LocalDateTime
LocalDateTime dateTime = LocalDateTime.ofInstant(expiresAt, ZoneId.systemDefault());

// 定义日期时间的格式
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

// 格式化日期时间并打印
return dateTime.format(formatter);
}

}

6. 测试(如何携带JWT)

携带一个正确的JWTgateway发送请求。
JWT写到HeaderAuthorization字段中,添加前缀Bearer(用空格隔开),向gateway微服务所在地址发送请求。

gateway日志输出。

六、后记

颁发JWT都归一个认证服务器管理,校验JWT都归Gateway管理,至于授权,则由各个微服务自己定义。耦合性低、性能较好。

关于授权,可以接着这篇文章。
微服务OAuth 2.1认证授权Demo方案(Spring Security 6)

微服务OAuth 2.1认证授权Demo方案(Spring Security 6)

微服务OAuth 2.1认证授权Demo方案(Spring Security 6)

书接上文
微服务OAuth 2.1认证授权可行性方案(Spring Security 6)

一、介绍

三个微服务

  • auth微服务作为认证服务器,用于颁发JWT
  • gateway微服务作为网关,用于拦截过滤。
  • content微服务作为资源服务器,用于校验授权。

以下是授权相关数据库。

  • user表示用户表
  • role表示角色表
  • user_role关联了用户和角色,表示某个用户是是什么角色。一个用户可以有多个角色
  • menu表示资源权限表。@PreAuthorize("hasAuthority('xxx')")时用的就是这里的code
  • permission关联了角色和资源权限,表示某个角色用于哪些资源访问权限,一个角色有多个资源访问权限。

当我们知道userId,我们就可以知道这个用户可以访问哪些资源,并把这些权限(也就是menu里的code字段)写成数组,写到JWT的负载部分的authorities字段中。当用户携带此JWT访问具有@PreAuthorize("hasAuthority('xxx')")修饰的资源时,我们解析出JWT中的authorities字段,判断是否包含hasAuthority指定的xxx权限,以此来完成所谓的的”授权”。

二、auth微服务代码

1. SecurityConfig

language-java
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
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
package com.xuecheng.auth.config;

import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
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.configurers.AbstractHttpConfigurer;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;

import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;


/**
* 身份验证服务器安全配置
*
* @author mumu
* @date 2024/02/13
*/
//@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
@Configuration
@EnableWebSecurity
public class AuthServerSecurityConfig {



private static KeyPair generateRsaKey() {

KeyPair keyPair;
try {

KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
keyPair = keyPairGenerator.generateKeyPair();
} catch (Exception ex) {

throw new IllegalStateException(ex);
}
return keyPair;
}

/**
* 密码编码器
* 用于加密认证服务器client密码和用户密码
*
* @return {@link PasswordEncoder}
*/
@Bean
public PasswordEncoder passwordEncoder() {

// 密码为明文方式
// return NoOpPasswordEncoder.getInstance();
// 或使用 BCryptPasswordEncoder
return new BCryptPasswordEncoder();
}

/**
* 授权服务器安全筛选器链
* <br/>
* 来自Spring Authorization Server示例,用于暴露Oauth2.1端点,一般不影响常规的请求
*
* @param http http
* @return {@link SecurityFilterChain}
* @throws Exception 例外
*/
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
throws Exception {

OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0
http
// Redirect to the login page when not authenticated from the
// authorization endpoint
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
)
// Accept access tokens for User Info and/or Client Registration
.oauth2ResourceServer((resourceServer) -> resourceServer
.jwt(Customizer.withDefaults()));

return http.build();
}

/**
* 默认筛选器链
* <br/>
* 这个才是我们需要关心的过滤链,可以指定哪些请求被放行,哪些请求需要JWT验证
*
* @param http http
* @return {@link SecurityFilterChain}
* @throws Exception 例外
*/
@Bean
@Order(2)
public SecurityFilterChain defaultFilterChain(HttpSecurity http) throws Exception {

http
.authorizeHttpRequests((authorize) ->
authorize
.requestMatchers(new AntPathRequestMatcher("/actuator/**")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/login")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/logout")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/wxLogin")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/register")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/oauth2/**")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/**/*.html")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/**/*.json")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/auth/**")).permitAll()
.anyRequest().authenticated()
)
.csrf(AbstractHttpConfigurer::disable)
//指定logout端点,用于退出登陆,不然二次获取授权码时会自动登陆导致短时间内无法切换用户
.logout(logout -> logout
.logoutUrl("/logout")
.addLogoutHandler(new SecurityContextLogoutHandler())
.logoutSuccessUrl("http://www.51xuecheng.cn")
)
.formLogin(Customizer.withDefaults())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())
// .jwt(jwt -> jwt
// .jwtAuthenticationConverter(jwtAuthenticationConverter())
// )
);

return http.build();
}

private JwtAuthenticationConverter jwtAuthenticationConverter() {

JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
return jwtConverter;
}

/**
* 客户端管理实例
* <br/>
* 来自Spring Authorization Server示例
*
* @return {@link RegisteredClientRepository}
*/
@Bean
public RegisteredClientRepository registeredClientRepository() {

RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("XcWebApp")
.clientSecret(passwordEncoder().encode("XcWebApp"))
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.redirectUri("http://www.51xuecheng.cn")
.redirectUri("http://localhost:63070/auth/wxLogin")
.redirectUri("http://www.51xuecheng.cn/sign.html")
// .postLogoutRedirectUri("http://localhost:63070/login?logout")
.scope("all")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.scope("message.read")
.scope("message.write")
.scope("read")
.scope("write")
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofHours(2)) // 设置访问令牌的有效期
.refreshTokenTimeToLive(Duration.ofDays(3)) // 设置刷新令牌的有效期
.reuseRefreshTokens(true) // 是否重用刷新令牌
.build())
.build();

return new InMemoryRegisteredClientRepository(registeredClient);
}

/**
* jwk源
* <br/>
* 对访问令牌进行签名的示例,里面包含公私钥信息。
*
* @return {@link JWKSource}<{@link SecurityContext}>
*/
@Bean
public JWKSource<SecurityContext> jwkSource() {

KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
JWKSet jwkSet = new JWKSet(rsaKey);
return new ImmutableJWKSet<>(jwkSet);
}

/**
* jwt解码器
* <br/>
* JWT解码器,主要就是基于公钥信息来解码
*
* @param jwkSource jwk源
* @return {@link JwtDecoder}
*/
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {

return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}

@Bean
public AuthorizationServerSettings authorizationServerSettings() {

return AuthorizationServerSettings.builder().build();
}

/**
* JWT定制器
* <BR/>
* 可以往JWT从加入额外信息,这里是加入authorities字段,是一个权限数组。
*
* @return {@link OAuth2TokenCustomizer}<{@link JwtEncodingContext}>
*/
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> jwtTokenCustomizer() {

return context -> {

Authentication authentication = context.getPrincipal();
if (authentication.getPrincipal() instanceof UserDetails userDetails) {

List<String> authorities = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
context.getClaims().claim("authorities", authorities);
}
};
}
}

这里需要注意几点

  • 使用BCryptPasswordEncoder密码加密,在设置clientSecret时需要手动使用密码编码器。
  • jwtTokenCustomizer解析UserDetails然后往JWT中添加authorities字段,为了后面的授权。

2. UserDetailsService

language-java
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
package com.xuecheng.ucenter.service.impl;


import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.xuecheng.ucenter.mapper.XcMenuMapper;
import com.xuecheng.ucenter.mapper.XcUserMapper;
import com.xuecheng.ucenter.model.dto.AuthParamsDto;
import com.xuecheng.ucenter.model.dto.XcUserExt;
import com.xuecheng.ucenter.model.po.XcMenu;
import com.xuecheng.ucenter.model.po.XcUser;
import com.xuecheng.ucenter.service.AuthService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

@Component
@Slf4j
public class UserServiceImpl implements UserDetailsService {


@Autowired
private MyAuthService myAuthService;
@Autowired
XcMenuMapper xcMenuMapper;


/**
* 用户统一认证
*
* @param s 用户信息Json字符串
* @return {@link UserDetails}
* @throws UsernameNotFoundException 找不到用户名异常
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

XcUserExt xcUserExt = myAuthService.execute(username);
return getUserPrincipal(xcUserExt);
}

public UserDetails getUserPrincipal(XcUserExt user){

//用户权限,如果不加报Cannot pass a null GrantedAuthority collection
List<XcMenu> xcMenus = xcMenuMapper.selectPermissionByUserId(user.getId());
String[] permissions = {
"read"};
if (ObjectUtils.isNotEmpty(xcMenus)){

permissions = xcMenus.stream().map(XcMenu::getCode).toList().toArray(String[]::new);
log.info("权限如下:{}", Arrays.toString(permissions));
}
//为了安全在令牌中不放密码
String password = user.getPassword();
user.setPassword(null);
//将user对象转json
String userString = JSON.toJSONString(user);
//创建UserDetails对象
return User.withUsername(userString).password(password).authorities(permissions).build();
}
}

这里需要注意几点

  • username就是前端/auth/login的时候输入的账户名。
  • myAuthService.execute(username)不抛异常,就默认表示账户存在,此时将password加入UserDetails 并返回,Spring Authorization Server对比校验两个密码。
  • myAuthService.execute(username)根据username获取用户信息返回,将用户信息存入withUsername中,Spring Authorization Server默认会将其加入到JWT中。
  • 现在Spring Authorization Server默认不会把authorities(permissions)写入JWT,需要配合OAuth2TokenCustomizer手动写入。

3. 总结

这样,auth微服务颁发的JWT,现在就会包含authorities字段。示例如下

language-json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{

"active": true,
"sub": "{\"cellphone\":\"17266666637\",\"createTime\":\"2024-02-13 10:33:13\",\"email\":\"1138882663@qq.com\",\"id\":\"012f3a90-2bc9-4a2c-82a3-f9777c9ac10a\",\"name\":\"xiamu\",\"nickname\":\"xiamu\",\"permissions\":[],\"status\":\"1\",\"updateTime\":\"2024-02-13 10:33:13\",\"username\":\"xiamu\",\"utype\":\"101001\",\"wxUnionid\":\"test\"}",
"aud": [
"XcWebApp"
],
"nbf": 1707830437,
"scope": "all",
"iss": "http://localhost:63070/auth",
"exp": 1707837637,
"iat": 1707830437,
"jti": "8a657c60-968f-4d98-8a4c-22a7b4ecd333",
"authorities": [
"xc_sysmanager",
"xc_sysmanager_company",
"xc_sysmanager_doc",
"xc_sysmanager_log",
"xc_teachmanager_course_list"
],
"client_id": "XcWebApp",
"token_type": "Bearer"
}

三、gateway微服务代码

1. 统一处理CORS问题

language-java
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
@EnableWebFluxSecurity
@Configuration
public class SecurityConfig {

//安全拦截配置
@Bean
public SecurityWebFilterChain webFluxSecurityFilterChain(ServerHttpSecurity http) throws Exception {

return http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeExchange(exchanges ->
exchanges
.pathMatchers("/**").permitAll()
.anyExchange().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.build();
}

@Bean
public CorsConfigurationSource corsConfigurationSource() {

CorsConfiguration corsConfig = new CorsConfiguration();
corsConfig.addAllowedOriginPattern("*"); // 允许任何源
corsConfig.addAllowedMethod("*"); // 允许任何HTTP方法
corsConfig.addAllowedHeader("*"); // 允许任何HTTP头
corsConfig.setAllowCredentials(true); // 允许证书(cookies)
corsConfig.setMaxAge(3600L); // 预检请求的缓存时间(秒)

UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfig); // 对所有路径应用这个配置
return source;
}
}

这里需要注意几点

  • 书接上文,这里虽然用了oauth2.jwt(Customizer.withDefaults()),但实际上基于远程auth微服务开放的jwkSetEndpoint配置的JwtDecoder
  • .cors(cors -> cors.configurationSource(corsConfigurationSource()))一次性处理CORS问题。

四、content微服务代码

1. controller

language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@PreAuthorize("hasAuthority('xc_teachmanager_course_list')")
@ApiResponse(responseCode = "200", description = "Successfully retrieved user")
@Operation(summary = "查询课程信息列表")
@PostMapping("/course/list")
public PageResult<CourseBase> list(
PageParams pageParams,
@Parameter(description = "请求具体内容") @RequestBody(required = false) QueryCourseParamsDto dto){

SecurityUtil.XcUser xcUser = SecurityUtil.getUser();
if (xcUser != null){

System.out.println(xcUser.getUsername());
System.out.println(xcUser.toString());
}
return courseBaseInfoService.queryCourseBaseList(pageParams, dto);
}

使用了@PreAuthorize("hasAuthority('xc_teachmanager_course_list')")修饰的controller资源。

2. SecurityConfig

language-java
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
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

//安全拦截配置
@Bean
public SecurityFilterChain defaultFilterChain(HttpSecurity http) throws Exception {

http
.authorizeHttpRequests((authorize) ->
authorize
.requestMatchers("/**").permitAll()
.anyRequest().authenticated()
)
.csrf(AbstractHttpConfigurer::disable)
.oauth2ResourceServer(oauth -> oauth.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
return http.build();
}

private JwtAuthenticationConverter jwtAuthenticationConverter() {

JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
jwtConverter.setJwtGrantedAuthoritiesConverter(jwt -> {

// 从JWT的claims中提取权限信息
List<String> authorities = jwt.getClaimAsStringList("authorities");
if (authorities == null) {

return Collections.emptyList();
}
return authorities.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
});
return jwtConverter;
}
}

需要注意几点

  • 使用@EnableMethodSecurity@PreAuthorize生效
  • gateway一样,需要基于远程auth微服务开放的jwkSetEndpoint配置JwtDecoder
  • 指定JwtAuthenticationConverter ,让anyRequest().authenticated()需要验证的请求,除了完成默认的JWT验证外,还需要完成JwtAuthenticationConverter 指定逻辑。
  • JwtAuthenticationConverter 中将JWTauthorities部分形成数组后写入GrantedAuthorities,这正是spring security6用于校验@PreAuthorize的字段。

3. 解析JWT Utils

language-java
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
@Slf4j
public class SecurityUtil {

public static XcUser getUser(){

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null){

return null;
}
if (authentication instanceof JwtAuthenticationToken) {

JwtAuthenticationToken jwtAuth = (JwtAuthenticationToken) authentication;
System.out.println(jwtAuth);
Map<String, Object> tokenAttributes = jwtAuth.getTokenAttributes();
System.out.println(tokenAttributes);
Object sub = tokenAttributes.get("sub");
return JSON.parseObject(sub.toString(), XcUser.class);
}
return null;
}
@Data
public static class XcUser implements Serializable {


private static final long serialVersionUID = 1L;

private String id;

private String username;

private String password;

private String salt;

private String name;
private String nickname;
private String wxUnionid;
private String companyId;
/**
* 头像
*/
private String userpic;

private String utype;

private LocalDateTime birthday;

private String sex;

private String email;

private String cellphone;

private String qq;

/**
* 用户状态
*/
private String status;

private LocalDateTime createTime;

private LocalDateTime updateTime;

}
}

JWT的信息解析回XcUser ,相当于用户携带JWT访问后端,后端可以根据JWT获取此用户的信息。当然,你可以尽情的自定义,扩展。

4. 总结

当用户携带JWT访问需要权限的资源时,现在可以正常的校验权限了。

五、一些坑

  1. RegisteredClient时注册那么多redirectUri是因为debug了很久,才发现获取授权码和获取JWT时,redirect_uri参数需要一致。
  2. cors问题,spring secuity6似乎会一开始直接默认拒绝cors,导致跨域请求刚到gateway就寄了,到不了content微服务,即使content微服务配置了CORS的处理方案,也无济于事。