二哥项目资料

✅如何基于Spring Security实现派聪明 RAG 知识库的RBAC 权限系统?

进阶篇 18,231 字 #34 原文
本文目录 · 12 节

RBAC,全称为 Role-Based Access Control,翻译过来叫基于角色的访问控制,通过将用户分配到不同的角色,并为角色分配权限,从而实现对资源的访问控制。

那如何实现 RBAC 呢?派聪明用的是 Spring Security。Spring Security 本质上是一个基于 Filter 的安全框架。当请求进入应用时,会经过一系列安全过滤器链,每个过滤器负责不同的安全功能,比如认证、授权、CSRF 防护等。

Spring Security 主要解决了两大核心问题:认证(Authentication)和授权(Authorization)。认证就是验证“你是谁”,比如用户登录时验证用户名密码是否正确;授权就是验证“你能做什么”,比如检查用户是否有权限访问某个页面或调用某个接口。

本篇教程将详细介绍如何基于 Spring Security 实现 RBAC,包括用户注册、登录、JWT 认证、请求拦截与授权等核心流程。

一、RBAC 的核心概念

RBAC 的核心思想是给用户赋予某些角色,而每个角色拥有某些权限。等于说,一个权限可以分配给多个角色,一个角色可以拥有多个权限,同样一个用户可以分配多个角色,一个角色也可以对应多个用户,对应模型如下所示:

对于派聪明来说,每个公司都有自己的组织架构,比如公司里管理知识库的人员有王老板和毕小三等,王老板不仅拥有毕小三的权限,也拥有李四眼的权限,像这种管理关系向下兼容的模式就需要用到角色继承的 RBAC 模型。角色继承的 RBAC 模型的思路是上层角色继承下层角色的所有权限,并且可以额外拥有其他权限。

在派聪明系统中,Spring Security 的 RBAC 实现分为几个层次:

①、基础角色的定义:

  • USER(普通用户) :可以上传文件、进行对话、查看自己的历史记录
  • ADMIN(管理员) :除了普通用户的所有权限外,还能管理所有用户、查看系统状态、管理知识库

②、URL 级别的权限控制:

// 任何人都能访问的接口(如登录、注册)
.requestMatchers("/api/v1/users/register", "/api/v1/users/login").permitAll()

// 普通用户和管理员都能访问的接口
.requestMatchers("/api/v1/upload/**").hasAnyRole("USER", "ADMIN")

// 只有管理员能访问的接口
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")

③、组织标签权限控制:

  • 私人空间 :以"PRIVATE_"开头的资源,只有创建者能访问
  • 组织资源 :根据用户的组织标签来控制访问权限
  • 公开资源 :所有认证用户都能访问

二、派聪明中权限控制的代码结构

派聪明的 Spring Security RBAC 实现采用了非常清晰的分层架构设计,每个层次都有明确的职责分工。

src/
├── main/
│   ├── java/com/yizhaoqi/smartpai/
│   │   ├── config/                # 配置类
│   │   │   ├── SecurityConfig.java
│   │   │   ├── JwtAuthenticationFilter.java
│   │   ├── controller/           # 控制器
│   │   │   ├── UserController.java
│   │   ├── service/              # 服务层
│   │   │   ├── UserService.java
│   │   │   ├── CustomUserDetailsService.java
│   │   ├── repository/           # 数据访问层
│   │   │   ├── UserRepository.java
│   │   ├── model/                # 数据模型
│   │   │   ├── User.java
│   │   ├── utils/                # 工具类
│   │   │   ├── PasswordUtil.java
│   │   │   ├── JwtUtils.java
│   ├── resources/
│       ├── application.yml        # 配置文件
└── test/
    ├── java/com/yizhaoqi/smartpai/
        ├── service/               # 测试类
            ├── UserServiceTest.java

配置层(config)是整个安全框架的"大脑",负责制定游戏规则。SecurityConfig.java 相当于安全系统的总指挥,定义了哪些 URL 需要什么权限,配置过滤器链的执行顺序。JwtAuthenticationFilter.java 则像门卫一样,检查每个请求的shenfenzheng(JWT 令牌)是否有效。

控制器层(controller)是用户请求的第一站,负责接收和响应。UserController.java 处理用户相关的所有操作,如登录、注册、获取用户信息等,是用户与系统交互的入口。

