Spring Boot + Spring Security + RBAC:从登录鉴权到权限模型设计

前言

如果说:

  • Spring Boot 解决的是项目骨架问题
  • MyBatis 解决的是 SQL 可控问题
  • Redis 解决的是缓存和高频访问问题
  • JWT 解决的是前后端分离下的登录态问题

那么接下来一定会遇到的,就是权限控制问题。

很多系统在早期做认证时,往往只做到这一步:

  1. 用户登录成功
  2. 生成 JWT
  3. 后续请求带 Token
  4. 服务端能识别"这个人是谁"

但很快你就会发现,这还远远不够。

真实系统里,你通常还需要解决:

  • 这个用户能访问哪些接口
  • 这个用户属于什么角色
  • 角色和菜单、按钮、数据范围如何关联
  • 接口权限和页面权限如何统一
  • 权限变更后如何生效

也就是说,认证只回答"你是谁",权限回答的是"你能做什么"。

这篇文章就围绕一个典型组合来展开:

  • Spring Boot
  • Spring Security
  • JWT
  • RBAC

目标不是只写一个登录 Demo,而是把认证和授权这一整条链路讲清楚,并给出一套更接近真实项目的代码结构。


1. 什么是 Spring Security

Spring Security 是 Spring 生态中的安全框架,主要解决两大问题:

  • 认证 Authentication
  • 授权 Authorization

可以把它理解为后端安全体系的基础设施层。

它可以做的事情很多,包括:

  • 用户登录认证
  • 接口访问控制
  • 密码加密
  • 会话管理
  • JWT 过滤器接入
  • 方法级权限控制
  • CSRF、防重放等安全机制

很多人第一次接触 Spring Security,会觉得它"配置很重、学习曲线高"。

这感觉不完全错,因为 Spring Security 确实不是一个"几行注解就完事"的框架。它本质上是一个完整安全体系的实现。

但换个角度看,这也是它的价值所在:

Spring Security 不是帮你临时加一个登录页,而是给你一套长期可扩展的安全模型。


2. RBAC 是什么

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

这是业务系统里最常见的一种权限模型。

一个典型的 RBAC 结构通常是:

  • 用户 User
  • 角色 Role
  • 权限 Permission

它们之间的关系一般是:

  • 用户和角色是多对多
  • 角色和权限是多对多

也就是说:

  • 用户本身不直接持有大量权限
  • 用户通过角色继承权限
  • 权限由角色集中管理

例如:

  • 用户 alice 拥有角色 ADMIN
  • 角色 ADMIN 拥有权限 user:readuser:createuser:delete

于是 alice 就具备了这些权限。

这套模型的核心优点是:

  • 好维护
  • 好扩展
  • 方便统一治理

3. 一个典型权限系统的数据模型

如果用数据库建模,通常至少有下面几张表:

3.1 用户表

