Spring Boot + JWT + RBAC 权限系统实战,从登录鉴权到接口级权限控制完整落地

文章目录

前言

在企业级后台系统开发中,登录认证、接口鉴权、角色管理、权限控制几乎是绕不过去的核心基础能力。

很多项目初期只是简单做了一个登录接口,等业务逐渐复杂之后,就会暴露出一系列问题:

  • 前端菜单能隐藏,但后端接口并没有真正做权限控制
  • 角色和权限混用,导致后续扩展困难
  • 接口命名混乱,权限点设计失控
  • 前端和后端权限规则不一致
  • JWT 只是"登录态载体",没有真正和权限体系打通

这篇文章就从实际项目角度出发,基于 Spring Boot + Spring Security + JWT + RBAC,带你完整搭建一套可落地的权限系统。

本文主要覆盖以下内容:

  • 什么是 RBAC,为什么它适合后台管理系统
  • JWT 在权限系统中的角色
  • Spring Security 如何接入 JWT
  • 如何实现接口级权限控制
  • 前后端权限协同如何设计
  • 权限系统常见坑点与扩展方向

一、权限系统为什么容易越做越乱

权限系统做乱,往往不是因为不会写代码,而是从建模开始就有问题。

常见错误主要有以下几类。

1. 角色和权限混为一谈

比如代码里直接写死:

  • 管理员可以新增用户
  • 普通用户只能查看自己的信息
  • 审核员可以审批订单

一开始角色少的时候还能勉强维护,但随着角色越来越多,代码里会充满各种 if else,后续每新增一个角色,业务判断都会继续膨胀。

2. 只做前端权限,不做后端权限

很多项目把"权限控制"理解成:

  • 菜单隐藏
  • 按钮隐藏
  • 页面不可见

这只能改善交互体验,不能形成真正的安全边界。只要知道接口地址,攻击者或者越权用户依然可能直接发请求访问接口。

3. 权限粒度设计不清晰

有的系统权限点是中文,有的是 URL,有的是动作名,最后会变成这种情况:

  • user:add
  • user_save
  • /sys/user/create
  • 新增用户

这种混乱命名一旦出现,权限表、代码注解、前端判断就会越来越难维护。

4. 登录认证和权限授权耦合严重

很多项目登录后返回一堆角色类型,后端再根据角色做硬编码判断,前端也根据角色做页面渲染。角色变更一次,就要同时改多个地方。

5. JWT 用了,但没有打通权限模型

很多系统确实使用了 JWT,但只是为了无状态登录。登录成功之后,token 里只有用户 ID,接口校验也只是"是否登录",并没有真正校验当前用户是否具备某项权限。

所以权限系统的关键,不是用了什么框架,而是是否把"认证"和"授权"分开设计,并且是否真正落实到了后端接口层。


二、权限系统要解决的核心问题

权限系统的目标不是"用户能登录",而是要真正解决下面几个问题。

1. 确认当前用户是谁

也就是认证,Authentication。

用户通过用户名密码、短信验证码、第三方登录等方式登录之后,系统需要确认身份。

2. 确认当前用户能做什么

也就是授权,Authorization。

登录成功只代表身份合法,不代表可以访问所有页面、所有菜单和所有接口。

3. 接口必须有真正的安全边界

菜单隐藏不是权限控制的终点,真正的边界一定是后端接口。

4. 支持后续角色和权限扩展

一个小系统可能一开始只有管理员和普通用户,但后面可能会扩展出:

  • 部门管理员
  • 运营人员
  • 审核专员
  • 咨询师
  • 租户管理员

如果模型一开始设计得过于简单,后期扩展成本会非常高。

5. 前后端规则要一致

后端负责最终权限校验,前端负责菜单、按钮、页面的动态控制,两者必须基于同一套权限点设计。


三、为什么大多数后台系统都适合用 RBAC

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

它最核心的关系链是:

用户 -> 角色 -> 权限

