Redis之缓存穿透问题解决方案实践SpringBoot3+Docker

Redis之缓存穿透问题解决方案实践SpringBoot3+Docker

一、介绍

当一种请求,总是能越过缓存,调用数据库,就是缓存穿透。

比如当请求一个数据库没有的数据,那么缓存也不会有,然后就一直请求,甚至高并发去请求,对数据库压力会增大。

二、方案介绍

  1. 如果key具有某种规则,那么可以对key增加校验机制,不符合直接返回。
  2. Redisson布隆过滤器
  3. 逻辑修改,当数据库没有此数据,以nullvalue,也插入redis缓存,但设置较短的过期时间。

三、Redis Docker部署

docker-compose示例如下,redis.conf从这里下载

language-yml
1
2
3
4
5
6
7
8
redis:
container_name: redis
image: redis:7.2
volumes:
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf
ports:
- "6379:6379"
command: [ "redis-server", "/usr/local/etc/redis/redis.conf" ]

四、SpringBoot3 Base代码

1. 依赖配置

language-xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- redis 连接线程池 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.11.1</version>
</dependency>
<!-- redisson -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.24.3</version>
</dependency>
language-yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
spring: 
data:
redis:
host: 192.168.101.65 # Redis服务器的主机名或IP地址
port: 6379 # Redis服务器的端口号
password: # 用于连接Redis服务器的密码
database: 0 # 要连接的Redis数据库的索引号
lettuce:
pool:
max-active: 20 # 连接池中最大的活跃连接数
max-idle: 10 # 连接池中最大的空闲连接数
min-idle: 0 # 连接池中最小的空闲连接数
timeout: 10000 # 连接超时时间(毫秒)
lock-watchdog-timeout: 100 # Redisson的分布式锁的看门狗超时时间(毫秒)

2. 基本代码

要演示的代码很简单,就是一个携带courseId请求过来,调用下面的service函数,然后查询数据库。

language-java
1
2
3
4
5
@Override
public CoursePublish getCoursePublish(Long courseId) {

return coursePublishMapper.selectById(courseId);
}

当我们使用redis改造时,基本代码如下

language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override
public CoursePublish getCoursePublishCache(Long courseId) {

String key = "content:course:publish:" + courseId;
//先查询redis
Object object = redisTemplate.opsForValue().get(key);
if (object != null){

String string = object.toString();
CoursePublish coursePublish = JSON.parseObject(string, CoursePublish.class);
return coursePublish;
}else {

//后查询数据库
CoursePublish coursePublish = getCoursePublish(courseId);
if (coursePublish != null){

redisTemplate.opsForValue().set(key, JSON.toJSONString(coursePublish));
}
return coursePublish;
}
}

五、缓存优化代码

1. 校验机制

我这里的id没规则,所以加不了,跳过。

2. 布隆过滤器

读取yaml配置

language-java
1
2
3
4
5
6
7
8
9
10
11
12
@Data
@Component
@ConfigurationProperties(prefix = "spring.data.redis")
public class RedisProperties {

private String host;
private int port;
private String password;
private int database;
private int lockWatchdogTimeout;
}

配置RedissonClient

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
@Slf4j
@Configuration
public class RedissionConfig {


@Autowired
private RedisProperties redisProperties;

@Bean
public RedissonClient redissonClient() {

RedissonClient redissonClient;

Config config = new Config();
//starter依赖进来的redisson要以redis://开头,其他不用
String url = "redis://"+ redisProperties.getHost() + ":" + redisProperties.getPort();
config.useSingleServer().setAddress(url)
//.setPassword(redisProperties.getPassword())
.setDatabase(redisProperties.getDatabase());

try {

redissonClient = Redisson.create(config);
return redissonClient;
} catch (Exception e) {

log.error("RedissonClient init redis url:[{}], Exception:", url, e);
return null;
}
}
}

把布隆过滤器加到service,如下

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
private RBloomFilter<String> bloomFilter;

@PostConstruct
public void init(){

//初始化布隆过滤器
bloomFilter = redissonClient.getBloomFilter("bloom-filter");
bloomFilter.tryInit(100, 0.003);
List<CoursePublish> coursePublishList = coursePublishMapper.selectList(new LambdaQueryWrapper<CoursePublish>());
coursePublishList.forEach(coursePublish -> {

String key = "content:course:publish:" + coursePublish.getId();
bloomFilter.add(key);
});
}

