模块深度解析 · 框架安全认证核心(ruoyi-framework.security)
📍 本模块核心源码集中在
ruoyi-framework/src/main/java/com/ruoyi/framework/下的security/、web/service/两个包。
1. 模块概述
安全认证子系统是若依最核心的技术资产,回答了后台系统的第一问题:”你是谁、你能做什么“。
它位于 ruoyi-framework 内,被 ruoyi-admin 中的 Controller 依赖;反过来它又依赖 ruoyi-system(查用户、菜单)和 ruoyi-common(域对象、Redis、工具)。属于框架层 + 业务层之间的粘合剂——它把 Spring Security 的”标准认证流程”与若依自己的”JWT + Redis 用户态”结合了起来。
职责边界:
| 包 | 职责 |
|---|---|
security/filter |
JwtAuthenticationTokenFilter:每个请求进来先解析 Token,恢复登录态 |
security/context |
AuthenticationContextHolder、PermissionContextHolder:跨调用传递上下文 |
security/handle |
认证失败、登出成功的处理器 |
web/service |
SysLoginService(登录)、TokenService(令牌)、SysPasswordService(密码锁定)、UserDetailsServiceImpl(加载用户)、SysPermissionService(权限)、PermissionService(@ss标签) |
config |
SecurityConfig:安全过滤链总装配 |
认证 vs 授权(先分清)
graph LR
subgraph "认证 Authentication(你是谁)"
A1[登录校验] --> A2[生成Token] --> A3[Redis存LoginUser]
end
subgraph "授权 Authorization(你能干什么)"
Z1[hasPermi 权限字符串] --> Z2[getMenuPermission 权限集合]
Z3[DataScope 数据权限] --> Z4[行级SQL过滤]
end
2. 命名体系与易混淆函数对比
2.1 命名规律拆解
| 前缀 | 类/对象 | 操作 | 含义 |
|---|---|---|---|
TokenService. |
v |
createToken |
生成 JWT 令牌 + 缓存 LoginUser |
TokenService. |
v |
getLoginUser |
从请求解析令牌并还原用户 |
TokenService. |
v |
refreshToken |
刷新 Redis 中的过期时间(滑动续期) |
TokenService. |
v |
verifyToken |
校验到期并自动续期 |
TokenService. |
v |
delLoginUser |
删除(登出)缓存中的用户 |
SysLoginService. |
v |
login |
登录主流程 |
SysLoginService. |
v |
validateCaptcha |
校验图形验证码 |
SysLoginService. |
v |
loginPreCheck |
登录前参数/黑名单预检 |
SysPasswordService. |
v |
validate |
登录时校验密码错误次数 |
SysPasswordService. |
v |
matches |
BCrypt 密码比对 |
UserDetailsServiceImpl. |
v |
loadUserByUsername |
Spring Security 标准加载用户 |
SysPermissionService. |
v |
getMenuPermission |
组装用户菜单权限集合 |
PermissionService. |
v |
hasPermi |
判断是否拥有某权限 |
命名规律总结:
XxxService是独立、按职责拆分的@Component/@Service,彼此通过构造器或字段注入协作,而非一个大类。getXxx/setXxx/validate/create/refresh动词很口语化,直接表达意图。SysPasswordService与AutnenticationContextHolder是密码校验专用通道——用 ThreadLocal 绕过 Spring Security 标准DaoAuthenticationProvider拿明文密码。
2.2 易混淆函数对比
对比一:SysLoginService.login(业务登录)vs UserDetailsServiceImpl.loadUserByUsername(标准加载)
| 对比维度 | SysLoginService.login |
UserDetailsServiceImpl.loadUserByUsername |
|---|---|---|
| 触发者 | 登录 Controller 调用 | Spring Security AuthenticationManager 内部回调 |
| 前置步骤 | 验证码 + 参数预检 + 黑名单 | 无(被回调时已通过 token 包装) |
| 职责范围 | 整条登录链 + 记日志 + 发令牌 | 仅”查库 + 拼权限 + 校验密码” |
| 返回值 | String(token) |
LoginUser(UserDetails) |
| 一句话区分 | 对外完整登录接口 = 预检 + 委托内部认证 + 生成令牌 | 对内被委托的用户加载器,只负责”查出并验证用户” |
对比二:TokenService.createToken(创建)vs TokenService.getLoginUser(还原)
| 对比维度 | createToken |
getLoginUser |
|---|---|---|
| 方向 | 登录成功 → 写 Redis | 每次请求 → 读 Redis |
| JWT 处理 | 生成带 uuid 的签名令牌 | 解析令牌拿 uuid |
| 关键依赖 | 写 login_tokens:<uuid> |
读 login_tokens:<uuid> |
| 一句话区分 | 登录时把用户态”存”进 Redis 并返回 token | 请求时用 token 把用户态”取”回来 |
对比三:PermissionService.hasPermi(页面标签/业务判断)vs SecurityUtils.hasPermi(代码内工具)
| 对比维度 | PermissionService.hasPermi(@ss) |
SecurityUtils.hasPermi |
|---|---|---|
| 调用形式 | ${@ss.hasPermi('xxx')} 或 @ss.hasPermi |
Java 代码直接 SecurityUtils.hasPermi() |
| 额外动作 | 设置 PermissionContextHolder |
无 |
| 匹配实现 | permissions.contains(x) 精确/通配 |
PatternMatchUtils.simpleMatch 通配 |
| 一句话区分 | 面向视图/模板的权限标签(会记录当前权限上下文) | 面向代码的静态权限判断 |
3. API Signatures
graph TB
LoginCtl[SysLoginController /login] --> LS[SysLoginService.login]
LS --> VC[validateCaptcha]
LS --> LPC[loginPreCheck]
LS --> AM[AuthenticationManager.authenticate]
AM --> UDS[UserDetailsServiceImpl]
UDS --> PWD[SysPasswordService.validate]
UDS --> SPE[getMenuPermission]
LS --> TOK[TokenService.createToken]
TOK --> REF[refreshToken]
TOK --> REDIS[(Redis)]
JWT[JwtAuthenticationTokenFilter] --> TOK2[TokenService.getLoginUser]
TOK2 --> VER[verifyToken]
SS[PermissionService @ss] --> SEC[SecurityUtils 读上下文]
1 |
|
1 |
|
1 |
|
1 |
|
1 |
|
4. 数据结构深度解析
TokenService(令牌服务)
4.a 存在的理由
没有它就缺一个”把认证结果变成可跨请求携带凭证”的枢纽。它同时承担:①JWT 的签发/解析;②与 Redis 的衔接(真正状态存储);③登录态续期与注销。若依把”JWT 无状态”改造成”JWT 有状态+缓存”正是通过它完成的。
4.c 字段三层分析表
| 字段 | 设计动机(为什么需要) | 反事实(如果去掉) | 替代方案 |
|---|---|---|---|
header |
从哪读 token(默认 Authorization) |
无法从请求提取令牌 | 写死常量,但可配置更灵活 |
secret |
JWT 签名密钥 | 无法验签/防篡改 | 用非对称 RSA 更安全,但配置复杂 |
expireTime |
Redis 过期时间(分钟) | 用户态永不过期,有安全风险 | 短时 access + 长时 refresh,复杂度高 |
redisCache |
存取 LoginUser 状态 | 无法把用户态存到 Redis | 纯 JWT 自带 claims,但无法即时踢人 |
LoginUser(登录用户域对象)
详见 01 章 LoginUser。核心:实现
UserDetails,持有userId / deptId / token / loginTime / expireTime / ipaddr / loginLocation / browser / os / permissions(Set) / user(SysUser)。
1 | stateDiagram-v2 |
SysUser 与 SysRole、SysMenu
SysUser继承BaseEntity,关键字段含deptId / userName / password / status / delFlag / dept / roles / roleIds / postIds(见 01 章 SysUser)。SysUser.isAdmin():userId == 1L即超级管理员,全局豁免权限判断。SysRole.dataScope:数据权限范围枚举值的载体(见 02 章数据权限)。
5. 函数逐行精讲
因篇幅,这里精讲认证链路最关键的两个方法。其余核心方法(refreshToken、getLoginUser、loginPreCheck 等)已在 3. API Signatures 列出意图,源码见对应文件。
5.a 场景卡片 + 5.b 逐行注释:SysLoginService.login
函数:
SysLoginService.login
- 调用时机:前端 POST
/login- 典型调用者:
SysLoginController.login- 前置条件:Redis 可达;验证码开启时已生成验证码
- 目的:完成验证码→预检→认证→发令牌的全流程
1 | public String login(String username, String password, String code, String uuid) |
5.a + 5.b:UserDetailsServiceImpl.loadUserByUsername
函数:
loadUserByUsername
- 调用时机:
authenticationManager.authenticate内部自动触发- 前置条件:密码错误次数未超限(由
SysPasswordService兜底)- 目的:查出用户、做状态校验、组权限,返回
LoginUser
1 | public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException |
5.a + 5.b:SysPasswordService.validate(密码锁定核心)
函数:
validate
- 调用时机:
loadUserByUsername内- 前置条件:用户状态合法已通过
- 目的:基于 Redis 实现”5 次错误锁 10 分钟”
1 | public void validate(SysUser user) |
5.a + 5.b:TokenService.createToken
函数:
createToken
- 调用时机:登录认证通过后
- 目的:生成带 uuid 载荷的 JWT,缓存 LoginUser
1 | public String createToken(LoginUser loginUser) |
6. 关键算法剖析
6.1 滑动过期(Sliding Expiration)
refreshToken 每次请求都会重算 expireTime = 当前时间 + 30min 并写回 Redis;verifyToken 判断”距过期不足 20 分钟”就续期。效果是活跃用户的会话永不因到期被打断,不活跃用户 30 分钟自动失效。
$$refreshToken: ; expireTime = now + 30min,; TTL_{redis} = 30min$$
flowchart LR
A[每次请求 getLoginUser] --> B{距过期 < 20min?}
B -- 是 --> C[refreshToken 续期]
B -- 否 --> D[不用续期]
C --> E[继续放行]
D --> E
- 优点:实现简单,活跃用户无感续期。
- 代价:Redis 里过期时间被反复写,每次请求多一次
set(写放大)。对读多写少的系统可接受。
6.2 密码锁定计数
基于 Redis 的计数器 + TTL。key = pwd_err_cnt:<username>,值 = 错误次数,TTL = 10 分钟。错误次数达到 maxRetryCount(默认 5)即抛出锁定异常。
⚠️ 注意一个”窗口滑动”细节:因为
setCacheObject(key, retryCount, lockTime, MINUTES)每次错误都会重置 TTL 为 10 分钟,所以理论上攻击者可以”每 9 分钟错一次”无限试探。这是经典 Redis 计数的常见边界,多数系统可接受。
6.3 权限集合匹配
1 | hasPermissions(permissions, permission) = permissions.contains(ALL_PERMISSION) || permissions.contains(permission) |
超级管理员由 SysPermissionService.getMenuPermission 直接塞入 Constants.ALL_PERMISSION(*:*:*)从而全通过;普通用户匹配 Set<String> 精确包含(PermissionService)或 PatternMatchUtils.simpleMatch 通配(SecurityUtils)。
7. 设计决策分析
7.1 为什么 JWT 只存 uuid,而非用户信息?
决策:createToken 的 claims 只放随机 uuid + 用户名,LoginUser 完整对象存 Redis。
理由:
- 可即时注销/踢人/改权限:由于状态在 Redis,
delLoginUser(登出)、refreshPermissionByRoleId(改角色后刷新在线用户)能立刻生效;纯 JWT 需等 token 过期。 - 避免 JWT 载荷过大:
LoginUser含角色、部门、权限集合,放 JWT 会显著变大,每次请求都要重新解析传输。 - 多端/会话管理:
login_tokens:*支持按 pattern 扫描在线用户(refreshPermissionByRoleId用keys login_tokens:*遍历)。
代价:引入 Redis 依赖(高可用要求);每次请求一次 Redis 读。若依默认场景(后台管理)收益远大于代价。
7.2 为什么用 ThreadLocal 旁路传递明文密码?
Spring Security 标准认证里,DaoAuthenticationProvider 会先做密码比对再回调 loadUserByUsername。若依要自定义”错误计数锁定”,就必须拿到明文密码与库里 BCrypt 比对,于是设计 AuthenticationContextHolder ThreadLocal 在 SysLoginService.login 与 SysPasswordService.validate 之间传递未认证令牌。
风险:必须 finally.clearContext(),否则线程池复用会串号。这一点在源码中已用 finally 保证。
7.3 超级管理员(userId=1)为什么全局豁免?
SecurityUtils.isAdmin(1L)、SysUser.isAdmin()、SysPermissionService 三处硬编码 userId == 1L。这是若依”第一用户即 root”的设计约定,简化了初始化建库(管理员天然拥有全部权限与数据)。
8. 学习检查点
📝 本章小结
- 若依用 JWT 做身份凭证、Redis 存真实状态(
login_tokens:<uuid>→LoginUser),实现无状态 + 可即时注销。 - 登录全流程 =
SysLoginService(验证码→预检→认证→令牌),核心密码锁定在SysPasswordService(Redis 计数 5 次锁 10 分钟)。 - 每个请求由
JwtAuthenticationTokenFilter解析 Token 还原用户并写入SecurityContext,SecurityUtils从中取当前用户。 - 权限授权分两级:
PermissionService/@ss判字符串权限集合,@DataScope判行级数据过滤。 - 超级管理员
userId==1全局豁免认证与数据权限。
🤔 思考题
为什么
SysLoginService.login要使用 ThreadLocal(AuthenticationContextHolder)传递用户名密码,而不是直接调用 userService?若忘记finally.clearContext()会有什么后果?参考答案
因为 Spring Security 标准的
DaoAuthenticationProvider会在回调loadUserByUsername之前内部完成密码比对,若依要自定义”错误次数锁定”,必须在validate()里拿到明文密码与数据库 BCrypt 比对,于是用 ThreadLocal 旁路传递未认证令牌(SysLoginService.java:74、SysPasswordService.java:46)。ThreadLocal 是线程隔离的,Tomcat 线程池会复用线程,若不清空,下一个请求可能读到上一个请求的认证对象(串号/越权)。若把
LoginUser整个塞进 JWT claims 而不是 Redis,会带来哪些工程问题?参考答案
JWT 是 Base64 非加密,塞进用户+角色+权限集合会明显增大 token 体积、每次请求传输开销大;更关键的是,登出/改角色/踢人都无法使已发 token 失效——除非改
secret全局失效。而若依把状态存 Redis(TokenService.refreshToken),就能用delLoginUser立刻注销、用refreshPermissionByRoleId实时刷新在线用户权限。LoginUser.getAuthorities()返回null(LoginUser.java:262-265),但若依又能正常鉴权,为什么?参考答案
若依不走 Spring Security 标准的
hasAuthority()鉴权,而是自定义PermissionService(@ss.hasPermi)与@DataScope,直接从loginUser.getPermissions()(一个Set<String>)匹配权限字符串(PermissionService.java:155-158)。所以getAuthorities()返回 null 不影响,还避免了 Spring Security 对GrantedAuthority的转换开销。