Spring Boot脚手架集成 Spring Security实现生产级RBAC鉴权

目录

前言与项目说明

本篇博客将详细讲解如何在单体 Spring Boot 脚手架项目中实现基于 Spring Security 6 的标准 B 端企业级 RBAC(基于角色的权限控制)安全管理体系。本教程以简单易懂、循序渐进的方式,结合真实项目代码,帮助初学者快速掌握 Spring Security 在生产环境中的实战写法。

环境准备说明

  • JDK 版本JDK 17
  • Spring Boot 版本Spring Boot 3.3.3
  • 项目名称spring-boot-base-demo
  • 基础分支master (主攻 C 端业务)
  • 集成分支feature/admin-auth-springsecurity (主攻 B 端管理后台鉴权)
  • 源码获取点击跳转Github获取

架构演进:传统 C 端 JWT 拦截器 vs B 端 Spring Security 鉴权

在脚手架项目的设计中,C 端业务B 端管理系统 在安全防护与权限深度上存在根本的差异。通过对比 master 分支和当前安全组件集成分支,我们可以清晰地看到这一架构的演变:

维度 Master 分支(传统 C 端 JWT 鉴权) Feature 分支(B 端 Spring Security 6 集成)
安全场景 面向 C 端移动端/小程序,业务高并发,鉴权规则相对简单。 面向 B 端管理后台,需要严苛的**角色(Role)按钮级权限(Permission)**粒度控制。
拦截机制 采用 Spring MVC 自定义的 HandlerInterceptorTokenInterceptor)。 采用 Spring Security 的**过滤器链(Filter Chain)**拦截,防线前置到 Servlet 容器级别。
权限维度 仅做"是否登录"的登录态检查,无法优雅地支持复杂的权限矩阵校验。 支持方法/接口级的权限声明(hasRole / hasAuthority),满足复杂的菜单及按钮权限判定。
代码变动 核心逻辑在 TokenInterceptor.java 中。 彻底清空并废弃了 TokenInterceptor.java,新建 TokenAuthenticationFilter 过滤器及 SpringSecurityConfig 配置类。
上下文维护 通过自定义 ThreadLocal 的 SessionContext 维护。 双重保障:保留 SessionContext 支持轻量业务调用,同时集成框架的 SecurityContextHolder 维护用户权限状态。

实践

RBAC 权限表结构设计

本脚手架集成了标准的 RBAC(Role-Based Access Control)权限模型,其关联的数据库实体包括:

  1. User(用户表)
    保存用户账号、姓名、手机号等核心数据。
  2. SysRole(角色表)
    保存角色基本信息,例如:系统管理员(admin)、运营人员(operator)等。
  3. SysUserRole(用户-角色多对多关联表)
    将用户与角色进行绑定,决定某个用户拥有哪些角色。
  4. SysPermission(权限/按钮表)
    按钮或操作级的细粒度权限标识,例如:用户添加(user:add)、用户修改(user:edit)。
  5. SysRolePermission(角色-权限关联表)
    将角色与权限进行绑定,决定某个角色能操作哪些具体按钮。
  6. SysMenu(菜单表)
    系统前端左侧菜单树结构,包括菜单名称、路由路径、图标等。
  7. SysRoleMenu(角色-菜单关联表)
    控制角色能够看到的菜单树范围。

流程概览(Mermaid 流程图)