这种模型的好处很明显:

  • 用户不直接绑定大量权限
  • 角色作为中间层,方便统一管理
  • 权限变更时只需要调整角色与权限关系,不必逐个修改用户

举个简单例子。

系统里有三个角色:

  • 系统管理员
  • 咨询师
  • 普通用户

系统管理员拥有全部权限;

咨询师可以查看用户信息、管理咨询记录;

普通用户只能查看自己的资料和订单。

如果没有角色层,就需要给每个用户单独分配权限,维护成本会迅速失控。

RBAC 的价值就在于把"用户"和"能力"解耦了。


四、权限点应该怎么设计

权限点设计建议采用统一命名规则:

text 复制代码
模块:资源:动作

例如:

text 复制代码
sys:user:view
sys:user:create
sys:user:update
sys:user:delete
sys:role:view
sys:role:assign
order:refund:approve

这样设计有几个明显优势。

1. 语义清晰

看到权限码就能知道它控制的是什么能力。

2. 前后端都能共用

前端按钮、菜单、路由守卫和后端 @PreAuthorize 可以使用同一套权限码。

3. 适合数据库持久化

权限表里直接维护 permission_code,便于检索、比对和扩展。

4. 便于接口级权限控制

Spring Security 中可以直接写:

java 复制代码
@PreAuthorize("hasAuthority('sys:user:view')")

这里要强调一个原则:

不要把 URL 当权限码,也不要把中文文案当权限码。

权限码应该是稳定、统一、可维护的系统内部标识。


五、JWT 在权限系统中的作用

JWT 本质上是一种令牌机制,用来在前后端之间传递登录态。

一个典型的 JWT 包括三部分:

  • Header
  • Payload
  • Signature

在权限系统中,JWT 主要承担的是"认证凭证"的角色,而不是权限系统本身。

JWT 的典型使用流程如下:

  1. 用户提交用户名和密码登录
  2. 后端验证通过后生成 JWT
  3. 前端保存 token
  4. 后续请求在请求头中携带:
http 复制代码
Authorization: Bearer xxx
  1. 后端通过过滤器解析 token,确认当前用户身份
  2. 结合权限信息完成授权判断

JWT 的优势:

  • 无状态,适合前后端分离
  • 适合 Web、App、小程序统一接入
  • 易于和网关、微服务体系整合

但 JWT 也有明显问题:

  • token 一旦签发,不容易立即失效
  • 如果把太多权限直接塞到 token 中,会带来权限变更延迟问题
  • 需要配合黑名单、短期 token、刷新机制做增强

因此,JWT 只是权限体系中的一环,而不是全部。


六、Spring Boot + JWT + Spring Security 的整体实现流程

一个完整的权限系统实现流程一般如下:

第一步:用户登录

前端调用登录接口,提交用户名和密码。

第二步:服务端校验身份

后端校验用户名密码是否正确。

第三步:查询用户角色和权限

根据当前用户查出其角色集合和权限集合。

第四步:签发 JWT

后端把用户身份信息和必要权限信息写入 token,返回给前端。

第五步:前端保存 token

后续请求统一通过请求头携带 token。

第六步:后端过滤器解析 token

JWT 过滤器提取用户名、权限,并构造 Spring Security 的认证上下文。

第七步:接口做权限校验

Controller 层通过 @PreAuthorize 校验权限,决定能否执行接口。

这套流程里,最关键的是两部分:

  • JWT 过滤器
  • 接口级权限控制

七、权限系统的数据模型怎么设计

最基础的 RBAC 表设计通常包括下面几张表:

1. 用户表 sys_user

保存用户名、密码、昵称、状态、创建时间等基础信息。

2. 角色表 sys_role

保存角色编码、角色名称、状态等信息。

3. 权限表 sys_permission

保存权限编码、权限名称、类型等。

4. 用户角色关联表 sys_user_role

描述用户和角色之间的多对多关系。

5. 角色权限关联表 sys_role_permission

描述角色和权限之间的多对多关系。

