如何使用 Spring Security 构建 RBAC 权限控制体系?

来源:Reactjs教程作者:北京GEO公司头衔:草根站长
导读:本期聚焦于北京GEO公司创作的《如何使用 Spring Security 构建 RBAC 权限控制体系?》,敬请观看详情。接口权限判断散落在 Controller、Service 甚至前端路由里,角色一变就要逐个修改代码,这种维护成本是否让你头疼?RBAC 通过用户、角色、权限三层模型把访问规则集中管理,而 Spring Security 提供了从认证到授权的完整扩展点。本文从表结构设计出发,讲清用户、角色、菜单权限、接口权限之间的关联,再结合 Spring Security 的 UserDetailsService、GrantedAuthority、安全元数据源等组件,演示如何动态加载权限并实现 URL 级和方法级控制。文中给出可落地的数据库脚本、核心配置类以及自定义授权决策代码,帮助你摆脱硬编码,构建灵活、可维护的权限体系。

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

如何使用 Spring Security 构建 RBAC 权限控制体系?

一、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

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/0919/59395.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。