业务控制器 (UserController) Spring Security 核心上下文 TokenAuthenticationFilter (过滤器) 业务控制器 (UserController) Spring Security 核心上下文 TokenAuthenticationFilter (过滤器) #mermaid-svg-ySySYu2eCONRslgY{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ySySYu2eCONRslgY .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ySySYu2eCONRslgY .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ySySYu2eCONRslgY .error-icon{fill:#552222;}#mermaid-svg-ySySYu2eCONRslgY .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ySySYu2eCONRslgY .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ySySYu2eCONRslgY .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ySySYu2eCONRslgY .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ySySYu2eCONRslgY .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ySySYu2eCONRslgY .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ySySYu2eCONRslgY .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ySySYu2eCONRslgY .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ySySYu2eCONRslgY .marker.cross{stroke:#333333;}#mermaid-svg-ySySYu2eCONRslgY svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ySySYu2eCONRslgY p{margin:0;}#mermaid-svg-ySySYu2eCONRslgY .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ySySYu2eCONRslgY text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-ySySYu2eCONRslgY .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ySySYu2eCONRslgY .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-ySySYu2eCONRslgY .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-ySySYu2eCONRslgY .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-ySySYu2eCONRslgY #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-ySySYu2eCONRslgY .sequenceNumber{fill:white;}#mermaid-svg-ySySYu2eCONRslgY #sequencenumber{fill:#333;}#mermaid-svg-ySySYu2eCONRslgY #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-ySySYu2eCONRslgY .messageText{fill:#333;stroke:none;}#mermaid-svg-ySySYu2eCONRslgY .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ySySYu2eCONRslgY .labelText,#mermaid-svg-ySySYu2eCONRslgY .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-ySySYu2eCONRslgY .loopText,#mermaid-svg-ySySYu2eCONRslgY .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-ySySYu2eCONRslgY .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ySySYu2eCONRslgY .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ySySYu2eCONRslgY .noteText,#mermaid-svg-ySySYu2eCONRslgY .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-ySySYu2eCONRslgY .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ySySYu2eCONRslgY .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ySySYu2eCONRslgY .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ySySYu2eCONRslgY .actorPopupMenu{position:absolute;}#mermaid-svg-ySySYu2eCONRslgY .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-ySySYu2eCONRslgY .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ySySYu2eCONRslgY .actor-man circle,#mermaid-svg-ySySYu2eCONRslgY line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-ySySYu2eCONRslgY :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 判断是否为放行路径 (如 /user/login) 从请求头/参数提取 Token 从 Redis 中获取缓存的 LoginUser (包含权限 and 角色) 执行权限检查 (@PreAuthorize) alt 权限不足 权限通过 alt Token 无效或过期 Token 有效 alt 是放行路径 不是放行路径 客户端 发起请求 (携带 Token) 1 直接放行 2 返回 401 (RetObj 格式 JSON) 3 设置 SecurityContextHolder (写入 CustomUserDetails) 4 放行到 Controller 5 触发 AccessDeniedHandler, 返回 403 (RetObj 格式 JSON) 6 返回业务数据 (RetObj) 7 客户端


第一步:引入依赖

在项目的 pom.xml 中引入 Spring Security 依赖:

xml 复制代码
<!-- 引入 spring security -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

第二步:定义自定义用户安全对象(CustomUserDetails)

我们需要实现 Spring Security 的 UserDetails 接口,以便将用户 ID、手机号和权限角色列表存入框架上下文。

文件路径:cn/xf/basedemo/common/model/CustomUserDetails.java

java 复制代码
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;

@Data
public class CustomUserDetails implements UserDetails {
    private Integer userId;     // 用户 ID
    private String phone;       // 手机号
    private Collection<? extends GrantedAuthority> authorities; // 权限与角色集合

    public CustomUserDetails(Integer userId, String phone, Collection<? extends GrantedAuthority> authorities) {
        this.userId = userId;
        this.phone = phone;
        this.authorities = authorities;
    }

    @Override
    public String getPassword() {
        return null; // 前后端分离无状态,无需保存密码
    }

    @Override
    public String getUsername() {
        // 返回唯一标识:优先使用手机号,无则使用用户ID
        return phone != null ? phone : (userId != null ? String.valueOf(userId) : null);
    }
}

第三步:登录成功时缓存权限数据至 Redis

在用户登录时,一次性把用户的角色 and 权限查询出来,并写入 LoginUser 实体中缓存进 Redis,这样在后续过滤器中能够快速获取,避免重复查询数据库。

文件路径:cn/xf/basedemo/service/impl/UserServiceImpl.java

java 复制代码
// 1. 用户登录验证成功后,构建 LoginUser
LoginUser loginUser = new LoginUser();
loginUser.setId(user.getId());
loginUser.setAccount(user.getAccount());
loginUser.setPhone(user.getPhone());

// 2. 核心性能优化:获取角色与权限数据并直接缓存到 LoginUser 中
loginUser.setPermissions(sysPermissionMapper.getPermissionListByRoleId(user.getId()));
loginUser.setRoles(sysRoleMapper.getRoleListByUserId(user.getId()));