@Override
public CoursePublish getCoursePublishCache(Long courseId) {

String key = "content:course:publish:" + courseId;
//布隆过滤器
boolean contains = bloomFilter.contains(key);
if (!contains){

return null;
}
//先查询redis
Object object = redisTemplate.opsForValue().get(key);
if (object != null){

String string = object.toString();
CoursePublish coursePublish = JSON.parseObject(string, CoursePublish.class);
return coursePublish;
}else {

//后查询数据库
CoursePublish coursePublish = getCoursePublish(courseId);
if (coursePublish != null){

bloomFilter.add(key);
redisTemplate.opsForValue().set(key, JSON.toJSONString(coursePublish));
}
return coursePublish;
}
}

3. 逻辑优化

当数据库没有此数据,以nullvalue,也插入redis缓存,但设置较短的过期时间。

language-java
1
2
3
4
5
6
7
8
9
10
11
//后查询数据库
CoursePublish coursePublish = getCoursePublish(courseId);
if (coursePublish != null) {

bloomFilter.add(key);
redisTemplate.opsForValue().set(key, JSON.toJSONString(coursePublish));
}else {

redisTemplate.opsForValue().set(key, JSON.toJSONString(coursePublish), 10, TimeUnit.SECONDS);
}
return coursePublish;
Redis之缓存雪崩问题解决方案

Redis之缓存雪崩问题解决方案

一、书接上文

Redis之缓存穿透问题解决方案实践SpringBoot3+Docker

二、介绍

缓存雪崩,指大量的缓存失效,大量的请求又同时落在数据库。主要的一种诱因是key设置的过期时间都一样。

三、解决方案

1. 锁

加锁,每次只让一个线程可以访问数据库,随后存入缓存。性能太差。

2. 不同的过期时间

最简单有效的解决办法是设置不同的过期时间。比如

language-java
1
2
int timeout = 10 + new Random().nextInt(20);
redisTemplate.opsForValue().set(key, JSON.toJSONString(coursePublish), timeout, TimeUnit.SECONDS);

3. 缓存预热和定时任务

使用缓存预热,把数据提前放入缓存,然后根据过期时间,发布合理的定时任务,主动去更新缓存。
缓存预热参考代码如下。

language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Component
public class RedisHandler implements InitializingBean {

@Autowired
RedisTemplate redisTemplate;
@Autowired
CoursePublishMapper coursePublishMapper;

@Override
public void afterPropertiesSet() throws Exception {

List<CoursePublish> coursePublishList = coursePublishMapper.selectList(new LambdaQueryWrapper<CoursePublish>());
//缓存预热
coursePublishList.forEach(coursePublish -> {

String key = "content:course:publish:" + coursePublish.getId();
redisTemplate.opsForValue().set(key, JSON.toJSONString(coursePublish));
});
}
}

至于定时任务,可以使用xxl-job。具体使用方法,可以参考这个文章
Docker部署xxl-job调度器并结合SpringBoot测试

Spring AMQP(3.1.1)设置ConfirmCallback和ReturnsCallback

Spring AMQP(3.1.1)设置ConfirmCallback和ReturnsCallback

环境如下

Version
SpringBoot 3.2.1
spring-amqp 3.1.1
RabbitMq 3-management

一、起因

老版本的spring-amqpCorrelationData上设置ConfirmCallback。但是今天却突然发现correlationData.getFuture()没有addCallback函数了。

查询文档和帖子后,发现ConfirmCallbackReturnsCallback都需要在RabbitTemplate中设置,同时ConfirmCallback中默认无法得到消息内容,如果想在ConfirmCallback中把消息内容存到数据库等地方进行记录,怎么办呢?

参考手册

二、代码