如果还需要控制菜单,可以继续增加菜单表:

6. 菜单表 sys_menu

菜单项可关联 permission_code,前端根据权限动态渲染。

注意一点:

菜单不是权限,菜单只是权限的展示入口。

真正的权限控制最终还是要落到接口上。


八、登录接口设计

登录接口的请求参数一般比较简单:

java 复制代码
public record LoginRequest(String username, String password) {
}

返回值建议包含三类信息:

  • token
  • 当前用户基础信息
  • 当前用户权限集合

例如:

java 复制代码
public record LoginResponse(
        String token,
        UserInfo user,
        Set<String> permissions
) {
    public record UserInfo(Long id, String username, String nickname, Set<String> roles) {
    }
}

登录服务逻辑示例:

java 复制代码
@Service
public class AuthService {

    private final UserService userService;
    private final PasswordEncoder passwordEncoder;
    private final JwtTokenProvider jwtTokenProvider;

    public LoginResponse login(LoginRequest request) {
        User user = userService.findByUsername(request.username())
                .orElseThrow(() -> new IllegalArgumentException("用户名或密码错误"));

        if (!passwordEncoder.matches(request.password(), user.getPassword())) {
            throw new IllegalArgumentException("用户名或密码错误");
        }

        Set<String> permissions = userService.getPermissions(user.getId());
        String token = jwtTokenProvider.generateToken(user.getUsername(), permissions);

        return new LoginResponse(
                token,
                new LoginResponse.UserInfo(user.getId(), user.getUsername(), user.getNickname(), user.getRoles()),
                permissions
        );
    }
}

登录接口设计要点

  • 密码必须加密存储,通常使用 BCrypt
  • 登录失败提示不要过于具体,避免账号枚举风险
  • 登录成功后尽量一次性返回前端所需核心信息,减少额外请求

九、JWT 工具类实现

JWT 工具类主要负责三件事:

  • 生成 token
  • 解析 token
  • 校验 token

示例代码如下:

java 复制代码
@Component
public class JwtTokenProvider {

    private final SecretKey secretKey;
    private final long expiration;

    public JwtTokenProvider(@Value("${security.jwt.secret}") String secret,
                            @Value("${security.jwt.expiration}") long expiration) {
        this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.expiration = expiration;
    }

    public String generateToken(String username, Set<String> permissions) {
        Date now = new Date();
        Date expireTime = new Date(now.getTime() + expiration);

        return Jwts.builder()
                .subject(username)
                .claim("permissions", permissions)
                .issuedAt(now)
                .expiration(expireTime)
                .signWith(secretKey)
                .compact();
    }

    public Claims parseToken(String token) {
        return Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    public String getUsername(String token) {
        return parseToken(token).getSubject();
    }

    public List<String> getPermissions(String token) {
        return parseToken(token).get("permissions", List.class);
    }
}

实际项目中的注意点

  • JWT 密钥不能太短
  • 密钥不能直接硬编码在代码中
  • token 有效期不建议过长
  • 高安全场景应增加刷新 token 或黑名单机制

十、JWT 过滤器接入 Spring Security

为了让 Spring Security 知道当前请求对应的是哪个用户,需要在请求进入 Controller 之前先经过 JWT 过滤器。

示例代码如下:

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

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        String header = request.getHeader(HttpHeaders.AUTHORIZATION);
        if (header != null && header.startsWith("Bearer ")) {
            String token = header.substring(7);

            try {
                String username = jwtTokenProvider.getUsername(token);
                List<String> permissions = jwtTokenProvider.getPermissions(token);

                List<GrantedAuthority> authorities = permissions.stream()
                        .map(SimpleGrantedAuthority::new)
                        .toList();

                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(username, null, authorities);

                SecurityContextHolder.getContext().setAuthentication(authentication);
            } catch (Exception ignored) {
            }
        }

        filterChain.doFilter(request, response);
    }
}