// 3. 生成 Token,并将完整的 LoginUser 缓存到 Redis
String token = JwtTokenUtils.createToken(user.getId());
loginUser.setToken(token);
redisTemplate.opsForValue().set("token:" + token, JSONObject.toJSONString(loginUser), 3600, TimeUnit.SECONDS);

第四步:自定义过滤器(TokenAuthenticationFilter)

拦截进入系统的请求,校验 Token 状态,并从 Redis 读取出用户的缓存数据装载到 Spring Security 安全框架中。

文件路径:cn/xf/basedemo/interceptor/TokenAuthenticationFilter.java

java 复制代码
@Component
public class TokenAuthenticationFilter extends OncePerRequestFilter {

    // 无需拦截放行的接口列表
    private static final List<String> EXCLUDE_PATH_LIST = Arrays.asList("/user/login", "/web/login", "/swagger-ui.html", "/v3/api-docs", "/swagger-ui/index.html");

    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) 
            throws ServletException, IOException {
        try {
            String requestURI = request.getRequestURI();
            // 1. 过滤放行路径
            if (EXCLUDE_PATH_LIST.contains(requestURI) || requestURI.contains("/swagger-ui") || requestURI.contains("/v3/api-docs")) {
                filterChain.doFilter(request, response);
                return;
            }

            // 2. 提取并校验 Token
            String token = RequestHeaderUtil.getToken(request);
            String value = (String) redisTemplate.opsForValue().get("token:" + token);
            if (StringUtils.isEmpty(value)) {
                throw new LoginException();
            }

            LoginUser loginUserInfo = JSONObject.parseObject(value, LoginUser.class);
            if (loginUserInfo == null || loginUserInfo.getId() <= 0) {
                throw new LoginException(SystemStatus.USER_INPUT_ERROR);
            }

            // 3. 延长 Token 有效期 (Key: token:xxx)
            redisTemplate.expire("token:" + token, 86700, TimeUnit.SECONDS);

            // 4. 将用户信息与权限设置到 Spring Security 上下文
            this.setSpringSecurityContext(loginUserInfo);
            filterChain.doFilter(request, response);
        } catch (LoginException e) {
            // 5. 异常情况输出统一的 RetObj JSON 结构
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write(JSONObject.toJSONString(new RetObj<>(e.getStatus().getCode(), e.getMessage())));
        } finally {
            // 6. 强行清理上下文,彻底杜绝高并发线程池环境下的 ThreadLocal 内存泄漏隐患
            SecurityContextHolder.clearContext();
        }
    }

    private void setSpringSecurityContext(LoginUser loginUserInfo) {
        // 直接从缓存中获取角色与权限列表,免去查库操作,极大提升响应性能
        List<String> permissionList = loginUserInfo.getPermissions();
        List<String> roleList = loginUserInfo.getRoles();
        
        List<String> authoritiesList = new java.util.ArrayList<>();
        if (!CollectionUtils.isEmpty(permissionList)) {
            authoritiesList.addAll(permissionList);
        }
        if (!CollectionUtils.isEmpty(roleList)) {
            // Spring Security 角色匹配默认带有 ROLE_ 前缀
            List<String> roleAuthorities = roleList.stream().map(role -> "ROLE_" + role).collect(Collectors.toList());
            authoritiesList.addAll(roleAuthorities);
        }

        List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList(authoritiesList);
        UserDetails userDetails = new CustomUserDetails(loginUserInfo.getId(), loginUserInfo.getPhone(), authorities);
        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
}

第五步:安全核心配置类(SpringSecurityConfig)

配置跨域、禁用CSRF防范、声明放行路由、添加鉴权拦截器,并为 401 (未登录) 与 403 (无权限) 设置统一的 API JSON 返回格式。

文件路径:cn/xf/basedemo/interceptor/SpringSecurityConfig.java