sql 复制代码
CREATE TABLE sys_user (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(64) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    nickname VARCHAR(64),
    status TINYINT NOT NULL DEFAULT 1,
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

3.2 角色表

sql 复制代码
CREATE TABLE sys_role (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    role_code VARCHAR(64) NOT NULL UNIQUE,
    role_name VARCHAR(64) NOT NULL,
    status TINYINT NOT NULL DEFAULT 1,
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

3.3 权限表

sql 复制代码
CREATE TABLE sys_permission (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    permission_code VARCHAR(128) NOT NULL UNIQUE,
    permission_name VARCHAR(128) NOT NULL,
    permission_type VARCHAR(32) NOT NULL,
    path VARCHAR(255),
    method VARCHAR(16),
    status TINYINT NOT NULL DEFAULT 1,
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

3.4 用户角色关联表

sql 复制代码
CREATE TABLE sys_user_role (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    role_id BIGINT NOT NULL,
    UNIQUE KEY uk_user_role (user_id, role_id)
);

3.5 角色权限关联表

sql 复制代码
CREATE TABLE sys_role_permission (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    role_id BIGINT NOT NULL,
    permission_id BIGINT NOT NULL,
    UNIQUE KEY uk_role_permission (role_id, permission_id)
);

这几张表已经足够构建一个基础 RBAC 系统。

如果系统更复杂,还可能继续扩展:

  • 部门表
  • 租户表
  • 菜单表
  • 数据范围表
  • 组织关系表

但核心骨架通常还是从这五张表开始。


4. 项目结构建议

如果你准备用 Spring Boot + Spring Security + RBAC 做一个中型后端服务,建议目录尽量清晰。

text 复制代码
src/main/java/com/example/securitydemo
├─ SecurityDemoApplication.java
├─ common
│  ├─ exception
│  ├─ response
│  └─ util
├─ config
│  ├─ SecurityConfig.java
│  └─ JacksonConfig.java
├─ security
│  ├─ JwtAuthenticationFilter.java
│  ├─ JwtProperties.java
│  ├─ JwtTokenProvider.java
│  ├─ LoginUser.java
│  ├─ SecurityUserDetailsService.java
│  └─ handler
│     ├─ RestAuthenticationEntryPoint.java
│     └─ RestAccessDeniedHandler.java
├─ auth
│  ├─ controller
│  ├─ service
│  └─ dto
├─ user
│  ├─ controller
│  ├─ service
│  ├─ mapper
│  └─ entity
├─ role
│  ├─ mapper
│  └─ entity
└─ permission
   ├─ mapper
   └─ entity

这种结构的好处是:

  • 认证逻辑集中在 securityauth
  • 用户、角色、权限是独立业务模块
  • Spring Security 的过滤器、异常处理器、配置类不会散落在业务代码里

5. 依赖配置

下面是一个典型依赖组合:

xml 复制代码
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>3.0.3</version>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.12.6</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.12.6</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.12.6</version>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

6. 配置文件示例

yaml 复制代码
server:
  port: 8080

spring:
  application:
    name: security-demo
  datasource:
    url: jdbc:mysql://localhost:3306/security_demo?useSSL=false&serverTimezone=UTC
    username: root
    password: 123456
  data:
    redis:
      host: localhost
      port: 6379
      database: 0

mybatis:
  mapper-locations: classpath:/mapper/**/*.xml
  type-aliases-package: com.example.securitydemo.user.entity,com.example.securitydemo.role.entity,com.example.securitydemo.permission.entity
  configuration:
    map-underscore-to-camel-case: true

jwt:
  secret: 01234567890123456789012345678901
  expire-seconds: 7200
  issuer: security-demo

7. 统一返回结构和异常处理

7.1 统一返回体

java 复制代码
package com.example.securitydemo.common.response;

public class ApiResponse<T> {

    private boolean success;
    private String code;
    private String message;
    private T data;

    public static <T> ApiResponse<T> success(T data) {
        ApiResponse<T> response = new ApiResponse<>();
        response.success = true;
        response.code = "OK";
        response.message = "success";
        response.data = data;
        return response;
    }

    public static <T> ApiResponse<T> fail(String code, String message) {
        ApiResponse<T> response = new ApiResponse<>();
        response.success = false;
        response.code = code;
        response.message = message;
        return response;
    }

    public boolean isSuccess() {
        return success;
    }

    public String getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }

    public T getData() {
        return data;
    }
}

7.2 业务异常

java 复制代码
package com.example.securitydemo.common.exception;

public class BizException extends RuntimeException {

    private final String code;

    public BizException(String code, String message) {
        super(message);
        this.code = code;
    }

    public String getCode() {
        return code;
    }
}

7.3 全局异常处理器

java 复制代码
package com.example.securitydemo.common.exception;

import com.example.securitydemo.common.response.ApiResponse;
import java.util.stream.Collectors;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BizException.class)
    public ApiResponse<Void> handleBizException(BizException ex) {
        return ApiResponse.fail(ex.getCode(), ex.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ApiResponse<Void> handleValidationException(MethodArgumentNotValidException ex) {
        String message = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> error.getField() + ": " + error.getDefaultMessage())
            .collect(Collectors.joining("; "));
        return ApiResponse.fail("PARAM_INVALID", message);
    }
}

8. 用户、角色、权限实体定义

8.1 用户实体

java 复制代码
package com.example.securitydemo.user.entity;

public class UserEntity {

    private Long id;
    private String username;
    private String passwordHash;
    private String nickname;
    private Integer status;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPasswordHash() {
        return passwordHash;
    }

    public void setPasswordHash(String passwordHash) {
        this.passwordHash = passwordHash;
    }

    public String getNickname() {
        return nickname;
    }

    public void setNickname(String nickname) {
        this.nickname = nickname;
    }

    public Integer getStatus() {
        return status;
    }

    public void setStatus(Integer status) {
        this.status = status;
    }
}

8.2 角色实体

java 复制代码
package com.example.securitydemo.role.entity;

public class RoleEntity {

    private Long id;
    private String roleCode;
    private String roleName;
    private Integer status;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getRoleCode() {
        return roleCode;
    }

    public void setRoleCode(String roleCode) {
        this.roleCode = roleCode;
    }

    public String getRoleName() {
        return roleName;
    }

    public void setRoleName(String roleName) {
        this.roleName = roleName;
    }

    public Integer getStatus() {
        return status;
    }

    public void setStatus(Integer status) {
        this.status = status;
    }
}

8.3 权限实体

java 复制代码
package com.example.securitydemo.permission.entity;

public class PermissionEntity {

    private Long id;
    private String permissionCode;
    private String permissionName;
    private String permissionType;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getPermissionCode() {
        return permissionCode;
    }

    public void setPermissionCode(String permissionCode) {
        this.permissionCode = permissionCode;
    }

    public String getPermissionName() {
        return permissionName;
    }

    public void setPermissionName(String permissionName) {
        this.permissionName = permissionName;
    }

    public String getPermissionType() {
        return permissionType;
    }

    public void setPermissionType(String permissionType) {
        this.permissionType = permissionType;
    }
}

9. MyBatis 查询用户及权限

9.1 UserMapper

java 复制代码
package com.example.securitydemo.user.mapper;

import com.example.securitydemo.permission.entity.PermissionEntity;
import com.example.securitydemo.role.entity.RoleEntity;
import com.example.securitydemo.user.entity.UserEntity;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

@Mapper
public interface UserMapper {

    UserEntity findByUsername(@Param("username") String username);

    List<RoleEntity> findRolesByUserId(@Param("userId") Long userId);

    List<PermissionEntity> findPermissionsByUserId(@Param("userId") Long userId);
}

9.2 UserMapper.xml

xml 复制代码
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.securitydemo.user.mapper.UserMapper">

    <select id="findByUsername" resultType="com.example.securitydemo.user.entity.UserEntity">
        SELECT id, username, password_hash, nickname, status
        FROM sys_user
        WHERE username = #{username}
        LIMIT 1
    </select>

    <select id="findRolesByUserId" resultType="com.example.securitydemo.role.entity.RoleEntity">
        SELECT r.id, r.role_code, r.role_name, r.status
        FROM sys_role r
        INNER JOIN sys_user_role ur ON r.id = ur.role_id
        WHERE ur.user_id = #{userId}
          AND r.status = 1
    </select>

    <select id="findPermissionsByUserId" resultType="com.example.securitydemo.permission.entity.PermissionEntity">
        SELECT DISTINCT p.id, p.permission_code, p.permission_name, p.permission_type
        FROM sys_permission p
        INNER JOIN sys_role_permission rp ON p.id = rp.permission_id
        INNER JOIN sys_user_role ur ON rp.role_id = ur.role_id
        WHERE ur.user_id = #{userId}
          AND p.status = 1
    </select>

</mapper>

这一步是整个权限系统的关键来源。

因为 Spring Security 最终做授权判断时,必须拿到当前用户拥有哪些角色、哪些权限。


10. 定义 LoginUser

在 Spring Security 里,通常会把当前登录用户抽象成一个实现了 UserDetails 的对象。

java 复制代码
package com.example.securitydemo.security;

import java.util.Collection;
import java.util.List;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

public class LoginUser implements UserDetails {

    private final Long userId;
    private final String username;
    private final String password;
    private final boolean enabled;
    private final List<String> authorities;

    public LoginUser(Long userId, String username, String password, boolean enabled, List<String> authorities) {
        this.userId = userId;
        this.username = username;
        this.password = password;
        this.enabled = enabled;
        this.authorities = authorities;
    }

    public Long getUserId() {
        return userId;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities.stream()
            .map(SimpleGrantedAuthority::new)
            .toList();
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return enabled;
    }
}

这里的 authorities 可以同时放:

  • 角色,例如 ROLE_ADMIN
  • 权限,例如 user:read

这样后面不论是基于角色还是基于权限做判断,都可以支持。


11. 实现 UserDetailsService

Spring Security 在认证时,会通过 UserDetailsService 去加载用户信息。

java 复制代码
package com.example.securitydemo.security;

import com.example.securitydemo.permission.entity.PermissionEntity;
import com.example.securitydemo.role.entity.RoleEntity;
import com.example.securitydemo.user.entity.UserEntity;
import com.example.securitydemo.user.mapper.UserMapper;
import java.util.ArrayList;
import java.util.List;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class SecurityUserDetailsService implements UserDetailsService {

    private final UserMapper userMapper;

    public SecurityUserDetailsService(UserMapper userMapper) {
        this.userMapper = userMapper;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity user = userMapper.findByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("用户不存在: " + username);
        }

        List<RoleEntity> roles = userMapper.findRolesByUserId(user.getId());
        List<PermissionEntity> permissions = userMapper.findPermissionsByUserId(user.getId());

        List<String> authorities = new ArrayList<>();
        for (RoleEntity role : roles) {
            authorities.add("ROLE_" + role.getRoleCode());
        }
        for (PermissionEntity permission : permissions) {
            authorities.add(permission.getPermissionCode());
        }

        return new LoginUser(
            user.getId(),
            user.getUsername(),
            user.getPasswordHash(),
            user.getStatus() != null && user.getStatus() == 1,
            authorities
        );
    }
}