1. 定义exchange和queue

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
@Slf4j
@Configuration
public class PayNotifyConfig{


//交换机
public static final String PAYNOTIFY_EXCHANGE_FANOUT = "paynotify_exchange_fanout";
//支付通知队列
public static final String PAYNOTIFY_QUEUE = "paynotify_queue";
//支付结果通知消息类型
public static final String MESSAGE_TYPE = "payresult_notify";


//声明交换机,且持久化
@Bean(PAYNOTIFY_EXCHANGE_FANOUT)
public FanoutExchange paynotify_exchange_fanout() {

// 三个参数:交换机名称、是否持久化、当没有queue与其绑定时是否自动删除
return new FanoutExchange(PAYNOTIFY_EXCHANGE_FANOUT, true, false);
}
//支付通知队列,且持久化
@Bean(PAYNOTIFY_QUEUE)
public Queue paynotify_queue() {

return QueueBuilder.durable(PAYNOTIFY_QUEUE).build();
}

//交换机和支付通知队列绑定
@Bean
public Binding binding_paynotify_queue(@Qualifier(PAYNOTIFY_QUEUE) Queue queue, @Qualifier(PAYNOTIFY_EXCHANGE_FANOUT) FanoutExchange exchange) {

return BindingBuilder.bind(queue).to(exchange);
}
}

2. RabbitTemplate

在上面的类中继续添加RabbitTemplate ,并设置ConfirmCallbackReturnsCallback

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
@Bean
public RabbitTemplate rabbitTemplate(final ConnectionFactory connectionFactory) {

final RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
//设置confirm callback
rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {

String body = "1";
if (correlationData instanceof EnhancedCorrelationData) {

body = ((EnhancedCorrelationData) correlationData).getBody();
}
if (ack) {

//消息投递到exchange
log.debug("消息发送到exchange成功:correlationData={},message_id={} ", correlationData, body);
System.out.println("消息发送到exchange成功:correlationData={},message_id={}"+correlationData+body);
} else {

log.debug("消息发送到exchange失败:cause={},message_id={}",cause, body);
System.out.println("消息发送到exchange失败:cause={},message_id={}"+cause+body);
}
});

//设置return callback
rabbitTemplate.setReturnsCallback(returned -> {

Message message = returned.getMessage();
int replyCode = returned.getReplyCode();
String replyText = returned.getReplyText();
String exchange = returned.getExchange();
String routingKey = returned.getRoutingKey();
// 投递失败,记录日志
log.error("消息发送失败,应答码{},原因{},交换机{},路由键{},消息{}",
replyCode, replyText, exchange, routingKey, message.toString());
});
return rabbitTemplate;
}

3. EnhancedCorrelationData

原始的CorrelationData,目前已经无法从中获取消息内容,也就是说现在的ConfirmCallback无法获取到消息的内容,因为设计上只关注是否投递到exchange成功。如果需要在ConfirmCallback中获取消息的内容,需要扩展这个类,并在发消息的时候,放入自定义数据。

language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class EnhancedCorrelationData extends CorrelationData {

private final String body;

public EnhancedCorrelationData(String id, String body) {

super(id);
this.body = body;
}

public String getBody() {

return body;
}
}

4. 发送消息

EnhancedCorrelationData把消息本身放进去,或者如果你有表记录消息,你可以只放入其id。这样触发ConfirmCallback的时候,就可以获取消息内容。

language-java
1
2
3
4
5
6
7
8
9
public void notifyPayResult() {

String message = "TEST Message";
Message message1 = MessageBuilder.withBody(message.getBytes(StandardCharsets.UTF_8))
.setDeliveryMode(MessageDeliveryMode.PERSISTENT)
.build();
CorrelationData correlationData = new EnhancedCorrelationData(UUID.randomUUID().toString(), message.toString());
rabbitTemplate.convertAndSend(PayNotifyConfig.PAYNOTIFY_EXCHANGE_FANOUT,"", message1, correlationData);
}
SpringBoot后端Long数据传到前端js精度损失问题

SpringBoot后端Long数据传到前端js精度损失问题

方案一、修改后端

在对应的字段上添加注解,将Long转为String后传输。

language-java
1
2
@JsonFormat(shape = JsonFormat.Shape.STRING)
private Long payNo;

方案二、修改前端

js对应的结果接收上使用BigInt

language-js
1
2
3
4
5
6
xxx().then((res) => {

if(res){

this.payNo = String(BigInt(res.payNo))
}
微服务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的处理方案,也无济于事。
Gateway中Spring Security6统一处理CORS

Gateway中Spring Security6统一处理CORS

一、起因

使用了gateway微服务作为整体的网关,并且整合了Spring Security6;还有一个system微服务,作为被请求的资源,当浏览器向gateway发送请求,请求system资源时,遇到CORS问题。

于是我在system对应的controller上加了@CrossOrigin,无效;配置WebMvcConfigurer,也无效。
后来发现,会不会是gatewayspring security6在一开始就拦截了CORS跨域请求,导致根本走不到后面的system配置。

查询了一波,果然如此。这里记录解决方法。

二、解决方法

这是依赖

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>

这是配置

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
@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;
}
}