java 复制代码
@Slf4j
@Configuration
@EnableMethodSecurity(prePostEnabled = true)  // 开启方法级权限控制 (@PreAuthorize)
public class SpringSecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http,
                                                   TokenAuthenticationFilter tokenAuthenticationFilter) throws Exception {
        http
                .cors(Customizer.withDefaults())  // 开启跨域
                .csrf(AbstractHttpConfigurer::disable) // 禁用 CSRF 防御
                .authorizeHttpRequests(auth -> auth
                        // 放行匿名访问路径
                        .requestMatchers("/user/login", "/web/login").permitAll()
                        .requestMatchers("/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
                        .requestMatchers("/doc.html", "/webjars/**", "/swagger-resources/**").permitAll()
                        // 其他所有请求必须通过鉴权
                        .anyRequest().authenticated()
                )
                // 生产优化:配置无状态会话策略,防止框架在后台创建 HttpSession 导致内存飙升
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                // 将我们自定义 of Token 验证过滤器加到 UsernamePasswordAuthenticationFilter 之前
                .addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                // 配置 401 与 403 异常处理器,输出与项目高度统一 of RetObj JSON 数据格式
                .exceptionHandling(exceptions -> exceptions
                        .authenticationEntryPoint((request, response, authException) -> {
                            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                            response.setContentType("application/json;charset=UTF-8");
                            RetObj<Void> retObj = new RetObj<>(SystemStatus.UNAUTHORIZED.getCode(), "未登录或Token失效");
                            response.getWriter().write(JSONObject.toJSONString(retObj));
                        })
                        .accessDeniedHandler((request, response, accessDeniedException) -> {
                            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                            response.setContentType("application/json;charset=UTF-8");
                            RetObj<Void> retObj = new RetObj<>(SystemStatus.FORBIDDEN.getCode(), "没有权限访问该资源");
                            response.getWriter().write(JSONObject.toJSONString(retObj));
                        })
                );
        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOriginPattern("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        config.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}

第六步:注解驱动鉴权运用(UserController)

最终,在 Controller 的映射方法上,使用简单的表达式即可安全限制访问权限。

文件路径:cn/xf/basedemo/controller/business/UserController.java

java 复制代码
// 只有具有 'user:add' 权限的用户才可以访问
@Operation(summary = "用户信息", description = "用户信息")
@PostMapping("/info")
@PreAuthorize("hasAuthority('user:add')") 
public RetObj info(){
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    CustomUserDetails user = (CustomUserDetails) auth.getPrincipal();
    return RetObj.success(user);
}

// 只有角色为 'admin' 的用户才可以访问
@Operation(summary = "获取用户权限数据", description = "用户信息")
@GetMapping("/getPermission")
@PreAuthorize("hasRole('admin')") 
public RetObj getPermission(){
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    CustomUserDetails user = (CustomUserDetails) auth.getPrincipal();
    return RetObj.success(user.getAuthorities());
}

总结

本文以脚手架项目实战代码为例,演示了如何集成标准 Spring Security 6 框架以实现标准的 RBAC 鉴权机制。通过引入自定义安全对象 CustomUserDetails、核心配置配置类 SpringSecurityConfig 以及过滤器拦截链路,项目无缝支持了基于角色的菜单与细粒度按钮权限校验。这不仅标准化了系统安全防御能力,还使接口校验逻辑与业务解耦,是构建高性能、可扩展单体脚手架不可或缺的核心安全模块。

相关推荐
宸津-代码粉碎机1 小时前
Spring AI企业级Agent实战|多工具自动规划+并行调度落地,彻底解决复杂业务AI任务编排问题
java·大数据·人工智能·spring boot·python·spring
lixia0417mul21 小时前
flink接入spring体系
java·spring·flink
biubiubiu07061 小时前
自定义starter 可以导入SpringBoot直接使用
java·spring boot·spring
用户2330713074792 小时前
对象的一生(上)
后端
爱勇宝2 小时前
如何评估 AI 大模型的商业价值?
前端·后端·程序员
小刘|2 小时前
SpringBoot整合LangChain4j实现流式AI对话
java·spring boot·langchain
AskHarries2 小时前
Landing Page 验证法
后端
贺国亚3 小时前
Buy领域智能体-Spring-AI全量工程
java·人工智能·spring
卷无止境4 小时前
C# 与 .NET 中的委托:把方法装进变量里
后端