Spring Security 权限控制
认证解决“你是谁”,权限控制解决“你能做什么、能操作哪些数据、能看到哪些字段”。真正的商业系统里,权限控制不是一个 @PreAuthorize 注解就结束,而是一套从接口、菜单、按钮、数据范围、字段脱敏到审计日志的完整体系。
先记住一句话:
登录成功只代表系统认识你,不代表你可以访问所有接口,更不代表你可以操作所有数据。
学习目标
学完本章你要能回答:
- 认证、授权、数据权限、字段权限分别解决什么问题。
- 角色和权限码有什么区别,为什么商业系统通常两者都要。
- URL 权限、方法权限、按钮权限、数据权限怎么分层。
- RBAC 模型怎么建表,用户、角色、权限怎么关联。
hasRole、hasAuthority、@PreAuthorize有什么区别和坑。- 为什么接口权限不能替代数据权限。
- 部门、租户、医院、创建人这些数据范围怎么落地到 SQL。
- 权限缓存怎么设计,权限变更后如何失效。
- 越权漏洞怎么产生,生产上怎么排查 403 和数据越权。
权限控制的分层
权限不是单层判断,而是多层网。
flowchart TD
A["用户请求"] --> B["是否已认证"]
B --> C["URL 或接口权限"]
C --> D["方法权限"]
D --> E["数据范围权限"]
E --> F["字段权限和脱敏"]
F --> G["审计日志"]| 层次 | 解决问题 | 示例 |
|---|---|---|
| 认证 | 你是谁 | 用户名密码、JWT、Session |
| URL 权限 | 能不能访问这个地址 | /admin/** 必须登录 |
| 方法权限 | 能不能执行这个业务方法 | asset:create 才能新增资产 |
| 按钮权限 | 前端是否展示操作入口 | 没有删除权限就隐藏删除按钮 |
| 数据权限 | 能不能操作这条数据 | 只能看本医院、本部门资产 |
| 字段权限 | 能不能看这个字段 | 普通用户看脱敏手机号 |
| 审计 | 谁在什么时候做了什么 | 删除、导出、改权限必须记录 |
只做 URL 权限,容易出现“能进接口就能操作所有数据”。只做前端按钮权限,用户可以直接调用接口绕过前端。真正安全的边界必须在后端。
角色和权限码
角色是权限集合,权限码是具体动作。
| 概念 | 含义 | 示例 |
|---|---|---|
| 角色 Role | 岗位、身份、权限包 | ROLE_ADMIN、ROLE_OPERATOR |
| 权限 Permission | 具体动作能力 | asset:read、asset:create |
| 数据范围 Data Scope | 能访问哪些数据 | 本人、本部门、本医院、全部 |
为什么不能只用角色?
假设系统有这些角色:
ROLE_ADMIN
ROLE_ASSET_MANAGER
ROLE_COLLECT_OPERATOR
ROLE_AUDITOR一开始看起来够用。但随着功能增加,你会遇到:
- 某个资产管理员只能查看,不能删除。
- 某个审计员只能导出审计日志,不能改权限。
- 同样是采集员,有人能启动任务,有人只能查看任务。
- 临时授权某个用户一个按钮能力,不想新建角色。
所以商业系统通常采用:
用户 -> 角色 -> 权限码角色用于批量授权,权限码用于精细控制。
RBAC 模型
RBAC 是 Role-Based Access Control,基于角色的访问控制。
flowchart TD
A["用户 sys_user"] --> B["用户角色 sys_user_role"]
B --> C["角色 sys_role"]
C --> D["角色权限 sys_role_permission"]
D --> E["权限 sys_permission"]
C --> F["角色数据范围 sys_role_data_scope"]最小表结构:
create table sys_user (
id bigint primary key,
username varchar(64) not null,
password_hash varchar(128) not null,
enabled tinyint not null,
department_id bigint,
tenant_id bigint,
unique key uk_username (username)
);
create table sys_role (
id bigint primary key,
role_code varchar(64) not null,
role_name varchar(128) not null,
enabled tinyint not null,
unique key uk_role_code (role_code)
);
create table sys_permission (
id bigint primary key,
permission_code varchar(128) not null,
permission_name varchar(128) not null,
resource_type varchar(32) not null,
unique key uk_permission_code (permission_code)
);
create table sys_user_role (
user_id bigint not null,
role_id bigint not null,
primary key (user_id, role_id)
);
create table sys_role_permission (
role_id bigint not null,
permission_id bigint not null,
primary key (role_id, permission_id)
);权限码命名建议:
asset:read
asset:create
asset:update
asset:delete
asset:export
collect:task:read
collect:task:start
collect:task:stop
user:role:assign
system:audit:read命名原则:
- 按资源和动作拆分。
- 不要写成
admin、normal这种模糊权限。 - 权限码要稳定,不能随便改,否则前端、后端、数据库都受影响。
- 高风险权限要单独拆出,例如
asset:export不要和asset:read混在一起。
Spring Security 里角色和权限的坑
Spring Security 中常见两个表达式:
hasRole("ADMIN")
hasAuthority("asset:read")它们不是完全一样。
| 写法 | 实际含义 |
|---|---|
hasRole("ADMIN") | 通常会匹配 ROLE_ADMIN |
hasAuthority("ROLE_ADMIN") | 精确匹配 ROLE_ADMIN |
hasAuthority("asset:read") | 精确匹配 asset:read |
常见坑:
@PreAuthorize("hasRole('ROLE_ADMIN')")这可能变成匹配 ROLE_ROLE_ADMIN。通常应写:
@PreAuthorize("hasRole('ADMIN')")或者统一使用权限码:
@PreAuthorize("hasAuthority('asset:read')")商业项目更推荐权限码做细粒度控制,角色更多用于授权管理和粗粒度身份区分。
URL 权限
URL 权限适合定义大的访问边界。
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/login").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").authenticated()
.anyRequest().denyAll()
);
return http.build();
}设计原则:
- 登录、验证码、公开文章等明确放行。
- 后台管理接口默认要求登录。
- 不认识的路径默认拒绝,而不是默认放行。
- URL 权限只做粗粒度边界,细粒度交给方法权限和数据权限。
如果把所有权限都写在 URL 规则里,会出现配置巨大、难维护、动态权限难更新的问题。
方法权限
方法权限更贴近业务。
开启:
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}使用:
@PreAuthorize("hasAuthority('asset:create')")
@PostMapping("/assets")
public AssetVO create(@RequestBody AssetCreateRequest request) {
return assetService.create(request);
}Service 层也可以加:
@PreAuthorize("hasAuthority('collect:task:start')")
public void startTask(Long taskId) {
collectTaskService.start(taskId);
}URL 权限和方法权限怎么选?
| 场景 | 推荐 |
|---|---|
| 公开接口和后台接口大边界 | URL 权限 |
| 某个业务动作需要具体权限码 | 方法权限 |
| 同一个 URL 内部根据参数走不同动作 | Service 方法权限或业务判断 |
| 数据归属判断 | Service 层数据权限 |
按钮权限
按钮权限主要给前端控制展示,例如:
{
"permissions": [
"asset:read",
"asset:create",
"asset:update",
"collect:task:start"
]
}前端可以根据权限码决定是否展示按钮:
有 asset:create -> 展示新增按钮
没有 asset:delete -> 隐藏删除按钮但注意:
前端按钮权限只改善用户体验,不是安全边界。
攻击者可以绕过前端,直接调用接口。所以后端必须重复校验权限。
数据权限为什么最难
接口权限只能判断“能不能调用接口”,数据权限判断“能不能操作这条具体数据”。
例子:
用户 A 有 asset:update 权限。
用户 B 也有 asset:update 权限。
资产 X 属于医院 H001。
用户 A 只属于医院 H002。如果只判断 asset:update,用户 A 就能改 H001 的资产,造成越权。
数据权限通常基于这些维度:
| 维度 | 示例 |
|---|---|
| 租户 | 只能访问当前租户 |
| 医院/机构 | 只能访问所属医院 |
| 部门 | 只能访问本部门或下级部门 |
| 创建人 | 只能访问自己创建的数据 |
| 负责人 | 只能访问自己负责的任务 |
| 数据密级 | 普通用户不能访问高敏数据 |
| 项目范围 | 只能访问参与项目 |
数据权限流程:
flowchart TD
A["用户已认证"] --> B["拥有接口权限"]
B --> C["查询用户数据范围"]
C --> D["加载业务数据"]
D --> E{"数据是否在授权范围内"}
E -- "是" --> F["允许访问"]
E -- "否" --> G["拒绝并记录审计"]数据权限落地方式
方式一:查询前加范围条件
适合列表查询。
select id, asset_no, asset_name, hospital_id
from medical_asset
where tenant_id = :tenantId
and hospital_id in (:allowedHospitalIds)
and deleted = 0
order by created_at desc
limit 20;优点:
- 不会把无权限数据查出来。
- 性能可控,可以配合索引。
- 适合分页列表。
缺点:
- 每个查询都要带范围条件。
- ORM 或手写 SQL 容易漏条件。
- 数据范围复杂时 SQL 会变复杂。
方式二:查询后校验资源归属
适合详情、修改、删除。
public AssetVO getAsset(Long assetId, LoginUser user) {
Asset asset = assetRepository.findById(assetId)
.orElseThrow(() -> new BizException("资产不存在"));
if (!dataScopeService.canReadAsset(user, asset)) {
throw new AccessDeniedException("无权访问该资产");
}
return AssetVO.from(asset);
}修改时也要校验:
@Transactional
public void updateAsset(Long assetId, UpdateAssetRequest request, LoginUser user) {
Asset asset = assetRepository.findByIdForUpdate(assetId)
.orElseThrow(() -> new BizException("资产不存在"));
if (!dataScopeService.canUpdateAsset(user, asset)) {
throw new AccessDeniedException("无权修改该资产");
}
asset.updateName(request.name());
assetRepository.save(asset);
}为什么修改要更谨慎?
- 修改会产生数据破坏。
- 校验和更新之间可能有并发变化。
- 高风险操作要记录审计。
- 必要时使用当前读或行锁保证判断和修改在同一事务内。
方式三:SQL 拦截器追加条件
有些项目会用 MyBatis 拦截器、JPA Specification、数据权限框架自动追加条件。
优点:
- 减少手写 SQL 漏条件。
- 列表查询统一处理。
- 适合部门、租户等通用过滤。
风险:
- SQL 被自动改写,排查复杂。
- 复杂 Join 容易追加错位置。
- 某些管理员查询需要放开时容易误伤。
- 不能替代详情和写操作的业务校验。
建议:自动追加适合通用读范围,关键写操作仍在 Service 中显式校验。
数据范围模型
常见数据范围枚举:
| 范围 | 含义 |
|---|---|
ALL | 全部数据 |
TENANT | 当前租户 |
ORG | 当前机构或医院 |
ORG_AND_CHILD | 当前机构及下级机构 |
DEPT | 当前部门 |
DEPT_AND_CHILD | 当前部门及下级部门 |
SELF | 仅本人 |
CUSTOM | 自定义机构、部门、项目集合 |
角色数据范围表:
create table sys_role_data_scope (
role_id bigint not null,
scope_type varchar(32) not null,
scope_value varchar(128) null,
primary key (role_id, scope_type, scope_value)
);如果一个用户有多个角色,数据范围如何合并?
| 策略 | 含义 | 适合 |
|---|---|---|
| 取并集 | 任一角色有权限即可 | 常见后台系统 |
| 取交集 | 必须同时满足多个角色 | 高安全系统 |
| 按角色优先级 | 某些角色覆盖其他角色 | 特殊组织模型 |
大多数管理后台使用并集。但高敏系统要谨慎,例如医疗敏感数据可能需要额外审批或字段脱敏,不宜简单并集放大权限。
字段权限和脱敏
字段权限解决“能不能看到某个字段”。
例子:
| 字段 | 普通运营 | 管理员 | 审计员 |
|---|---|---|---|
| 患者姓名 | 脱敏 | 可见 | 脱敏 |
| 手机号 | 脱敏 | 可见 | 脱敏 |
| 身份证 | 不可见 | 可见 | 脱敏 |
| 内部接口地址 | 不可见 | 可见 | 不可见 |
脱敏 Demo:
public class MaskingUtils {
public static String maskPhone(String phone) {
if (phone == null || phone.length() < 7) {
return "****";
}
return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
}
}业务返回:
public PatientVO toVO(Patient patient, LoginUser user) {
boolean canViewSensitive = user.hasAuthority("patient:sensitive:read");
return new PatientVO(
patient.getId(),
patient.getName(),
canViewSensitive ? patient.getPhone() : MaskingUtils.maskPhone(patient.getPhone())
);
}字段权限不能只靠前端隐藏,因为接口响应里如果已经返回了敏感字段,前端隐藏也没有意义。
权限加载到 Authentication
登录时需要把权限放进 Authentication。
public class CustomUserDetailsService implements UserDetailsService {
private final UserMapper userMapper;
private final PermissionMapper permissionMapper;
public CustomUserDetailsService(UserMapper userMapper,
PermissionMapper permissionMapper) {
this.userMapper = userMapper;
this.permissionMapper = permissionMapper;
}
@Override
public UserDetails loadUserByUsername(String username) {
UserAccount user = userMapper.findByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("用户不存在");
}
List<String> permissions = permissionMapper.findPermissionCodesByUserId(user.getId());
return User.withUsername(user.getUsername())
.password(user.getPasswordHash())
.authorities(permissions.toArray(String[]::new))
.disabled(!user.isEnabled())
.build();
}
}如果使用 JWT,有两种策略:
| 策略 | 优点 | 缺点 |
|---|---|---|
| Token 中放权限快照 | 请求无需查库,性能好 | 权限变更不能立即生效 |
| Token 中只放 userId,每次查权限缓存 | 权限变更可控 | 多一次缓存或数据库查询 |
折中方案:Token 放 userId 和 tokenVersion,权限放 Redis 缓存。权限变更时删除缓存或增加版本号。
权限缓存和失效
权限查询通常会访问多张表,不能每个请求都查数据库。
常见缓存结构:
security:perm:user:{userId} -> ["asset:read", "asset:update"]
security:scope:user:{userId} -> 数据范围
security:token:version:{userId} -> 版本号权限变更时必须失效:
flowchart TD
A["管理员修改用户角色"] --> B["提交数据库事务"]
B --> C["删除用户权限缓存"]
C --> D["增加 tokenVersion"]
D --> E["记录审计日志"]
E --> F["用户下次请求重新加载权限"]如果不失效缓存,会出现:
- 权限收回后用户仍可操作。
- 新授权后用户仍然 403。
- 多实例之间权限不一致。
- 面试时被问“权限变更怎么实时生效”答不上来。
越权漏洞怎么产生
常见越权有两类:
| 类型 | 说明 | 例子 |
|---|---|---|
| 水平越权 | 同级用户访问对方数据 | 用户 A 修改用户 B 的文章 |
| 垂直越权 | 低权限用户访问高权限能力 | 普通用户调用管理员接口 |
水平越权示例:
@PreAuthorize("hasAuthority('article:update')")
@PutMapping("/articles/{id}")
public void update(@PathVariable Long id, @RequestBody UpdateArticleRequest request) {
articleService.update(id, request);
}如果 articleService.update 不检查文章作者,任何有 article:update 权限的人都能改所有文章。
修复:
public void update(Long id, UpdateArticleRequest request, LoginUser user) {
Article article = articleRepository.findById(id)
.orElseThrow(() -> new BizException("文章不存在"));
if (!article.getAuthorId().equals(user.getUserId()) && !user.hasRole("ROLE_ADMIN")) {
throw new AccessDeniedException("只能修改自己的文章");
}
article.update(request.title(), request.content());
}商业场景:医疗数据采集与资产平台
权限模型:
| 用户 | 权限 | 数据范围 |
|---|---|---|
| 平台管理员 | 所有菜单和按钮 | 全部租户或指定租户 |
| 医院管理员 | 医院资产管理 | 本医院 |
| 科室负责人 | 查看采集任务和资产 | 本科室 |
| 采集运维 | 启停采集任务 | 授权医院或项目 |
| 审计员 | 查看审计日志 | 指定租户或全部 |
资产查询 SQL:
select id, asset_no, asset_name, hospital_id, department_id, status
from medical_asset
where tenant_id = :tenantId
and hospital_id in (:allowedHospitalIds)
and deleted = 0
order by created_at desc
limit :limit offset :offset;启动采集任务:
@PreAuthorize("hasAuthority('collect:task:start')")
@PostMapping("/collect/tasks/{id}/start")
public void start(@PathVariable Long id, @CurrentUser LoginUser user) {
collectTaskService.start(id, user);
}Service 校验:
@Transactional
public void start(Long taskId, LoginUser user) {
CollectTask task = taskRepository.findByIdForUpdate(taskId)
.orElseThrow(() -> new BizException("采集任务不存在"));
if (!dataScopeService.canOperateHospital(user, task.getHospitalId())) {
throw new AccessDeniedException("无权操作该医院采集任务");
}
if (!task.canStart()) {
throw new BizException("当前状态不能启动");
}
task.start(user.getUserId());
auditLogService.record(user.getUserId(), "collect:task:start", taskId);
}这里同时做了:
- 方法权限:是否有启动任务能力。
- 数据权限:是否能操作该医院任务。
- 状态校验:任务状态是否允许启动。
- 审计:记录谁启动了任务。
生产排查
明明登录了却 401
flowchart TD
A["出现 401"] --> B["请求是否带 Token/Cookie"]
B --> C["Token 是否过期或签名错误"]
C --> D["JWT Filter 是否执行"]
D --> E["是否写入 SecurityContext"]
E --> F["异常是否被 AuthenticationEntryPoint 处理"]明明有权限却 403
flowchart TD
A["出现 403"] --> B["Authentication 是否存在"]
B --> C["authorities 是否包含权限码"]
C --> D["hasRole/hasAuthority 是否写错"]
D --> E["方法权限是否开启"]
E --> F["是否被数据权限拒绝"]
F --> G["权限缓存是否过期或未刷新"]用户能访问不该访问的数据
flowchart TD
A["发现越权"] --> B["确认接口权限是否过宽"]
B --> C["检查 Service 是否校验资源归属"]
C --> D["检查 SQL 是否带租户/部门/医院条件"]
D --> E["检查缓存中的数据范围"]
E --> F["补审计和回溯影响数据"]排查清单:
| 问题 | 排查方向 |
|---|---|
@PreAuthorize 不生效 | 是否开启 @EnableMethodSecurity,方法是否通过代理调用 |
hasRole 不生效 | 是否漏了 ROLE_ 前缀规则 |
| 权限变更不生效 | 权限缓存、JWT 权限快照、tokenVersion |
| 列表看到多余数据 | SQL 是否缺租户、部门、医院过滤 |
| 详情接口越权 | Service 是否校验资源归属 |
| 前端按钮显示错 | 权限接口返回是否缓存过期 |
常见坑
| 坑 | 后果 | 正确做法 |
|---|---|---|
| 只做前端按钮权限 | 接口可被直接调用 | 后端必须校验 |
只做 @PreAuthorize | 可能水平越权 | Service 校验资源归属 |
| JWT 长期保存权限 | 权限变更后旧 Token 仍有效 | 短 Token、权限缓存、版本号 |
hasRole('ROLE_ADMIN') | 可能匹配错 | hasRole('ADMIN') 或 hasAuthority |
| 数据范围 SQL 漏条件 | 用户看到越权数据 | 统一数据范围组件和代码审查 |
| 超级管理员绕过全部审计 | 高危操作不可追踪 | 管理员也要审计 |
| 权限码随意改名 | 前后端和历史数据失效 | 权限码稳定治理 |
面试标准回答
Spring Security 权限控制怎么设计?
我会分层设计。认证确认用户是谁;URL 权限控制大范围访问边界;方法权限用 @PreAuthorize 和权限码控制具体业务动作;按钮权限给前端控制展示;数据权限在 Service 或 SQL 层结合租户、部门、机构、创建人、资源归属继续校验;高风险操作记录审计日志。角色用于批量授权,权限码用于细粒度控制。
角色和权限有什么区别?
角色是权限集合,类似岗位,例如管理员、运营、审计员;权限是具体动作,例如 asset:read、asset:create、asset:delete。商业系统通常用户绑定角色,角色绑定权限码,这样既方便批量授权,又能细粒度控制按钮和接口。
为什么只用权限注解不够?
权限注解只能判断当前用户是否有调用方法的能力,例如是否有 asset:update。但它不知道这条资产属于哪个租户、医院、部门或创建人。为了防水平越权,必须在业务层或数据访问层继续做数据权限校验。
权限变更后怎么生效?
如果权限每次查库,实时但性能差;生产通常会缓存用户权限和数据范围。管理员修改角色或权限后,要删除用户权限缓存、增加 tokenVersion 或让旧 Token 失效。JWT 如果长期保存权限快照,权限收回不会立刻生效,所以要短有效期、刷新机制或权限版本控制。
关联知识点
| 知识点 | 说明 |
|---|---|
| Spring Security 从零到生产级掌握 | 认证、授权、JWT、CSRF、数据权限主线 |
| 核心架构 | Filter Chain、SecurityContext、Authentication |
| 登录认证 | 用户认证和权限加载 |
| JWT 认证 | Token 权限快照和撤销 |
| 过滤器链实战 | 认证过滤器和异常处理 |
| Spring Security 面试题 | 标准回答和追问 |
| 信息安全 | 加密、哈希、签名、防重放 |
本章小结
权限控制不是“登录后判断一个角色”这么简单。成熟系统要把角色、权限码、菜单按钮、接口方法、数据范围、字段脱敏、缓存失效和审计日志放在一条链路里。最容易出事故的不是“没登录”,而是“已经登录且有某个权限,但访问了不属于自己的数据”。所以数据权限必须成为后端业务逻辑的一部分。