这样,认证成功之后,当前用户的权限集合就进入了 Spring Security 的上下文。


12. 密码加密

生产环境里绝对不要明文密码。

Spring Security 提供了标准密码编码器:

java 复制代码
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

注册用户时:

java 复制代码
String encodedPassword = passwordEncoder.encode(rawPassword);

登录校验时:

java 复制代码
passwordEncoder.matches(rawPassword, encodedPassword)

BCrypt 的优点是:

  • 自带随机盐
  • 安全性较高
  • Spring Security 原生支持

13. JWT 工具类

13.1 配置类

java 复制代码
package com.example.securitydemo.security;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {

    private String secret;
    private Long expireSeconds;
    private String issuer;

    public String getSecret() {
        return secret;
    }

    public void setSecret(String secret) {
        this.secret = secret;
    }

    public Long getExpireSeconds() {
        return expireSeconds;
    }

    public void setExpireSeconds(Long expireSeconds) {
        this.expireSeconds = expireSeconds;
    }

    public String getIssuer() {
        return issuer;
    }

    public void setIssuer(String issuer) {
        this.issuer = issuer;
    }
}

13.2 Token Provider

java 复制代码
package com.example.securitydemo.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date;
import java.util.Map;
import javax.crypto.SecretKey;
import org.springframework.stereotype.Component;