需要注意的是,在gatewayspring security中处理了CORS问题后,后续的system什么的,就不需要再二次处理了。因为CORS是一个浏览器的策略,只要处理一次,告诉浏览器我允许跨域,浏览器收到后就不再阻拦请求了。

微服务OAuth 2.1扩展额外信息到JWT并解析(Spring Security 6)

微服务OAuth 2.1扩展额外信息到JWT并解析(Spring Security 6)

一、简介

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

Spring Authorization Server 使用JWT时,前两部分默认格式如下

language-json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{

"kid": "33bd1cad-62a6-4415-89a6-c2c816f3d3b1",
"alg": "RS256"
}
{

"sub": "XcWebApp",
"aud": "XcWebApp",
"nbf": 1707373072,
"iss": "http://localhost:63070/auth",
"exp": 1707380272,
"iat": 1707373072,
"jti": "62e885c5-6b3f-49a2-aa10-b2e872a52b33"
}

现在我们要把用户信息也扩展到JWT,最简便的方法就是将用户信息写成JSON字符串替换sub字段。其中用户信息由xc_user数据库表存储。

二、重写UserDetailsService

注释掉原来的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
@Component
@Slf4j
public class UserServiceImpl implements UserDetailsService {

@Autowired
XcUserMapper xcUserMapper;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

//根据username查询数据库
XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>()
.eq(XcUser::getUsername, username));
//用户不存在,返回null
if (xcUser == null){

return null;
}
//用户存在,拿到密码,封装成UserDetails,密码对比由框架进行
String password = xcUser.getPassword();
//扩展用户信息
xcUser.setPassword(null);
String userInfo = JSON.toJSONString(xcUser);
UserDetails userDetails = User.withUsername(userInfo).password(password).authorities("read").build();
return userDetails;
}
}
  • 如果XcUsernull,返回null,这里处理了用户不存在的情况。
  • 如果用户存在,则获取密码,放到UserDetails 中,密码比对过程我们不关心,由框架完成。如果使用了加密算法,这里的password应该是密文。
  • 我们可以把用户信息转成JSON字符串放入withUsername。这样框架生成JWT时就会把用户信息也放进去。

是的,你没有猜错,UserDetails 只要返回密码框架就能比对成功,不需要再返回username

三、Controller解析JWT获取用户信息

写一个工具类,通过Security ContextHolder.getContext()上下文获取Authentication然后解析JWT

language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Slf4j
public class SecurityUtil {

public static XcUser getUser(){

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

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

//...
}
}

JwtAuthenticationTokenSpring Authorization Server的一个类,可以帮助我们解析JWT

四、后记

