初始化
This commit is contained in:
643
rdms-framework/rdms-spring-boot-starter-security/README.md
Normal file
643
rdms-framework/rdms-spring-boot-starter-security/README.md
Normal file
@@ -0,0 +1,643 @@
|
||||
# rdms-spring-boot-starter-security
|
||||
|
||||
## 1. 模块定位
|
||||
|
||||
`rdms-spring-boot-starter-security` 是项目中的安全基础模块,当前实际包含两块能力:
|
||||
|
||||
- 安全认证与权限校验
|
||||
- 操作日志记录
|
||||
|
||||
这个模块不是单纯引入 Spring Security 依赖,而是已经在项目里落了完整的自动装配链路,包括:
|
||||
|
||||
- 基于 Token 的无状态认证
|
||||
- 登录用户上下文维护
|
||||
- URL 与方法级权限控制
|
||||
- 跨服务 `LoginUser` 透传
|
||||
- 401 / 403 统一返回
|
||||
- 操作日志采集与异步上报
|
||||
|
||||
## 2. 设计思路
|
||||
|
||||
### 2.1 认证与权限分层
|
||||
|
||||
这个模块把安全相关能力拆成了几层:
|
||||
|
||||
1. 配置层
|
||||
通过 `SecurityProperties` 统一管理 token 请求头、token 参数名、白名单、mock 登录、密码加密强度等配置。
|
||||
|
||||
2. 过滤器层
|
||||
通过 `TokenAuthenticationFilter` 解析请求中的登录信息,并把 `LoginUser` 放入 Spring Security 上下文。
|
||||
|
||||
3. Spring Security 配置层
|
||||
通过 `SecurityFilterChain` 统一配置无状态认证、放行规则、异常处理和过滤器顺序。
|
||||
|
||||
4. 权限服务层
|
||||
通过 `SecurityFrameworkService` 对外提供权限、角色、scope 判断能力,供 `@PreAuthorize` 等表达式直接使用。
|
||||
|
||||
5. RPC 透传层
|
||||
通过 Feign `RequestInterceptor` 把当前 `LoginUser` 继续透传给下游服务。
|
||||
|
||||
### 2.2 当前认证链路
|
||||
|
||||
当前项目的认证链路可以理解为:
|
||||
|
||||
1. 请求进入服务
|
||||
2. 优先尝试从 `login-user` 请求头恢复登录用户
|
||||
适用于网关或上游服务已经完成认证并透传用户信息的场景
|
||||
3. 如果没有 `login-user`,再尝试从 `Authorization` 或请求参数中获取 token
|
||||
4. 通过 `OAuth2TokenCommonApi` 校验 token
|
||||
5. 构造 `LoginUser`
|
||||
6. 放入 Spring Security 上下文,供后续权限判断、业务代码、日志记录使用
|
||||
|
||||
这意味着当前项目跨服务调用时,主要透传的是 `LoginUser`,而不是原始 `Authorization`。
|
||||
|
||||
### 2.3 权限判断思路
|
||||
|
||||
权限判断没有把权限数据直接塞进本地配置,而是通过远程接口获取:
|
||||
|
||||
- 权限校验走 `PermissionCommonApi`
|
||||
- token 校验走 `OAuth2TokenCommonApi`
|
||||
|
||||
也就是说,这个模块负责“接入和执行安全规则”,权限与 token 数据本身仍然由系统服务提供。
|
||||
|
||||
### 2.4 操作日志思路
|
||||
|
||||
操作日志能力不是自己从零实现注解解析,而是集成 `bizlog-sdk`:
|
||||
|
||||
- 业务代码通过 `@LogRecord` 声明日志
|
||||
- 本模块提供 `ILogRecordService` 实现
|
||||
- 最终通过 `OperateLogCommonApi` 异步上报操作日志
|
||||
|
||||
这样做的特点是:
|
||||
|
||||
- 业务代码侧书写简单
|
||||
- 日志记录逻辑统一
|
||||
- 日志存储仍集中在系统服务
|
||||
|
||||
## 3. 自动装配入口
|
||||
|
||||
当前自动装配入口在:
|
||||
|
||||
- `RdmsSecurityRpcAutoConfiguration`
|
||||
- `RdmsSecurityAutoConfiguration`
|
||||
- `RdmsWebSecurityConfigurerAdapter`
|
||||
- `RdmsOperateLogConfiguration`
|
||||
- `RdmsOperateLogRpcAutoConfiguration`
|
||||
|
||||
说明这个 starter 当前已经不是占位模块,而是有完整自动配置入口的基础模块。
|
||||
|
||||
## 4. 核心功能点
|
||||
|
||||
### 4.1 基于 Token 的无状态认证
|
||||
|
||||
通过 `SecurityFilterChain` 配置为无状态模式:
|
||||
|
||||
- 禁用 Session
|
||||
- 禁用表单登录
|
||||
- 禁用 httpBasic
|
||||
- 通过自定义 `TokenAuthenticationFilter` 完成登录态识别
|
||||
|
||||
这意味着服务本身不维护 Session,认证主要依赖 token 或上游透传的 `login-user`。
|
||||
|
||||
### 4.2 登录用户上下文维护
|
||||
|
||||
模块内定义了 `LoginUser`,当前主要包含这些信息:
|
||||
|
||||
- `id`
|
||||
- `userType`
|
||||
- `info`
|
||||
- `tenantId`
|
||||
- `scopes`
|
||||
- `expiresTime`
|
||||
- `visitTenantId`
|
||||
|
||||
认证成功后,`LoginUser` 会进入 Spring Security 上下文,后续可以通过 `SecurityFrameworkUtils` 获取,例如:
|
||||
|
||||
- 当前用户 ID
|
||||
- 当前用户昵称
|
||||
- 当前用户部门 ID
|
||||
|
||||
### 4.3 支持两种登录信息来源
|
||||
|
||||
`TokenAuthenticationFilter` 当前支持两种方式恢复登录用户:
|
||||
|
||||
1. 从 `login-user` 请求头恢复
|
||||
适合网关转发、服务间调用、Feign 透传场景
|
||||
|
||||
2. 从 token 恢复
|
||||
适合直接请求服务、没有上游透传 `login-user` 的场景
|
||||
|
||||
这也是为什么当前服务间调用时,即使不继续透传原始 token,下游仍然可以识别当前登录用户。
|
||||
|
||||
### 4.4 跨服务 LoginUser 透传
|
||||
|
||||
模块内提供了 `LoginUserRequestInterceptor`。
|
||||
|
||||
作用是:
|
||||
|
||||
- 发起 Feign 请求时,读取当前线程中的 `LoginUser`
|
||||
- 将其序列化后放到 `login-user` 请求头
|
||||
- 下游服务再通过 `TokenAuthenticationFilter` 还原为登录用户上下文
|
||||
|
||||
当前项目跨服务透传的重点是“登录用户信息透传”,不是“原始 token 透传”。
|
||||
|
||||
### 4.5 URL 级访问控制
|
||||
|
||||
`RdmsWebSecurityConfigurerAdapter` 里统一配置了请求访问规则,当前支持:
|
||||
|
||||
- 静态资源默认放行
|
||||
- `@PermitAll` 标注的方法或类自动放行
|
||||
- `rdms.security.permit-all-urls` 配置的 URL 放行
|
||||
- 各业务模块通过 `AuthorizeRequestsCustomizer` 继续追加自定义规则
|
||||
- 其余请求默认需要认证
|
||||
|
||||
其中 `AuthorizeRequestsCustomizer` 是一个扩展点,适合各模块自己补充 URL 安全规则。
|
||||
|
||||
例如 WebSocket 模块就是通过继承这个类,把自己的连接地址加入放行规则。
|
||||
|
||||
### 4.6 方法级权限校验
|
||||
|
||||
模块开启了 `@EnableMethodSecurity`,并注册了名为 `ss` 的权限服务 Bean。
|
||||
|
||||
因此业务代码可以直接写:
|
||||
|
||||
```java
|
||||
@PreAuthorize("@ss.hasPermission('system:user:create')")
|
||||
public Long createUser(UserSaveReqVO reqVO) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
当前支持的能力包括:
|
||||
|
||||
- `hasPermission`
|
||||
- `hasAnyPermissions`
|
||||
- `hasRole`
|
||||
- `hasAnyRoles`
|
||||
- `hasScope`
|
||||
- `hasAnyScopes`
|
||||
|
||||
### 4.7 权限与角色远程校验
|
||||
|
||||
权限、角色判断不是本地硬编码,而是通过 `PermissionCommonApi` 远程校验。
|
||||
|
||||
为了减少频繁 RPC 调用,当前还做了本地 Guava 缓存:
|
||||
|
||||
- 权限判断缓存 1 分钟
|
||||
- 角色判断缓存 1 分钟
|
||||
|
||||
`scope` 判断则直接基于当前 `LoginUser.scopes` 完成。
|
||||
|
||||
### 4.8 401 / 403 统一处理
|
||||
|
||||
模块内提供了统一异常处理器:
|
||||
|
||||
- `AuthenticationEntryPointImpl`:未登录访问需要认证的资源时返回 401
|
||||
- `AccessDeniedHandlerImpl`:已登录但权限不足时返回 403
|
||||
|
||||
这样前端收到的是统一 JSON 结构,而不是默认的 Spring Security 页面或跳转行为。
|
||||
|
||||
### 4.9 SecurityContext 跨线程传递
|
||||
|
||||
模块把 `SecurityContextHolder` 的策略切换成了 `TransmittableThreadLocalSecurityContextHolderStrategy`。
|
||||
|
||||
作用是:
|
||||
|
||||
- 在 `@Async` 等异步线程场景下,尽量保留当前安全上下文
|
||||
- 减少使用普通 `ThreadLocal` 时上下文丢失的问题
|
||||
|
||||
### 4.10 支持开发期 mock 登录
|
||||
|
||||
当配置开启后,可以使用 mock token 构造登录用户,便于本地调试。
|
||||
|
||||
这块能力由 `SecurityProperties` 中的以下配置控制:
|
||||
|
||||
- `mock-enable`
|
||||
- `mock-secret`
|
||||
|
||||
这类能力只适合开发调试环境,不适合生产环境开启。
|
||||
|
||||
### 4.11 操作日志能力
|
||||
|
||||
这个模块还同时集成了操作日志能力。
|
||||
|
||||
当前实现方式是:
|
||||
|
||||
1. 通过 `@EnableLogRecord` 开启日志能力
|
||||
2. 业务方法上使用 `@LogRecord`
|
||||
3. 本模块的 `LogRecordServiceImpl` 收集日志内容
|
||||
4. 自动补充用户信息、请求信息、链路追踪信息
|
||||
5. 通过 `OperateLogCommonApi` 异步上报
|
||||
|
||||
日志中会补充的典型信息包括:
|
||||
|
||||
- 用户 ID
|
||||
- 用户类型
|
||||
- 请求方法
|
||||
- 请求 URL
|
||||
- 请求 IP
|
||||
- User-Agent
|
||||
- TraceId
|
||||
|
||||
## 5. 配置项
|
||||
|
||||
当前可见的核心配置在 `rdms.security` 下:
|
||||
|
||||
```yaml
|
||||
rdms:
|
||||
security:
|
||||
token-header: Authorization
|
||||
token-parameter: token
|
||||
mock-enable: false
|
||||
mock-secret: test
|
||||
permit-all-urls:
|
||||
- /admin-api/system/auth/login
|
||||
password-encoder-length: 4
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `token-header`:请求头里 token 的字段名,默认是 `Authorization`
|
||||
- `token-parameter`:请求参数里 token 的字段名,主要兼容不能方便传 header 的场景
|
||||
- `mock-enable` / `mock-secret`:开发期 mock 登录控制
|
||||
- `permit-all-urls`:额外白名单 URL
|
||||
- `password-encoder-length`:BCrypt 加密强度
|
||||
|
||||
## 6. 快速上手
|
||||
|
||||
如果从“一个开发者接手后该怎么用”来理解,这个模块最常见的上手路径如下。
|
||||
|
||||
### 6.1 引入依赖后,会自动得到什么
|
||||
|
||||
业务模块引入这个 starter 后,默认会自动得到这些能力:
|
||||
|
||||
- Spring Security 过滤链
|
||||
- Token 认证过滤器
|
||||
- `@EnableMethodSecurity`
|
||||
- `@ss` 权限表达式 Bean
|
||||
- 401 / 403 统一返回
|
||||
- Feign 的 `login-user` 透传
|
||||
- `@LogRecord` 操作日志能力
|
||||
|
||||
也就是说,通常不需要你再手写一套 Security 配置类,除非业务模块有额外的特殊规则。
|
||||
|
||||
### 6.2 最小配置怎么写
|
||||
|
||||
最常见的基础配置如下:
|
||||
|
||||
```yaml
|
||||
rdms:
|
||||
security:
|
||||
token-header: Authorization
|
||||
token-parameter: token
|
||||
mock-enable: false
|
||||
permit-all-urls:
|
||||
- /admin-api/system/auth/login
|
||||
- /admin-api/system/auth/refresh-token
|
||||
```
|
||||
|
||||
如果没有特殊需求,通常只需要确认:
|
||||
|
||||
- 哪些接口要放行
|
||||
- token 从哪个 header 取
|
||||
|
||||
### 6.3 一个新接口,默认是什么状态
|
||||
|
||||
默认情况下:
|
||||
|
||||
- 没有显式放行的接口,都会要求登录
|
||||
- 已登录后,如果方法上写了 `@PreAuthorize`,还会继续做权限校验
|
||||
|
||||
也就是说,一个普通接口最常见的开发方式是:
|
||||
|
||||
1. 默认需要登录
|
||||
2. 再按需要加权限注解
|
||||
|
||||
例如:
|
||||
|
||||
```java
|
||||
@PreAuthorize("@ss.hasPermission('system:user:query')")
|
||||
@GetMapping("/page")
|
||||
public CommonResult<PageResult<UserRespVO>> getUserPage(UserPageReqVO reqVO) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### 6.4 哪些接口不需要登录,怎么做
|
||||
|
||||
有两种常用方式。
|
||||
|
||||
方式一:在接口或类上标 `@PermitAll`
|
||||
|
||||
```java
|
||||
@PermitAll
|
||||
@GetMapping("/public-info")
|
||||
public CommonResult<String> getPublicInfo() {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
方式二:通过配置白名单放行
|
||||
|
||||
```yaml
|
||||
rdms:
|
||||
security:
|
||||
permit-all-urls:
|
||||
- /admin-api/system/auth/login
|
||||
- /admin-api/system/auth/refresh-token
|
||||
```
|
||||
|
||||
实践建议:
|
||||
|
||||
- 固定的公共接口、登录接口、刷新 token 接口,更适合放配置白名单
|
||||
- 个别无需登录的业务接口,更适合直接写 `@PermitAll`
|
||||
|
||||
### 6.5 需要权限控制,怎么做
|
||||
|
||||
最常用的是在 Controller 或 Service 方法上写 `@PreAuthorize`:
|
||||
|
||||
```java
|
||||
@PreAuthorize("@ss.hasPermission('system:user:create')")
|
||||
@PostMapping("/create")
|
||||
public CommonResult<Long> createUser(@Valid @RequestBody UserSaveReqVO reqVO) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
当前可直接使用的表达式包括:
|
||||
|
||||
- `@ss.hasPermission('xxx')`
|
||||
- `@ss.hasAnyPermissions('a', 'b')`
|
||||
- `@ss.hasRole('admin')`
|
||||
- `@ss.hasAnyRoles('admin', 'manager')`
|
||||
- `@ss.hasScope('user.read')`
|
||||
- `@ss.hasAnyScopes('a', 'b')`
|
||||
|
||||
最常见的是 `hasPermission`。
|
||||
|
||||
### 6.5.1 接口可以按三类理解
|
||||
|
||||
从实际开发角度,可以把接口简单分成三类:
|
||||
|
||||
1. 匿名可访问
|
||||
这类接口不要求登录。
|
||||
常见做法:
|
||||
- 使用 `@PermitAll`
|
||||
- 或加入 `rdms.security.permit-all-urls` 白名单
|
||||
|
||||
2. 登录即可访问
|
||||
这类接口要求登录,但不校验具体 permission。
|
||||
常见做法:
|
||||
- 不加 `@PermitAll`
|
||||
- 不加白名单
|
||||
- 也不加 `@PreAuthorize`
|
||||
|
||||
在当前配置下,这类接口会被默认的 `authenticated()` 规则保护:
|
||||
- 未登录无法访问
|
||||
- 已登录可以访问
|
||||
|
||||
3. 登录后还要校验具体权限
|
||||
这类接口除了要求登录,还要求具备具体权限点。
|
||||
常见做法:
|
||||
- 使用 `@PreAuthorize("@ss.hasPermission('xxx')")`
|
||||
|
||||
一个比较实用的开发策略是:
|
||||
|
||||
- 大部分普通接口只要求登录
|
||||
- 删除、导出、审批、分配等敏感动作再加具体权限校验
|
||||
|
||||
### 6.6 权限校验是怎么触发的
|
||||
|
||||
很多时候容易疑惑:`@PreAuthorize("@ss.hasPermission('system:dept:update')")` 这行代码本身没有手动调用 `PermissionCommonApi`,那远程权限校验是什么时候发生的?
|
||||
|
||||
当前链路可以直接理解成下面这几步:
|
||||
|
||||
```text
|
||||
请求进入服务
|
||||
-> TokenAuthenticationFilter 完成登录态恢复
|
||||
-> 请求到达 Controller / Service 方法前
|
||||
-> Spring Security 拦截 @PreAuthorize
|
||||
-> 解析表达式 @ss.hasPermission('system:dept:update')
|
||||
-> 调用 Spring 容器里的 ss Bean
|
||||
-> 进入 SecurityFrameworkServiceImpl.hasPermission(...)
|
||||
-> 转到 hasAnyPermissions(...)
|
||||
-> 先查本地 1 分钟缓存
|
||||
-> 缓存未命中时,调用 PermissionCommonApi 远程判断权限
|
||||
-> 返回 true:允许进入方法
|
||||
-> 返回 false:拒绝访问,返回 403
|
||||
```
|
||||
|
||||
可以把它理解为:
|
||||
|
||||
- `@PreAuthorize` 负责“触发权限判断”
|
||||
- `@ss` 负责“找到权限判断 Bean”
|
||||
- `SecurityFrameworkServiceImpl` 负责“执行权限判断逻辑”
|
||||
- `PermissionCommonApi` 负责“向权限服务查询结果”
|
||||
|
||||
所以真正触发 `PermissionCommonApi` 的,不是 Controller 代码手动调用,而是 Spring Security 在执行 `@PreAuthorize` 表达式时自动触发。
|
||||
|
||||
### 6.7 业务代码里怎么拿当前登录用户
|
||||
|
||||
最常用的是通过 `SecurityFrameworkUtils` 获取:
|
||||
|
||||
```java
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
String nickname = SecurityFrameworkUtils.getLoginUserNickname();
|
||||
Long deptId = SecurityFrameworkUtils.getLoginUserDeptId();
|
||||
```
|
||||
|
||||
如果需要更完整的信息:
|
||||
|
||||
```java
|
||||
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
|
||||
```
|
||||
|
||||
适用场景:
|
||||
|
||||
- 新增、修改数据时记录操作人
|
||||
- 查询当前用户自己的数据
|
||||
- 记录审计字段
|
||||
|
||||
### 6.8 为什么跨服务还能拿到当前用户
|
||||
|
||||
当前项目的链路是这样的:
|
||||
|
||||
1. 前端把 token 传给网关
|
||||
2. 网关校验 token 后,把用户信息转成 `login-user` 请求头
|
||||
3. 下游服务读取 `login-user`,恢复 `LoginUser`
|
||||
4. 如果下游服务再通过 Feign 调别的服务,`LoginUserRequestInterceptor` 会继续透传 `login-user`
|
||||
|
||||
所以开发时可以这样理解:
|
||||
|
||||
- Web 入口主要靠 token 建立登录态
|
||||
- 服务间调用主要靠 `login-user` 继续传递登录态
|
||||
|
||||
### 6.9 什么时候需要自己补 URL 规则
|
||||
|
||||
如果某个模块有特殊放行需求,可以继承 `AuthorizeRequestsCustomizer`。
|
||||
|
||||
例如:
|
||||
|
||||
```java
|
||||
public class XxxAuthorizeRequestsCustomizer extends AuthorizeRequestsCustomizer {
|
||||
|
||||
@Override
|
||||
public void customize(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry registry) {
|
||||
registry.requestMatchers("/xxx/**").permitAll();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
常见场景:
|
||||
|
||||
- WebSocket 握手地址
|
||||
- 第三方回调接口
|
||||
- 某些模块自己的公共入口
|
||||
|
||||
### 6.10 需要记录操作日志,怎么做
|
||||
|
||||
在业务方法上直接加 `@LogRecord`:
|
||||
|
||||
```java
|
||||
@LogRecord(type = "SYSTEM_USER", subType = "CREATE", bizNo = "{{#user.id}}",
|
||||
success = "创建用户成功")
|
||||
public Long createUser(UserSaveReqVO reqVO) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
这个注解更适合加在“有明确业务动作”的方法上,例如:
|
||||
|
||||
- 新增用户
|
||||
- 修改角色
|
||||
- 删除字典
|
||||
- 分配权限
|
||||
|
||||
### 6.11 开发时最容易混淆的几个点
|
||||
|
||||
1. 这个模块负责“认证接入和权限执行”,不负责 token 签发
|
||||
token 的签发和存储仍在系统服务等其他模块。
|
||||
|
||||
2. 当前跨服务透传的是 `login-user`,不是原始 token
|
||||
所以下游服务能识别当前用户,不等于它一直拿着原始 `Authorization`。
|
||||
|
||||
3. 不加 `@PreAuthorize` 不代表匿名可访问
|
||||
默认仍然需要登录,只是不会进一步做权限点校验。
|
||||
|
||||
4. `@PermitAll` 和 `permit-all-urls` 都是放行登录校验
|
||||
放行后通常也不会再走权限控制。
|
||||
|
||||
## 7. 常见使用方式
|
||||
|
||||
### 7.1 方法权限控制
|
||||
|
||||
```java
|
||||
@PreAuthorize("@ss.hasPermission('system:user:query')")
|
||||
@GetMapping("/page")
|
||||
public CommonResult<PageResult<UserRespVO>> getUserPage(UserPageReqVO reqVO) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
适用场景:
|
||||
|
||||
- 控制某个接口必须具备某个权限点
|
||||
- 细粒度控制新增、修改、删除、导出等操作
|
||||
|
||||
### 7.2 放行无需登录的接口
|
||||
|
||||
方式一:直接标注 `@PermitAll`
|
||||
|
||||
```java
|
||||
@PermitAll
|
||||
@GetMapping("/public-info")
|
||||
public CommonResult<String> getPublicInfo() {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
方式二:通过配置加入白名单
|
||||
|
||||
```yaml
|
||||
rdms:
|
||||
security:
|
||||
permit-all-urls:
|
||||
- /admin-api/system/auth/login
|
||||
- /admin-api/system/auth/refresh-token
|
||||
```
|
||||
|
||||
### 7.3 为某个模块补充 URL 安全规则
|
||||
|
||||
如果某个模块有自己的特殊放行需求,可以继承 `AuthorizeRequestsCustomizer`:
|
||||
|
||||
```java
|
||||
public class XxxAuthorizeRequestsCustomizer extends AuthorizeRequestsCustomizer {
|
||||
|
||||
@Override
|
||||
public void customize(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry registry) {
|
||||
registry.requestMatchers("/xxx/**").permitAll();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
适用场景:
|
||||
|
||||
- WebSocket 握手地址放行
|
||||
- 回调接口放行
|
||||
- 某些特殊模块追加自己的 URL 规则
|
||||
|
||||
### 7.4 记录操作日志
|
||||
|
||||
```java
|
||||
@LogRecord(type = "SYSTEM_USER", subType = "CREATE", bizNo = "{{#user.id}}",
|
||||
success = "创建用户成功")
|
||||
public Long createUser(UserSaveReqVO reqVO) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
适用场景:
|
||||
|
||||
- 新增、修改、删除等需要审计的核心业务动作
|
||||
- 希望把操作人、请求来源、业务对象编号一起记录下来
|
||||
|
||||
## 8. 模块边界
|
||||
|
||||
阅读这个模块时,需要把“它负责什么”和“它不负责什么”分开看。
|
||||
|
||||
当前它负责:
|
||||
|
||||
- 接入 Spring Security
|
||||
- 解析 token 或 `login-user`
|
||||
- 构建登录用户上下文
|
||||
- 提供权限判断能力
|
||||
- 统一未登录 / 未授权响应
|
||||
- 透传 `LoginUser`
|
||||
- 集成操作日志记录
|
||||
|
||||
当前它不负责:
|
||||
|
||||
- token 的签发
|
||||
- 权限数据本身的维护
|
||||
- 用户、角色、菜单、权限模型的存储
|
||||
- 原始 `Authorization` 的 Feign 透传
|
||||
|
||||
这些数据来源和业务模型,当前仍由系统服务等其他模块提供。
|
||||
|
||||
## 9. 总结
|
||||
|
||||
`rdms-spring-boot-starter-security` 当前已经是一个实际落地的基础模块,不是占位模块。
|
||||
|
||||
从当前代码看,它的核心价值在于:
|
||||
|
||||
- 统一接入认证与权限体系
|
||||
- 把登录用户上下文在 Web、Spring Security、Feign 调用之间串起来
|
||||
- 给业务层提供简单直接的权限表达式入口
|
||||
- 把操作日志能力收进统一基础设施
|
||||
|
||||
如果后续继续扩展,这个模块仍然适合承接更多安全通用能力,例如:
|
||||
|
||||
- 原始 token 透传
|
||||
- 更细的权限表达式
|
||||
- 更完整的审计日志字段
|
||||
- 更严格的跨租户访问控制
|
||||
72
rdms-framework/rdms-spring-boot-starter-security/pom.xml
Normal file
72
rdms-framework/rdms-spring-boot-starter-security/pom.xml
Normal file
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.njcn</groupId>
|
||||
<artifactId>rdms-framework</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>rdms-spring-boot-starter-security</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>${project.artifactId}</name>
|
||||
<description>
|
||||
1. security:用户的认证、权限的校验,实现「谁」可以做「什么事」
|
||||
2. operatelog:操作日志,实现「谁」在「什么时间」对「什么」做了「什么事」
|
||||
</description>
|
||||
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.njcn</groupId>
|
||||
<artifactId>rdms-common</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring 核心 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-aop</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Web 相关 -->
|
||||
<dependency>
|
||||
<groupId>com.njcn</groupId>
|
||||
<artifactId>rdms-spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<!-- spring boot 配置所需依赖 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- RPC 远程调用相关 -->
|
||||
<dependency>
|
||||
<groupId>com.njcn</groupId>
|
||||
<artifactId>rdms-spring-boot-starter-rpc</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- 工具类相关 -->
|
||||
<dependency>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>guava</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<!-- Spring Boot 通用操作日志组件,基于注解实现 -->
|
||||
<!-- 此组件解决的问题是:「谁」在「什么时间」对「什么」做了「什么事」 -->
|
||||
<groupId>io.github.mouzt</groupId>
|
||||
<artifactId>bizlog-sdk</artifactId>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.njcn.rdms.framework.operatelog.config;
|
||||
|
||||
import com.mzt.logapi.service.ILogRecordService;
|
||||
import com.mzt.logapi.starter.annotation.EnableLogRecord;
|
||||
import com.njcn.rdms.framework.operatelog.core.service.LogRecordServiceImpl;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
|
||||
/**
|
||||
* 操作日志配置类
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@EnableLogRecord(tenant = "") // 貌似用不上 tenant 这玩意给个空好啦
|
||||
@AutoConfiguration
|
||||
@Slf4j
|
||||
public class RdmsOperateLogConfiguration {
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
public ILogRecordService iLogRecordServiceImpl() {
|
||||
return new LogRecordServiceImpl();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.njcn.rdms.framework.operatelog.config;
|
||||
|
||||
import com.njcn.rdms.framework.common.biz.system.logger.OperateLogCommonApi;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
|
||||
/**
|
||||
* OperateLog 使用到 Feign 的配置项
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@EnableFeignClients(clients = {OperateLogCommonApi.class}) // 主要是引入相关的 API 服务
|
||||
public class RdmsOperateLogRpcAutoConfiguration {
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* 占位,无特殊作用
|
||||
*/
|
||||
package com.njcn.rdms.framework.operatelog.core;
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.njcn.rdms.framework.operatelog.core.service;
|
||||
|
||||
import com.mzt.logapi.beans.LogRecord;
|
||||
import com.mzt.logapi.service.ILogRecordService;
|
||||
import com.njcn.rdms.framework.common.biz.system.logger.OperateLogCommonApi;
|
||||
import com.njcn.rdms.framework.common.biz.system.logger.dto.OperateLogCreateReqDTO;
|
||||
import com.njcn.rdms.framework.common.util.monitor.TracerUtils;
|
||||
import com.njcn.rdms.framework.common.util.servlet.ServletUtils;
|
||||
import com.njcn.rdms.framework.security.core.LoginUser;
|
||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 操作日志 ILogRecordService 实现类
|
||||
*
|
||||
* 基于 {@link OperateLogCommonApi} 实现,记录操作日志
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@Slf4j
|
||||
public class LogRecordServiceImpl implements ILogRecordService {
|
||||
|
||||
@Resource
|
||||
private OperateLogCommonApi operateLogApi;
|
||||
|
||||
@Override
|
||||
public void record(LogRecord logRecord) {
|
||||
OperateLogCreateReqDTO reqDTO = new OperateLogCreateReqDTO();
|
||||
try {
|
||||
reqDTO.setTraceId(TracerUtils.getTraceId());
|
||||
// 补充用户信息
|
||||
fillUserFields(reqDTO);
|
||||
// 补全模块信息
|
||||
fillModuleFields(reqDTO, logRecord);
|
||||
// 补全请求信息
|
||||
fillRequestFields(reqDTO);
|
||||
|
||||
// 2. 异步记录日志
|
||||
operateLogApi.createOperateLogAsync(reqDTO);
|
||||
} catch (Throwable ex) {
|
||||
// 由于 @Async 异步调用,这里打印下日志,更容易跟进
|
||||
log.error("[record][url({}) log({}) 发生异常]", reqDTO.getRequestUrl(), reqDTO, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void fillUserFields(OperateLogCreateReqDTO reqDTO) {
|
||||
// 使用 SecurityFrameworkUtils。因为要考虑,rpc、mq、job,它其实不是 web;
|
||||
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
|
||||
if (loginUser == null) {
|
||||
return;
|
||||
}
|
||||
reqDTO.setUserId(loginUser.getId());
|
||||
reqDTO.setUserType(loginUser.getUserType());
|
||||
}
|
||||
|
||||
public static void fillModuleFields(OperateLogCreateReqDTO reqDTO, LogRecord logRecord) {
|
||||
reqDTO.setType(logRecord.getType()); // 大模块类型,例如:CRM 客户
|
||||
reqDTO.setSubType(logRecord.getSubType());// 操作名称,例如:转移客户
|
||||
reqDTO.setBizId(Long.parseLong(logRecord.getBizNo())); // 业务编号,例如:客户编号
|
||||
reqDTO.setAction(logRecord.getAction());// 操作内容,例如:修改编号为 1 的用户信息,将性别从男改成女,将姓名从灿能改成源码。
|
||||
reqDTO.setExtra(logRecord.getExtra()); // 拓展字段,有些复杂的业务,需要记录一些字段 ( JSON 格式 ),例如说,记录订单编号,{ orderId: "1"}
|
||||
}
|
||||
|
||||
private static void fillRequestFields(OperateLogCreateReqDTO reqDTO) {
|
||||
// 获得 Request 对象
|
||||
HttpServletRequest request = ServletUtils.getRequest();
|
||||
if (request == null) {
|
||||
return;
|
||||
}
|
||||
// 补全请求信息
|
||||
reqDTO.setRequestMethod(request.getMethod());
|
||||
reqDTO.setRequestUrl(request.getRequestURI());
|
||||
reqDTO.setUserIp(ServletUtils.getClientIP(request));
|
||||
reqDTO.setUserAgent(ServletUtils.getUserAgent(request));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<LogRecord> queryLog(String bizNo, String type) {
|
||||
throw new UnsupportedOperationException("使用 OperateLogApi 进行操作日志的查询");
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<LogRecord> queryLogByBizNo(String bizNo, String type, String subType) {
|
||||
throw new UnsupportedOperationException("使用 OperateLogApi 进行操作日志的查询");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 基于 mzt-log 框架
|
||||
* 实现操作日志功能
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
package com.njcn.rdms.framework.operatelog;
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.njcn.rdms.framework.security.config;
|
||||
|
||||
import com.njcn.rdms.framework.web.config.WebProperties;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
|
||||
|
||||
/**
|
||||
* 自定义的 URL 的安全配置
|
||||
* 目的:每个 Maven Module 可以自定义规则!
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
public abstract class AuthorizeRequestsCustomizer
|
||||
implements Customizer<AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry>, Ordered {
|
||||
|
||||
@Resource
|
||||
private WebProperties webProperties;
|
||||
|
||||
protected String buildAdminApi(String url) {
|
||||
return webProperties.getAdminApi().getPrefix() + url;
|
||||
}
|
||||
|
||||
protected String buildAppApi(String url) {
|
||||
return webProperties.getAppApi().getPrefix() + url;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.njcn.rdms.framework.security.config;
|
||||
|
||||
import com.njcn.rdms.framework.common.biz.system.oauth2.OAuth2TokenCommonApi;
|
||||
import com.njcn.rdms.framework.common.biz.system.permission.PermissionCommonApi;
|
||||
import com.njcn.rdms.framework.security.core.context.TransmittableThreadLocalSecurityContextHolderStrategy;
|
||||
import com.njcn.rdms.framework.security.core.filter.TokenAuthenticationFilter;
|
||||
import com.njcn.rdms.framework.security.core.handler.AccessDeniedHandlerImpl;
|
||||
import com.njcn.rdms.framework.security.core.handler.AuthenticationEntryPointImpl;
|
||||
import com.njcn.rdms.framework.security.core.service.SecurityFrameworkService;
|
||||
import com.njcn.rdms.framework.security.core.service.SecurityFrameworkServiceImpl;
|
||||
import com.njcn.rdms.framework.web.core.handler.GlobalExceptionHandler;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.config.MethodInvokingFactoryBean;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigureOrder;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
|
||||
/**
|
||||
* Spring Security 自动配置类,主要用于相关组件的配置
|
||||
*
|
||||
* 注意,不能和 {@link RdmsWebSecurityConfigurerAdapter} 用一个,原因是会导致初始化报错。
|
||||
* 参见 https://stackoverflow.com/questions/53847050/spring-boot-delegatebuilder-cannot-be-null-on-autowiring-authenticationmanager 文档。
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@AutoConfigureOrder(-1) // 目的:先于 Spring Security 自动配置,避免一键改包后,org.* 基础包无法生效
|
||||
@EnableConfigurationProperties(SecurityProperties.class)
|
||||
public class RdmsSecurityAutoConfiguration {
|
||||
|
||||
@Resource
|
||||
private SecurityProperties securityProperties;
|
||||
|
||||
/**
|
||||
* 认证失败处理类 Bean
|
||||
*/
|
||||
@Bean
|
||||
public AuthenticationEntryPoint authenticationEntryPoint() {
|
||||
return new AuthenticationEntryPointImpl();
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限不够处理器 Bean
|
||||
*/
|
||||
@Bean
|
||||
public AccessDeniedHandler accessDeniedHandler() {
|
||||
return new AccessDeniedHandlerImpl();
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring Security 加密器
|
||||
* 考虑到安全性,这里采用 BCryptPasswordEncoder 加密器
|
||||
*
|
||||
* @see <a href="http://stackabuse.com/password-encoding-with-spring-security/">Password Encoding with Spring Security</a>
|
||||
*/
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder(securityProperties.getPasswordEncoderLength());
|
||||
}
|
||||
|
||||
/**
|
||||
* Token 认证过滤器 Bean
|
||||
*/
|
||||
@Bean
|
||||
public TokenAuthenticationFilter authenticationTokenFilter(GlobalExceptionHandler globalExceptionHandler,
|
||||
OAuth2TokenCommonApi oauth2TokenApi) {
|
||||
return new TokenAuthenticationFilter(securityProperties, globalExceptionHandler, oauth2TokenApi);
|
||||
}
|
||||
|
||||
@Bean("ss") // 使用 Spring Security 的缩写,方便使用
|
||||
public SecurityFrameworkService securityFrameworkService(PermissionCommonApi permissionApi) {
|
||||
return new SecurityFrameworkServiceImpl(permissionApi);
|
||||
}
|
||||
|
||||
/**
|
||||
* 声明调用 {@link SecurityContextHolder#setStrategyName(String)} 方法,
|
||||
* 设置使用 {@link TransmittableThreadLocalSecurityContextHolderStrategy} 作为 Security 的上下文策略
|
||||
*/
|
||||
@Bean
|
||||
public MethodInvokingFactoryBean securityContextHolderMethodInvokingFactoryBean() {
|
||||
MethodInvokingFactoryBean methodInvokingFactoryBean = new MethodInvokingFactoryBean();
|
||||
methodInvokingFactoryBean.setTargetClass(SecurityContextHolder.class);
|
||||
methodInvokingFactoryBean.setTargetMethod("setStrategyName");
|
||||
methodInvokingFactoryBean.setArguments(TransmittableThreadLocalSecurityContextHolderStrategy.class.getName());
|
||||
return methodInvokingFactoryBean;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.njcn.rdms.framework.security.config;
|
||||
|
||||
import com.njcn.rdms.framework.common.biz.system.oauth2.OAuth2TokenCommonApi;
|
||||
import com.njcn.rdms.framework.common.biz.system.permission.PermissionCommonApi;
|
||||
import com.njcn.rdms.framework.security.core.rpc.LoginUserRequestInterceptor;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
/**
|
||||
* Security 使用到 Feign 的配置项
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@EnableFeignClients(clients = {OAuth2TokenCommonApi.class, // 主要是引入相关的 API 服务
|
||||
PermissionCommonApi.class})
|
||||
public class RdmsSecurityRpcAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
public LoginUserRequestInterceptor loginUserRequestInterceptor() {
|
||||
return new LoginUserRequestInterceptor();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package com.njcn.rdms.framework.security.config;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import com.google.common.collect.HashMultimap;
|
||||
import com.google.common.collect.Multimap;
|
||||
import com.njcn.rdms.framework.security.core.filter.TokenAuthenticationFilter;
|
||||
import com.njcn.rdms.framework.web.config.WebProperties;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.annotation.security.PermitAll;
|
||||
import jakarta.servlet.DispatcherType;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigureOrder;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
|
||||
import org.springframework.web.util.pattern.PathPattern;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static com.njcn.rdms.framework.common.util.collection.CollectionUtils.convertList;
|
||||
|
||||
/**
|
||||
* 自定义的 Spring Security 配置适配器实现
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@AutoConfigureOrder(-1) // 目的:先于 Spring Security 自动配置,避免一键改包后,org.* 基础包无法生效
|
||||
@EnableMethodSecurity(securedEnabled = true)
|
||||
public class RdmsWebSecurityConfigurerAdapter {
|
||||
|
||||
@Resource
|
||||
private WebProperties webProperties;
|
||||
@Resource
|
||||
private SecurityProperties securityProperties;
|
||||
|
||||
/**
|
||||
* 认证失败处理类 Bean
|
||||
*/
|
||||
@Resource
|
||||
private AuthenticationEntryPoint authenticationEntryPoint;
|
||||
/**
|
||||
* 权限不够处理器 Bean
|
||||
*/
|
||||
@Resource
|
||||
private AccessDeniedHandler accessDeniedHandler;
|
||||
/**
|
||||
* Token 认证过滤器 Bean
|
||||
*/
|
||||
@Resource
|
||||
private TokenAuthenticationFilter authenticationTokenFilter;
|
||||
|
||||
/**
|
||||
* 自定义的权限映射 Bean 们
|
||||
*
|
||||
* @see #filterChain(HttpSecurity)
|
||||
*/
|
||||
@Resource
|
||||
private List<AuthorizeRequestsCustomizer> authorizeRequestsCustomizers;
|
||||
|
||||
@Resource
|
||||
private ApplicationContext applicationContext;
|
||||
|
||||
/**
|
||||
* 由于 Spring Security 创建 AuthenticationManager 对象时,没声明 @Bean 注解,导致无法被注入
|
||||
* 通过覆写父类的该方法,添加 @Bean 注解,解决该问题
|
||||
*/
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManagerBean(AuthenticationConfiguration authenticationConfiguration) throws Exception {
|
||||
return authenticationConfiguration.getAuthenticationManager();
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置 URL 的安全配置
|
||||
*
|
||||
* anyRequest | 匹配所有请求路径
|
||||
* access | SpringEl表达式结果为true时可以访问
|
||||
* anonymous | 匿名可以访问
|
||||
* denyAll | 用户不能访问
|
||||
* fullyAuthenticated | 用户完全认证可以访问(非remember-me下自动登录)
|
||||
* hasAnyAuthority | 如果有参数,参数表示权限,则其中任何一个权限可以访问
|
||||
* hasAnyRole | 如果有参数,参数表示角色,则其中任何一个角色可以访问
|
||||
* hasAuthority | 如果有参数,参数表示权限,则其权限可以访问
|
||||
* hasIpAddress | 如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
|
||||
* hasRole | 如果有参数,参数表示角色,则其角色可以访问
|
||||
* permitAll | 用户可以任意访问
|
||||
* rememberMe | 允许通过remember-me登录的用户访问
|
||||
* authenticated | 用户登录后可访问
|
||||
*/
|
||||
@Bean
|
||||
protected SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
|
||||
// 登出
|
||||
httpSecurity
|
||||
// 开启跨域
|
||||
.cors(Customizer.withDefaults())
|
||||
// CSRF 禁用,因为不使用 Session
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.httpBasic(AbstractHttpConfigurer::disable)
|
||||
.formLogin(AbstractHttpConfigurer::disable)
|
||||
// 基于 token 机制,所以不需要 Session
|
||||
.sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.headers(c -> c.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))
|
||||
// 一堆自定义的 Spring Security 处理器
|
||||
.exceptionHandling(c -> c.authenticationEntryPoint(authenticationEntryPoint)
|
||||
.accessDeniedHandler(accessDeniedHandler));
|
||||
// 登录、登录暂时不使用 Spring Security 的拓展点,主要考虑一方面拓展多用户、多种登录方式相对复杂,一方面用户的学习成本较高
|
||||
|
||||
// 获得 @PermitAll 带来的 URL 列表,免登录
|
||||
Multimap<HttpMethod, String> permitAllUrls = getPermitAllUrlsFromAnnotations();
|
||||
// 设置每个请求的权限
|
||||
httpSecurity
|
||||
// ①:全局共享规则
|
||||
.authorizeHttpRequests(c -> c
|
||||
// 1.1 静态资源,可匿名访问
|
||||
.requestMatchers(HttpMethod.GET, "/*.html", "/*.css", "/*.js").permitAll()
|
||||
// 1.2 设置 @PermitAll 无需认证
|
||||
.requestMatchers(HttpMethod.GET, permitAllUrls.get(HttpMethod.GET).toArray(new String[0])).permitAll()
|
||||
.requestMatchers(HttpMethod.POST, permitAllUrls.get(HttpMethod.POST).toArray(new String[0])).permitAll()
|
||||
.requestMatchers(HttpMethod.PUT, permitAllUrls.get(HttpMethod.PUT).toArray(new String[0])).permitAll()
|
||||
.requestMatchers(HttpMethod.DELETE, permitAllUrls.get(HttpMethod.DELETE).toArray(new String[0])).permitAll()
|
||||
.requestMatchers(HttpMethod.HEAD, permitAllUrls.get(HttpMethod.HEAD).toArray(new String[0])).permitAll()
|
||||
.requestMatchers(HttpMethod.PATCH, permitAllUrls.get(HttpMethod.PATCH).toArray(new String[0])).permitAll()
|
||||
// 1.3 基于 rdms.security.permit-all-urls 无需认证
|
||||
.requestMatchers(securityProperties.getPermitAllUrls().toArray(new String[0])).permitAll()
|
||||
)
|
||||
// ②:每个项目的自定义规则
|
||||
.authorizeHttpRequests(c -> authorizeRequestsCustomizers.forEach(customizer -> customizer.customize(c)))
|
||||
// ③:兜底规则,必须认证
|
||||
.authorizeHttpRequests(c -> c
|
||||
.dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll() // WebFlux 异步请求,无需认证,目的:SSE 场景
|
||||
.anyRequest().authenticated());
|
||||
|
||||
// 添加 Token Filter
|
||||
httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
return httpSecurity.build();
|
||||
}
|
||||
|
||||
private String buildAppApi(String url) {
|
||||
return webProperties.getAppApi().getPrefix() + url;
|
||||
}
|
||||
|
||||
private Multimap<HttpMethod, String> getPermitAllUrlsFromAnnotations() {
|
||||
Multimap<HttpMethod, String> result = HashMultimap.create();
|
||||
// 获得接口对应的 HandlerMethod 集合
|
||||
RequestMappingHandlerMapping requestMappingHandlerMapping = (RequestMappingHandlerMapping)
|
||||
applicationContext.getBean("requestMappingHandlerMapping");
|
||||
Map<RequestMappingInfo, HandlerMethod> handlerMethodMap = requestMappingHandlerMapping.getHandlerMethods();
|
||||
// 获得有 @PermitAll 注解的接口
|
||||
for (Map.Entry<RequestMappingInfo, HandlerMethod> entry : handlerMethodMap.entrySet()) {
|
||||
HandlerMethod handlerMethod = entry.getValue();
|
||||
if (!handlerMethod.hasMethodAnnotation(PermitAll.class) // 方法级
|
||||
&& !handlerMethod.getBeanType().isAnnotationPresent(PermitAll.class)) { // 接口级
|
||||
continue;
|
||||
}
|
||||
Set<String> urls = new HashSet<>();
|
||||
if (entry.getKey().getPatternsCondition() != null) {
|
||||
urls.addAll(entry.getKey().getPatternsCondition().getPatterns());
|
||||
}
|
||||
if (entry.getKey().getPathPatternsCondition() != null) {
|
||||
urls.addAll(convertList(entry.getKey().getPathPatternsCondition().getPatterns(), PathPattern::getPatternString));
|
||||
}
|
||||
if (urls.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 特殊:使用 @RequestMapping 注解,并且未写 method 属性,此时认为都需要免登录
|
||||
Set<RequestMethod> methods = entry.getKey().getMethodsCondition().getMethods();
|
||||
if (CollUtil.isEmpty(methods)) {
|
||||
result.putAll(HttpMethod.GET, urls);
|
||||
result.putAll(HttpMethod.POST, urls);
|
||||
result.putAll(HttpMethod.PUT, urls);
|
||||
result.putAll(HttpMethod.DELETE, urls);
|
||||
result.putAll(HttpMethod.HEAD, urls);
|
||||
result.putAll(HttpMethod.PATCH, urls);
|
||||
continue;
|
||||
}
|
||||
// 根据请求方法,添加到 result 结果
|
||||
entry.getKey().getMethodsCondition().getMethods().forEach(requestMethod -> {
|
||||
switch (requestMethod) {
|
||||
case GET:
|
||||
result.putAll(HttpMethod.GET, urls);
|
||||
break;
|
||||
case POST:
|
||||
result.putAll(HttpMethod.POST, urls);
|
||||
break;
|
||||
case PUT:
|
||||
result.putAll(HttpMethod.PUT, urls);
|
||||
break;
|
||||
case DELETE:
|
||||
result.putAll(HttpMethod.DELETE, urls);
|
||||
break;
|
||||
case HEAD:
|
||||
result.putAll(HttpMethod.HEAD, urls);
|
||||
break;
|
||||
case PATCH:
|
||||
result.putAll(HttpMethod.PATCH, urls);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.njcn.rdms.framework.security.config;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
@ConfigurationProperties(prefix = "rdms.security")
|
||||
@Validated
|
||||
@Data
|
||||
public class SecurityProperties {
|
||||
|
||||
/**
|
||||
* HTTP 请求时,访问令牌的请求 Header
|
||||
*/
|
||||
@NotEmpty(message = "Token Header 不能为空")
|
||||
private String tokenHeader = "Authorization";
|
||||
/**
|
||||
* HTTP 请求时,访问令牌的请求参数
|
||||
*
|
||||
* 初始目的:解决 WebSocket 无法通过 header 传参,只能通过 token 参数拼接
|
||||
*/
|
||||
@NotEmpty(message = "Token Parameter 不能为空")
|
||||
private String tokenParameter = "token";
|
||||
|
||||
/**
|
||||
* mock 模式的开关
|
||||
*/
|
||||
@NotNull(message = "mock 模式的开关不能为空")
|
||||
private Boolean mockEnable = false;
|
||||
/**
|
||||
* mock 模式的密钥
|
||||
* 一定要配置密钥,保证安全性
|
||||
*/
|
||||
@NotEmpty(message = "mock 模式的密钥不能为空") // 这里设置了一个默认值,因为实际上只有 mockEnable 为 true 时才需要配置。
|
||||
private String mockSecret = "test";
|
||||
|
||||
/**
|
||||
* 免登录的 URL 列表
|
||||
*/
|
||||
private List<String> permitAllUrls = Collections.emptyList();
|
||||
|
||||
/**
|
||||
* PasswordEncoder 加密复杂度,越高开销越大
|
||||
*/
|
||||
private Integer passwordEncoderLength = 4;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.njcn.rdms.framework.security.core;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.njcn.rdms.framework.common.enums.UserTypeEnum;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 登录用户信息
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@Data
|
||||
public class LoginUser {
|
||||
|
||||
public static final String INFO_KEY_NICKNAME = "nickname";
|
||||
public static final String INFO_KEY_DEPT_ID = "deptId";
|
||||
|
||||
/**
|
||||
* 用户编号
|
||||
*/
|
||||
private Long id;
|
||||
/**
|
||||
* 用户类型
|
||||
*
|
||||
* 关联 {@link UserTypeEnum}
|
||||
*/
|
||||
private Integer userType;
|
||||
/**
|
||||
* 额外的用户信息
|
||||
*/
|
||||
private Map<String, String> info;
|
||||
/**
|
||||
* 租户编号
|
||||
*/
|
||||
private Long tenantId;
|
||||
/**
|
||||
* 授权范围
|
||||
*/
|
||||
private List<String> scopes;
|
||||
/**
|
||||
* 过期时间
|
||||
*/
|
||||
private LocalDateTime expiresTime;
|
||||
|
||||
// ========== 上下文 ==========
|
||||
/**
|
||||
* 上下文字段,不进行持久化
|
||||
*
|
||||
* 1. 用于基于 LoginUser 维度的临时缓存
|
||||
*/
|
||||
@JsonIgnore
|
||||
private Map<String, Object> context;
|
||||
/**
|
||||
* 访问的租户编号
|
||||
*/
|
||||
private Long visitTenantId;
|
||||
|
||||
public void setContext(String key, Object value) {
|
||||
if (context == null) {
|
||||
context = new HashMap<>();
|
||||
}
|
||||
context.put(key, value);
|
||||
}
|
||||
|
||||
public <T> T getContext(String key, Class<T> type) {
|
||||
return MapUtil.get(context, key, type);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.njcn.rdms.framework.security.core.context;
|
||||
|
||||
import com.alibaba.ttl.TransmittableThreadLocal;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolderStrategy;
|
||||
import org.springframework.security.core.context.SecurityContextImpl;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* 基于 TransmittableThreadLocal 实现的 Security Context 持有者策略
|
||||
* 目的是,避免 @Async 等异步执行时,原生 ThreadLocal 的丢失问题
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
public class TransmittableThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {
|
||||
|
||||
/**
|
||||
* 使用 TransmittableThreadLocal 作为上下文
|
||||
*/
|
||||
private static final ThreadLocal<SecurityContext> CONTEXT_HOLDER = new TransmittableThreadLocal<>();
|
||||
|
||||
@Override
|
||||
public void clearContext() {
|
||||
CONTEXT_HOLDER.remove();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SecurityContext getContext() {
|
||||
SecurityContext ctx = CONTEXT_HOLDER.get();
|
||||
if (ctx == null) {
|
||||
ctx = createEmptyContext();
|
||||
CONTEXT_HOLDER.set(ctx);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setContext(SecurityContext context) {
|
||||
Assert.notNull(context, "Only non-null SecurityContext instances are permitted");
|
||||
CONTEXT_HOLDER.set(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SecurityContext createEmptyContext() {
|
||||
return new SecurityContextImpl();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.njcn.rdms.framework.security.core.filter;
|
||||
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.njcn.rdms.framework.common.biz.system.oauth2.OAuth2TokenCommonApi;
|
||||
import com.njcn.rdms.framework.common.biz.system.oauth2.dto.OAuth2AccessTokenCheckRespDTO;
|
||||
import com.njcn.rdms.framework.common.exception.ServiceException;
|
||||
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||
import com.njcn.rdms.framework.common.util.json.JsonUtils;
|
||||
import com.njcn.rdms.framework.common.util.servlet.ServletUtils;
|
||||
import com.njcn.rdms.framework.security.config.SecurityProperties;
|
||||
import com.njcn.rdms.framework.security.core.LoginUser;
|
||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.njcn.rdms.framework.web.core.handler.GlobalExceptionHandler;
|
||||
import com.njcn.rdms.framework.web.core.util.WebFrameworkUtils;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Token 过滤器,验证 token 的有效性
|
||||
* 验证通过后,获得 {@link LoginUser} 信息,并加入到 Spring Security 上下文
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TokenAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final SecurityProperties securityProperties;
|
||||
|
||||
private final GlobalExceptionHandler globalExceptionHandler;
|
||||
|
||||
private final OAuth2TokenCommonApi oauth2TokenApi;
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("NullableProblems")
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
// 情况一,基于 header[login-user] 获得用户,例如说来自 Gateway 或者其它服务透传
|
||||
LoginUser loginUser = buildLoginUserByHeader(request);
|
||||
|
||||
// 情况二,基于 Token 获得用户
|
||||
// 注意,这里主要满足直接使用 Nginx 直接转发到 Spring Cloud 服务的场景。
|
||||
if (loginUser == null) {
|
||||
String token = SecurityFrameworkUtils.obtainAuthorization(request,
|
||||
securityProperties.getTokenHeader(), securityProperties.getTokenParameter());
|
||||
if (StrUtil.isNotEmpty(token)) {
|
||||
Integer userType = WebFrameworkUtils.getLoginUserType(request);
|
||||
try {
|
||||
// 1.1 基于 token 构建登录用户
|
||||
loginUser = buildLoginUserByToken(token, userType);
|
||||
// 1.2 模拟 Login 功能,方便日常开发调试
|
||||
if (loginUser == null) {
|
||||
loginUser = mockLoginUser(request, token, userType);
|
||||
}
|
||||
} catch (Throwable ex) {
|
||||
CommonResult<?> result = globalExceptionHandler.allExceptionHandler(request, ex);
|
||||
ServletUtils.writeJSON(response, result);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 设置当前用户
|
||||
if (loginUser != null) {
|
||||
SecurityFrameworkUtils.setLoginUser(loginUser, request);
|
||||
}
|
||||
// 继续过滤链
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private LoginUser buildLoginUserByToken(String token, Integer userType) {
|
||||
try {
|
||||
// 校验访问令牌
|
||||
OAuth2AccessTokenCheckRespDTO accessToken = oauth2TokenApi.checkAccessToken(token).getCheckedData();
|
||||
if (accessToken == null) {
|
||||
return null;
|
||||
}
|
||||
// 用户类型不匹配,无权限
|
||||
// 注意:只有 /admin-api/* 和 /app-api/* 有 userType,才需要比对用户类型
|
||||
// 类似 WebSocket 的 /ws/* 连接地址,是不需要比对用户类型的
|
||||
if (userType != null
|
||||
&& ObjectUtil.notEqual(accessToken.getUserType(), userType)) {
|
||||
throw new AccessDeniedException("错误的用户类型");
|
||||
}
|
||||
// 构建登录用户
|
||||
return new LoginUser().setId(accessToken.getUserId()).setUserType(accessToken.getUserType())
|
||||
.setInfo(accessToken.getUserInfo()) // 额外的用户信息
|
||||
.setTenantId(accessToken.getTenantId()).setScopes(accessToken.getScopes())
|
||||
.setExpiresTime(accessToken.getExpiresTime());
|
||||
} catch (ServiceException serviceException) {
|
||||
// 校验 Token 不通过时,考虑到一些接口是无需登录的,所以直接返回 null 即可
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟登录用户,方便日常开发调试
|
||||
*
|
||||
* 注意,在线上环境下,一定要关闭该功能!!!
|
||||
*
|
||||
* @param request 请求
|
||||
* @param token 模拟的 token,格式为 {@link SecurityProperties#getMockSecret()} + 用户编号
|
||||
* @param userType 用户类型
|
||||
* @return 模拟的 LoginUser
|
||||
*/
|
||||
private LoginUser mockLoginUser(HttpServletRequest request, String token, Integer userType) {
|
||||
if (!securityProperties.getMockEnable()) {
|
||||
return null;
|
||||
}
|
||||
// 必须以 mockSecret 开头
|
||||
if (!token.startsWith(securityProperties.getMockSecret())) {
|
||||
return null;
|
||||
}
|
||||
// 构建模拟用户
|
||||
Long userId = Long.valueOf(token.substring(securityProperties.getMockSecret().length()));
|
||||
return new LoginUser().setId(userId).setUserType(userType)
|
||||
.setTenantId(WebFrameworkUtils.getTenantId(request));
|
||||
}
|
||||
|
||||
private LoginUser buildLoginUserByHeader(HttpServletRequest request) {
|
||||
String loginUserStr = request.getHeader(SecurityFrameworkUtils.LOGIN_USER_HEADER);
|
||||
if (StrUtil.isEmpty(loginUserStr)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
loginUserStr = URLDecoder.decode(loginUserStr, StandardCharsets.UTF_8); // 解码,解决中文乱码问题
|
||||
LoginUser loginUser = JsonUtils.parseObject(loginUserStr, LoginUser.class);
|
||||
// 用户类型不匹配,无权限
|
||||
// 注意:只有 /admin-api/* 和 /app-api/* 有 userType,才需要比对用户类型
|
||||
// 类似 WebSocket 的 /ws/* 连接地址,是不需要比对用户类型的
|
||||
Integer userType = WebFrameworkUtils.getLoginUserType(request);
|
||||
if (userType != null
|
||||
&& loginUser != null
|
||||
&& ObjectUtil.notEqual(loginUser.getUserType(), userType)) {
|
||||
throw new AccessDeniedException("错误的用户类型");
|
||||
}
|
||||
return loginUser;
|
||||
} catch (Exception ex) {
|
||||
log.error("[buildLoginUserByHeader][解析 LoginUser({}) 发生异常]", loginUserStr, ex); ;
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.njcn.rdms.framework.security.core.handler;
|
||||
|
||||
import com.njcn.rdms.framework.common.exception.enums.GlobalErrorCodeConstants;
|
||||
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||
import com.njcn.rdms.framework.common.util.servlet.ServletUtils;
|
||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.security.web.access.ExceptionTranslationFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import static com.njcn.rdms.framework.common.exception.enums.GlobalErrorCodeConstants.FORBIDDEN;
|
||||
|
||||
/**
|
||||
* 访问一个需要认证的 URL 资源,已经认证(登录)但是没有权限的情况下,返回 {@link GlobalErrorCodeConstants#FORBIDDEN} 错误码。
|
||||
*
|
||||
* 补充:Spring Security 通过 {@link ExceptionTranslationFilter#handleAccessDeniedException(HttpServletRequest, HttpServletResponse, FilterChain, AccessDeniedException)} 方法,调用当前类
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@Slf4j
|
||||
@SuppressWarnings("JavadocReference")
|
||||
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
|
||||
|
||||
@Override
|
||||
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e)
|
||||
throws IOException, ServletException {
|
||||
// 打印 warn 的原因是,不定期合并 warn,看看有没恶意破坏
|
||||
log.warn("[commence][访问 URL({}) 时,用户({}) 权限不够]", request.getRequestURI(),
|
||||
SecurityFrameworkUtils.getLoginUserId(), e);
|
||||
// 返回 403
|
||||
ServletUtils.writeJSON(response, CommonResult.error(FORBIDDEN));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.njcn.rdms.framework.security.core.handler;
|
||||
|
||||
import com.njcn.rdms.framework.common.exception.enums.GlobalErrorCodeConstants;
|
||||
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||
import com.njcn.rdms.framework.common.util.servlet.ServletUtils;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.security.web.access.ExceptionTranslationFilter;
|
||||
|
||||
import static com.njcn.rdms.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED;
|
||||
|
||||
/**
|
||||
* 访问一个需要认证的 URL 资源,但是此时自己尚未认证(登录)的情况下,返回 {@link GlobalErrorCodeConstants#UNAUTHORIZED} 错误码,从而使前端重定向到登录页
|
||||
*
|
||||
* 补充:Spring Security 通过 {@link ExceptionTranslationFilter#sendStartAuthentication(HttpServletRequest, HttpServletResponse, FilterChain, AuthenticationException)} 方法,调用当前类
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@Slf4j
|
||||
@SuppressWarnings("JavadocReference") // 忽略文档引用报错
|
||||
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
|
||||
|
||||
@Override
|
||||
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) {
|
||||
log.debug("[commence][访问 URL({}) 时,没有登录]", request.getRequestURI(), e);
|
||||
// 返回 401
|
||||
ServletUtils.writeJSON(response, CommonResult.error(UNAUTHORIZED));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.njcn.rdms.framework.security.core.rpc;
|
||||
|
||||
import com.njcn.rdms.framework.common.util.json.JsonUtils;
|
||||
import com.njcn.rdms.framework.security.core.LoginUser;
|
||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import feign.RequestInterceptor;
|
||||
import feign.RequestTemplate;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* LoginUser 的 RequestInterceptor 实现类:Feign 请求时,将 {@link LoginUser} 设置到 header 中,继续透传给被调用的服务
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@Slf4j
|
||||
public class LoginUserRequestInterceptor implements RequestInterceptor {
|
||||
|
||||
@Override
|
||||
public void apply(RequestTemplate requestTemplate) {
|
||||
LoginUser user = SecurityFrameworkUtils.getLoginUser();
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String userStr = JsonUtils.toJsonString(user);
|
||||
userStr = URLEncoder.encode(userStr, StandardCharsets.UTF_8); // 编码,避免中文乱码
|
||||
requestTemplate.header(SecurityFrameworkUtils.LOGIN_USER_HEADER, userStr);
|
||||
} catch (Exception ex) {
|
||||
log.error("[apply][序列化 LoginUser({}) 发生异常]", user, ex);
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.njcn.rdms.framework.security.core.service;
|
||||
|
||||
/**
|
||||
* Security 框架 Service 接口,定义权限相关的校验操作
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
public interface SecurityFrameworkService {
|
||||
|
||||
/**
|
||||
* 判断是否有权限
|
||||
*
|
||||
* @param permission 权限
|
||||
* @return 是否
|
||||
*/
|
||||
boolean hasPermission(String permission);
|
||||
|
||||
/**
|
||||
* 判断是否有权限,任一一个即可
|
||||
*
|
||||
* @param permissions 权限
|
||||
* @return 是否
|
||||
*/
|
||||
boolean hasAnyPermissions(String... permissions);
|
||||
|
||||
/**
|
||||
* 判断是否有角色
|
||||
*
|
||||
* 注意,角色使用的是 SysRoleDO 的 code 标识
|
||||
*
|
||||
* @param role 角色
|
||||
* @return 是否
|
||||
*/
|
||||
boolean hasRole(String role);
|
||||
|
||||
/**
|
||||
* 判断是否有角色,任一一个即可
|
||||
*
|
||||
* @param roles 角色数组
|
||||
* @return 是否
|
||||
*/
|
||||
boolean hasAnyRoles(String... roles);
|
||||
|
||||
/**
|
||||
* 判断是否有授权
|
||||
*
|
||||
* @param scope 授权
|
||||
* @return 是否
|
||||
*/
|
||||
boolean hasScope(String scope);
|
||||
|
||||
/**
|
||||
* 判断是否有授权范围,任一一个即可
|
||||
*
|
||||
* @param scope 授权范围数组
|
||||
* @return 是否
|
||||
*/
|
||||
boolean hasAnyScopes(String... scope);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.njcn.rdms.framework.security.core.service;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import com.google.common.cache.CacheLoader;
|
||||
import com.google.common.cache.LoadingCache;
|
||||
import com.njcn.rdms.framework.common.biz.system.permission.PermissionCommonApi;
|
||||
import com.njcn.rdms.framework.common.core.KeyValue;
|
||||
import com.njcn.rdms.framework.security.core.LoginUser;
|
||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.SneakyThrows;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static com.njcn.rdms.framework.common.util.cache.CacheUtils.buildCache;
|
||||
import static com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
|
||||
import static com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils.skipPermissionCheck;
|
||||
|
||||
/**
|
||||
* 默认的 {@link SecurityFrameworkService} 实现类
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
public class SecurityFrameworkServiceImpl implements SecurityFrameworkService {
|
||||
|
||||
private final PermissionCommonApi permissionApi;
|
||||
|
||||
/**
|
||||
* 针对 {@link #hasAnyRoles(String...)} 的缓存
|
||||
*/
|
||||
private final LoadingCache<KeyValue<Long, List<String>>, Boolean> hasAnyRolesCache = buildCache(
|
||||
Duration.ofMinutes(1L), // 过期时间 1 分钟
|
||||
new CacheLoader<KeyValue<Long, List<String>>, Boolean>() {
|
||||
|
||||
@Override
|
||||
public Boolean load(KeyValue<Long, List<String>> key) {
|
||||
return permissionApi.hasAnyRoles(key.getKey(), key.getValue().toArray(new String[0])).getCheckedData();
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
/**
|
||||
* 针对 {@link #hasAnyPermissions(String...)} 的缓存
|
||||
*/
|
||||
private final LoadingCache<KeyValue<Long, List<String>>, Boolean> hasAnyPermissionsCache = buildCache(
|
||||
Duration.ofMinutes(1L), // 过期时间 1 分钟
|
||||
new CacheLoader<KeyValue<Long, List<String>>, Boolean>() {
|
||||
|
||||
@Override
|
||||
public Boolean load(KeyValue<Long, List<String>> key) {
|
||||
return permissionApi.hasAnyPermissions(key.getKey(), key.getValue().toArray(new String[0])).getCheckedData();
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@Override
|
||||
public boolean hasPermission(String permission) {
|
||||
return hasAnyPermissions(permission);
|
||||
}
|
||||
|
||||
@Override
|
||||
@SneakyThrows
|
||||
public boolean hasAnyPermissions(String... permissions) {
|
||||
// 特殊:跨租户访问
|
||||
if (skipPermissionCheck()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 权限校验
|
||||
Long userId = getLoginUserId();
|
||||
if (userId == null) {
|
||||
return false;
|
||||
}
|
||||
return hasAnyPermissionsCache.get(new KeyValue<>(userId, Arrays.asList(permissions)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasRole(String role) {
|
||||
return hasAnyRoles(role);
|
||||
}
|
||||
|
||||
@Override
|
||||
@SneakyThrows
|
||||
public boolean hasAnyRoles(String... roles) {
|
||||
// 特殊:跨租户访问
|
||||
if (skipPermissionCheck()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 权限校验
|
||||
Long userId = getLoginUserId();
|
||||
if (userId == null) {
|
||||
return false;
|
||||
}
|
||||
return hasAnyRolesCache.get(new KeyValue<>(userId, Arrays.asList(roles)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasScope(String scope) {
|
||||
return hasAnyScopes(scope);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasAnyScopes(String... scope) {
|
||||
// 特殊:跨租户访问
|
||||
if (skipPermissionCheck()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 权限校验
|
||||
LoginUser user = SecurityFrameworkUtils.getLoginUser();
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
return CollUtil.containsAny(user.getScopes(), Arrays.asList(scope));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.njcn.rdms.framework.security.core.util;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.ObjUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.njcn.rdms.framework.security.core.LoginUser;
|
||||
import com.njcn.rdms.framework.web.core.util.WebFrameworkUtils;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
/**
|
||||
* 安全服务工具类
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
public class SecurityFrameworkUtils {
|
||||
|
||||
/**
|
||||
* HEADER 认证头 value 的前缀
|
||||
*/
|
||||
public static final String AUTHORIZATION_BEARER = "Bearer";
|
||||
|
||||
public static final String LOGIN_USER_HEADER = "login-user";
|
||||
|
||||
private SecurityFrameworkUtils() {}
|
||||
|
||||
/**
|
||||
* 从请求中,获得认证 Token
|
||||
*
|
||||
* @param request 请求
|
||||
* @param headerName 认证 Token 对应的 Header 名字
|
||||
* @param parameterName 认证 Token 对应的 Parameter 名字
|
||||
* @return 认证 Token
|
||||
*/
|
||||
public static String obtainAuthorization(HttpServletRequest request,
|
||||
String headerName, String parameterName) {
|
||||
// 1. 获得 Token。优先级:Header > Parameter
|
||||
String token = request.getHeader(headerName);
|
||||
if (StrUtil.isEmpty(token)) {
|
||||
token = request.getParameter(parameterName);
|
||||
}
|
||||
if (!StringUtils.hasText(token)) {
|
||||
return null;
|
||||
}
|
||||
// 2. 去除 Token 中带的 Bearer
|
||||
int index = token.indexOf(AUTHORIZATION_BEARER + " ");
|
||||
return index >= 0 ? token.substring(index + 7).trim() : token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得当前认证信息
|
||||
*
|
||||
* @return 认证信息
|
||||
*/
|
||||
public static Authentication getAuthentication() {
|
||||
SecurityContext context = SecurityContextHolder.getContext();
|
||||
if (context == null) {
|
||||
return null;
|
||||
}
|
||||
return context.getAuthentication();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户
|
||||
*
|
||||
* @return 当前用户
|
||||
*/
|
||||
@Nullable
|
||||
public static LoginUser getLoginUser() {
|
||||
Authentication authentication = getAuthentication();
|
||||
if (authentication == null) {
|
||||
return null;
|
||||
}
|
||||
return authentication.getPrincipal() instanceof LoginUser ? (LoginUser) authentication.getPrincipal() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得当前用户的编号,从上下文中
|
||||
*
|
||||
* @return 用户编号
|
||||
*/
|
||||
@Nullable
|
||||
public static Long getLoginUserId() {
|
||||
LoginUser loginUser = getLoginUser();
|
||||
return loginUser != null ? loginUser.getId() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得当前用户的昵称,从上下文中
|
||||
*
|
||||
* @return 昵称
|
||||
*/
|
||||
@Nullable
|
||||
public static String getLoginUserNickname() {
|
||||
LoginUser loginUser = getLoginUser();
|
||||
return loginUser != null ? MapUtil.getStr(loginUser.getInfo(), LoginUser.INFO_KEY_NICKNAME) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得当前用户的部门编号,从上下文中
|
||||
*
|
||||
* @return 部门编号
|
||||
*/
|
||||
@Nullable
|
||||
public static Long getLoginUserDeptId() {
|
||||
LoginUser loginUser = getLoginUser();
|
||||
return loginUser != null ? MapUtil.getLong(loginUser.getInfo(), LoginUser.INFO_KEY_DEPT_ID) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前用户
|
||||
*
|
||||
* @param loginUser 登录用户
|
||||
* @param request 请求
|
||||
*/
|
||||
public static void setLoginUser(LoginUser loginUser, HttpServletRequest request) {
|
||||
// 创建 Authentication,并设置到上下文
|
||||
Authentication authentication = buildAuthentication(loginUser, request);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
|
||||
// 额外设置到 request 中,用于 ApiAccessLogFilter 可以获取到用户编号;
|
||||
// 原因是,Spring Security 的 Filter 在 ApiAccessLogFilter 后面,在它记录访问日志时,线上上下文已经没有用户编号等信息
|
||||
if (request != null) {
|
||||
WebFrameworkUtils.setLoginUserId(request, loginUser.getId());
|
||||
WebFrameworkUtils.setLoginUserType(request, loginUser.getUserType());
|
||||
}
|
||||
}
|
||||
|
||||
private static Authentication buildAuthentication(LoginUser loginUser, HttpServletRequest request) {
|
||||
// 创建 UsernamePasswordAuthenticationToken 对象
|
||||
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
|
||||
loginUser, null, Collections.emptyList());
|
||||
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
return authenticationToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否条件跳过权限校验,包括数据权限、功能权限
|
||||
*
|
||||
* @return 是否跳过
|
||||
*/
|
||||
public static boolean skipPermissionCheck() {
|
||||
LoginUser loginUser = getLoginUser();
|
||||
if (loginUser == null) {
|
||||
return false;
|
||||
}
|
||||
if (loginUser.getVisitTenantId() == null) {
|
||||
return false;
|
||||
}
|
||||
// 重点:跨租户访问时,无法进行权限校验
|
||||
return ObjUtil.notEqual(loginUser.getVisitTenantId(), loginUser.getTenantId());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 基于 Spring Security 框架
|
||||
* 实现安全认证功能
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
package com.njcn.rdms.framework.security;
|
||||
@@ -0,0 +1,5 @@
|
||||
com.njcn.rdms.framework.security.config.RdmsSecurityRpcAutoConfiguration
|
||||
com.njcn.rdms.framework.security.config.RdmsSecurityAutoConfiguration
|
||||
com.njcn.rdms.framework.security.config.RdmsWebSecurityConfigurerAdapter
|
||||
com.njcn.rdms.framework.operatelog.config.RdmsOperateLogConfiguration
|
||||
com.njcn.rdms.framework.operatelog.config.RdmsOperateLogRpcAutoConfiguration
|
||||
Reference in New Issue
Block a user