@Component
public class JwtTokenProvider {

    private final JwtProperties jwtProperties;

    public JwtTokenProvider(JwtProperties jwtProperties) {
        this.jwtProperties = jwtProperties;
    }

    public String generateToken(LoginUser loginUser) {
        Instant now = Instant.now();
        Instant expireAt = now.plusSeconds(jwtProperties.getExpireSeconds());

        return Jwts.builder()
            .issuer(jwtProperties.getIssuer())
            .subject(String.valueOf(loginUser.getUserId()))
            .claims(Map.of("username", loginUser.getUsername()))
            .issuedAt(Date.from(now))
            .expiration(Date.from(expireAt))
            .signWith(getSigningKey())
            .compact();
    }

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

    public boolean validateToken(String token) {
        try {
            parseToken(token);
            return true;
        } catch (Exception ex) {
            return false;
        }
    }

    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8));
    }
}

14. 登录接口实现

14.1 登录请求 DTO

java 复制代码
package com.example.securitydemo.auth.dto;

import jakarta.validation.constraints.NotBlank;

public class LoginRequest {

    @NotBlank(message = "username 不能为空")
    private String username;

    @NotBlank(message = "password 不能为空")
    private String password;

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}

14.2 登录响应 DTO

java 复制代码
package com.example.securitydemo.auth.dto;

