认证授权体系
1. 模块概述
RuoYi-Cloud 的认证授权体系是整个系统的 安全中枢,横跨多个模块:
- ruoyi-auth:登录、注册、Token 签发
- ruoyi-gateway:入口鉴权(AuthFilter)
- ruoyi-common-security:权限校验引擎(AuthLogic)、AOP 切面(PreAuthorizeAspect)、Token 管理(TokenService)
- ruoyi-common-core:JWT 工具(JwtUtils)、安全常量
核心设计理念:JWT 负责无状态传递,Redis 负责有状态管理。JWT 中只存储最小必要的身份标识(user_key、user_id、username),完整的角色和权限数据存储在 Redis 中,实现即时失效和灵活管理。
2. 命名体系与易混淆函数对比
2.1 命名规律拆解
| 前缀 | 模块/对象 | 操作 | 含义 |
|---|---|---|---|
Auth |
Logic |
checkLogin |
校验当前用户是否已登录,未登录抛异常 |
Auth |
Logic |
checkPermi |
校验当前用户是否拥有指定权限 |
Auth |
Logic |
checkRole |
校验当前用户是否拥有指定角色 |
Auth |
Logic |
hasPermi |
判断当前用户是否拥有指定权限(返回 boolean) |
Auth |
Logic |
hasRole |
判断当前用户是否拥有指定角色(返回 boolean) |
Auth |
Util |
checkLogin |
委托层:转调 AuthLogic.checkLogin() |
Auth |
Util |
checkPermi |
委托层:转调 AuthLogic.checkPermi() |
Token |
Service |
createToken |
创建 JWT Token 并将 LoginUser 存入 Redis |
Token |
Service |
getLoginUser |
从 Redis 中获取缓存的用户信息 |
Token |
Service |
verifyToken |
验证 Token 有效期,不足 120 分钟自动刷新 |
Token |
Service |
delLoginUser |
从 Redis 中删除用户缓存(登出) |
命名规律总结:
checkXxxvshasXxx:check方法是 断言式,不满足时抛异常;has方法是 查询式,返回 boolean。业务代码中权限校验应使用check,条件判断应使用has。AuthUtil是 AuthLogic 的 静态门面,提供静态方法方便调用,内部通过SpringUtils.getBean()获取 AuthLogic 实例。
2.2 易混淆函数对比表
checkPermi(String) vs checkPermi(RequiresPermissions) vs checkPermiAnd() vs checkPermiOr()
| 对比维度 | checkPermi(String) | checkPermi(RequiresPermissions) | checkPermiAnd | checkPermiOr |
|---|---|---|---|---|
| 参数 | 单个权限字符串 | 注解对象(含 logical) | 可变参数 String… | 可变参数 String… |
| 校验逻辑 | 单一权限匹配 | 根据注解的 logical 分发到 AND/OR | 必须全部拥有 | 只需其一 |
| 调用者 | 手动编码调用 | AOP 切面 | checkPermi(注解) 内部 | checkPermi(注解) 内部 |
| 一句话区分 | 单权限手动校验 vs 注解驱动自动分发 vs 全匹配 vs 任一匹配 |
getToken() vs getTokenKey() vs getUserKey()
| 对比维度 | getToken() | getTokenKey() | getUserKey() |
|---|---|---|---|
| 所属类 | AuthFilter | TokenService / AuthFilter | JwtUtils |
| 输入 | HttpRequest | String token | String token / Claims |
| 输出 | Bearer Token 字符串 | Redis Key (“login_tokens:” + token) | JWT 中存储的 user_key |
| 一句话区分 | 从 HTTP 请求中提取 JWT → 拼装 Redis 缓存 Key → 从 JWT Claims 中提取 UUID |
3. API Signatures
调用关系图
graph TD
subgraph 入口
TC[TokenController.login]
TC2[TokenController.logout]
end
subgraph 登录服务
SLS[SysLoginService.login]
SLS2[SysLoginService.logout]
SPS[SysPasswordService.validate]
SRL[SysRecordLogService.recordLogininfor]
end
subgraph Token管理
TS[TokenService.createToken]
TS2[TokenService.getLoginUser]
TS3[TokenService.verifyToken]
TS4[TokenService.delLoginUser]
TS5[TokenService.refreshToken]
end
subgraph JWT工具
JU1[JwtUtils.createToken]
JU2[JwtUtils.parseToken]
JU3[JwtUtils.getUserKey]
end
subgraph 权限引擎
AL1[AuthLogic.checkLogin]
AL2[AuthLogic.checkPermi]
AL3[AuthLogic.checkRole]
AL4[AuthLogic.getPermiList]
AL5[AuthLogic.getRoleList]
end
subgraph AOP切面
PA[PreAuthorizeAspect.around]
IAA[InnerAuthAspect.innerAround]
end
TC --> SLS
SLS --> SPS
SLS --> SRL
TC --> TS
TS --> JU1
TS --> TS5
TC2 --> TS4
PA --> AL2
PA --> AL3
IAA -.->|检查Header| PA
完整签名
1 | // === TokenService === |
4. 数据结构深度解析
4.a LoginUser — 为什么需要这个结构体
在分布式认证体系中,用户信息需要在多个服务间传递和缓存。如果每次都实时查询数据库,性能开销大且耦合度高。LoginUser 作为 Redis 缓存的用户快照,包含了当前会话需要的所有信息:用户基本信息、角色集合、权限集合。同时它也是 TokenService 与 AuthLogic 之间传递用户上下文的核心载体。
4.b 结构定义
1 | public class LoginUser { |
4.c 字段三层分析表
| 字段 | 设计动机(为什么需要) | 反事实(如果去掉会怎样) | 替代方案(还能怎么做) |
|---|---|---|---|
token |
JWT 中只存 user_key,Redis 以 token 为 Key 存储完整对象 | 无法关联 JWT 和 Redis 缓存,双令牌机制失效 | 可以把所有信息都放 JWT,但无法实现即时失效 |
permissions |
权限校验时直接从 Redis 读取,无需每次查数据库 | 每次鉴权都要联表查询菜单-角色-用户关系 | 可以用缓存预热,但数据一致性更难保证 |
expireTime |
支持 Token 自动续期判断(不足 120 分钟则刷新) | 无法实现滑动过期,用户体验下降 | 可以用固定过期 + 前端刷新机制,但增加了复杂度 |
ipaddr |
记录登录 IP,支持 IP 变更检测和安全审计 | 无法追踪登录来源,安全审计缺失 | 可以记录在登录日志表中,但缓存中获取更快 |
sysUser |
携带用户基本信息(部门 ID、状态等),避免二次查询 | 数据权限过滤时需再次查询用户表 | 可以只缓存 userId,每次用时查库,但性能差 |
roles |
角色校验和数据权限判断时直接从缓存获取 | 每次角色校验都要查库 | 可以缓存到本地 Map,但多实例间不一致 |
4.d 生命周期状态图
stateDiagram-v2
[*] --> 创建: TokenService.createToken()
创建 --> 活跃: 存入Redis
过期时间=TOKEN_EXPIRE_TIME
活跃 --> 续期: verifyToken()
剩余时间≤120分钟
续期 --> 活跃: 刷新expireTime
活跃 --> 过期: 超过TOKEN_EXPIRE_TIME
未续期
活跃 --> 注销: delLoginUser()
主动登出
过期 --> [*]: Redis自动删除
注销 --> [*]: Redis删除
5. 函数逐行精讲
5.a 登录流程核心
- 调用时机:用户通过
/login接口提交用户名密码- 典型调用者:TokenController
- 前置条件:请求已通过网关的验证码校验(ValidateCodeFilter)
- 目的:执行多道安全防线校验后,返回完整的 LoginUser 对象
1 | public LoginUser login(String username, String password) |
5.b Token 创建
- 调用时机:登录校验通过后
- 典型调用者:TokenController
- 前置条件:SysLoginService.login() 已返回有效的 LoginUser
- 目的:生成 JWT 并将用户信息存入 Redis
1 | public Map<String, Object> createToken(LoginUser loginUser) |
5.c Token 续期
- 调用时机:每次请求经过 HeaderInterceptor 时
- 典型调用者:AuthUtil.verifyLoginUserExpire() → AuthLogic.verifyLoginUserExpire()
- 前置条件:LoginUser 已从 Redis 中获取
- 目的:实现滑动过期,避免用户在使用中突然被踢出
1 | public void verifyToken(LoginUser loginUser) |
5.d 权限校验核心
- 调用时机:每次
checkPermi()或hasPermi()调用时- 典型调用者:PreAuthorizeAspect.around()
- 前置条件:当前用户已登录,权限列表已加载
- 目的:判断用户权限列表是否包含目标权限,支持通配符匹配
1 | public boolean hasPermi(Collection<String> authorities, String permission) |
PatternMatchUtils.simpleMatch 的匹配规则:
system:user:list精确匹配system:user:listsystem:user:*匹配system:user:list、system:user:add等*:*:*匹配所有权限(超级管理员专属)
6. 关键算法剖析
6.1 双令牌认证流程
sequenceDiagram
participant C as 客户端
participant G as Gateway
participant R as Redis
participant S as Auth/System服务
C->>G: 1. POST /login
G->>S: 2. 转发到Auth服务
S->>R: 3. 存储LoginUser
G->>C: 4. 返回JWT
C->>G: 5. GET /api
(Header: Bearer JWT)
G->>G: 6. 解析JWT获取user_key
G->>R: 7. hasKey(user_key)
R-->>G: 8. true
G->>S: 9. 注入Header
(user_id, username, user_key)
S->>S: 10. 处理业务
时间复杂度分析:每次请求的认证开销为 $O(1)$——JWT 解析 $O(1)$ + Redis 查询 $O(1)$。
6.2 权限通配符匹配
PatternMatchUtils.simpleMatch 是 Spring 提供的 Ant 风格路径匹配工具。在若依中的应用:
- 管理员拥有
*:*:*,匹配所有权限,无需遍历 - 模块管理员拥有
system:user:*,匹配该模块下所有操作 - 普通用户拥有精确权限如
system:user:list
匹配在 $O(n)$ 时间内完成,其中 $n$ 为用户权限数量。
7. 设计决策分析
为什么 JWT 中只存 user_key 而不存角色权限?
这是若依认证体系最核心的设计决策:
- 即时失效:如果需要踢出用户或修改权限,只需删除 Redis 中的缓存即可。如果角色权限写在 JWT 中,必须等 JWT 自然过期
- JWT 体积控制:JWT 在每次请求的 Header 中传输,体积越小越好。角色和权限列表可能很大(数百条),放在 JWT 中会显著增加网络开销
- 安全性:JWT 的 payload 只是 Base64 编码(非加密),任何人都可以解码查看。敏感信息应存在服务端的 Redis 中
为什么选择 BCrypt 而非 MD5/SHA?
BCrypt 的核心优势:
- 内置盐值:每次加密自动生成随机盐,无需手动管理
- 计算慢:BCrypt 故意设计为计算密集型(可配置 rounds),有效抵御暴力破解
- 不可逆:即使攻击者获取了数据库和源码,也无法从哈希值反推明文密码
为什么 InnerAuth 切面的 order 是 HIGHEST_PRECEDENCE + 1?
InnerAuthAspect.getOrder() 返回 Ordered.HIGHEST_PRECEDENCE + 1:
HIGHEST_PRECEDENCE(Integer.MIN_VALUE)确保在所有切面之前执行+1留出最高优先级给框架内置切面(如事务)- 比 PreAuthorizeAspect 更早执行——内部调用先验证
from-source请求头,通过后才进行权限注解校验
8. 学习检查点
📝 本章小结
- 认证体系采用 JWT(无状态传递)+ Redis(有状态管理)双令牌策略
- JWT 中只存储 user_key(UUID)、user_id、username 三个最小字段
- AuthLogic 提供 check(抛异常)和 has(返回 boolean)两套权限校验 API
- TokenService.verifyToken() 在每次请求时自动检查并续期,实现滑动过期
- 权限匹配支持 Ant 风格通配符(
*:*:*、system:user:*),由PatternMatchUtils.simpleMatch实现
🤔 思考题
如果攻击者获取了用户的 JWT,在 Redis 中删除该用户缓存后,攻击者还能访问系统吗?为什么?
参考答案
不能。网关 AuthFilter.filter() 的第 64-68 行逻辑:解析 JWT 获取 user_key →
redisService.hasKey(getTokenKey(userkey))→ 如果 Redis 中不存在则返回 “登录状态已过期”。即使 JWT 本身未过期,只要 Redis 中的缓存被删除,请求就会被拒绝。这正是双令牌设计的安全优势——服务端可以随时使任意 Token 失效。AuthLogic.checkPermiOr() 中,如果
permissions.length > 0才抛异常。如果传入空数组会怎样?这种设计合理吗?参考答案
如果传入空数组,for 循环不执行,
permissions.length > 0为 false,方法静默返回(不抛异常)。这意味着”不要求任何权限”等同于”通过校验”。这种设计是合理的:注解@RequiresPermissions(value = {})在语义上不应拒绝访问。但在checkPermiAnd()中,空数组也会静默通过(for 循环不执行),语义上同样合理——“需要同时满足零个权限”即永远通过。需要注意:如果注解上不写 value,默认值是空数组{},此时两个方法都会放行,这可能是安全隐患,应由开发者注意。为什么 TokenService.refreshToken() 使用
loginUser.getLoginTime() + TOKEN_EXPIRE_TIME * MILLIS_MINUTE而非System.currentTimeMillis() + TOKEN_EXPIRE_TIME * MILLIS_MINUTE?参考答案
从代码看,
loginUser.setLoginTime(System.currentTimeMillis())在第 163 行先设置为当前时间,然后第 164 行用loginUser.getLoginTime()计算过期时间。实际上两者等价——因为刚设置了 loginTime。这样写的好处是:expireTime的计算基准明确来自loginTime字段,如果将来需要在setLoginTime和setExpireTime之间插入其他逻辑,过期时间的基准不会漂移。这是一种防御性编码风格——让expireTime始终相对于loginTime,保持数据自洽。