服务层(service)是业务逻辑的核心,负责具体的业务处理。UserService.java 处理用户相关的业务逻辑,比如用户注册时的数据验证、密码加密等。CustomUserDetailsService.java 专门为 Spring Security 服务,负责从数据库加载用户信息并转换成 Spring Security 能理解的格式。

数据访问层(repository)是与数据库打交道的地方。UserRepository.java 定义了如何从数据库中查询用户信息,使用 Spring Data JPA 简化数据库操作。

数据模型层(model)是数据的"蓝图"。User.java 定义了用户实体的结构,包括用户名、密码、角色、组织标签等字段。

工具类层(utils)是系统的"工具箱"。PasswordUtil.java 负责密码的加密和验证,确保密码安全。JwtUtils.java 负责 JWT 令牌的生成、解析和验证,是无状态认证的核心。

配置文件(resources)中的 application.yml 是系统的配置中心,包含数据库连接、JWT 密钥、Redis 配置等重要参数。

测试层(test)中的 UserServiceTest.java 确保业务逻辑的正确性,通过单元测试验证各个功能模块。

三、派聪明中权限控制的核心代码实现

01、用户模型类

User 实体类是派聪明系统实现 RBAC 权限控制的数据基础,通过 JPA 注解实现完整的用户信息管理。 @Entity 和 @Table 注解将其映射到数据库的"users"表,并通过 @UniqueConstraint(columnNames = "username")在数据库层面确保用户名的唯一性,防止重复注册。

@Data
@Entity
@Table(name = "users", uniqueConstraints = @UniqueConstraint(columnNames = "username"))
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String username;

    @Column(nullable = false)
    private String password;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;

    @CreationTimestamp
    private LocalDateTime createdAt;

    @UpdateTimestamp
    private LocalDateTime updatedAt;

    public enum Role {
        USER, ADMIN
    }
}

role 字段是 RBAC 的核心,使用 @Enumerated(EnumType.STRING) 将枚举值以字符串形式存储在数据库中。内部定义的 Role 枚举包含 USER 和 ADMIN 两个角色。Spring Security 可以轻松地将角色转换为 GrantedAuthority ,实现方法级和 URL 级的权限控制。

02、用户数据访问接口

UserRepository 提供了一个方法 findByUsername,用于根据用户名查找用户。

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
}

03、密码工具类

PasswordUtil工具类在用户注册时调用 encode() 方法对密码进行加密,在用户登录时通过 matches() 方法对密码进行验证。

public class PasswordUtil {
    private static final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();

    public static String encode(String rawPassword) {
        return encoder.encode(rawPassword);
    }

    public static boolean matches(String rawPassword, String encodedPassword) {
        return encoder.matches(rawPassword, encodedPassword);
    }
}

BCrypt 算法的优势在于它是一种自适应哈希函数,内置了盐值(salt)机制,每次加密同一个密码都会产生不同的哈希值,可以防止彩虹表攻击。同时 BCrypt 算法具有可调节的计算复杂度,能够抵御暴力pojie攻击。

04、JWT 工具类

JwtUtils负责生成、验证和解析 JWT 令牌。类中使用 @Value("${jwt.secret-key}") 从配置文件读取 Base64 编码的密钥,通过 getSigningKey() 方法jiema并生成 SecretKey 对象。令牌有效期设置为24小时。并使用 HS256 签名算法确保令牌的完整性。

@Component
public class JwtUtils {
    private static final String SECRET_KEY = "test_secret_key";
    private static final long EXPIRATION_TIME = 86400000; // 24 hours

    public String generateToken(String username) {
        return Jwts.builder()
                .setSubject(username)
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
                .compact();
    }