public class LoginResponse {

    private String token;
    private Long userId;
    private String username;

    public String getToken() {
        return token;
    }

    public void setToken(String token) {
        this.token = token;
    }

    public Long getUserId() {
        return userId;
    }

    public void setUserId(Long userId) {
        this.userId = userId;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }
}

14.3 AuthService

java 复制代码
package com.example.securitydemo.auth.service;

import com.example.securitydemo.auth.dto.LoginRequest;
import com.example.securitydemo.auth.dto.LoginResponse;
import com.example.securitydemo.security.JwtTokenProvider;
import com.example.securitydemo.security.LoginUser;
import com.example.securitydemo.security.SecurityUserDetailsService;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;

@Service
public class AuthService {

    private final AuthenticationManager authenticationManager;
    private final SecurityUserDetailsService userDetailsService;
    private final JwtTokenProvider jwtTokenProvider;

    public AuthService(
            AuthenticationManager authenticationManager,
            SecurityUserDetailsService userDetailsService,
            JwtTokenProvider jwtTokenProvider) {
        this.authenticationManager = authenticationManager;
        this.userDetailsService = userDetailsService;
        this.jwtTokenProvider = jwtTokenProvider;
    }

    public LoginResponse login(LoginRequest request) {
        Authentication authentication = authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
        );

        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        String token = jwtTokenProvider.generateToken(loginUser);

        LoginResponse response = new LoginResponse();
        response.setToken(token);
        response.setUserId(loginUser.getUserId());
        response.setUsername(loginUser.getUsername());
        return response;
    }
}

14.4 AuthController

java 复制代码
package com.example.securitydemo.auth.controller;

import com.example.securitydemo.auth.dto.LoginRequest;
import com.example.securitydemo.auth.dto.LoginResponse;
import com.example.securitydemo.auth.service.AuthService;
import com.example.securitydemo.common.response.ApiResponse;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthService authService;

    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    @PostMapping("/login")
    public ApiResponse<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
        return ApiResponse.success(authService.login(request));
    }
}

15. JWT 认证过滤器

在 Spring Security 里,如果你使用 JWT,通常会加一个过滤器,在每次请求时:

  1. 读取请求头里的 Token
  2. 解析 Token
  3. 恢复用户身份
  4. 放进 SecurityContext
java 复制代码
package com.example.securitydemo.security;

import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private final SecurityUserDetailsService userDetailsService;

    public JwtAuthenticationFilter(
            JwtTokenProvider jwtTokenProvider,
            SecurityUserDetailsService userDetailsService) {
        this.jwtTokenProvider = jwtTokenProvider;
        this.userDetailsService = userDetailsService;
    }

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

        String authHeader = request.getHeader("Authorization");
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);
            if (jwtTokenProvider.validateToken(token)) {
                Claims claims = jwtTokenProvider.parseToken(token);
                String username = (String) claims.get("username");

                LoginUser loginUser = (LoginUser) userDetailsService.loadUserByUsername(username);
                UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        filterChain.doFilter(request, response);
    }
}

16. Spring Security 配置

Spring Security 6 以后,推荐的配置方式是基于 SecurityFilterChain

java 复制代码
package com.example.securitydemo.config;

import com.example.securitydemo.security.JwtAuthenticationFilter;
import com.example.securitydemo.security.SecurityUserDetailsService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final SecurityUserDetailsService userDetailsService;

    public SecurityConfig(
            JwtAuthenticationFilter jwtAuthenticationFilter,
            SecurityUserDetailsService userDetailsService) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
        this.userDetailsService = userDetailsService;
    }

    @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").permitAll()
                .requestMatchers("/actuator/health").permitAll()
                .anyRequest().authenticated()
            )
            .authenticationProvider(authenticationProvider())
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public DaoAuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return provider;
    }

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

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }
}