SecurityContextHolder.getContext().getAuthentication()解析我们放进去的XcUser的方法不止一种,将其打印出来就可以看出,有多个地方包含了XcUser,例如。

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
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
{

"authenticated": true,
"authorities": [{

"authority": "SCOPE_all"
}],
"credentials": {

"audience": ["XcWebApp"],
"claims": {

"sub": "{\"createTime\":\"2022-09-28 08:32:03\",\"id\":\"48\",\"name\":\"系统管理员\",\"sex\":\"1\",\"status\":\"1\",\"username\":\"admin\",\"utype\":\"101003\"}",
"aud": ["XcWebApp"],
"nbf": "2024-02-08T14:01:16Z",
"scope": ["all"],
"iss": "http://localhost:63070/auth",
"exp": "2024-02-08T16:01:16Z",
"iat": "2024-02-08T14:01:16Z",
"jti": "91df8f15-2096-4e03-a927-877b51bf5997"
},
"expiresAt": "2024-02-08T16:01:16Z",
"headers": {

"kid": "e14df18d-1c95-441d-80d6-8457f3ceba9e",
"alg": "RS256"
},
"id": "91df8f15-2096-4e03-a927-877b51bf5997",
"issuedAt": "2024-02-08T14:01:16Z",
"issuer": "http://localhost:63070/auth",
"notBefore": "2024-02-08T14:01:16Z",
"subject": "{\"createTime\":\"2022-09-28 08:32:03\",\"id\":\"48\",\"name\":\"系统管理员\",\"sex\":\"1\",\"status\":\"1\",\"username\":\"admin\",\"utype\":\"101003\"}",
"tokenValue": "eyJraWQiOiJlMTRkZjE4ZC0xYzk1LTQ0MWQtODBkNi04NDU3ZjNjZWJhOWUiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ7XCJjcmVhdGVUaW1lXCI6XCIyMDIyLTA5LTI4IDA4OjMyOjAzXCIsXCJpZFwiOlwiNDhcIixcIm5hbWVcIjpcIuezu-e7n-euoeeQhuWRmFwiLFwic2V4XCI6XCIxXCIsXCJzdGF0dXNcIjpcIjFcIixcInVzZXJuYW1lXCI6XCJhZG1pblwiLFwidXR5cGVcIjpcIjEwMTAwM1wifSIsImF1ZCI6IlhjV2ViQXBwIiwibmJmIjoxNzA3NDAwODc2LCJzY29wZSI6WyJhbGwiXSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo2MzA3MC9hdXRoIiwiZXhwIjoxNzA3NDA4MDc2LCJpYXQiOjE3MDc0MDA4NzYsImp0aSI6IjkxZGY4ZjE1LTIwOTYtNGUwMy1hOTI3LTg3N2I1MWJmNTk5NyJ9.NZ3f_Pkh871L1c8XkV2PxHfn17pWjaRvBd9HQQTRJhfFvNBN7zoh2riumpfVUj_xVmnCadVX3YE4ARxc0CuiV1QyVDpFnmKiuvWdsRVV9NF5Kkb67CGtF2zw1l2gFdSDFWAwOq1SemtogHX5a4XFF2kG6kx9ZOSL4EoiQMMhUOwfY6mL9Zdcgq_E28kfnrAk__q84rgo9JPvj3jH6cm_oS63-tNXZYdClDG61DHS4Bw7cswUfVf_bcI_a8kXfiM8SzCnROvxe1hU2dM88qxUgkI1GPlrtZhe9z7113XP7ilaPo2UknCFh_OSbfUeUDmP1GpTaspfGmnHhBXLQyG06Q"
},
"details": {

"remoteAddress": "192.168.222.1"
},
"name": "{\"createTime\":\"2022-09-28 08:32:03\",\"id\":\"48\",\"name\":\"系统管理员\",\"sex\":\"1\",\"status\":\"1\",\"username\":\"admin\",\"utype\":\"101003\"}",
"principal": {

"audience": ["XcWebApp"],
"claims": {

"sub": "{\"createTime\":\"2022-09-28 08:32:03\",\"id\":\"48\",\"name\":\"系统管理员\",\"sex\":\"1\",\"status\":\"1\",\"username\":\"admin\",\"utype\":\"101003\"}",
"aud": ["XcWebApp"],
"nbf": "2024-02-08T14:01:16Z",
"scope": ["all"],
"iss": "http://localhost:63070/auth",
"exp": "2024-02-08T16:01:16Z",
"iat": "2024-02-08T14:01:16Z",
"jti": "91df8f15-2096-4e03-a927-877b51bf5997"
},
"expiresAt": "2024-02-08T16:01:16Z",
"headers": {

"kid": "e14df18d-1c95-441d-80d6-8457f3ceba9e",
"alg": "RS256"
},
"id": "91df8f15-2096-4e03-a927-877b51bf5997",
"issuedAt": "2024-02-08T14:01:16Z",
"issuer": "http://localhost:63070/auth",
"notBefore": "2024-02-08T14:01:16Z",
"subject": "{\"createTime\":\"2022-09-28 08:32:03\",\"id\":\"48\",\"name\":\"系统管理员\",\"sex\":\"1\",\"status\":\"1\",\"username\":\"admin\",\"utype\":\"101003\"}",
"tokenValue": "eyJraWQiOiJlMTRkZjE4ZC0xYzk1LTQ0MWQtODBkNi04NDU3ZjNjZWJhOWUiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ7XCJjcmVhdGVUaW1lXCI6XCIyMDIyLTA5LTI4IDA4OjMyOjAzXCIsXCJpZFwiOlwiNDhcIixcIm5hbWVcIjpcIuezu-e7n-euoeeQhuWRmFwiLFwic2V4XCI6XCIxXCIsXCJzdGF0dXNcIjpcIjFcIixcInVzZXJuYW1lXCI6XCJhZG1pblwiLFwidXR5cGVcIjpcIjEwMTAwM1wifSIsImF1ZCI6IlhjV2ViQXBwIiwibmJmIjoxNzA3NDAwODc2LCJzY29wZSI6WyJhbGwiXSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo2MzA3MC9hdXRoIiwiZXhwIjoxNzA3NDA4MDc2LCJpYXQiOjE3MDc0MDA4NzYsImp0aSI6IjkxZGY4ZjE1LTIwOTYtNGUwMy1hOTI3LTg3N2I1MWJmNTk5NyJ9.NZ3f_Pkh871L1c8XkV2PxHfn17pWjaRvBd9HQQTRJhfFvNBN7zoh2riumpfVUj_xVmnCadVX3YE4ARxc0CuiV1QyVDpFnmKiuvWdsRVV9NF5Kkb67CGtF2zw1l2gFdSDFWAwOq1SemtogHX5a4XFF2kG6kx9ZOSL4EoiQMMhUOwfY6mL9Zdcgq_E28kfnrAk__q84rgo9JPvj3jH6cm_oS63-tNXZYdClDG61DHS4Bw7cswUfVf_bcI_a8kXfiM8SzCnROvxe1hU2dM88qxUgkI1GPlrtZhe9z7113XP7ilaPo2UknCFh_OSbfUeUDmP1GpTaspfGmnHhBXLQyG06Q"
},
"token": {

"audience": ["XcWebApp"],
"claims": {

"sub": "{\"createTime\":\"2022-09-28 08:32:03\",\"id\":\"48\",\"name\":\"系统管理员\",\"sex\":\"1\",\"status\":\"1\",\"username\":\"admin\",\"utype\":\"101003\"}",
"aud": ["XcWebApp"],
"nbf": "2024-02-08T14:01:16Z",
"scope": ["all"],
"iss": "http://localhost:63070/auth",
"exp": "2024-02-08T16:01:16Z",
"iat": "2024-02-08T14:01:16Z",
"jti": "91df8f15-2096-4e03-a927-877b51bf5997"
},
"expiresAt": "2024-02-08T16:01:16Z",
"headers": {

"kid": "e14df18d-1c95-441d-80d6-8457f3ceba9e",
"alg": "RS256"
},
"id": "91df8f15-2096-4e03-a927-877b51bf5997",
"issuedAt": "2024-02-08T14:01:16Z",
"issuer": "http://localhost:63070/auth",
"notBefore": "2024-02-08T14:01:16Z",
"subject": "{\"createTime\":\"2022-09-28 08:32:03\",\"id\":\"48\",\"name\":\"系统管理员\",\"sex\":\"1\",\"status\":\"1\",\"username\":\"admin\",\"utype\":\"101003\"}",
"tokenValue": "eyJraWQiOiJlMTRkZjE4ZC0xYzk1LTQ0MWQtODBkNi04NDU3ZjNjZWJhOWUiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ7XCJjcmVhdGVUaW1lXCI6XCIyMDIyLTA5LTI4IDA4OjMyOjAzXCIsXCJpZFwiOlwiNDhcIixcIm5hbWVcIjpcIuezu-e7n-euoeeQhuWRmFwiLFwic2V4XCI6XCIxXCIsXCJzdGF0dXNcIjpcIjFcIixcInVzZXJuYW1lXCI6XCJhZG1pblwiLFwidXR5cGVcIjpcIjEwMTAwM1wifSIsImF1ZCI6IlhjV2ViQXBwIiwibmJmIjoxNzA3NDAwODc2LCJzY29wZSI6WyJhbGwiXSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo2MzA3MC9hdXRoIiwiZXhwIjoxNzA3NDA4MDc2LCJpYXQiOjE3MDc0MDA4NzYsImp0aSI6IjkxZGY4ZjE1LTIwOTYtNGUwMy1hOTI3LTg3N2I1MWJmNTk5NyJ9.NZ3f_Pkh871L1c8XkV2PxHfn17pWjaRvBd9HQQTRJhfFvNBN7zoh2riumpfVUj_xVmnCadVX3YE4ARxc0CuiV1QyVDpFnmKiuvWdsRVV9NF5Kkb67CGtF2zw1l2gFdSDFWAwOq1SemtogHX5a4XFF2kG6kx9ZOSL4EoiQMMhUOwfY6mL9Zdcgq_E28kfnrAk__q84rgo9JPvj3jH6cm_oS63-tNXZYdClDG61DHS4Bw7cswUfVf_bcI_a8kXfiM8SzCnROvxe1hU2dM88qxUgkI1GPlrtZhe9z7113XP7ilaPo2UknCFh_OSbfUeUDmP1GpTaspfGmnHhBXLQyG06Q"
},
"tokenAttributes": {

"sub": "{\"createTime\":\"2022-09-28 08:32:03\",\"id\":\"48\",\"name\":\"系统管理员\",\"sex\":\"1\",\"status\":\"1\",\"username\":\"admin\",\"utype\":\"101003\"}",
"aud": ["XcWebApp"],
"nbf": "2024-02-08T14:01:16Z",
"scope": ["all"],
"iss": "http://localhost:63070/auth",
"exp": "2024-02-08T16:01:16Z",
"iat": "2024-02-08T14:01:16Z",
"jti": "91df8f15-2096-4e03-a927-877b51bf5997"
}
}
Spring Authorization Server Spring Security密码加密