    /**
     * 验证 JWT Token 是否有效
     *
     * @param token JWT Token
     * @return 如果 Token 有效则返回 true,否则返回 false
     */
    public boolean validateToken(String token) {
        try {
            Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * 从 JWT Token 中提取用户名
     *
     * @param token JWT Token
     * @return 用户名
     */
    public String extractUsernameFromToken(String token) {
        Claims claims = Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody();
        return claims.getSubject();
    }
}

generateToken(String username) 方法是权限信息封装的核心,它不仅将用户名作为 subject,还将权限信息封装到 claims 中,包括用户角色(role)、用户ID(userId)、组织标签(orgTags)和主组织(primaryOrg)等。这种设计使得 JWT 令牌成为一个自包含的权限载体,避免频繁的数据库查询。

JwtUtils还提供了多个 extract 方法,分别用于提取用户名、用户ID、角色、组织标签等信息。这些方法可以在 JwtAuthenticationFilter 和 OrgTagAuthorizationFilter 中将令牌转换为权限上下文。特别是 extractOrgTagsFromToken() 和 extractPrimaryOrgFromToken() 方法,为组织级别的权限控制提供了数据基础。

05、自定义用户详情服务

CustomUserDetailsService实现了 Spring Security 的 UserDetailsService 接口,负责在认证过程中加载用户详细信息。

/**
 * 实现 Spring Security 的 UserDetailsService 接口,用于加载用户的详细信息(包括用户名、密码和权限)。
 * 通过用户名从数据库中查找用户,并将其转换为 Spring Security 所需的 UserDetails 格式
 */
@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository; // 用于访问用户数据

    /**
     * 根据用户名加载用户详细信息。
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 从数据库中查找用户
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found"));

        // 返回 Spring Security 所需的 UserDetails 对象
        return new org.springframework.security.core.userdetails.User(
                user.getUsername(),
                user.getPassword(),
                getAuthorities(user.getRole()) // 获取用户的角色权限
        );
    }

    /**
     * 将用户的角色转换为 Spring Security 的权限格式。
     */
    private Collection<? extends GrantedAuthority> getAuthorities(User.Role role) {
        return Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + role.name()));
    }

当用户尝试登录时,Spring Security 会调用 loadUserByUsername 方法根据用户名查找用户信息。并将 User 实体转换为 Spring Security 标准的 UserDetails 对象,包含三个关键信息:用户名( user.getUsername() )、加密密码( user.getPassword() )和权限集合( getAuthorities(user.getRole()) )。

getAuthorities(User.Role role) 方法则将用户的角色枚举转换为 Spring Security 的 GrantedAuthority 格式。通过在角色名前添加"ROLE_"前缀(如"ROLE_ADMIN"、"ROLE_USER"),使得系统能够使用 Spring Security 的方法级和 URL 级权限注解,如 @PreAuthorize("hasRole('ADMIN')") 和 @Secured("ROLE_USER") 。

06、JWT 认证过滤器

JwtAuthenticationFilter继承了 OncePerRequestFilter,确保每个请求只被处理一次,负责在每个 HTTP 请求中验证 JWT 令牌并设置 Spring Security 认证上下文。

/**
 * 自定义的过滤器,用于解析请求头中的 JWT Token,并验证用户身份。
 * 如果 Token 有效,则将用户信息和权限设置到 Spring Security 的上下文中,后续的请求可以基于用户角色进行授权。
 */
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtUtils jwtUtils; // 用于生成和解析 JWT Token

    @Autowired
    private UserDetailsService userDetailsService; // 加载用户详细信息

    /**
     * 每次请求都会调用此方法,用于解析 JWT Token 并设置用户认证信息。
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        try {
            // 从请求头中提取 JWT Token
            String token = extractToken(request);
            if (token != null && jwtUtils.validateToken(token)) { // 验证 Token 是否有效
                String username = jwtUtils.extractUsernameFromToken(token); // 从 Token 中提取用户名
                UserDetails userDetails = userDetailsService.loadUserByUsername(username); // 加载用户详细信息

                // 创建认证对象并设置到 Security 上下文中
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception e) {
            // 记录错误日志
            logger.error("Cannot set user authentication: {}", e);
        }
        filterChain.doFilter(request, response); // 继续执行过滤链
    }

    /**
     * 从请求头中提取 JWT Token。
     */
    private String extractToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7); // 去掉 "Bearer " 前缀
        }
        return null;
    }
}

doFilterInternal() 方法是过滤器的核心,在每个请求到达控制器之前被调用。方法首先通过 extractToken(request) 从HTTP请求头的"Authorization"字段中提取 JWT 令牌,支持标准的"Bearer "前缀格式。如果请求头中没有令牌或格式不正确,则跳过认证过程,让请求继续传递到下一个过滤器。

当成功提取到令牌后,过滤器调用 jwtUtils.validateToken(token) 验证令牌的有效性,包括签名验证、过期时间检查等。验证通过后,使用 jwtUtils.extractUsernameFromToken(token) 从令牌中提取用户名,然后通过 userDetailsService.loadUserByUsername(username) 加载完整的用户详细信息,包括用户权限。