这个过滤器的本质就是:

  • 把请求头中的 token 解析出来
  • 把 token 中的用户信息和权限信息转成 Spring Security 可识别的认证对象
  • 放进 SecurityContext

后续接口层的权限校验就能基于这个上下文进行。


十一、Spring Security 配置

核心配置通常包括以下几件事:

  • 关闭 CSRF
  • 配置无状态 Session
  • 放行登录接口和 Swagger
  • 所有其他接口默认要求认证
  • 注册 JWT 过滤器
  • 开启方法级权限控制

示例代码如下:

java 复制代码
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session ->
                        session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/auth/login", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
                        .anyRequest().authenticated()
                )
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

这里的核心原则是:

登录接口放行,其他接口默认先要求登录,再在具体接口方法上细分权限。


十二、接口级权限控制为什么推荐 @PreAuthorize

相比把所有权限规则都堆在过滤器里,把权限写在接口方法上更清晰、更可维护。

示例:

java 复制代码
@PreAuthorize("hasAuthority('sys:user:view')")
@GetMapping("/api/users")
public ApiResponse<List<UserResponse>> list() {
    return ApiResponse.ok(userService.findAll());
}

再比如新增用户:

java 复制代码
@PreAuthorize("hasAuthority('sys:user:create')")
@PostMapping("/api/users")
public ApiResponse<UserResponse> create(@RequestBody @Valid UserRequest request) {
    return ApiResponse.ok(userService.create(request));
}

这种方式的优点很明确:

  • 权限要求和业务接口天然绑定
  • 容易读懂
  • 容易审查
  • 粒度足够细
  • 支持复杂表达式

例如:

java 复制代码
@PreAuthorize("hasAuthority('sys:role:view') or hasAuthority('sys:menu:view')")

十三、RESTful 用户管理接口设计

用户管理接口建议遵循 RESTful 风格设计:

  • GET /api/users 查询用户列表
  • GET /api/users/{id} 查询用户详情
  • POST /api/users 新增用户
  • PUT /api/users/{id} 更新用户
  • DELETE /api/users/{id} 删除用户
  • GET /api/users/me 查询当前登录用户信息

示例代码如下:

java 复制代码
@RestController
@RequestMapping("/api/users")
public class UserController {

    @PreAuthorize("hasAuthority('sys:user:view')")
    @GetMapping
    public ApiResponse<List<UserResponse>> list() {
        return ApiResponse.ok(userService.findAll());
    }

    @PreAuthorize("hasAuthority('sys:user:view')")
    @GetMapping("/{id}")
    public ApiResponse<UserResponse> detail(@PathVariable Long id) {
        return ApiResponse.ok(userService.findById(id));
    }

    @PreAuthorize("hasAuthority('sys:user:create')")
    @PostMapping
    public ApiResponse<UserResponse> create(@RequestBody @Valid UserRequest request) {
        return ApiResponse.ok(userService.create(request));
    }

    @PreAuthorize("hasAuthority('sys:user:update')")
    @PutMapping("/{id}")
    public ApiResponse<UserResponse> update(@PathVariable Long id,
                                            @RequestBody @Valid UserRequest request) {
        return ApiResponse.ok(userService.update(id, request));
    }

    @PreAuthorize("hasAuthority('sys:user:delete')")
    @DeleteMapping("/{id}")
    public ApiResponse<Void> delete(@PathVariable Long id) {
        userService.delete(id);
        return ApiResponse.ok(null);
    }
}

不要把接口写成:

  • /getUserList
  • /saveUser
  • /deleteUserById

这种动作式路径不利于规范统一,也不利于接口文档维护。


十四、统一返回结构的必要性

很多项目会对返回值做统一包装,例如:

java 复制代码
public record ApiResponse<T>(boolean success, String message, T data) {
    public static <T> ApiResponse<T> ok(T data) {
        return new ApiResponse<>(true, "success", data);
    }

    public static <T> ApiResponse<T> fail(String message) {
        return new ApiResponse<>(false, message, null);
    }
}