Spring Authorization Server Spring Security密码加密

一、修改密码编码器

BCryptPasswordEncoder举例。
直接将其注册成PasswordEncoderBean即可。

language-java
1
2
3
4
5
6
7
8
    @Bean
public PasswordEncoder passwordEncoder() {

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

二、效果

使用了加密算法后,无论是RegisteredClient的密码还是UserDetailsService的密码,都会以密文的方式存储在服务器上。

但是前端输入的密码仍然是以明文的方式出现并传到服务器,之后服务器会对明文进行相同的手段(指对同样的明文,密文相同)加密,比较两个密文是否一致。

三、注意点

1. RegisteredClient

language-java
1
2
3
4
5
6
7
8
9
   @Bean
public RegisteredClientRepository registeredClientRepository() {

RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("XcWebApp")
.clientSecret(passwordEncoder().encode("XcWebApp"))
.build();
return new InMemoryRegisteredClientRepository(registeredClient);
}

RegisteredClientclientSecret仍然需要提供密文。

是因为,加密这个行为,只在服务器校验前端发送的明文时使用,至于对照物,则是代码中提供好的密文,所以这个需要提供密文。

2. UserDetailsService

对于UserDetailsService也是,密码也需要提供现成的密文形式。
下面的代码中数据库保存的是password密文。

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
@Component
@Slf4j
public class UserServiceImpl implements UserDetailsService {

@Autowired
XcUserMapper xcUserMapper;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

//根据username查询数据库
XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>()
.eq(XcUser::getUsername, username));
//用户不存在,返回null
if (xcUser == null){

return null;
}
//用户存在,拿到密码,封装成UserDetails,密码对比由框架进行
String password = xcUser.getPassword();

UserDetails userDetails = User.withUsername(username).password(password).authorities("read").build();
return userDetails;
}
}