07、安全配置类

SecurityConfig定义了哪些 URL 需要什么级别的权限,并配置了认证和授权过滤器的执行顺序,以及会话管理策略。

/**
 * 配置Spring Security的类
 * 该类定义了应用的安全配置,包括请求的授权规则、CSRF保护的配置以及会话管理策略
 */
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // 日志记录器,用于记录安全配置的相关信息
    private static final Logger logger = LoggerFactory.getLogger(SecurityConfig.class);

    /**
     * 配置SecurityFilterChain bean的方法
     * 该方法主要用于配置应用的安全规则,包括哪些请求需要授权、CSRF保护的启用或禁用、会话管理策略等
     *
     * @param http HttpSecurity对象,用于配置应用的安全规则
     * @return SecurityFilterChain对象,代表配置好的安全过滤链
     * @throws Exception 如果配置过程中发生错误,会抛出异常
     */
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        try {
            // 禁用CSRF保护
            http.csrf(csrf -> csrf.disable())
                    // 配置请求的授权规则
                    .authorizeHttpRequests(authorize -> authorize
                            .requestMatchers("/api/v1/users/register", "/api/v1/users/login").permitAll()
                            .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
                            .requestMatchers("/api/v1/user/**").hasRole("USER")
                            .anyRequest().authenticated())
                    // 配置会话管理策略
                    // 设置会话创建策略为STATELESS,表示不会创建会话,通常用于无状态的API应用
                    .sessionManagement(session -> session
                            .sessionCreationPolicy(SessionCreationPolicy.STATELESS));
            // 记录安全配置加载成功的信息
            logger.info("Security configuration loaded successfully.");
            // 返回配置好的安全过滤链
            return http.build();
        } catch (Exception e) {
            // 记录配置安全过滤链失败的错误信息
            logger.error("Failed to configure security filter chain", e);
            // 抛出异常,以便外部处理
            throw e;
        }
    }
}

比如说静态资源和 WebSocket 连接使用 permitAll() 允许匿名访问;登录注册接口对所有用户kaifang;文件上传、对话历史、搜索等业务接口要求 hasAnyRole("USER", "ADMIN") ,允许普通用户和管理员访问;管理员专属接口使用 hasRole("ADMIN") 严格限制只有管理员才能访问;其他所有请求都需要通过 authenticated() 进行身份验证。

并采用 SessionCreationPolicy.STATELESS 无状态会话策略,完全依赖 JWT 令牌进行身份验证,不在服务器端维护会话状态。

四、RBAC 的实现流程

整个认证与权限控制流程从用户注册开始。用户首先通过 /api/v1/users/register 接口提交用户名和密码,系统会先检查用户名是否已存在,如果通过校验,则会为该用户分配一个默认角色(通常是 USER)。

注册完成后,用户可以通过 /api/v1/users/login 接口进行登录。系统会根据提交的用户名和密码进行验证,验证通过后,会生成一个包含用户信息(如用户名和角色)的 JWT Token,返回给客户端。

客户端在后续访问其他接口时,需要将这个 JWT Token 放入请求头中的 Authorization 字段中。系统会通过自定义的 JwtAuthenticationFilter 对请求进行拦截,提取并解析 Token,验证其合法性,并将对应的用户信息加载到 Spring Security 的上下文中。

最后,访问控制是基于角色来实现的。Spring Security 会根据配置类 SecurityConfig 中设定的权限规则,判断当前用户是否有权访问请求的接口。例如,只有拥有 ADMIN 角色的用户才能访问后台管理接口。

五、面试题:派聪明的 RBAC 还能继续优化吗?

首先是支持更多角色。如果系统有新的业务需求,比如需要增加一个“版主(MODERATOR)”角色,只需要在 User.Role 枚举中添加对应的枚举值,并在 SecurityConfig 中为这个新角色配置访问规则即可。

其次是引入动态权限管理。相比在代码里写死角色权限,我们可以将角色与权限信息存储到数据库中,通过服务在运行时动态加载。配合 Spring Security 提供的 @PreAuthorize 注解,可以实现粒度更细、控制更灵活的方法级权限校验。

最后是Token 刷新机制。为提升用户体验,我们可以增加刷新 Token 的功能。当用户的访问 Token 快过期时,通过一个专门的接口获取新的 Token,从而延长登录有效期,避免频繁重新登录。这一步通常需要配合 Refresh Token 实现。这也是面试中可能考察的一个知识点。