它的优点是:

  • 前端统一处理
  • 错误提示风格一致
  • 易于后续扩展错误码、traceId、分页信息

但也要注意:

统一返回结构不等于可以忽略 HTTP 状态码。

例如:

  • 参数错误返回 400
  • 未登录返回 401
  • 无权限返回 403
  • 服务异常返回 500

HTTP 状态码和业务响应体应该一起使用,而不是互相替代。


十五、Swagger 为什么应该尽早接入

权限系统类项目接口很多,调试频率也很高,所以 Swagger 文档很有必要。

优势主要有三个:

  • 便于前后端联调
  • 便于测试人员验证接口
  • 便于后期维护和交接

Spring Boot 中可以使用 springdoc-openapi,配置完成后访问:

text 复制代码
/swagger-ui.html

即可查看接口文档。

如果接口需要 Bearer Token,还可以在 OpenAPI 中配置安全方案,让 Swagger 页面直接支持携带 token 测试受保护接口。


十六、前后端权限协同怎么做

前端和后端的职责一定要分清楚:

后端职责

  • 登录认证
  • 接口鉴权
  • 权限边界兜底

前端职责

  • 菜单动态渲染
  • 按钮动态显示
  • 路由守卫
  • 提升用户体验

一个典型做法是:

登录成功后,后端返回:

  • token
  • 用户信息
  • 权限集合

然后前端再调用一个权限元数据接口,例如:

text 复制代码
GET /api/permissions/meta

返回:

  • 当前角色
  • 权限集合
  • 菜单配置

前端根据权限集合判断:

  • 哪些菜单显示
  • 哪些按钮显示
  • 哪些路由允许访问

但需要再次强调:

前端的权限判断只能改善体验,真正的安全校验必须由后端完成。


十七、权限系统常见坑点

这一部分是最容易踩坑的地方。

1. 只做菜单权限,不做接口权限

这不是权限系统,只是 UI 隐藏。

2. 把角色判断写死在业务代码里

例如:

java 复制代码
if ("admin".equals(role)) {
    ...
}

这种方式在角色扩展后会迅速失控。

3. 权限码不统一

同一个系统里混用多种命名风格,后续维护会非常痛苦。

4. 把全部权限都塞进 JWT

权限一旦变更,旧 token 中的权限快照可能仍然有效。

5. 没有 token 失效策略

比如用户被禁用、修改密码、角色调整后,旧 token 依然可用。

6. 没有区分功能权限和数据权限

能访问用户列表,不代表能访问所有用户数据。


十八、数据权限怎么扩展

当系统除了控制"功能能不能访问",还要控制"数据能不能看到"时,就进入了数据权限层。

典型数据范围包括:

  • 仅本人数据
  • 本部门数据
  • 本部门及子部门数据
  • 全部数据
  • 自定义数据范围

实现方式一般有两类:

1. 在 Service 层加过滤条件

例如只能查当前用户自己创建的数据:

sql 复制代码
creator_id = currentUserId

2. 在持久层统一注入数据范围条件

例如 MyBatis 拦截器、SQL 拼接等。

这里要明确一点:

接口权限和数据权限不是一回事。

你可能有权限访问某个接口,但返回数据范围仍然需要继续过滤。


十九、JWT 失效与刷新策略

JWT 常见的两个问题是:

  • 权限变更后旧 token 如何处理
  • token 过期后如何续签

一个较成熟的方案是双 token:

  • access token:短期有效
  • refresh token:长期有效

access token 用于访问接口,refresh token 用于换发新 token。

如果系统规模不大,也可以先做简化版:

  • access token 有效期 2 小时
  • 重新登录获取新 token
  • 配合账号状态校验
  • 必要时加入 token 黑名单或 token 版本号机制

核心不是一步做到最复杂,而是设计上要预留升级空间。


二十、权限系统为什么需要审计日志

权限系统如果没有审计能力,后期排查问题会非常被动。