这里做了几件关键的事:

  • 关闭 CSRF(前后端分离 + JWT 场景里常见)
  • 使用无状态 Session 策略
  • 放行登录接口
  • 其他接口默认需要认证
  • 在用户名密码过滤器前加 JWT 过滤器

17. 基于角色和权限做控制

Spring Security 支持两种常见写法:

  • 基于角色:hasRole
  • 基于权限:hasAuthority

17.1 Controller 示例

java 复制代码
package com.example.securitydemo.user.controller;

import com.example.securitydemo.common.response.ApiResponse;
import java.util.List;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping
    @PreAuthorize("hasAuthority('user:read')")
    public ApiResponse<List<String>> listUsers() {
        return ApiResponse.success(List.of("alice", "bob", "charlie"));
    }

    @GetMapping("/admin")
    @PreAuthorize("hasRole('ADMIN')")
    public ApiResponse<String> adminOnly() {
        return ApiResponse.success("admin resource");
    }
}

注意:

  • hasRole('ADMIN') 实际会匹配 ROLE_ADMIN
  • hasAuthority('user:read') 则匹配精确权限字符串

所以在组装 LoginUser 权限集合时,要区分这两类编码。


18. 未登录和无权限的统一处理

很多项目接入 Spring Security 后,第一个问题就是:

  • 未登录返回了一堆默认 HTML
  • 或者报文结构和项目其他接口完全不一致

这时需要自定义两个组件:

  • AuthenticationEntryPoint:未认证时触发
  • AccessDeniedHandler:已认证但权限不足时触发

18.1 未登录处理

java 复制代码
package com.example.securitydemo.security.handler;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;

    public RestAuthenticationEntryPoint(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        objectMapper.writeValue(response.getWriter(), Map.of(
            "success", false,
            "code", "UNAUTHORIZED",
            "message", "请先登录"
        ));
    }
}

18.2 无权限处理

java 复制代码
package com.example.securitydemo.security.handler;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

@Component
public class RestAccessDeniedHandler implements AccessDeniedHandler {

    private final ObjectMapper objectMapper;

    public RestAccessDeniedHandler(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        objectMapper.writeValue(response.getWriter(), Map.of(
            "success", false,
            "code", "FORBIDDEN",
            "message", "没有访问权限"
        ));
    }
}

然后在 SecurityConfig 中接入。

java 复制代码
http.exceptionHandling(ex -> ex
    .authenticationEntryPoint(restAuthenticationEntryPoint)
    .accessDeniedHandler(restAccessDeniedHandler)
);

19. 一次完整权限请求的链路

渲染错误: Mermaid 渲染失败: Parse error on line 15: ...ntroller] M --> N@PreAuthorize ----------------------^ Expecting 'AMP', 'COLON', 'PIPE', 'TESTSTR', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'LINK_ID'

这条链路可以概括成:

  1. 登录时加载用户、角色、权限
  2. 认证成功后签发 JWT
  3. 每次请求通过 JWT 恢复身份
  4. 权限注解根据当前上下文进行授权判断

20. RBAC 在真实项目里的扩展点

基础 RBAC 足够解决很多后台管理系统问题,但真实项目通常还会继续扩展。

20.1 菜单权限和接口权限分离

很多系统里,页面菜单权限和接口权限并不是完全一回事。

例如:

  • 菜单权限:控制前端是否显示某菜单
  • 按钮权限:控制前端是否显示某按钮
  • 接口权限:控制后端接口是否允许调用

所以权限表里常会有 permission_type

  • MENU
  • BUTTON
  • API

20.2 数据权限

系统可能还需要控制:

  • 只能看自己创建的数据
  • 只能看本部门数据
  • 只能看本部门及下级部门数据
  • 可以看全量数据

