Skip to content

Spring Security 权限控制

认证解决“你是谁”,权限控制解决“你能做什么、能操作哪些数据、能看到哪些字段”。真正的商业系统里,权限控制不是一个 @PreAuthorize 注解就结束,而是一套从接口、菜单、按钮、数据范围、字段脱敏到审计日志的完整体系。

先记住一句话:

登录成功只代表系统认识你,不代表你可以访问所有接口,更不代表你可以操作所有数据。

学习目标

学完本章你要能回答:

  1. 认证、授权、数据权限、字段权限分别解决什么问题。
  2. 角色和权限码有什么区别,为什么商业系统通常两者都要。
  3. URL 权限、方法权限、按钮权限、数据权限怎么分层。
  4. RBAC 模型怎么建表,用户、角色、权限怎么关联。
  5. hasRolehasAuthority@PreAuthorize 有什么区别和坑。
  6. 为什么接口权限不能替代数据权限。
  7. 部门、租户、医院、创建人这些数据范围怎么落地到 SQL。
  8. 权限缓存怎么设计,权限变更后如何失效。
  9. 越权漏洞怎么产生,生产上怎么排查 403 和数据越权。

权限控制的分层

权限不是单层判断,而是多层网。

mermaid
flowchart TD
    A["用户请求"] --> B["是否已认证"]
    B --> C["URL 或接口权限"]
    C --> D["方法权限"]
    D --> E["数据范围权限"]
    E --> F["字段权限和脱敏"]
    F --> G["审计日志"]