建议至少记录以下行为:

  • 登录成功和失败
  • 登出
  • 用户创建、修改、删除
  • 角色分配
  • 权限变更
  • 高危接口调用
  • 审批、退款、导出等关键操作

审计日志建议包含:

  • 操作人
  • 操作时间
  • 请求 IP
  • 请求参数
  • 操作资源
  • 操作结果
  • 变更前后内容
  • traceId

一旦出现越权、误删、误授权,这些信息就是最直接的排查依据。


二十一、一个建议的落地路径

如果你要从零搭建权限系统,建议按阶段推进。

第一阶段:最小可用版

  • 用户登录
  • JWT 鉴权
  • 接口级 @PreAuthorize
  • 基础角色和权限表
  • 前端菜单和按钮控制

第二阶段:管理能力补齐

  • 用户管理
  • 角色管理
  • 权限管理
  • 菜单管理
  • Swagger 文档

第三阶段:安全增强

  • token 刷新
  • 登录失败限制
  • 用户禁用
  • token 黑名单
  • 密码策略

第四阶段:复杂场景扩展

  • 数据权限
  • 多租户
  • 单点登录
  • 审计日志
  • 网关统一鉴权

这样做的好处是每一阶段都可以形成明确交付,而不是一开始就做成一个难以落地的大系统。


总结

权限系统是后台项目中非常基础、但也非常容易被做偏的一部分。

真正可落地的权限系统,至少要满足下面几个原则:

  • 认证和授权分开设计
  • 前端权限只负责展示控制
  • 后端接口权限才是真正边界
  • 角色只是组织方式,权限才是能力边界
  • 权限点命名必须统一
  • 后续必须考虑数据权限、审计和 token 失效机制

从工程实践角度看,Spring Boot + Spring Security + JWT + RBAC 是一套足够成熟且稳定的方案。只要权限模型清晰、接口层保护到位、前后端协同一致,这套方案完全可以支撑大多数企业后台系统。


参考代码片段汇总

登录接口权限流程

java 复制代码
@PostMapping("/api/auth/login")
public ApiResponse<LoginResponse> login(@RequestBody LoginRequest request) {
    return ApiResponse.ok(authService.login(request));
}

接口级权限控制

java 复制代码
@PreAuthorize("hasAuthority('sys:user:view')")
@GetMapping("/api/users")
public ApiResponse<List<UserResponse>> list() {
    return ApiResponse.ok(userService.findAll());
}

JWT 过滤器

java 复制代码
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (header != null && header.startsWith("Bearer ")) {
    String token = header.substring(7);
}

互动话题

你在项目里是怎么做权限控制的?

  • 只做了登录,暂时没做权限
  • 做了菜单权限,但后端接口没完全拦
  • 已经做了完整的 RBAC
  • 正在准备做数据权限

欢迎在评论区交流。

相关推荐
yusheng_xyb2 小时前
互联网大厂Java求职面试实录
java·面试·互联网·技术面试
oem1102 小时前
Django全栈开发入门:构建一个博客系统
jvm·数据库·python
treacle田2 小时前
达梦数据库-后期更改数据库(单机)实例目录及相关目录步骤-记录总结
数据库·达梦数据库·更改目录
smxgn2 小时前
FrankenPHP实践
java
9稳2 小时前
基于智能巡检机器人与PLC系统联动控制设计
开发语言·网络·数据库·嵌入式硬件·plc
小江的记录本2 小时前
【Filter / Interceptor】过滤器(Filter)与拦截器(Interceptor)全方位对比解析(附底层原理 + 核心对比表)
java·前端·后端·spring·java-ee·前端框架·web
我不听你讲话2 小时前
第 2 章 MySQL 数据库操作
数据库·mysql·adb
小年糕是糕手2 小时前
【35天从0开始备战蓝桥杯 -- Day5】
数据结构·数据库·c++·算法·蓝桥杯
weisian1512 小时前
Java并发编程--16-ConcurrentHashMap演进:从分段锁到CAS+synchronized
java·hashmap·分段锁·cas+同步·longaddr思想