这时仅靠接口权限就不够,还需要数据范围模型。

20.3 多租户

如果系统服务多个租户,就要继续叠加:

  • 用户属于哪个租户
  • 角色是否租户隔离
  • 权限是否租户隔离

所以很多成熟权限系统,最终都会从简单 RBAC 演进成:

  • RBAC + 数据权限
  • RBAC + 组织架构
  • RBAC + 多租户

21. 实战中最常见的坑

21.1 角色和权限编码混用

ROLE_ADMINadminuser:read 混在一起不做规范,后面很容易乱。

建议:

  • 角色统一 ROLE_XXX
  • 权限统一 module:action

21.2 把权限全放 JWT 里

如果权限变化频繁,把大量权限直接塞 JWT,会导致:

  • Token 太大
  • 权限修改后无法立即生效

更稳妥的做法通常是:

  • JWT 里放用户身份基础信息
  • 权限通过数据库或 Redis 做动态加载/缓存

21.3 登录成功了,但接口还是 403

这通常是以下几个原因:

  • hasRolehasAuthority 用混了
  • 角色没有带 ROLE_ 前缀
  • 权限没正确装入 LoginUser
  • JWT 过滤器没把认证信息放进 SecurityContext

21.4 Spring Security 默认行为没改干净

如果不自定义未认证/无权限处理,前后端分离项目经常会收到默认 HTML 响应,体验很差。


22. 总结

Spring Security 和 RBAC 经常被认为"重",但这恰恰说明它们解决的是系统里最不该轻视的问题。

认证和授权不是一个补丁功能,而是后端架构的一部分。

这篇文章最值得带走的几个结论是:

  • Spring Security 负责认证与授权框架
  • RBAC 负责权限模型组织方式
  • JWT 负责前后端分离场景下的身份表达
  • UserDetailsService、过滤器、权限注解,是 Spring Security 落地的关键节点

如果你的项目只做到了"登录后有 Token",那还只是完成了认证链路的一半。

真正成熟的权限系统,至少要进一步做到:

  • 角色清晰
  • 权限清晰
  • 未登录和无权限响应统一
  • 鉴权链路稳定可控
  • 角色、权限、菜单、数据范围可以持续扩展

下一步如果继续深入,通常最自然的话题会是:

  • Spring Security + OAuth2
  • Refresh Token 设计
  • 数据权限模型
  • 多租户权限体系
  • 菜单树与前端权限联动

但无论系统后面多复杂,今天这篇文章里的这套骨架,已经足够构成很多业务后台系统的安全基础设施了。

相关推荐
AC赳赳老秦1 小时前
OpenClaw + 飞书多维表格:自动同步数据、生成统计图表、触发自动化任务
java·大数据·python·缓存·自动化·deepseek·openclaw
CS_SKILL1 小时前
吉比特 C++ 实习一面面经:一轮把 C++、容器、并发、排序和网络全扫了一遍
java·开发语言·校招面经·实习面经·技术面经·吉比特校招
2601_961963382 小时前
Spring Boot集成电子签章的7个典型问题与解决方案:从入门到生产级实践
大数据·人工智能·spring boot·python·区块链·智能合约
星轨zb2 小时前
[Corner项目实战]Spring Boot + LangChain4j Tool Calling实战:让AI自动选择推荐策略
人工智能·spring boot·后端·langchain4j
Jul1en_2 小时前
【SpringCloud】SkyWalking 链路追踪知识详解及部署教程
java·后端·spring·spring cloud·skywalking
宸津-代码粉碎机2 小时前
Spring AI 企业级实战|智能记忆摘要+自动遗忘机制落地,彻底解决上下文爆炸与Token冗余
java·大数据·人工智能·后端·python·spring·云计算
逻极2 小时前
Spring Boot 微服务开发提速:我们如何将接口响应时间降低60%
java·spring boot·微服务·性能优化·自动配置
Yvonne爱编码2 小时前
JAVA EE初阶---DAY 2 计算机网络
java·开发语言·计算机网络·算法·java-ee·php
潇凝子潇2 小时前
IDEA插件
java·ide·intellij-idea