比如说:项目中的jwt token使用那哪种类型的token(长token还是短token, access token还是refresh token)?

第一种回答,用的长 token,配置了 30 天的有效期,这样用户体验更流畅,不用频繁重新登录。 用户登录成功后,后端将生成的 jwt 返回给前端,然后前端将其保存在本地缓存;之后前端与后端的交互时,都将 jwt 放在请求头中,比如可以将其放在 Http 身份认证的请求头 Authorization 中,也可以通过自定义的请求头来传递;后端接收到用户的请求,从请求头中获取 jwt,然后进行校验,通过之后,才响应相关的接口;否则表示未登录。

追问:jwt 不支持续期的话,会不会用户还在使用中,结果登录失效了?

是的,确实会这样。但可以这样解决:每次用户请求时,如果离过期不远了(比如只剩 5 分钟),就自动生成一个新的 token;返回给前端 → 前端替换旧 token; 或者用 Redis 设置一个有效期 + TTL(Time To Live,生存时间)滑动延长。

+--------+                                           +---------------+
  |        |--(A)------- Authorization Grant --------->|               |
  |        |                                           |               |
  |        |<-(B)----------- Access Token -------------|               |
  |        |               & Refresh Token             |               |
  |        |                                           |               |
  |        |                            +----------+   |               |
  |        |--(C)---- Access Token ---->|          |   |               |
  |        |                            |          |   |               |
  |        |<-(D)- Protected Resource --| Resource |   | Authorization |
  | Client |                            |  Server  |   |     Server    |
  |        |--(E)---- Access Token ---->|          |   |               |
  |        |                            |          |   |               |
  |        |<-(F)- Invalid Token Error -|          |   |               |
  |        |                            +----------+   |               |
  |        |                                           |               |
  |        |--(G)----------- Refresh Token ----------->|               |
  |        |                                           |               |
  |        |<-(H)----------- Access Token -------------|               |
  +--------+           & Optional Refresh Token        +---------------+
               Figure 2: Refreshing an Expired Access Token

用户登录的时候,生成一个 token,将 token 存入 Redis,设置有效期,比如说三十分钟。 用户发起请求时,在拦截器中获取 token,验证其是否在 Redis 中,是否过期。如果有效内,自动延长 30 分钟。 每次请求都延期一下,让 TTL 滑动前进,用户只要活跃就不会掉线。一旦用户长时间不操作,TTL 自动过期,登录状态失效。

登录接口

@RestController
@RequestMapping("/api")
public class LoginController {

    @Autowired
    private RedisUtils redisUtils;

    @PostMapping("/login")
    public ResponseEntity<String> login(@RequestParam String username, @RequestParam String password) {
        // 模拟登录成功
        if ("admin".equals(username) && "123456".equals(password)) {
            String token = UUID.randomUUID().toString();
            String redisKey = "LOGIN_TOKEN:" + token;

            // 设置 TTL 为 30 分钟
            redisUtils.set(redisKey, username, Duration.ofMinutes(30));

            return ResponseEntity.ok(token);
        }
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("用户名或密码错误");
    }
}

拦截器:

@Component
public class TokenInterceptor implements HandlerInterceptor {

    @Autowired
    private RedisUtils redisUtils;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
        String token = request.getHeader("Authorization");
        if (token == null || token.isEmpty()) {
            response.sendError(HttpStatus.UNAUTHORIZED.value(), "缺少 Token");
            return false;
        }

        String redisKey = "LOGIN_TOKEN:" + token;
        String username = redisUtils.get(redisKey);

        if (username == null) {
            response.sendError(HttpStatus.UNAUTHORIZED.value(), "Token 无效或已过期");
            return false;
        }

        // 刷新 TTL(滑动过期)
        redisUtils.expire(redisKey, Duration.ofMinutes(30));

        // 可设置当前登录用户上下文
        request.setAttribute("currentUser", username);
        return true;
    }
}

Redis 工具类:

@Component
public class RedisUtils {

    @Autowired
    private StringRedisTemplate redisTemplate;

    public void set(String key, String value, Duration ttl) {
        redisTemplate.opsForValue().set(key, value, ttl);
    }

    public String get(String key) {
        return redisTemplate.opsForValue().get(key);
    }

    public void expire(String key, Duration ttl) {
        redisTemplate.expire(key, ttl);
    }
}