从RSA角度出发解析JWT原理

从RSA角度出发解析JWT原理

在今天的数字化世界中,安全地传递信息变得越来越重要。JSON Web TokenJWT)作为一种流行的开放标准,为简化服务器与客户端之间的安全交流提供了一种高效的方式。本文旨在深入解析JWT的工作原理,并通过示例演示如何使用JWT进行安全通信。我们将从JWT的基本组成部分讲起,探讨公密钥加密的原理,解释为什么JWT是安全的,以及如何验证JWT的有效性。

一、JWT介绍

1. JWT组成部分

JWT由三个部分组成,它们分别是头部(Header)、载荷(Payload)、和签名(Signature)。通过将这三部分用点(.)连接起来,形成了一个完整的JWT字符串。例如:xxxxx.yyyyy.zzzzz

2. 头部(Header)

头部通常由两部分组成:令牌类型(typ)和签名算法(alg)。例如:

language-json
1
2
3
4
5
{

"kid": "33bd1cad-62a6-4415-89a6-c2c816f3d3b1",
"alg": "RS256"
}
  • kid (Key ID):密钥标识符,用于指明验证JWT签名时使用的密钥。在含有多个密钥的系统中,这可以帮助接收者选择正确的密钥进行验证。这里的值33bd1cad-62a6-4415-89a6-c2c816f3d3b1是一个UUID,唯一标识了用于签名的密钥。
  • alg (Algorithm):指明用于签名的算法,这里是RS256,表示使用RSA签名算法和SHA-256散列算法。这种算法属于公钥/私钥算法,意味着使用私钥进行签名,而用公钥进行验证。

