在管理后台、电商系统或企业级应用中,权限控制往往不是判断一下用户是否登录那么简单。系统需要精确回答用户能不能访问某个接口、能不能看到某个菜单、能不能执行某条数据上的操作。RBAC 模型通过引入角色这一中间层,把用户和权限解耦:用户只关联角色,角色再关联权限,当权限策略变化时,只需调整角色与权限的关系,而不用逐个修改用户。Spring Security 作为 Java 生态中最成熟的认证授权框架,提供了过滤器链、认证管理器、授权管理器等一系列可扩展组件,非常适合落地 RBAC 权限体系。本文会从表结构设计、认证主链路、动态 URL 授权、方法级安全四个层面,给出可落地的实现方案。

一、RBAC 表结构设计与数据关系
RBAC 的核心思想是用户不直接拥有权限,权限被赋予角色,用户通过角色间接获得许可。最基础的 RBAC 模型涉及五张数据表:用户表、角色表、权限表、用户角色关联表、角色权限关联表。用户表保存登录账号、密码、状态等基本信息;角色表保存角色编码和名称,例如 ROLE_ADMIN、ROLE_OPERATOR;权限表既可以存储接口权限,也可以存储菜单权限,常见做法是用资源类型字段区分目录、菜单、按钮、接口。关联表通过外键建立多对多关系,这种结构让同一个用户可以拥有多个角色,同一个角色也能包含多个权限。
建表时建议给权限表增加权限编码字段,编码采用模块:功能:操作三段式,例如 system:user:create、system:user:update、system:role:delete。这样在代码里直接使用权限编码做判断,比使用数据库自增 ID 更加直观,也方便前端根据权限编码控制按钮显隐。菜单权限和接口权限可以分开维护,也可以合在同一个表里,通过 type 字段区分,具体取决于系统复杂度。如果权限需要细分到数据行,还需要引入用户、角色与组织或数据范围的关系,但那已经属于扩展模型,本文讨论的重点仍是接口和菜单级别的 RBAC。
下面给出一个精简版建表 SQL,去掉了外键约束以降低演示复杂度,实际项目可自行补充索引和逻辑删除字段。
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(100) NOT NULL,
status TINYINT DEFAULT 1
);
CREATE TABLE sys_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
role_code VARCHAR(50) NOT NULL UNIQUE,
role_name VARCHAR(50) NOT NULL
);
CREATE TABLE sys_permission (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
perm_code VARCHAR(100) NOT NULL UNIQUE,
perm_name VARCHAR(50) NOT NULL,
perm_type VARCHAR(20) NOT NULL,
url VARCHAR(200)
);
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)
);
上面的 sys_permission 表中,perm_type 可以填 menu 或 api,url 字段用于接口权限匹配。如果系统有专门的菜单表,也可以把菜单权限和接口权限拆开,角色同时关联菜单和接口权限即可。无论采用哪种表结构,最终目的都是让权限配置集中化,避免在业务代码里散落大量 if 判断。
二、Spring Security 认证与授权主链路
Spring Security 默认通过一条过滤器链处理认证和授权。AuthenticationFilter 负责从请求中提取凭证并构建 Authentication 对象,随后 AuthenticationManager 完成身份校验,校验通过后 Authentication 被放入 SecurityContextHolder。对 RBAC 来说,最重要的步骤发生在 UserDetailsService:系统根据用户名查询用户,同时查出该用户的所有角色和权限,并转换成 GrantedAuthority 集合。Spring Security 不关心权限字符串叫什么,它只认识 GrantedAuthority,因此角色可以表示为 ROLE_ADMIN,权限可以表示为 system:user:create,这些最终都会成为 authorities。
自定义 UserDetailsService 时,不要只返回密码和账号状态,要一并把权限编码查出来。实际项目中可以用一条 SQL 通过 join 关联查出用户、角色、权限,也可以在 UserDetailsService 里多次查询,不过前者性能更好。查出的权限集合通常以 perm_code 的形式放入 SimpleGrantedAuthority。需要注意的是,如果既想使用 hasRole 判断角色,又想使用 hasAuthority 判断权限,可以把角色编码统一加上 ROLE_ 前缀,并为权限编码保持原样。对于方法级安全,两者都可以使用,但要保持命名一致。
下面是一个典型的 UserDetailsService 实现,省去了数据库访问细节,重点展示如何组装权限。
@Service
public class RbacUserDetailsService implements UserDetailsService {
private final UserMapper userMapper;
public RbacUserDetailsService(UserMapper userMapper) {
this.userMapper = userMapper;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userMapper.findByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("用户不存在");
}
List<String> authorityCodes = userMapper.findAuthorityCodesByUserId(user.getId());
List<GrantedAuthority> authorities = authorityCodes.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPassword(),
user.isEnabled(),
true,
true,
true,
authorities
);
}
}
认证完成后,授权环节会根据请求 URL 或方法注解判断当前用户是否具备所需权限。传统实现通常在 FilterSecurityInterceptor 中读取安全元数据源,再委托 AccessDecisionManager 投票决定是否放行。Spring Security 6 之后推荐使用 AuthorizationFilter 配合 AuthorizationManager,写法更函数式,也更适合动态权限场景。无论采用哪种方式,本质都是将当前请求与权限配置做匹配,再从 Authentication 的 authorities 中查找是否包含目标权限。
三、动态 URL 权限与自定义 AuthorizationManager
如果权限规则固定,配置类里直接写 authorizeHttpRequests 就够用,例如 requestMatchers("/admin/**").hasAuthority("system:admin")。但实际系统里权限经常在后台动态调整,新增角色、修改菜单权限不应要求重新编译发布。这时就需要从数据库加载资源权限映射,并让 Spring Security 在每次请求时动态判断。动态权限的核心是自定义 AuthorizationManager,它不再依赖硬编码的 requestMatchers,而是根据当前请求路径和数据库配置决定是否允许访问。
自定义 AuthorizationManager 的典型做法是:启动时或每次请求时查询所有需要鉴权的资源及其所需权限编码,使用 AntPathRequestMatcher 对当前请求进行模式匹配。如果匹配到资源,就检查当前 Authentication 是否拥有任意一个所需权限;如果未匹配到任何资源,可以选择放行,也可以选择拒绝,通常登录接口、验证码接口和公共资源应提前在配置中放行,其余进入动态鉴权。匹配方式要注意路径匹配规则,例如 /system/user/** 会匹配 /system/user/list 和 /system/user/1。
下面是一个基于 Spring Security 6 的 AuthorizationManager 实现示例,其中 ResourcePermissionService 从缓存或数据库读取资源与权限的映射关系。
@Component
public class RbacAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {
private final ResourcePermissionService resourcePermissionService;
public RbacAuthorizationManager(ResourcePermissionService resourcePermissionService) {
this.resourcePermissionService = resourcePermissionService;
}
@Override
public AuthorizationDecision check(Supplier<Authentication> authenticationSupplier,
RequestAuthorizationContext context) {
Authentication authentication = authenticationSupplier.get();
if (authentication == null || !authentication.isAuthenticated()) {
return new AuthorizationDecision(false);
}
String requestUrl = context.getRequest().getRequestURI();
String method = context.getRequest().getMethod();
List<ResourcePermission> resourcePermissions =
resourcePermissionService.getAllResourcePermissions();
for (ResourcePermission rp : resourcePermissions) {
AntPathRequestMatcher matcher = new AntPathRequestMatcher(rp.getUrl(), rp.getMethod());
if (matcher.matches(context.getRequest())) {
boolean granted = authentication.getAuthorities().stream()
.anyMatch(authority -> authority.getAuthority().equals(rp.getPermCode()));
return new AuthorizationDecision(granted);
}
}
return new AuthorizationDecision(false);
}
}
配置类中只需将自定义的 AuthorizationManager 注册到授权链即可。注意要在 authorizeHttpRequests 中先放行静态资源和登录接口,再对剩余请求应用自定义管理器。如果某些请求不需要任何权限就能访问,数据库里可以配置一个特殊的白名单资源,或者直接在配置类里写清楚。动态权限配合缓存时,还要考虑权限变更后的缓存失效问题,通常可以在后台修改权限时主动刷新缓存,而不是依赖短期过期。
配置代码大致如下。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final RbacAuthorizationManager rbacAuthorizationManager;
public SecurityConfig(RbacAuthorizationManager rbacAuthorizationManager) {
this.rbacAuthorizationManager = rbacAuthorizationManager;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/captcha", "/static/**").permitAll()
.anyRequest().access(rbacAuthorizationManager)
)
.formLogin(form -> form.loginProcessingUrl("/login"))
.logout(logout -> logout.logoutUrl("/logout"));
return http.build();
}
}
需要说明的是,上面为了演示关闭了 CSRF,实际项目中如果前后端分离并且使用 JWT,通常会关闭 CSRF,但要确保其他防护措施到位。如果采用 Session 模式,最好保留 CSRF 防御。
四、方法级权限控制与注解扩展
URL 级别权限只能控制到接口粒度,某些业务操作可能需要更细的控制,例如同一个 Controller 方法中管理员可以删除任意用户,而普通操作员只能删除自己创建的用户。这种场景适合使用方法级安全。开启方法级安全需要在配置类上添加 @EnableMethodSecurity 注解,然后可以在 Service 或 Controller 方法上使用 @PreAuthorize 表达式。常见写法有 @PreAuthorize("hasAuthority('system:user:delete')") 或 @PreAuthorize("hasRole('ADMIN')")。
权限编码规范化对方法级安全非常重要。建议统一使用 模块:功能:操作 格式,例如 system:user:delete、order:report:export。前端按钮根据权限编码显示,后端 URL 授权和方法注解都使用同一套编码,能大幅降低权限不一致的风险。如果仅仅使用字符串硬编码在注解里,新增权限时仍需要修改代码,更好的做法是定义一个权限表达式 Bean,通过方法判断当前用户是否拥有指定权限,这样权限编码只需存在于数据库和前端配置中。
例如自定义一个 PermissionService,提供 hasPerm 方法。
@Component("ss")
public class PermissionService {
public boolean hasPerm(String permCode) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
return false;
}
return authentication.getAuthorities().stream()
.anyMatch(authority -> authority.getAuthority().equals(permCode));
}
}
然后在方法上使用。
@RestController
@RequestMapping("/system/user")
public class SystemUserController {
@PreAuthorize("@ss.hasPerm('system:user:delete')")
@DeleteMapping("/{id}")
public Result<Void> deleteUser(@PathVariable Long id) {
userService.deleteById(id);
return Result.success();
}
}
方法级安全虽然灵活,但不宜滥用。如果所有权限都集中在 Controller 层方法上,URL 级动态授权就成了重复工作;如果只在 Service 层做方法级控制,Controller 仍可能暴露入口导致未授权请求进入。实际项目里通常让 URL 级授权负责粗粒度拦截,方法级授权负责细粒度操作,两者使用同一套权限编码,形成双层防护。此外,对于需要基于数据所有权的控制,还可以自定义 PermissionEvaluator 或使用 @PostAuthorize 过滤返回结果,但要注意性能开销。
五、常见问题与优化建议
动态权限体系落地时,缓存策略是首先要考虑的问题。权限数据变动频率不高,但每次请求都查询数据库不可接受,因此需要引入本地缓存或分布式缓存。后台修改角色权限后,应主动刷新权限缓存,而不是依赖过期时间,否则会出现权限修改后不生效的问题。Spring Cache 或 Caffeine 都可以完成这个任务,关键是保证缓存更新与数据库事务处理的一致性,例如先更新数据库再清除缓存,避免旧权限影响新请求。
另一个常见问题是权限字符串的命名混乱。角色编码和权限编码没有规范,导致 hasRole、hasAuthority 混用,注解里一会儿写 ROLE_ADMIN 一会儿写 system:user:delete,后期维护非常痛苦。建议在项目启动时就制定命名规范:角色编码统一带 ROLE_ 前缀,权限编码统一使用三段式,方法级和 URL 级只认权限编码,不直接认角色。这样可以更灵活地组合角色权限,某个新角色只要关联不同权限集合即可,不用修改代码。
最后是越权防护。即使有了 URL 级和方法级权限,仍可能存在接口对象级越权,例如用户 A 通过修改 URL 中的 id 访问用户 B 的数据。RBAC 只能保证用户有访问该接口的资格,不能保证他访问的数据属于自己。解决对象级越权需要结合数据权限方案,常见做法是在查询条件中注入数据范围,或使用 AOP 在方法执行前校验资源归属。RBAC 和数据权限相互补充,才能形成完整的权限控制体系。
Spring SecurityRBAC权限控制修改时间:2026-09-19 22:16:56