层次解决问题示例
认证你是谁用户名密码、JWT、Session
URL 权限能不能访问这个地址/admin/** 必须登录
方法权限能不能执行这个业务方法asset:create 才能新增资产
按钮权限前端是否展示操作入口没有删除权限就隐藏删除按钮
数据权限能不能操作这条数据只能看本医院、本部门资产
字段权限能不能看这个字段普通用户看脱敏手机号
审计谁在什么时候做了什么删除、导出、改权限必须记录

只做 URL 权限,容易出现“能进接口就能操作所有数据”。只做前端按钮权限,用户可以直接调用接口绕过前端。真正安全的边界必须在后端。

角色和权限码

角色是权限集合,权限码是具体动作。

概念含义示例
角色 Role岗位、身份、权限包ROLE_ADMINROLE_OPERATOR
权限 Permission具体动作能力asset:readasset:create
数据范围 Data Scope能访问哪些数据本人、本部门、本医院、全部

为什么不能只用角色?

假设系统有这些角色:

text
ROLE_ADMIN
ROLE_ASSET_MANAGER
ROLE_COLLECT_OPERATOR
ROLE_AUDITOR

一开始看起来够用。但随着功能增加,你会遇到:

  1. 某个资产管理员只能查看,不能删除。
  2. 某个审计员只能导出审计日志,不能改权限。
  3. 同样是采集员,有人能启动任务,有人只能查看任务。
  4. 临时授权某个用户一个按钮能力,不想新建角色。

所以商业系统通常采用:

text
用户 -> 角色 -> 权限码

角色用于批量授权,权限码用于精细控制。

RBAC 模型

RBAC 是 Role-Based Access Control,基于角色的访问控制。

mermaid
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"]

最小表结构:

sql
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)
);

权限码命名建议:

text
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

命名原则:

  1. 按资源和动作拆分。
  2. 不要写成 adminnormal 这种模糊权限。
  3. 权限码要稳定,不能随便改,否则前端、后端、数据库都受影响。
  4. 高风险权限要单独拆出,例如 asset:export 不要和 asset:read 混在一起。

Spring Security 里角色和权限的坑

Spring Security 中常见两个表达式:

java
hasRole("ADMIN")
hasAuthority("asset:read")

它们不是完全一样。

写法实际含义
hasRole("ADMIN")通常会匹配 ROLE_ADMIN
hasAuthority("ROLE_ADMIN")精确匹配 ROLE_ADMIN
hasAuthority("asset:read")精确匹配 asset:read

常见坑:

java
@PreAuthorize("hasRole('ROLE_ADMIN')")

这可能变成匹配 ROLE_ROLE_ADMIN。通常应写:

java
@PreAuthorize("hasRole('ADMIN')")

或者统一使用权限码:

java
@PreAuthorize("hasAuthority('asset:read')")

商业项目更推荐权限码做细粒度控制,角色更多用于授权管理和粗粒度身份区分。

URL 权限

URL 权限适合定义大的访问边界。

java
@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();
}

设计原则:

  1. 登录、验证码、公开文章等明确放行。
  2. 后台管理接口默认要求登录。
  3. 不认识的路径默认拒绝,而不是默认放行。
  4. URL 权限只做粗粒度边界,细粒度交给方法权限和数据权限。

如果把所有权限都写在 URL 规则里,会出现配置巨大、难维护、动态权限难更新的问题。

方法权限

方法权限更贴近业务。

开启:

java
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}

使用:

java
@PreAuthorize("hasAuthority('asset:create')")
@PostMapping("/assets")
public AssetVO create(@RequestBody AssetCreateRequest request) {
    return assetService.create(request);
}

Service 层也可以加:

java
@PreAuthorize("hasAuthority('collect:task:start')")
public void startTask(Long taskId) {
    collectTaskService.start(taskId);
}

URL 权限和方法权限怎么选?

场景推荐
公开接口和后台接口大边界URL 权限
某个业务动作需要具体权限码方法权限
同一个 URL 内部根据参数走不同动作Service 方法权限或业务判断
数据归属判断Service 层数据权限

按钮权限

按钮权限主要给前端控制展示,例如:

json
{
  "permissions": [
    "asset:read",
    "asset:create",
    "asset:update",
    "collect:task:start"
  ]
}

前端可以根据权限码决定是否展示按钮:

text
有 asset:create -> 展示新增按钮
没有 asset:delete -> 隐藏删除按钮

但注意:

前端按钮权限只改善用户体验,不是安全边界。

攻击者可以绕过前端,直接调用接口。所以后端必须重复校验权限。

数据权限为什么最难

接口权限只能判断“能不能调用接口”,数据权限判断“能不能操作这条具体数据”。

例子:

text
用户 A 有 asset:update 权限。
用户 B 也有 asset:update 权限。
资产 X 属于医院 H001。
用户 A 只属于医院 H002。

如果只判断 asset:update,用户 A 就能改 H001 的资产,造成越权。

数据权限通常基于这些维度:

维度示例
租户只能访问当前租户
医院/机构只能访问所属医院
部门只能访问本部门或下级部门
创建人只能访问自己创建的数据
负责人只能访问自己负责的任务
数据密级普通用户不能访问高敏数据
项目范围只能访问参与项目

数据权限流程:

mermaid
flowchart TD
    A["用户已认证"] --> B["拥有接口权限"]
    B --> C["查询用户数据范围"]
    C --> D["加载业务数据"]
    D --> E{"数据是否在授权范围内"}
    E -- "是" --> F["允许访问"]
    E -- "否" --> G["拒绝并记录审计"]

数据权限落地方式

方式一:查询前加范围条件

适合列表查询。

sql
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;

优点:

  1. 不会把无权限数据查出来。
  2. 性能可控,可以配合索引。
  3. 适合分页列表。

缺点:

  1. 每个查询都要带范围条件。
  2. ORM 或手写 SQL 容易漏条件。
  3. 数据范围复杂时 SQL 会变复杂。

方式二:查询后校验资源归属

适合详情、修改、删除。

java
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);
}

修改时也要校验:

java
@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);
}

为什么修改要更谨慎?

  1. 修改会产生数据破坏。
  2. 校验和更新之间可能有并发变化。
  3. 高风险操作要记录审计。
  4. 必要时使用当前读或行锁保证判断和修改在同一事务内。

方式三:SQL 拦截器追加条件

有些项目会用 MyBatis 拦截器、JPA Specification、数据权限框架自动追加条件。

优点:

  1. 减少手写 SQL 漏条件。
  2. 列表查询统一处理。
  3. 适合部门、租户等通用过滤。

风险:

  1. SQL 被自动改写,排查复杂。
  2. 复杂 Join 容易追加错位置。
  3. 某些管理员查询需要放开时容易误伤。
  4. 不能替代详情和写操作的业务校验。

建议:自动追加适合通用读范围,关键写操作仍在 Service 中显式校验。

数据范围模型

常见数据范围枚举:

范围含义
ALL全部数据
TENANT当前租户
ORG当前机构或医院
ORG_AND_CHILD当前机构及下级机构
DEPT当前部门
DEPT_AND_CHILD当前部门及下级部门
SELF仅本人
CUSTOM自定义机构、部门、项目集合

角色数据范围表:

sql
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:

java
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);
    }
}

业务返回:

java
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

java
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 放 userIdtokenVersion,权限放 Redis 缓存。权限变更时删除缓存或增加版本号。

权限缓存和失效

权限查询通常会访问多张表,不能每个请求都查数据库。

常见缓存结构:

text
security:perm:user:{userId} -> ["asset:read", "asset:update"]
security:scope:user:{userId} -> 数据范围
security:token:version:{userId} -> 版本号

权限变更时必须失效:

mermaid
flowchart TD
    A["管理员修改用户角色"] --> B["提交数据库事务"]
    B --> C["删除用户权限缓存"]
    C --> D["增加 tokenVersion"]
    D --> E["记录审计日志"]
    E --> F["用户下次请求重新加载权限"]

如果不失效缓存,会出现:

  1. 权限收回后用户仍可操作。
  2. 新授权后用户仍然 403。
  3. 多实例之间权限不一致。
  4. 面试时被问“权限变更怎么实时生效”答不上来。

越权漏洞怎么产生

常见越权有两类:

类型说明例子
水平越权同级用户访问对方数据用户 A 修改用户 B 的文章
垂直越权低权限用户访问高权限能力普通用户调用管理员接口

水平越权示例:

java
@PreAuthorize("hasAuthority('article:update')")
@PutMapping("/articles/{id}")
public void update(@PathVariable Long id, @RequestBody UpdateArticleRequest request) {
    articleService.update(id, request);
}

如果 articleService.update 不检查文章作者,任何有 article:update 权限的人都能改所有文章。

修复:

java
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:

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;

启动采集任务:

java
@PreAuthorize("hasAuthority('collect:task:start')")
@PostMapping("/collect/tasks/{id}/start")
public void start(@PathVariable Long id, @CurrentUser LoginUser user) {
    collectTaskService.start(id, user);
}

Service 校验:

java
@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);
}

这里同时做了:

  1. 方法权限:是否有启动任务能力。
  2. 数据权限:是否能操作该医院任务。
  3. 状态校验:任务状态是否允许启动。
  4. 审计:记录谁启动了任务。

生产排查

明明登录了却 401

mermaid
flowchart TD
    A["出现 401"] --> B["请求是否带 Token/Cookie"]
    B --> C["Token 是否过期或签名错误"]
    C --> D["JWT Filter 是否执行"]
    D --> E["是否写入 SecurityContext"]
    E --> F["异常是否被 AuthenticationEntryPoint 处理"]

明明有权限却 403

mermaid
flowchart TD
    A["出现 403"] --> B["Authentication 是否存在"]
    B --> C["authorities 是否包含权限码"]
    C --> D["hasRole/hasAuthority 是否写错"]
    D --> E["方法权限是否开启"]
    E --> F["是否被数据权限拒绝"]
    F --> G["权限缓存是否过期或未刷新"]

用户能访问不该访问的数据

mermaid
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:readasset:createasset:delete。商业系统通常用户绑定角色,角色绑定权限码,这样既方便批量授权,又能细粒度控制按钮和接口。

为什么只用权限注解不够?
权限注解只能判断当前用户是否有调用方法的能力,例如是否有 asset:update。但它不知道这条资产属于哪个租户、医院、部门或创建人。为了防水平越权,必须在业务层或数据访问层继续做数据权限校验。

权限变更后怎么生效?
如果权限每次查库,实时但性能差;生产通常会缓存用户权限和数据范围。管理员修改角色或权限后,要删除用户权限缓存、增加 tokenVersion 或让旧 Token 失效。JWT 如果长期保存权限快照,权限收回不会立刻生效,所以要短有效期、刷新机制或权限版本控制。

关联知识点

知识点说明
Spring Security 从零到生产级掌握认证、授权、JWT、CSRF、数据权限主线
核心架构Filter Chain、SecurityContext、Authentication
登录认证用户认证和权限加载
JWT 认证Token 权限快照和撤销
过滤器链实战认证过滤器和异常处理
Spring Security 面试题标准回答和追问
信息安全加密、哈希、签名、防重放

本章小结

权限控制不是“登录后判断一个角色”这么简单。成熟系统要把角色、权限码、菜单按钮、接口方法、数据范围、字段脱敏、缓存失效和审计日志放在一条链路里。最容易出事故的不是“没登录”,而是“已经登录且有某个权限,但访问了不属于自己的数据”。所以数据权限必须成为后端业务逻辑的一部分。