3. 载荷(Payload)

载荷包含了所要传递的信息,这些信息以声明(claims)的形式存在。声明可以是用户的身份标识,也可以是其他任何必要的信息。载荷示例:

language-json
1
2
3
4
5
6
7
8
9
10
{

"sub": "XcWebApp",
"aud": "XcWebApp",
"nbf": 1707373072,
"iss": "http://localhost:63070/auth",
"exp": 1707380272,
"iat": 1707373072,
"jti": "62e885c5-6b3f-49a2-aa10-b2e872a52b33"
}
  • sub (Subject):主题,标识了这个JWT的主体,通常是指用户的唯一标识。这里XcWebApp可能是一个应用或用户标识。
  • aud (Audience):受众,标识了这个JWT的预期接收者。这里同样是XcWebApp,意味着这个JWT是为XcWebApp这个应用或服务生成的。
  • nbf (Not Before):生效时间,这个时间之前,JWT不应被接受处理。这里的时间是Unix时间戳格式,表示JWT生效的具体时间。
  • iss (Issuer):发行者,标识了这个JWT的发行方。这里是http://localhost:63070/auth,表明JWT由本地的某个认证服务器发行。
  • exp (Expiration Time):过期时间,这个时间之后,JWT不再有效。同样是Unix时间戳格式,表示JWT过期的具体时间。
  • iat (Issued At):发行时间,JWT创建的时间。这提供了JWT的时间信息,也是Unix时间戳格式。
  • jti (JWT ID):JWT的唯一标识符,用于防止JWT被重放(即两次使用同一个JWT)。这里的值是一个UUID,确保了JWT的唯一性。

4. 签名(Signature)

签名是对头部和载荷的加密保护,确保它们在传输过程中未被篡改。根据头部中指定的算法(例如HS256),使用私钥对头部和载荷进行签名。

二、深入理解JWT签名验证

1. 签名生成

  1. 哈希处理 :首先对JWT的头部和载荷部分(经过Base64编码并用.连接的字符串)进行哈希处理,生成一个哈希值。这个步骤是为了确保数据的完整性,即使是微小的改动也会导致哈希值有很大的不同。
  2. 私钥加密哈希值 :然后使用发行者的私钥对这个哈希值进行加密,生成的结果就是JWT的签名部分。这个加密的哈希值(签名)附加在JWT的后面。

2. 签名验证

  1. 哈希处理 :接收方收到JWT后,会独立地对其头部和载荷部分进行同样的哈希处理,生成一个哈希值。
  2. 公钥解密签名 :接收方使用发行者的公钥对签名(加密的哈希值)进行解密,得到另一个哈希值。
  3. 哈希值比较比较这两个哈希值。如果它们相同,就证明了JWT在传输过程中未被篡改,因为只有相应的私钥能够生成一个对应的、能够通过公钥解密并得到原始哈希值的签名。

3. 为什么JWT是安全的

由于破解RSA算法的难度极高,没有私钥就无法生成有效的签名,因此无法伪造签名。这就是为什么使用公钥进行签名验证是安全的原因。

三、如何验证JWT是否有效

对于一个JWT,我们可以使用Base64解码,获取前两部分信息,可以进行token是否过期等验证,具体取决于前两部分具体内容,这是可以人为设置的。
对于签名部分,可以用公钥去验证其有效性(即是否被纂改)。

四、 Why JWT?

为什么是JWT?
首先是不可伪造的安全性,其次,因为你会发现,只要有公钥就可以验证JWT这种token,这也就意味着对于微服务来说,任意微服务都有能力自行验证JWT,而不需要额外的验证模块。这种自校验没有用到网络通信,性能十分好。同时,JWT有时间限制,一定程度上也提高了最坏情况的安全性。