第四章 · 安全与鉴权(security Starter)
这一章是全工程鉴权架构的心脏。它回答三个问题:Token 从哪来、谁校验它、权限怎么判。读完你会明白:为什么请求能带上
LoginUser,为什么@PreAuthorize("@ss.hasPermission('system:user:query')")能拦人,以及 framework 如何”不依赖 system 却拿到权限”。
1. 模块概述
yudao-spring-boot-starter-security 在 Spring Security 之上做了一层”面向多用户类型、基于 Token、无状态“的定制封装(17 个文件)。核心能力:
- Token 认证:
TokenAuthenticationFilter每次请求从 Header/参数取 Token → 换LoginUser→ 注入SecurityContext; - 权限评定:
SecurityFrameworkService+@PreAuthorize注解式权限校验,语义对齐 Spring Security 的hasPermission/hasAnyPermissions/hasRole/hasAnyRoles/hasScope; - 策略可插拔:
AuthorizeRequestsCustomizer允许每个 Maven 模块自定义访问规则;@PermitAll注解自动扫描成免登录白名单; - 登录模型:
LoginUser作为认证主体(principal),携带 id/userType/tenantId/scopes/info/expiresTime。
在系统中的位置与协作:
graph LR
subgraph security[security Starter(本章,framework 层)]
F[TokenAuthenticationFilter]
C[YudaoWebSecurityConfigurerAdapter]
S[SecurityFrameworkService]
U[SecurityFrameworkUtils]
L[LoginUser]
end
subgraph system[system 模块]
API[OAuth2TokenApi]
PERM[PermissionApi]
TOKEN[OAuth2TokenService]
end
subgraph web[web Starter]
WEBUTIL[WebFrameworkUtils]
end
F -->|checkAccessToken| API --> TOKEN
F -->|setLoginUser| U --> WEBUTIL
S -->|hasPermission| PERM
C -->|注册 Filter 链| F
🔑 这条依赖方向是理解全篇的关键:security Starter 面向
OAuth2TokenApi与PermissionApi两个接口编程,而不是直接依赖cn.iocoder.yudao.module.system.*的 Service。这就是 第一章 说的”framework 不依赖业务、只依赖接口”的具象化。
2. 命名体系与易混淆对象对比
2.1 命名规律拆解
| 前缀 | 模块/对象 | 操作 | 含义 |
|---|---|---|---|
LoginUser |
(对象) | — | 登录用户主体,存于 Security 上下文 |
SecurityFramework |
Service/ServiceImpl |
hasPermission 等 |
封装权限判定的门面 |
SecurityFramework |
Utils |
getLoginUser/obtainAuthorization/setLoginUser |
读取/写入安全上下文的工具 |
Token |
Authentication |
Filter |
从 token 恢复登录态的过滤器 |
Yudao |
WebSecurity |
ConfigurerAdapter |
组装 Security Filter Chain |
AuthorizeRequests |
Customizer |
customize |
模块级访问规则定制点 |
命名规律总结:
SecurityFrameworkXxx是”对 Security 框架做统一收口”的命名族,把分散的 Spring Security API 收敛成项目内一致的语义。- 过滤器、处理器、适配器遵循 Spring 生态命名(
XxxFilter/XxxHandler/XxxConfigurerAdapter)。 @ss(SecurityFrameworkService的 Bean 名)是注解式权限的入口别名,见 第八章 实际使用。
2.2 易混淆对象对比:SecurityFrameworkUtils vs SecurityFrameworkService
| 对比维度 | SecurityFrameworkUtils |
SecurityFrameworkService |
|---|---|---|
| 定位 | 静态工具类,读写安全上下文 | Bean 注入的服务,判定”是否有权” |
| 代表方法 | getLoginUser()、obtainAuthorization()、setLoginUser() |
hasPermission()、hasAnyRoles() |
| 是否涉及外部 API | 否(纯本地上下文) | 是(内部调用 PermissionApi) |
| 一句话区分 | 回答”当前登录的是谁“;Service 回答”他有没有权限“。 |
再对比两个返回”用户类型”的入口:WebFrameworkUtils.getLoginUserType(request)(依据 URL 前缀推断 ADMIN/MEMBER)与 SecurityFrameworkUtils.getLoginUser()(依据上下文中的 LoginUser.userType)——前者是”路径决定终端”,后者是”token 决定身份”。
3. API Signatures
核心调用关系(一次受保护请求的认证链路):
graph TD
R["HTTP 请求 + Bearer token"] --> OBTAIN["SecurityFrameworkUtils.obtainAuthorization"]
OBTAIN --> F["TokenAuthenticationFilter.doFilterInternal"]
F --> CHECK["oauth2TokenApi.checkAccessToken"]
CHECK -->|LoginUser| SET["SecurityFrameworkUtils.setLoginUser"]
SET --> CTX["SecurityContext + request 属性"]
CTX --> AUTH{"@PreAuthorize 判定"}
AUTH -->|hasPermission| PERM["SecurityFrameworkService"]
PERM --> API["PermissionApi.hasAnyPermissions"]
API -->|true| OK["放行进入 Controller"]
API -->|false| DENY["AccessDeniedException → 403"]
1 | // —— 登录用户主体 —— |
关键方法用途:
SecurityFrameworkUtils.obtainAuthorization(request, headerName, parameterName):优先级 Header > Parameter,自动剥离Bearer前缀——它是”Token 入口”的唯一收口。SecurityFrameworkUtils.setLoginUser(loginUser, request):构造UsernamePasswordAuthenticationToken塞进 SecurityContext,同时把 userId/userType 写进 request 属性(供 第三章 的访问日志读取)。YudaoWebSecurityConfigurerAdapter.filterChain(...):定义精准的安全过滤链。
4. 数据结构深度解析
4.a 结构体存在的理由:LoginUser
为什么需要一套自己的
LoginUser而不是直接用 Spring Security 的UserDetails?因为UserDetails是为”用户名+密码+角色”的经典场景设计的,承载不了 yudao 的多终端语义(admin 与 member 并存)、租户、scope、以及”临时缓存”需求。LoginUser是更贴合业务的数据载体,能被塞进 Spring Security 的上下文、又能在需要时轻松取出扩展。
4.b 代码:返回 LoginUser 的 buildLoginUserByToken
📍 源码:TokenAuthenticationFilter.java:71-93
1 | private LoginUser buildLoginUserByToken(String token, Integer userType) { |
4.c 字段三层分析表(LoginUser)
| 字段 | 设计动机(为什么需要) | 反事实(如果去掉会怎样) | 替代方案(还能怎么做) |
|---|---|---|---|
id |
识别”是谁”,所有业务都以它为准 | 无法知道当前操作者 | 每次查库,性能差 |
userType |
区分 admin/member,避免越权(admin token 不能访问 app 接口) | 类型混淆,漏洞风险 | 用不同前缀 URL 隐式区分,不够安全 |
tenantId |
多租户环境唯一标识;token 里带上可免每次解析 | 每次请求重新解析租户 | 从 Header 每次取,但周期状态丢失 |
scopes |
OAuth2 scope 授权范围(hasScope 判断) |
无法做细粒度授权范围控制 | 仅用角色/权限,粒度不足 |
info |
高效携带昵称/部门等”高频展示字段”,避免每次查库 | 每次渲染都要查用户表 | 前端按 id 查,多一次请求 |
context |
基于 LoginUser 维度的临时缓存(@JsonIgnore,不持久化) |
无法做请求级/用户级缓存 | 用 Redis 全局缓存,过重 |
expiresTime |
请求侧再兜底判断 token 是否过期 | 依赖远端 API 每次判断,慢 | 无 |
4.d 生命周期状态图(LoginUser 从诞生到消亡)
stateDiagram-v2
[*] --> 未登录: 请求无 token / token 无效 / mock 未开
state 未登录 {
ifsi: 请求已登录?
ifsi --> [*]: 是,放行(PermitAll)/ 否决(401)
}
[*] --> 已认证: TokenAuthenticationFilter 用 token 恢复 LoginUser
已认证 --> 注入上下文: SecurityFrameworkUtils.setLoginUser
注入上下文 --> 判定: @PreAuthorize / hasPermission
判定 --> [*]: 通过
判定 --> 403: 失败
注入上下文 --> 请求结束: request 属性清空(请求终态)
5. 函数逐行精讲
5.a 场景卡片
**函数:
TokenAuthenticationFilter.doFilterInternal**(源码:40-69)
- 调用时机:每个 HTTP 请求进入 Spring Security 过滤链时,且排在
UsernamePasswordAuthenticationFilter之前。- 典型调用者:Spring Security 的
SecurityFilterChain(由YudaoWebSecurityConfigurerAdapter装配)。- 前置条件:
SecurityProperties、OAuth2TokenApi、GlobalExceptionHandler已注入。- 目的:把”请求携带的 token”翻译成”可用的登录身份”,并让无需登录的接口畅通。
5.b 逐行注释式精讲
1 | protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) |
🔍 mock 登录:
mockLoginUser校验 token 是否以mockSecret开头,是则把 token 尾部的 userId 当作登录用户,免去真实登录。源码注释反复强调”线上必须关闭”,因为这是明显的安全后门。
5.c 场景卡片:SecurityFrameworkServiceImpl.hasAnyPermissions
函数:
hasAnyPermissions(String...)
- 调用时机:
@PreAuthorize("@ss.hasAnyPermissions(...)")表达式求值时。- 典型调用者:Spring Security AOP(方法级拦截)。
- 前置条件:请求已注入
LoginUser。- 目的:只要用户拥有参数列表中任意一个权限即放行。
1 |
|
6. 关键机制剖析
6.1 无状态会话(Stateless)
YudaoWebSecurityConfigurerAdapter 通过 SessionCreationPolicy.STATELESS 关闭 Spring Security 的 HttpSession 机制——不存 session、不查 session,身份完全由请求头里的 token 决定。这使应用天然可水平扩展(任意节点都能认证任意请求),是云原生/微服务化的前提。代价是 token 需要随身携带、需自建失效机制(此处用 Redis 过期时间 + MySQL 刷新令牌)。
6.2 免登录白名单的三层来源
filterChain 里把”哪些 URL 免登录”分成三类叠加:
flowchart LR
A["静态资源 /*.html css js"] --> P["permitAll"]
B["@PermitAll 注解自动扫描 → getPermitAllUrlsFromAnnotations"] --> P
C["yudao.security.permit-all-urls 配置"] --> P
P --> D["其余 anyRequest 必须 authenticated"]
其中 getPermitAllUrlsFromAnnotations 通过 RequestMappingHandlerMapping.getHandlerMethods() 反查所有 @PermitAll 的处理器,把它们的路径按 HTTP 方法抽出成白名单——写一个注解,自动免登录。
6.3 权限判定委托(为何 framework 不依赖 system)
SecurityFrameworkService → PermissionApi(接口)→ 由 system 模块 PermissionApiImpl 实现 → 内部调用 PermissionService。权限数据(用户→角色→菜单→权限串)最终落在 system 的缓存/库里。**framework 提供”判定骨架”,system 提供”判定数据”**,二者只通过接口连接。
7. 设计决策分析
- 为什么不直接用 Spring Security 的 UsernamePasswordAuthenticationFilter 做登录? 源码注释给了答案:一方面多用户、多登录方式(账号/短信/社交/小程序)拓展复杂;另一方面登录状态要写 Redis、记登录日志、发 token,走自定义
AuthController + AuthService更可控。Spring Security 只保留”权限判定”这一最刚需的能力。 - 为什么
AccessDeniedException在GlobalExceptionHandler里又兜一层 403?@PreAuthorizeAOP 抛出的AccessDeniedException走 Controller 级异常处理器更统一,因此全局处理器特判它返回403 没有该操作权限。 - 为什么把用户类型校验放在 token 校验里(admin token 不能访问 app-api)?在
buildLoginUserByToken里比对accessToken.getUserType()与 URL 推断的userType,从端到端隔离 admin 与 C 端,避免 token 串用越权。
8. 学习检查点
📝 本章小结
- 认证主体是自定义
LoginUser,承载 id/userType/tenantId/scopes/info。 TokenAuthenticationFilter每个请求恢复登录态;无 token 放行,带 token 校验并注入上下文。- 权限判定走
SecurityFrameworkService→PermissionApi(接口),framework 不依赖 system。 - 免登录三层白名单(静态资源 +
@PermitAll注解扫描 + 配置文件)。 - 无状态会话(STATELESS)+ Redis/MySQL 双写 token,支撑水平扩展。
🤔 思考题
如果一个没带 token 的请求访问了需要登录的接口,会走到哪一步被拦下来?(提示:Filter 的放行策略 vs
authenticated())参考答案
TokenAuthenticationFilter对无 token 请求直接放行(chain.doFilter继续走,不加身份)。但过滤链进入filterChain的”③ 兜底规则”时,anyRequest().authenticated()会判断当前上下文无身份 → 判为未认证 → 由AuthenticationEntryPointImpl处理,返回401 账号未登录。所以”放行进 Filter”不等于”能进 Controller”。为什么
setLoginUser既要写入 SecurityContext 又要写入 request 属性?两者用途差异在哪?参考答案
SecurityContext:供 Spring Security 本身(
authenticated()、后续@PreAuthorize、FilterSecurityInterceptor)做授权判定,是”安全语义”的载体;request 属性:供不经过 Security 的组件(如ApiAccessLogFilter、GlobalExceptionHandler、MyBatis 字段填充)在任意时机读取用户 id,因为它们不(总能)拿到 SecurityContext。两者是”一个给安全链路、一个给普通业务/日志链路”的分工。如果想让某个接口”仅允许 admin 用户访问,且要求具备
system:dict:query权限”,注解该怎么写?参考答案
@PreAuthorize("@ss.hasPermission('system:dict:query')")即可(admin 终端由 URL 前缀/admin-api已隔离)。权限字符串与菜单表system_menu的permission字段一一对应,由 PermissionApi 背后的权限服务把 userId → 角色 → 菜单 → 权限串解出来比对,通过则放行、否则抛 AccessDeniedException 最终转 403。