Spring Security 分布式并发 session 控制
Spring Security 分布式并发 session 控制:Spring Security 概述、基于 RBAC 的权限模型,身份认证流程、访问授权流程、分布式配置、分布式并发 session 控制、相关源码。
版本
- jdk:17
- spring:6.2.6
- spring boot:3.4.5
- spring security:6.4.5
目录
- Spring Security 分布式并发 session 控制
- 1 概述
- 2 RBAC 权限模型
- 3 认证流程
- 4 授权流程
- 5 分布式配置
- 6 分布式并发 session 控制
- 7 源码
- 7.1 UserDetailsVo 与 UserDetailsService
- 7.2 SessionInformation 与 SessionRegistry
- 7.3 SecurityContextRepository
- 7.4 SecurityMetadataSource 与 AuthorizationManager
- 7.5 TokenSessionFilter
- 7.6 AuthenticationEntryPoint
- 7.7 AuthenticationSuccessHandler 与 AuthenticationFailureHandler
- 7.8 LogoutHandler 与 LogoutSuccessHandler
- 7.9 AccessDeniedHandler
- 7.10 SessionExpiredHandler
- 7.11 SecurityConfig
1 概述
1.1 Spring Security
1.1.1 定义
Spring Security 是一个功能强大且高度可定制的 身份认证 和 访问控制 权限框架。其源于 Spring 社区,可与 Spring 家族内其它产品无缝集成,如 Spring Boot、Spring Session 等。关于其更多详细信息可查看 Spring Security 官方文档。
- 认证(Authentication):你是谁?即验证用户身份,能否登录该系统。如根据用户名密码验证、短信验证码验证、Token 令牌验证等。
- 授权(Authorization):你能做什么?即对一个已经通过认证的用来说,他可以执行哪些操作。如可看到哪些菜单或页面、可点击哪些按钮、可访问哪些数据等。
- 与 Sa-Token 相比:
- Sa-Token 为轻量级权限框架,Spring Security 为重量级权限框架。
- Sa-Token 开箱即用,Spring Security 高度可定制。
1.1.2 核心特性
- 1、身份认证(Authentication):
- 用户名密码认证:基于用户名密码的身份认证,即表单认证,是最基本也是应用最广泛的认证方式。
- JWT 认证:用于 RESTful API 的无状态认证。
- OAuth 2.0 支持:支持第三方平台登录应用(如使用 GitHub、Gitee、微信、支付宝等第三方平台登录),也支持应用作为授权服务器为其它平台颁发令牌。
- 记住我模式:通过持久化或加密 token 实现长时间免登录。
- 2、访问授权(Authorization):
- 请求级授权:基于请求 URL 模式进行拦截和控制。
- 方法级授权:通过在 service 层的方法上添加
@PreAuthorize、@PostAuthorize等注解进行精细化授权。 - 领域对象级授权:用于更复杂的、基于领域对象(Domain Object)的权限控制。如,判断用户是否有权删除某篇特定的文章,而不是是否有权删除文章。
- 3、会话管理(SessionManagement):
- 提供会话固定保护与防止会话劫持。
- 并发会话控制:限制同账号同时登录数量。
- 4、防护常见攻击(Security Protection):
- CSRF:默认开启对跨站请求伪造(Cross-Site Request Forgery)的防护。
- CORS:提供对跨域资源共享(Cross-Origin Resource Sharing)的灵活配置。
- 安全头部:自动注入一系列 HTTP 安全头部。如,X-Content-Type-Options(防止 MIME 类型混淆攻击)、Strict-Transport-Security(强制使用 HTTPS)、X-Frame-Options(防止点击劫持)、X-XSS-Protection(抑制反射型 XSS 攻击)。
- 5、与 Spring 生态无缝集成:
- 可与 Spring MVC、Spring Boot、Spring Data、Spring Session 等框架无缝集成。
- 6、高度可定制化:
- Spring Security 通过一系列过滤器链来实现权限控制。其几乎每一个默认行为都可通过实现或扩展特定接口来覆盖或自定义,以满足任何特殊业务场景。
1.2 阅前思考
阅读下文前请先思考以下问题:
- RBAC 权限模型如何实现?
- 认证流程是怎样的?
- 授权流程是怎样的?
- 如何实现分布式适配?
- 并发 session(单账号多登录)如何控制?
- 服务重启如何对用户无感知?
2 RBAC 权限模型
2.1 RBAC 概述
RBAC(Role-Based Access Control)即基于角色的访问控制。它是一种策略无关的访问控制模型,即用户与权限不直接关联,而是通过角色间接关联。其核心模型为:
- 用户(User):系统使用者、登录账号。
- 角色(Role):一组权限,表示一类用户所有用的职责、职能或权限。如 管理员、业务员、访客等。
- 权限(Perm):对系统资源的操作许可,一般分为数据权限和操作权限。如菜单、页面、按钮、数据等。
其核心模型关系可概括为:用户 -> 分配 -> 角色 -> 分配 -> 权限。即 用户与角色 和 角色与权限 都是多对多的关系。
当然,RBAC 权限模型有着具体的演进过程,如基础模型、角色继承、约束模型和统一模型等,详细信息可自行了解。
2.2 RBAC 设计
一般情况下,通过 权限表、角色表、用户表、角色-权限关系表、用户-角色关系表 这五张表来实现 RBAC 权限模型。其各核心字段说明如下:
-
权限表(sys_perm):
字段名 中文描述 备注 id 主键 推荐自增 parent_id 父节点(父权限)id 根节点可为空或默认为 0 perm_name 权限名称 如菜单名称、页面名称、按钮名称、接口名称等 perm_type 权限类型 如 0:菜单;1:操作;2:资源 access_code 访问标识 一般用于前端控制菜单、页面、按钮等组件的显隐,唯一 api_url api 地址 数据接口 path,一般用于数据权限 -
角色表(sys_role):
字段名 中文描述 备注 id 主键 推荐自增 role_code 角色编码 唯一 role_name 角色名称 -
用户表(sys_user):
字段名 中文描述 备注 id 主键 自增 account 账号/用户名 唯一 password 密码 加密存储 account_type 账号类型 如 0:超级管理员;1:管理员;2:普通 -
角色权限关系表(sys_role_perm):
字段名 中文描述 备注 id 主键 自增 role_id 角色 id perm_id 权限 id -
用户角色关系表(sys_user_role):
字段名 中文描述 备注 id 主键 自增 user_id 用户 id / 账号 id role_id 角色 id
3 认证流程
3.1 认证流程图
Spring Security 框架中根据用户名密码进行身份认证的流程图如下:

3.2 认证流程详述
现阶段大多数系统为前后端分离模式,故本例默认为前后端分离,如有特殊之处会进行说明。
3.2.1 表单登录配置器 FormLoginConfigurer
在表单登录(即用户名密码登录)场景下,Spring Security 提供了表单登录配置器 FormLoginConfigurer 来支持定制化表单登录配置。如配置登录接口地址/登录页面地址、表单参数、认证成功处理器、认证失败处理器等。其核心配置项如下:
loginProcessingUrl:登录地址配置。支持两种配置方式。- 在前后端分离模式下,其为登录接口地址,如
/admin/user/login,实际上服务端并不需要定义该接口,但需要指定一个地址,来标识登录逻辑的入口,当表单提交至该地址时,系统会自动执行内置登录处理逻辑。 - 在 Spring MVC 环境下,该地址将映射到登录模板,如
/admin/login,即我们需要定义一个 path 为/admin/login的controller,也需要创建一个对应的登录模板。
- 在前后端分离模式下,其为登录接口地址,如
usernameParameter:用户名参数名配置。可通过该配置指定用户名参数名,默认为username。passwordParameter:密码参数名配置。可通过该配置指定密码参数名,默认为password。successHandler:认证成功处理器配置。其接受一个AuthenticationSuccessHandler实例,可用来指定认证成功后处理逻辑,如将认证信息响应给前端。failureHandler:认证失败处理器配置。其接受一个AuthenticationFailureHandler实例,可用来指定认证失败后的处理逻辑,如可在该处理器中处理认证异常信息,将具体异常对应的信息响应给前端,比如用户名或密码错误、账号被锁定、账号被禁用等。
注:示例代码见 [7.11 SecurityConfig 章节](#7.11 SecurityConfig)。
3.2.2 用户名密码认证过滤器 UsernamePasswordAuthenticationFilter
用户名密码认证过滤器 UsernamePasswordAuthenticationFilter 负责处理用户名密码认证,即当用户点击登录调用 /admin/user/login 接口时会进入该过滤器。
首先会调用 UserDetailsService#loadUserByUsername() 方法根据表单提交的用户名获取用户数据。该接口默认从内存中获取内置的默认用户(默认情况下在服务启动时,会在控制台输出内置的用户名和密码),故一般情况下我们需要自定义实现 UserDetailsService 接口,来从数据库中获取用户数据,一般也需要返回用户角色(权限)信息,以便前端进行组件显隐的渲染。若未获取到用户数据则可抛出用户名不存在异常 UsernameNotFoundException。
获取到用户信息后框架会自动进行密码校验,若校验未通过则会抛出密码错误异常 BadCredentialsException。
认证通过后会将认证信息封装为 Authentication 对象,并进行认证信息的存储和认证 session 的处理。
3.2.3 认证信息存储
认证通过后,会讲认证信息包装为安全上下文 SecurityContext 对象,并分别存储到安全安全上下文持有器 SecurityContextHolderStrategy 和安全上下文存储器 SecurityContextRepository 中。
SecurityContextHolderStrategy 中存储的认证信息主要服务于当前线程,其基于 ThreadLocal 实现。即当接收到请求后会从 SecurityContextRepository 中获取安全上下文信息,并存储但当前 web 线程中,以便在当前线程运行时获取安全上下文信息,请求处理完毕后会清除其存储的安全上下文信息。
SecurityContextRepository 中存储的认证信息服务于整个应用程序的生命周期,默认情况下其使用基于内存的实现。在程序运行期间,用户登录成功后,其安全上下文信息会一直保存在该存储器中,后续的访问授权也是基于该存储器中的信息。服务停止时,信息会被释放,当然也可依赖 redis 等外部存储介质存储。
注:示例代码见 [7.3 SecurityContextRepository 章节](#7.3 SecurityContextRepository)。
3.2.4 session 认证策略 SessionAuthenticationStrategy
session 认证策略主要是指在认证时关于 session 的一些操作,如 session 安全、并发 session 控制和 session 存储等。在下文 [6.1 认证时并发 session 处理流程](#6.1 认证时并发 session 处理流程) 章节中会详细描述。
3.2.5 认证成功处理器 AuthenticationSuccessHandler
AuthenticationSuccessHandler 用来实现认证成功后的一些操作,一般需要自定义,如将认证信息(用户名、用户角色/权限等)响应给前端等。
注:示例代码见 [7.7.1 AuthenticationSuccessHandler 章节](#7.7.1 AuthenticationSuccessHandler)。
3.2.6 认证失败处理器 AuthenticationFailureHandler
AuthenticationFailureHandler 用来实现认证失败后的一些操作,一般需要自定义,如可在该处理器中处理认证异常信息,将具体异常对应的信息响应给前端,比如用户名或密码错误、账号被锁定、账号被禁用等。
注:示例代码见 [7.7.1 AuthenticationFailureHandler 章节](#7.7.2 AuthenticationFailureHandler)。
4 授权流程
4.1 授权流程图
Spring Security 框架中请求级访问授权流程图如下:

4.2 授权流程详述
4.2.1 初始化资源角色元数据 MySecurityMetedataSource
为了方便访问授权和减少数据库访问,我们可以在服务启动时将每个资源和访问该资源所需的角色的映射关系数据加载到内存中,如 Map<String, Set<String>> 这种结构,其中 key 为资源标识(如 /admin/user/list),value 为访问该资源所需的角色集合(如 ["SUPER-ADMIN", "ADMIN"])。
注:示例代码见 [7.4.1 MySecurityMetedataSource 章节](#7.4.1 MySecurityMetedataSource)。
4.2.2 授权过滤器 AuthorizationFilter
Spring Security 过滤器链中的 AuthorizationFilter 负责访问授权处理,即当请求到达后,在经过该过滤器时会进行权限校验。
首先需要获取包含当前登录用户认证信息 Authentication 的安全上下文 SecurityContext 数据。先从线程安全上下文持有器 SecurityContextHolderStrategy 中获取,若未获取到则从全局安全上下文存储器 SecurityContextRepository 中获取,获取到则返回认证信息 Authentication,否则会创建一个拥有匿名角色 ROLE_ANONYMOUS 的认证对象。
之后便会通过授权管理器 AuthorizationManager 进行权限校验。
4.2.3 授权管理器 AuthenticationManager
Spring Security 提供了授权管理器 AuthenticationManager 接口来支持定制化的授权流程实现。所以一般情况下我们需要自定义实现该接口,并实现其定义的 check() 方法。示例如下:
- 先获取到请求路径,判断其是否为登录路径,若是则授权通过(因为登录操作不需要权限限制)。
- 然后从资源角色元数据
MySecurityMetedataSource中根据请求路径获取到访问该资源需要的角色列表,判断该列表是否为空。若不为空则说明该资源受限,否则说明该资源不受限则授权通过返回。 - 若该资源受限,则判断当前用户认证信息
Authenticaion是否为匿名角色(即判断当前用户是否登录)。若是则说明当前用户未认证,此时可抛出未认证异常InsufficientAuthenticationException;否则进行具体的角色校验。 - 执行到这里,说明当前访问的资源受限,且当前用户已认证,则只需要判断当前用户认证信息中的角色列表是否包含访问该资源需要的角色列表中的一个角色即可。若包含则授权通过,否则抛出访问拒绝异常
AssceeDeniedException。
注:示例代码见 [7.4.2 AuthorizationManager 章节](#7.4.2 AuthorizationManager)。
4.2.4 异常转换处理器 ExceptionTranslationFilter
异常转换处理器 ExceptionTranslationFilter 是 Spring Security 提供的用来处理所有认证和授权异常的过滤器,所以我们在授权过程中抛出的未认证异常 InsufficientAuthenticationException 和访问拒绝异常 AccessDeniedException 会在该过滤器中被处理。
其判断当前异常实例是否为认证异常 AuthenticationException(InsufficientAuthenticationException 为其子类)。若是则执行认证入口点处理器 AuthenticationEntryPoint,一般需要自定义实现该接口,可自定义向前端响应未认证相关信息,如:请先登录,前端可根据此信息跳转到登录页面。
然后判断当前异常实例是否为访问拒绝异常 AccessDeniedException,若是则执行访问拒绝处理器 AccessDeniedHandler,一般需要自定义实现该处理器,可在该处理器中将访问拒绝信息响应给前端,如:权限不足!
4.2.5 授权时 session 处理
授权过程中也需要对相关 session 进行处理,在下文 [6.2 请求时并发 session 处理流程](#6.2 请求时并发 session 处理流程) 章节中会详细描述。
5 分布式配置
分布式配置是指当应用程序多节点部署时,关于认证信息数据(安全上下文 SecurityContext)和 session 相关数据的处理。在默认情况下这两类数据皆存储于内存中,显然不适用分布式系统,故我们需要通过外部存储介质来统一存储和管理这两类数据。一般需要借助 redis 实现。
5.1 安全上下文存储器 SecurityContextRepository
Spring Security 提供了安全上下文存储器 SecurityContextRepository 接口来存储和管理安全上下文信息,所有我们只需要自定义实现该接口即可。
注:示例代码见 [7.3 SecurityContextRepository 章节](#7.3 SecurityContextRepository)。
5.2 会话注册注册器 SessionRegistry
Spring Security 提供了会话注册器 SessionRegistrty 接口来存储和管理 session 相关信息,所以我们只需要自定义实现该接口即可。
注:示例代码见 [7.2.2 SessionRegistry 章节](#7.2.2 SessionRegistry)。
5.3 TokenSessionFilter
为了解决服务重启浏览器刷新后 session 重新创建问题,我们引入了 token(并非 JWT,只是用来代替 sessionId 的字符串而已)。通过存储 token-sessionId 和 sessionId-token 的映射关系来实现。
通过自定义过滤器 TokenSessionFilter 来实现,其流程说明见 [6.3.2 请求时 session 处理流程详述章节](#6.3.2 请求时 session 处理流程详述) ,示例代码见 [7.5 TokenSessionFilter 章节](#7.5 TokenSessionFilter)。
6 分布式并发 session 控制
6.1 会话管理配置器 SessionManagementConfigurer
Spring Security 提供了会话管理配置器 SessionManagementConfigurer 来支持定制化的会话管理相关配置。如配置最大会话数、指定会话注册器、配置过期会话策略等。其核心配置项如下:
maximumSessions:最大会话数。用来配置一个账号允许的同时在线数量,即并发 session。sessionRegistry:会话注册器。用来指定会话注册器。expiredSessionStrategy:过期会话策略。用来指定过期会话策略,即当某个会话过期后要执行的逻辑。
6.2 认证时并发 session 处理流程
6.2.1 认证时并发 session 处理流程图
Spring Security 框架中身份认证时并发 session 处理流程图如下:

6.2.2 认证时并发 session 处理流程详述
-
认证处理过滤器
AbstractAuthenticationProcessingFilter:为
UsernamePasswordAuthenticationFilter的父类,认证时(登录)会进入该过滤器,在认证成功后通过会话认证策略SessionAuthenticationStrategy来处理认证过程中的 session。 -
会话认证策略
SessionAuthenticationStrategy:内置了多个实现,并在
AbstractAuthenticationProcessingFilter中持有了一个组合会话认证策略CompositeSessionAuthenticationStrategy实例,该实例维护了多个策略,如并发会话控制认证策略ConcurrentSessionControlAuthenticationStrategy、注册器会话认证策略RegisterSessionAuthenticationStrategy,这些策略会被逐一执行。
具体会话认证过程如下:
- 获取最大会话数,通过会话注册器
SessionRegistry根据认证信息从 redis 中获取登录当前账号的所有有效 session 信息(记作 sessions 数组)。 - 判断已登录有效 session 数量是否小于允许的最大会话数。
- 若小于,则通过会话注册器
SessionRegistry将相关 session 信息存储至 redis,如 sesion 信息、token-sessionId 映射关系、sessionId-token 映射关系、登录当前账号的 sessionId 集合等。 - 若不小于,则从 sessions 数组中找到最近最少使用的 session,将其置为失效。
6.3 请求时并发 session 处理流程
6.3.1 请求时并发 session 处理流程图
Spring Security 框架中请求时并发 session 处理流程图如下:

6.3.2 请求时 session 处理流程详述
- 进入 token-session 过滤器
TokenSessionFilter进行 token-sessionId 映射关系处理。- 首先判断请求中是否携带 token 和 sessionId。若未携带则说明该请求不需要授权,直接放过;若携带则执行一些逻辑。
- 根据 token 从 redis 的 token-session 映射关系中获取 旧 sessionId 记为
cacheSessionId。 - 判断
cacheSessionId是否不为空、cacheSessionId与sessionId是否相等、redis 中是否存在 session-token 映射关系。若是则说明服务未重启,进入ConcurrentSessionFilter;否则执行以下逻辑。 - 判断
cacheSessionId是否为空。若是则将 token-sessionId 和 sessionId-token 映射关系存储至 redis;若不是则执行以下逻辑。 - 判断
cacheSessionId于sessionId是否相等,若相等则进入ConcurrentSessionFilter,否则执行以下逻辑。 - 将 redis 中登录当前账号的
sessionId集合中的当前 token 对应的sessionId更新为sessionId;更新 redis 中当前 token 对应的 session 信息;根据sessionId更新 redis 中 token-sessionId 映射关系;根据cacheSessionId移除 redis 中旧的 sessionId-token 映射关系;向 redis 添加新的 session-token 映射关系。
- 进入并发会话过滤器
ConcurrentSessionFilter进行并发会话处理。- 判断当前 session 信息是否有效。若是则更新 redis 中该 session 的最后操作时间,流程结束;否则执行以下逻辑。
- 执行登出处理器
LogoutHanlder。- 一般需要自定义实现,在处理器中移除 redis 中该 token 对应的认证信息和 session 信息。
- 执行会话过期策略
SessionInformationExpiredStrategy。- 一般需要自定义实现,将相关信息响应给前端,如:账号在其它设备登录,请重新登录!
7 源码
部分非关键性源码未列出,如 APIResponse、ResponseCode、ResponseUtils、GlobalException 等,若未有问题请留言!
7.1 UserDetailsVo 与 UserDetailsService
7.1.1 UserDetailsVo
// UserDetails 为 Spring Security 内置接口 必须实现
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class UserDetailsVo implements UserDetails {
private Long id; // 用户 id
private String account; // 账号
private String password; // 密码
private String token; // token
private List<String> roleCodes; // 角色编码列表
private List<String> accessCodes; // 访问标识(前端用于控制菜单、页面、按钮等组件的显隐)
@JsonIgnore
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<SimpleGrantedAuthority> authorities = new ArrayList<>(this.roleCodes.size());
this.roleCodes.forEach(role -> authorities.add(new SimpleGrantedAuthority(role)));
return authorities;
}
// 在 Spring Security 中认证信息属于敏感信息 默认情况下不支持序列化 故需自定义
public static class UserDetailsVoDeserializer extends JsonDeserializer<UserDetailsVo> {
@Override
public UserDetailsVo deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JacksonException {
ObjectMapper mapper = (ObjectMapper) jsonParser.getCodec();
JsonNode jsonNode = mapper.readTree(jsonParser);
long id = getJsonNode(jsonNode, "id").asLong();
String account = getJsonNode(jsonNode, "account").asText();
String password = getJsonNode(jsonNode, "password").asText("");
String token = getJsonNode(jsonNode, "token").asText();
List<String> roleCodes = mapper.convertValue(getJsonNode(jsonNode, "roleCodes"), new TypeReference<>() {});
List<String> accessCodes = mapper.convertValue(getJsonNode(jsonNode, "accessCodes"), new TypeReference<>() {});
return UserDetailsVo.builder()
.id(id)
.account(account)
.password(password)
.token(token)
.roleCodes(roleCodes)
.accessCodes(accessCodes)
.build();
}
private JsonNode getJsonNode(JsonNode jsonNode, String field) {
return jsonNode.has(field) ? jsonNode.get(field) : MissingNode.getInstance();
}
}
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY, property = "@class")
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE,
isGetterVisibility = JsonAutoDetect.Visibility.NONE)
@JsonDeserialize(using = UserDetailsVoDeserializer.class)
public abstract static class UserDetailsVoMixin {}
}
7.1.2 UserDetailsService
// UserDetailsService 为 Spring Security 内置接口 必须实现
@Service
@RequiredArgsConstructor
public class MyUserDetailsService implements UserDetailsService {
private final UserMapper userMapper;
@Override // 根据用户名获取用户信息 认证时调用
public UserDetailsVo loadUserByUsername(String username) throws UsernameNotFoundException {
UserEntity user = this.userMapper.selectOne(new LambdaQueryWrapper<UserEntity>()
.eq(UserEntity::getAccount, username));
if (user == null) {
throw GlobalException.error(ResponseCode.ACCOUNT_DOES_NOT_EXIST);
}
UserDetailsVo vo = UserDetailsVo.builder()
.id(user.getId())
.account(user.getAccount())
.password(PASSWORD_ID + user.getPassword())
.token(user.getId() + StringEnum.SIGN_2.getCode() + UUID.randomUUID().toString()
.replaceAll(StringEnum.SIGN_3.getCode(), StringEnum.SIGN_4.getCode()))
.build();
// 设置用户角色 和 访问标识 列表 请自行实现
vo.setRoleCodes();
vo.setAccessCodes();
return vo;
}
}
7.2 SessionInformation 与 SessionRegistry
7.2.1 SessionInformation
// SessionInformation 为内置类 需要通过扩展来实现 session 过期逻辑
public class MySessionInformation extends SessionInformation {
private boolean expireState;
public MySessionInformation(Object principal, String sessionId, Date lastRequest, boolean expireState) {
super(principal, sessionId, lastRequest);
this.expireState = expireState;
}
@Override // 覆盖其原有 session 过期逻辑 session 过期时调用
public void expireNow() {
String sessionId = getSessionId();
MySessionInformation sessionInformation = MySessionRegistry.getSessionInformation(sessionId, null);
if (sessionInformation != null) {
super.expireNow();
sessionInformation.setExpireState(true);
MySessionRegistry.updateSessionState(sessionInformation);
}
}
@Override
public boolean isExpired() {
return this.expireState;
}
public boolean isExpireState() {
return expireState;
}
public void setExpireState(boolean expireState) {
this.expireState = expireState;
}
}
7.2.2 SessionRegistry
@Slf4j
@Component
public class MySessionRegistry implements SessionRegistry {
// string key: token value: SessionInformation
private static final String SESSION_INFORMATION_PREFIX = "security:session:information:";
// string key: Principal value: sessionIds
private static final String SESSION_SESSIONS_PREFIX = "security:session:sessions:";
// string key: token value: sessionId
private static final String TOKEN_SESSION_PREFIX = "security:session:token_session:";
private static final String SESSION_TOKEN_PREFIX = "security:session:session_token:";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static RedisTemplate<String, Object> redisTemplate;
@Autowired
public void setRedisTemplate(RedisTemplate<String, Object> redisTemplate) {
MySessionRegistry.redisTemplate = redisTemplate;
}
@Override // 获取所有认证信息 该方法并不会被调用
public List<Object> getAllPrincipals() {
return new ArrayList<>(RedisUtils.getSetValue(redisTemplate, SESSION_SESSIONS_PREFIX));
}
@Override // 根据认证信息从 redis 中获取登录当前账号的所有有效 session 信息
public List<SessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions) {
if (principal == null) {
return new ArrayList<>();
}
Set<Object> sessionIds = this.getSessionIds(principal);
if (CollectionUtils.isEmpty(sessionIds)) {
return new ArrayList<>();
}
SessionInformation information;
List<SessionInformation> list = new ArrayList<>();
for (Object sessionId : sessionIds) {
if (sessionId == null) {
continue;
}
information = getSessionInformation(sessionId.toString());
if (information == null) {
continue;
}
if (includeExpiredSessions || !information.isExpired()) {
list.add(information);
}
}
return list;
}
@Override // 根据 sessionId 获取 session 信息
public SessionInformation getSessionInformation(String sessionId) {
return getSessionInformation(sessionId, null);
}
@Override // 更新 redis 中该 session 的最后操作时间
public void refreshLastRequest(String sessionId) {
Assert.hasText(sessionId, "SessionId required as per interface contract");
String token = getToken(sessionId);
SessionInformation information = getSessionInformation(sessionId, token);
if (token != null && information != null) {
information.refreshLastRequest();
RedisUtils.addValue(redisTemplate, SESSION_INFORMATION_PREFIX + token, information,
YmlConfig.getRedisExpirationTimeout(), TimeUnit.SECONDS);
RedisUtils.addValue(redisTemplate, TOKEN_SESSION_PREFIX + token, sessionId,
YmlConfig.getRedisExpirationTimeout(), TimeUnit.SECONDS);
RedisUtils.addValue(redisTemplate, SESSION_TOKEN_PREFIX + sessionId, token,
YmlConfig.getRedisExpirationTimeout(), TimeUnit.SECONDS);
}
}
@Override // 认证成功后注册新 session
public void registerNewSession(String sessionId, Object principal) {
Assert.hasText(sessionId, "SessionId required as per interface contract");
Assert.notNull(principal, "Principal required as per interface contract");
if (this.getSessionInformation(sessionId) != null) {
removeSessionInformation(sessionId);
}
UserDetailsVo userDetails = OBJECT_MAPPER.convertValue(principal, new TypeReference<>() {});
RedisUtils.addValue(redisTemplate, SESSION_INFORMATION_PREFIX + userDetails.getToken(),
new MySessionInformation(userDetails.getAccount(), sessionId, new Date(), false),
YmlConfig.getRedisExpirationTimeout(), TimeUnit.SECONDS);
RedisUtils.addValue(redisTemplate, TOKEN_SESSION_PREFIX + userDetails.getToken(), sessionId,
YmlConfig.getRedisExpirationTimeout(), TimeUnit.SECONDS);
RedisUtils.addValue(redisTemplate, SESSION_TOKEN_PREFIX + sessionId, userDetails.getToken(),
YmlConfig.getRedisExpirationTimeout(), TimeUnit.SECONDS);
RedisUtils.addSetValue(redisTemplate, SESSION_SESSIONS_PREFIX + userDetails.getAccount(), sessionId);
}
@Override // 登出时移除 session 信息
public void removeSessionInformation(String sessionId) {
Assert.hasText(sessionId, "SessionId required as per interface contract");
this.removeSessionInformation(getToken(sessionId), sessionId);
}
// 处理 token 与 session 映射关系 在 TokenSessionFilter 中被调用
public static void mappingTokenSession(String token, String sessionId) {
if (!StringUtils.hasText(token) || !StringUtils.hasText(sessionId)) {
return;
}
String cacheSessionId = MySessionRegistry.getSessionId(token);
if (cacheSessionId != null && cacheSessionId.equals(sessionId)
&& RedisUtils.containKey(redisTemplate, SESSION_TOKEN_PREFIX + sessionId)) {
return;
}
MySessionInformation information = getSessionInformation(sessionId, token);
if (information == null) {
return;
}
if (cacheSessionId == null) {
RedisUtils.addValue(redisTemplate, TOKEN_SESSION_PREFIX + token, sessionId,
YmlConfig.getRedisExpirationTimeout(), TimeUnit.SECONDS);
RedisUtils.addValue(redisTemplate, SESSION_TOKEN_PREFIX + sessionId, token,
YmlConfig.getRedisExpirationTimeout(), TimeUnit.SECONDS);
return;
}
if (!cacheSessionId.equals(sessionId)) {
RedisUtils.removeSetValue(redisTemplate, SESSION_SESSIONS_PREFIX + information.getPrincipal(), cacheSessionId);
RedisUtils.addSetValue(redisTemplate, SESSION_SESSIONS_PREFIX + information.getPrincipal(), sessionId);
RedisUtils.addValue(redisTemplate, SESSION_INFORMATION_PREFIX + token, information,
YmlConfig.getRedisExpirationTimeout(), TimeUnit.SECONDS);
RedisUtils.addValue(redisTemplate, TOKEN_SESSION_PREFIX + token, sessionId,
YmlConfig.getRedisExpirationTimeout(), TimeUnit.SECONDS);
RedisUtils.addValue(redisTemplate, SESSION_TOKEN_PREFIX + sessionId, token,
YmlConfig.getRedisExpirationTimeout(), TimeUnit.SECONDS);
RedisUtils.deleteKey(redisTemplate, SESSION_TOKEN_PREFIX + cacheSessionId);
}
}
// 更新 session state
public static void updateSessionState(SessionInformation sessionInformation) {
RedisUtils.addValue(redisTemplate, SESSION_INFORMATION_PREFIX + getToken(sessionInformation.getSessionId()),
sessionInformation, YmlConfig.getRedisExpirationTimeout(), TimeUnit.SECONDS);
}
// get session-information
public static MySessionInformation getSessionInformation(String sessionId, String token) {
Assert.hasText(sessionId, "SessionId required as per interface contract");
if (token == null) {
token = getToken(sessionId);
}
Object value = RedisUtils.getValue(redisTemplate, SESSION_INFORMATION_PREFIX + token);
if (value == null) {
return null;
}
Map<String, Object> map = OBJECT_MAPPER.convertValue(value, new TypeReference<>() {});
Date lastRequest = new Date((Long) map.get("lastRequest"));
Object principal = map.get("principal");
boolean expireState = (boolean) map.get("expireState");
return new MySessionInformation(principal, sessionId, lastRequest, expireState);
}
// get token by sessionId
public static String getToken(String sessionId) {
if (sessionId == null) {
return null;
}
Object value = RedisUtils.getValue(redisTemplate, SESSION_TOKEN_PREFIX + sessionId);
return value != null ? value.toString() : null;
}
// get sessionId by token
public static String getSessionId(String token) {
if (token == null) {
return null;
}
Object value = RedisUtils.getValue(redisTemplate, TOKEN_SESSION_PREFIX + token);
return value != null ? value.toString() : null;
}
// 根据 principal 获取 sessionIds
private Set<Object> getSessionIds(Object principal) {
UserDetailsVo userDetails = OBJECT_MAPPER.convertValue(principal, new TypeReference<>() {});
return RedisUtils.getSetValue(redisTemplate, SESSION_SESSIONS_PREFIX + userDetails.getAccount());
}
// remove session-information
public void removeSessionInformation(String token, String sessionId) {
SessionInformation sessionInformation = getSessionInformation(sessionId, token);
if (token == null || sessionInformation == null) {
return;
}
RedisUtils.deleteKey(redisTemplate, SESSION_INFORMATION_PREFIX + token);
RedisUtils.deleteKey(redisTemplate, TOKEN_SESSION_PREFIX + token);
RedisUtils.deleteKey(redisTemplate, SESSION_TOKEN_PREFIX + sessionId);
if (sessionInformation.getPrincipal() == null) {
return;
}
RedisUtils.removeSetValue(redisTemplate, SESSION_SESSIONS_PREFIX + sessionInformation.getPrincipal(), sessionId);
}
}
7.3 SecurityContextRepository
@Slf4j
@Component
public class MySecurityContextRepository implements SecurityContextRepository {
// string key: account value: context(SecurityContext)
private static final String ACCOUNT_CONTEXT_PREFIX = "security:account:context:";
// string key: token value: account
private static final String ACCOUNT_TOKEN_PREFIX = "security:account:token:";
// set key: account value: tokens
private static final String ACCOUNT_TOKENS_PREFIX = "security:account:tokens:";
private final AuthenticationTrustResolver trustResolver = new AuthenticationTrustResolverImpl();
private final RedisTemplate<String, Object> springSecurityRedisTemplate;
private final ObjectMapper springSecurityObjectMapper;
public MySecurityContextRepository(RedisTemplate<String, Object> springSecurityRedisTemplate,
@Qualifier("springSecurityObjectMapper") ObjectMapper springSecurityObjectMapper) {
this.springSecurityRedisTemplate = springSecurityRedisTemplate;
this.springSecurityObjectMapper = springSecurityObjectMapper;
}
@Override // 授权时获取当前登录用户的安全上下文信息(包含认证信息)
public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
return getSecurityContext(requestResponseHolder.getRequest());
}
@Override // 认证通过后保存安全上下文信息
public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
Authentication authentication = context.getAuthentication();
if (authentication == null || this.trustResolver.isAnonymous(authentication)) {
return;
}
UserDetailsVo vo = (UserDetailsVo) authentication.getPrincipal();
String account = vo.getAccount();
RedisUtils.addValue(springSecurityRedisTemplate, ACCOUNT_CONTEXT_PREFIX + account, context,
YmlConfig.getRedisExpirationTimeout(), TimeUnit.SECONDS);
RedisUtils.addValue(springSecurityRedisTemplate, ACCOUNT_TOKEN_PREFIX + vo.getToken(), account,
YmlConfig.getRedisExpirationTimeout(), TimeUnit.SECONDS);
RedisUtils.addSetValue(springSecurityRedisTemplate, ACCOUNT_TOKENS_PREFIX + account, vo.getToken());
}
@Override // 是否包含安全上下文信息
public boolean containsContext(HttpServletRequest request) {
String token = request.getHeader(StringEnum.TOKEN.getCode());
if (token == null) {
return false;
}
return RedisUtils.containKey(springSecurityRedisTemplate, ACCOUNT_TOKEN_PREFIX + token);
}
// 清除 context 登出时调用
public void clearContext(HttpServletRequest request) {
String token = request.getHeader(StringEnum.TOKEN.getCode());
if (token == null) {
return;
}
SecurityContext context = this.getSecurityContext(request);
RedisUtils.deleteKey(springSecurityRedisTemplate, ACCOUNT_TOKEN_PREFIX + token);
if (context != null && context.getAuthentication() != null && context.getAuthentication().getPrincipal() != null) {
UserDetailsVo vo = (UserDetailsVo) context.getAuthentication().getPrincipal();
RedisUtils.removeSetValue(springSecurityRedisTemplate, ACCOUNT_TOKENS_PREFIX + vo.getAccount(), token);
Set<Object> tokens = RedisUtils.getSetValue(springSecurityRedisTemplate, ACCOUNT_TOKENS_PREFIX + vo.getAccount());
if (CollectionUtils.isEmpty(tokens)) {
RedisUtils.deleteKey(springSecurityRedisTemplate, ACCOUNT_CONTEXT_PREFIX + vo.getAccount());
}
}
}
// 获取 security context
private SecurityContext getSecurityContext(HttpServletRequest request) {
SecurityContext emptyContext = SecurityContextHolder.createEmptyContext();
String token = request.getHeader(StringEnum.TOKEN.getCode());
if (token == null) {
return emptyContext;
}
Object account = RedisUtils.getValue(springSecurityRedisTemplate, ACCOUNT_TOKEN_PREFIX + token);
if (account == null) {
return emptyContext;
}
try {
Object result = RedisUtils.getValue(springSecurityRedisTemplate, ACCOUNT_CONTEXT_PREFIX + account);
SecurityContext securityContext = springSecurityObjectMapper.convertValue(result, SecurityContext.class);
return securityContext == null ? emptyContext : securityContext;
} catch (Exception e) {
log.error("Failed to convert Map to Object!", e);
return emptyContext;
}
}
}
7.4 SecurityMetadataSource 与 AuthorizationManager
7.4.1 MySecurityMetedataSource
@Component
public class MySecurityMetadataSource {
private final Map<String, Set<String>> requestPathRole = new HashMap<>();
private final PermMapper permMapper;
public MySecurityMetadataSource(PermMapper permMapper) {
this.permMapper = permMapper;
}
// 服务启动时初始化 资源-角色 映射关系
@PostConstruct
public void init() {
this.refreshPermRole();
}
// 根据请求 path 获取角色
public Set<String> getRequestPathRole(String requestPath) {
return this.requestPathRole.get(requestPath);
}
// 刷新权限-角色列表映射 当 角色、权限 数据更新时调用
public void refreshPermRole() {
List<PermRoleVo> allPermRole = permMapper.allPermRole();
for (PermRoleVo perm : allPermRole) {
if (!StringUtils.hasText(perm.getApiUrl())) {
continue;
}
Set<String> roles;
if (this.requestPathRole.containsKey(perm.getApiUrl())) {
roles = this.requestPathRole.get(perm.getApiUrl());
} else {
roles = new HashSet<>();
}
for (RoleVo role : perm.getRoles()) {
if (!StringUtils.hasText(role.getRoleCode())) {
continue;
}
roles.add(role.getRoleCode());
}
this.requestPathRole.put(perm.getApiUrl(), roles);
}
}
}
7.4.2 AuthorizationManager
@Component
public class MyAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {
private static final AuthorizationDecision AUTHORIZED = new AuthorizationDecision(true);
private final AuthenticationTrustResolver authenticationTrustResolver = new AuthenticationTrustResolverImpl();
private final MySecurityMetadataSource mySecurityMetadataSource;
public MyAuthorizationManager(MySecurityMetadataSource mySecurityMetadataSource) {
this.mySecurityMetadataSource = mySecurityMetadataSource;
}
@Override
public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext object) {
Authentication tempAuthentication = authentication.get();
Collection<? extends GrantedAuthority> grantedAuthorities = tempAuthentication.getAuthorities();
String requestUrl = UrlUtils.buildRequestUrl(object.getRequest());
Set<String> requestPathRole;
if (StringEnum.LOGIN_PATH.getCode().equals(requestUrl) ||
CollectionUtils.isEmpty(requestPathRole = this.mySecurityMetadataSource.getRequestPathRole(requestUrl))) {
return AUTHORIZED;
}
if (this.authenticationTrustResolver.isAnonymous(tempAuthentication)) {
throw new InsufficientAuthenticationException("请先登录!");
}
String authority;
for (GrantedAuthority grantedAuthority : grantedAuthorities) {
if (!StringUtils.hasText(authority = grantedAuthority.getAuthority())) {
continue;
}
if (requestPathRole.contains(authority)) {
return AUTHORIZED;
}
}
throw new AccessDeniedException("权限不足!");
}
}
7.5 TokenSessionFilter
public class TokenSessionFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String sessionId;
HttpSession session = request.getSession(false);
String token = request.getHeader(StringEnum.TOKEN.getCode());
if (session != null && (sessionId = session.getId()) != null && StringUtils.hasText(token)) {
MySessionRegistry.mappingTokenSession(token, sessionId);
}
filterChain.doFilter(request, response);
}
}
7.6 AuthenticationEntryPoint
@Slf4j
@Component // 授权时未获取到认证信息时执行
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
throws IOException, ServletException {
log.error(authException.getMessage());
ResponseUtils.responseJson(response, APIResponse.failure(1004, "请先登录!"));
}
}
7.7 AuthenticationSuccessHandler 与 AuthenticationFailureHandler
7.7.1 AuthenticationSuccessHandler
@Slf4j
@Component
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException, ServletException {
UserDetailsVo vo = (UserDetailsVo) authentication.getPrincipal();
vo.setPassword(null);
ResponseUtils.responseJson(response, APIResponse.success("Login successful!", vo));
}
}
7.7.2 AuthenticationFailureHandler
@Slf4j
@Component
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception)
throws IOException, ServletException {
log.error(exception.getMessage());
String message;
if (exception instanceof UsernameNotFoundException || exception instanceof BadCredentialsException) {
message = "用户名或密码错误!";
} else if (exception instanceof LockedException) {
message = "账号被锁定!";
} else if (exception instanceof DisabledException) {
message = "账号被禁用!";
} else if (exception instanceof AccountExpiredException) {
message = "账号已过期!";
} else if (exception instanceof CredentialsExpiredException) {
message = "密码已过期!";
} else {
message = exception.getMessage();
}
ResponseUtils.responseJson(response, APIResponse.failure(1000, message));
}
}
7.8 LogoutHandler 与 LogoutSuccessHandler
7.8.1 LogoutHandler
@Component
public class MyLogoutHandler implements LogoutHandler {
private final MySecurityContextRepository mySecurityContextRepository;
private final MySessionRegistry sessionRegistry;
public MyLogoutHandler(MySecurityContextRepository mySecurityContextRepository, MySessionRegistry sessionRegistry) {
this.mySecurityContextRepository = mySecurityContextRepository;
this.sessionRegistry = sessionRegistry;
}
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
sessionRegistry.removeSessionInformation(request.getHeader(StringEnum.TOKEN.getCode()), request.getSession().getId());
mySecurityContextRepository.clearContext(request);
}
}
7.8.2 LogoutSuccessHandler
@Component
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException, ServletException {
ResponseUtils.responseJson(response, APIResponse.success("登出成功!"));
}
}
7.9 AccessDeniedHandler
@Slf4j
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException)
throws IOException, ServletException {
log.error(accessDeniedException.getMessage());
ResponseUtils.responseJson(response, APIResponse.failure(1001, "权限不足!"));
}
}
7.10 SessionExpiredHandler
@Component
public class MySessionExpiredHandler implements SessionInformationExpiredStrategy {
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
ResponseUtils.responseJson(event.getResponse(), APIResponse.failure(R1005, "账号在其它设备登录,请重新登录!"));
}
}
7.11 SecurityConfig
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity,
UserDetailsService userDetailsService,
AuthenticationEntryPoint authenticationEntryPoint,
AuthenticationSuccessHandler authenticationSuccessHandler,
AuthenticationFailureHandler authenticationFailureHandler,
MySessionRegistry mySessionRegistry,
SessionInformationExpiredStrategy sessionExpiredHandler,
SecurityContextRepository securityContextRepository,
AuthorizationManager<RequestAuthorizationContext> authorizationManager,
AccessDeniedHandler accessDeniedHandler,
LogoutHandler logoutHandler,
LogoutSuccessHandler logoutSuccessHandler) throws Exception {
// 因为是前后端分离 所以关闭 CSRF
httpSecurity.csrf(AbstractHttpConfigurer::disable);
// 跨域配置
httpSecurity.cors(configurer -> configurer.configurationSource(this.configurationSource()));
// 指定 UserDetailsService 认证时通过该实现获取用户信息
httpSecurity.userDetailsService(userDetailsService);
// 用户名密码认证配置
httpSecurity.formLogin(configurer -> configurer.loginProcessingUrl("/admin/user/login")
.usernameParameter("account")
.passwordParameter("password")
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler));
// session 管理配置
httpSecurity.addFilterBefore(new TokenSessionFilter(), UsernamePasswordAuthenticationFilter.class)
.sessionManagement(configurer -> configurer.maximumSessions(2)
.sessionRegistry(mySessionRegistry)
.expiredSessionStrategy(sessionExpiredHandler));
// 指定 security 安全上下文存储器 用来存储和管理认证后的用户信息
httpSecurity.securityContext(configurer -> configurer.securityContextRepository(securityContextRepository));
// 指定认证端点异常处理器 当当前访问的资源需要认证但未认证时会执行该处理器
httpSecurity.exceptionHandling(configurer -> configurer.authenticationEntryPoint(authenticationEntryPoint));
// 指定授权管理器 即自定义实现的授权逻辑
httpSecurity.authorizeHttpRequests(authorize -> authorize.anyRequest()
.access(authorizationManager));
// 指定授权失败处理器 当授权失败时执行
httpSecurity.exceptionHandling(configurer -> configurer.accessDeniedHandler(accessDeniedHandler));
// 登出配置
httpSecurity.logout(configurer -> configurer.logoutUrl("/admin/user/logout")
.addLogoutHandler(logoutHandler)
.logoutSuccessHandler(logoutSuccessHandler));
return httpSecurity.build();
}
@Bean("springSecurityObjectMapper")
public ObjectMapper springSecurityObjectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
// 因为放入 redis 的 SecurityContext 中的 Authentication 接口的相关实现类没有无参构造函数 会导致反序列化失败
// 所以需要借助 security 中的相关 module
objectMapper.registerModules(SecurityJackson2Modules.getModules(getClass().getClassLoader()));
// 由于反序列化经常出现漏洞 故 spring security 增加了反序列化类的白名单 即只有加入此白名单的类才可被反序列化
// 而放入 redis 中的 SecurityContext 中的 authentication 中的 principal 对象实际上是我们自定义实现的用户类(UserDetailsVo)
// 该类需要被反序列化 但却没被加入白名单 所以需要将其加入白名单
// 加入白名单有两种方式 即加入 mixIn 和 使用 jackson 相关注解(以下使用方式一)
objectMapper.addMixIn(UserDetailsVo.class, UserDetailsVo.UserDetailsVoMixin.class);
return objectMapper;
}
@Bean("objectMapper")
public ObjectMapper objectMapper() {
return new ObjectMapper();
}
// 跨域
private CorsConfigurationSource configurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedHeader("*");
configuration.addAllowedMethod("*");
configuration.addAllowedOriginPattern("*");
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**",configuration);
return source;
}
}
1975

被折叠的 条评论
为什么被折叠?



