前言
如果说:
- Spring Boot 解决的是项目骨架问题
- MyBatis 解决的是 SQL 可控问题
- Redis 解决的是缓存和高频访问问题
- JWT 解决的是前后端分离下的登录态问题
那么接下来一定会遇到的,就是权限控制问题。
很多系统在早期做认证时,往往只做到这一步:
- 用户登录成功
- 生成 JWT
- 后续请求带 Token
- 服务端能识别"这个人是谁"
但很快你就会发现,这还远远不够。
真实系统里,你通常还需要解决:
- 这个用户能访问哪些接口
- 这个用户属于什么角色
- 角色和菜单、按钮、数据范围如何关联
- 接口权限和页面权限如何统一
- 权限变更后如何生效
也就是说,认证只回答"你是谁",权限回答的是"你能做什么"。
这篇文章就围绕一个典型组合来展开:
- 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:read、user:create、user: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
这种结构的好处是:
- 认证逻辑集中在
security和auth - 用户、角色、权限是独立业务模块
- 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,通常会加一个过滤器,在每次请求时:
- 读取请求头里的 Token
- 解析 Token
- 恢复用户身份
- 放进
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_ADMINhasAuthority('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'
这条链路可以概括成:
- 登录时加载用户、角色、权限
- 认证成功后签发 JWT
- 每次请求通过 JWT 恢复身份
- 权限注解根据当前上下文进行授权判断
20. RBAC 在真实项目里的扩展点
基础 RBAC 足够解决很多后台管理系统问题,但真实项目通常还会继续扩展。
20.1 菜单权限和接口权限分离
很多系统里,页面菜单权限和接口权限并不是完全一回事。
例如:
- 菜单权限:控制前端是否显示某菜单
- 按钮权限:控制前端是否显示某按钮
- 接口权限:控制后端接口是否允许调用
所以权限表里常会有 permission_type:
MENUBUTTONAPI
20.2 数据权限
系统可能还需要控制:
- 只能看自己创建的数据
- 只能看本部门数据
- 只能看本部门及下级部门数据
- 可以看全量数据
这时仅靠接口权限就不够,还需要数据范围模型。
20.3 多租户
如果系统服务多个租户,就要继续叠加:
- 用户属于哪个租户
- 角色是否租户隔离
- 权限是否租户隔离
所以很多成熟权限系统,最终都会从简单 RBAC 演进成:
- RBAC + 数据权限
- RBAC + 组织架构
- RBAC + 多租户
21. 实战中最常见的坑
21.1 角色和权限编码混用
ROLE_ADMIN 和 admin、user:read 混在一起不做规范,后面很容易乱。
建议:
- 角色统一
ROLE_XXX - 权限统一
module:action
21.2 把权限全放 JWT 里
如果权限变化频繁,把大量权限直接塞 JWT,会导致:
- Token 太大
- 权限修改后无法立即生效
更稳妥的做法通常是:
- JWT 里放用户身份基础信息
- 权限通过数据库或 Redis 做动态加载/缓存
21.3 登录成功了,但接口还是 403
这通常是以下几个原因:
hasRole和hasAuthority用混了- 角色没有带
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 设计
- 数据权限模型
- 多租户权限体系
- 菜单树与前端权限联动
但无论系统后面多复杂,今天这篇文章里的这套骨架,已经足够构成很多业务后台系统的安全基础设施了。