SpringSecurity6 企业级安全框架
Spring Security 基础
什么是 Spring Security
Spring Security 是 Spring 生态中的安全框架,主要用于处理 Servlet 应用和响应式应用中的身份认证、访问授权及常见安全防护。
与 Apache Shiro 相比,Spring Security 与 Spring Boot、Spring MVC、OAuth 2.0 等组件的集成更紧密,扩展点也更丰富。框架能力较多,因此学习时应先掌握过滤器链、认证对象和授权规则等核心概念。
认证与授权
企业应用通常需要解决两个核心问题:
- 身份认证(Authentication):确认当前访问者是谁,以及其身份是否合法。
- 访问授权(Authorization):确认已经通过认证的用户是否有权访问某项资源或执行某个操作。
以在线教育平台为例:
| 用户类型 | 允许执行的操作 |
|---|---|
| 学生 | 查看课程 |
| 教师 | 发布课程 |
| 管理员 | 删除用户 |
认证解决“你是谁”,授权解决“你能做什么”。
Spring Security 的主要能力
Spring Security 的常见能力包括:
- 用户名密码认证、证书认证、一次性令牌认证等身份认证机制。
- URL 级别与方法级别的访问授权。
- 密码编码与校验。
- 会话管理、固定会话攻击防护和并发会话控制。
- CSRF 防护、安全响应头等 Web 安全能力。
- OAuth 2.0 客户端、资源服务器和 OpenID Connect 集成。
XSS 主要需要通过输出编码、内容安全策略和前端安全开发规范进行防护。Spring Security 可以配置部分安全响应头,但不能替代业务层面的 XSS 防护。
CSRF
CSRF 是跨站请求伪造。攻击者诱导已经登录合法站点的用户访问恶意页面,浏览器可能自动携带合法站点的 Cookie,从而使合法站点误以为请求由用户本人发起。
XSS
XSS 是跨站脚本攻击。攻击者设法将恶意脚本注入网页,其他用户访问页面时,浏览器执行该脚本,可能造成信息窃取、身份冒用或页面篡改。
Session 模式与 Token 模式
Spring Security 同时支持有状态和无状态认证。前后端分离并不意味着只能使用 Token,但在分布式 API 中,使用 Bearer Token 的无状态方案较为常见。
| 对比项 | Session 有状态认证 | Token 无状态认证 |
|---|---|---|
| 状态位置 | 服务端保存会话,客户端通常保存会话 Cookie | 服务端通常不保存登录会话,客户端携带访问令牌 |
| 集群部署 | 需要会话复制、共享存储或粘性会话 | 各节点可独立验证令牌 |
| 浏览器自动携带 | Cookie 通常由浏览器自动携带 | Authorization 请求头通常由客户端代码主动添加 |
| 注销与撤销 | 服务端删除会话即可立即失效 | 需要短有效期、黑名单或令牌版本等机制 |
| 典型场景 | 服务端渲染网站、同源 Web 应用 | 前后端分离 API、微服务资源服务器 |
将 Token 存入 localStorage 会增加 Token 被 XSS 窃取的风险。生产系统应根据威胁模型选择内存、受保护 Cookie 或其他存储方案。若使用浏览器自动携带的 Cookie,仍需评估并配置 CSRF 防护。
核心架构

Spring Security 的 Servlet 安全能力建立在过滤器链之上。请求进入业务控制器之前,会先经过 Spring Security 选择的 SecurityFilterChain。
常见核心组件如下:
| 组件 | 作用 |
|---|---|
SecurityFilterChain | 声明当前请求需要执行的安全过滤器 |
FilterChainProxy | 根据请求匹配并执行相应的 SecurityFilterChain |
Authentication | 表示认证请求或当前已认证主体 |
AuthenticationManager | 定义认证入口 |
AuthenticationProvider | 执行某一种具体认证方式 |
UserDetailsService | 按用户名加载用户信息 |
PasswordEncoder | 编码密码并校验密码 |
SecurityContext | 保存当前请求关联的 Authentication |
SecurityContextHolder | 提供访问 SecurityContext 的统一入口 |
面试题:Spring Security 的主要作用是什么?
Spring Security 主要解决应用中的身份认证和访问授权问题,并提供密码编码、会话管理、CSRF 防护、安全响应头以及 OAuth 2.0 集成等安全能力。
Spring Boot 3 整合 Spring Security
环境要求
本课程示例使用以下环境:
Spring Boot 3.3+
Java 17+
Spring Security 6
Maven
Vue 3
引入依赖
<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>
</dependencies>编写第一个接口
@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping("/hello")
public String hello() {
return "hello security";
}
}启动项目后访问 http://localhost:8080/user/hello。在没有自定义安全配置时,Spring Boot 会为 Web 应用启用默认安全配置,并显示登录页面。

默认用户
当项目中没有自定义 UserDetailsService、AuthenticationProvider 等认证配置时,Spring Boot 通常会创建默认用户:
username: user
password: 启动时随机生成
随机密码会输出到控制台。

自定义默认用户名和密码
spring:
security:
user:
name: admin
password: 123456
该配置适合入门演示,不适合生产环境。生产系统应从数据库、LDAP 或外部身份服务加载用户,并使用 PasswordEncoder 保存密码摘要。
默认登录页面的形成过程
在默认配置下,可将启动和访问过程概括为:
Spring Boot 启动
→ 加载 Spring Security 自动配置
→ 创建安全过滤器链
→ 请求被过滤器链拦截
→ 发现请求尚未认证
→ 进入登录流程或返回认证响应
面试题:为什么 Spring Security 不只使用 Controller 拦截请求?
Spring Security 的 Servlet 支持建立在 Filter 机制之上。过滤器位于 DispatcherServlet 和控制器之前,可以在请求进入业务层之前完成认证、授权和安全上下文管理。
典型顺序可以简化为:
Servlet Filter → DispatcherServlet → HandlerInterceptor → Controller
Spring Security 核心原理
学习目标
完成本章后,应能够:
- 理解 Spring Security 的整体请求流程。
- 掌握
SecurityFilterChain的工作机制。 - 理解
Authentication的两种主要用途。 - 掌握
AuthenticationManager与AuthenticationProvider的关系。 - 理解
SecurityContext与SecurityContextHolder的职责。 - 分析用户名密码认证的主要执行步骤。
基于 Filter 的安全处理
在传统 Spring MVC 应用中,请求流程可以简化为:
客户端请求
→ Servlet Filter
→ DispatcherServlet
→ HandlerInterceptor
→ Controller
→ Service
→ DAO
→ Database
Spring Security 选择过滤器作为 Servlet 安全入口,使认证和授权可以在业务代码执行前完成。
例如,用户请求 GET /api/user/delete/1 时,安全过滤器链会先判断用户是否已经认证以及是否具有删除权限。校验失败时,请求不会进入目标控制器。
SecurityFilterChain
SecurityFilterChain 表示一组用于处理安全任务的过滤器。一个应用可以声明多个安全过滤器链,每条链负责不同的请求范围。

DelegatingFilterProxy 与 FilterChainProxy
Spring Security 的 Servlet 请求转发关系可以概括为:
DelegatingFilterProxy
→ FilterChainProxy
→ 匹配到的 SecurityFilterChain
→ 链内的 Security Filter

DelegatingFilterProxy注册在 Servlet 容器中,负责把过滤工作委托给 Spring 容器管理的FilterBean。FilterChainProxy是 Spring Security 的核心入口,会根据当前请求匹配合适的SecurityFilterChain。- 被选中的
SecurityFilterChain按顺序执行其中的安全过滤器。
过滤器的具体组成取决于配置。常见过滤器包括:
SecurityContextHolderFilter:加载并管理安全上下文。CsrfFilter:启用 CSRF 时校验 CSRF Token。UsernamePasswordAuthenticationFilter:启用表单登录时处理用户名密码登录请求。BearerTokenAuthenticationFilter:配置 OAuth 2.0 资源服务器时处理 Bearer Token。AnonymousAuthenticationFilter:为匿名请求提供匿名Authentication。ExceptionTranslationFilter:把认证和授权异常转换为相应处理流程。AuthorizationFilter:执行请求级授权判断。
部分旧版架构图中会出现 FilterSecurityInterceptor。在 Spring Security 6 的常用配置中,请求授权通常由 AuthorizationFilter 完成。实际过滤器列表应以项目启动日志或调试输出为准。
Authentication 认证模型

Authentication 的作用
Authentication 有两种主要用途:
- 作为待认证凭证提交给
AuthenticationManager。 - 表示当前已经通过认证的主体,并保存到
SecurityContext中。
接口中的常用方法如下:
public interface Authentication extends Principal, Serializable {
Object getPrincipal();
Object getCredentials();
Collection<? extends GrantedAuthority> getAuthorities();
boolean isAuthenticated();
}| 方法 | 作用 |
|---|---|
getPrincipal() | 返回主体,用户名密码认证成功后通常为 UserDetails |
getCredentials() | 返回凭证,通常为密码;认证成功后可能被清除 |
getAuthorities() | 返回角色、权限或作用域集合 |
isAuthenticated() | 表示当前对象是否已经通过认证 |
认证前后的状态
登录前创建的 UsernamePasswordAuthenticationToken 通常只包含用户名和密码,并处于未认证状态:
UsernamePasswordAuthenticationToken
├── principal: admin
├── credentials: 123456
├── authorities: []
└── authenticated: false
认证成功后返回的新对象通常包含用户详情和权限,并处于已认证状态:
UsernamePasswordAuthenticationToken
├── principal: UserDetails
├── credentials: null
├── authorities: [ROLE_ADMIN, permission:query]
└── authenticated: true
不要通过不安全方式把任意未验证对象直接标记为已认证状态。
AuthenticationManager 与 AuthenticationProvider
AuthenticationManager 定义统一认证入口:
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
ProviderManager 是最常见的 AuthenticationManager 实现。它会依次询问多个 AuthenticationProvider,由支持当前 Authentication 类型的 Provider 执行认证。
典型用户名密码认证流程如下:
Authentication
→ AuthenticationManager
→ ProviderManager
→ DaoAuthenticationProvider
→ UserDetailsService
→ PasswordEncoder
不同认证方式可以使用不同 Provider。例如:
DaoAuthenticationProvider处理数据库用户名密码认证。- OAuth 2.0 资源服务器中的
JwtAuthenticationProvider处理 JWT Bearer Token。 - 自定义
AuthenticationProvider可以处理短信验证码等业务凭证。
UserDetailsService
Spring Security 不知道用户保存在哪里,因此通过 UserDetailsService 抽象用户查询过程。
public interface UserDetailsService {
UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException;
}
用户信息可以来自 MySQL、Redis、LDAP 或其他用户中心。
下面是一个简化示例:
@Service
@RequiredArgsConstructor
public class UserService implements UserDetailsService {
private final UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) {
User user = userMapper.findByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("账号不存在");
}
return org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
.password(user.getPassword())
.roles("USER")
.build();
}
}SecurityContext 与 SecurityContextHolder
SecurityContext 保存当前认证对象,SecurityContextHolder 提供对当前安全上下文的访问入口。
Authentication authentication = SecurityContextHolder
.getContext()
.getAuthentication();
在 Servlet 应用中,SecurityContextHolder 默认使用 ThreadLocal 策略,使同一请求线程中的代码可以读取当前用户。
ThreadLocal 只是默认策略,并不表示一个请求在所有情况下都始终使用同一线程。异步任务、线程池和响应式编程需要使用相应的上下文传播机制。
面试题:SecurityContext 为什么默认使用 ThreadLocal?
默认 Servlet 请求通常在线程中处理。使用 ThreadLocal 可以让同一线程中的代码直接获取当前认证信息,避免层层传递参数,并隔离不同请求线程的安全上下文。Spring Security 会在请求结束后清理上下文,避免线程复用造成身份泄露。
用户名密码登录流程
启用表单登录时,典型流程如下:
POST /login
→ UsernamePasswordAuthenticationFilter
→ 创建未认证 UsernamePasswordAuthenticationToken
→ AuthenticationManager
→ ProviderManager
→ DaoAuthenticationProvider
→ UserDetailsService 查询用户
→ PasswordEncoder 校验密码
→ 返回已认证 Authentication
→ 保存到 SecurityContext
→ 执行认证成功处理本课程后续使用自定义 LoginController 调用 AuthenticationManager。这种情况下,登录请求不会经过 UsernamePasswordAuthenticationFilter,控制器负责生成 JWT;后续请求则由自定义 JWT 过滤器解析令牌并建立当前请求的 SecurityContext。
本章总结
| 组件 | 作用 |
|---|---|
SecurityFilterChain | 定义请求需要执行的安全过滤器 |
FilterChainProxy | 匹配并执行安全过滤器链 |
Authentication | 表示认证请求或当前认证主体 |
AuthenticationManager | 提供统一认证入口 |
ProviderManager | 调度多个 AuthenticationProvider |
AuthenticationProvider | 执行具体认证逻辑 |
UserDetailsService | 加载用户信息 |
PasswordEncoder | 编码并校验密码 |
SecurityContext | 保存当前认证对象 |
SecurityContextHolder | 访问当前安全上下文 |
面试题:Authentication 与 UserDetails 有什么区别?
UserDetails 描述从用户数据源加载出的账号信息,例如用户名、密码摘要、账号状态和权限。
Authentication 既可以表示待认证凭证,也可以表示已经通过认证的当前主体。认证成功后,其 principal 通常是一个 UserDetails 实例。
前后端分离身份认证

本章采用以下方案:
用户名密码登录
→ AuthenticationManager 校验
→ 服务端签发 JWT
→ 客户端使用 Authorization 请求头携带 JWT
→ JWT 过滤器验签并建立 SecurityContext
→ Spring Security 执行访问授权
数据库表结构
下面的示例保留用户、角色、权限和菜单关系。角色表增加稳定的英文 role_key,用于生成 ROLE_ADMIN、ROLE_USER 等权限标识。
DROP TABLE IF EXISTS `t_menu_role`;
DROP TABLE IF EXISTS `t_role_permission`;
DROP TABLE IF EXISTS `t_menu`;
DROP TABLE IF EXISTS `t_permission`;
DROP TABLE IF EXISTS `t_user`;
DROP TABLE IF EXISTS `t_role`;
CREATE TABLE `t_menu` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` VARCHAR(255) DEFAULT NULL COMMENT '菜单名称',
`icon` VARCHAR(255) DEFAULT NULL COMMENT '菜单图标',
`url` VARCHAR(255) DEFAULT NULL COMMENT '菜单 URL',
`pid` BIGINT DEFAULT NULL COMMENT '父菜单 ID',
`remark` VARCHAR(255) DEFAULT NULL COMMENT '备注',
`level` INT DEFAULT NULL COMMENT '菜单层级',
`is_link` TINYINT DEFAULT 0 COMMENT '是否可跳转',
PRIMARY KEY (`id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
CREATE TABLE `t_role` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`role_name` VARCHAR(255) NOT NULL,
`role_key` VARCHAR(64) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_role_key` (`role_key`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
CREATE TABLE `t_permission` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`permission_name` VARCHAR(255) NOT NULL,
`permission_key` VARCHAR(255) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_permission_key` (`permission_key`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
CREATE TABLE `t_user` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(255) NOT NULL,
`password` VARCHAR(255) NOT NULL,
`role_id` BIGINT NOT NULL,
`nickname` VARCHAR(255) DEFAULT NULL,
`email` VARCHAR(255) DEFAULT NULL,
`phone` VARCHAR(255) DEFAULT NULL,
`freeze` TINYINT DEFAULT 0,
`dept_id` BIGINT DEFAULT NULL,
`remark` VARCHAR(255) DEFAULT NULL,
`insert_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
CREATE TABLE `t_role_permission` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`role_id` BIGINT NOT NULL,
`permission_id` BIGINT NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_role_permission` (`role_id`, `permission_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
CREATE TABLE `t_menu_role` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`menu_id` BIGINT NOT NULL,
`role_id` BIGINT NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_menu_role` (`menu_id`, `role_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
START TRANSACTION;
INSERT INTO `t_role` (`id`, `role_name`, `role_key`) VALUES
(1, '系统管理员', 'ADMIN'),
(2, '普通用户', 'USER');
INSERT INTO `t_permission` (`id`, `permission_name`, `permission_key`) VALUES
(1, '查询', 'permission:query'),
(2, '新增', 'permission:insert'),
(3, '修改', 'permission:update'),
(4, '删除', 'permission:delete');
INSERT INTO `t_role_permission` (`id`, `role_id`, `permission_id`) VALUES
(50, 1, 1),
(51, 1, 2),
(52, 1, 3),
(53, 1, 4),
(61, 2, 1);
INSERT INTO `t_menu` (`id`, `name`, `icon`, `url`, `pid`, `remark`, `level`, `is_link`) VALUES
(1, '系统设置', 'layui-icon-fire', NULL, -1, '', 1, 0),
(2, '分类管理', 'layui-icon-name', 'category/list', 1, '', 2, 1),
(3, '商品管理', 'layui-icon-service', NULL, -1, '', 1, 0),
(4, '商品列表', 'layui-icon-rate', 'asset-info/list', 3, '', 2, 1);
INSERT INTO `t_menu_role` (`id`, `menu_id`, `role_id`) VALUES
(1, 1, 1),
(2, 2, 1),
(3, 3, 1),
(4, 4, 1),
(5, 3, 2),
(6, 4, 2);
INSERT INTO `t_user`
(`id`, `username`, `password`, `role_id`, `nickname`, `insert_time`)
VALUES
(1, 'admin',
'$2a$10$t9BSP6hInmZm5RJocXVMdOLXzVXh4wgiBaYrM6iUslsrb.5z.eYce',
1, '超级管理员', '2021-05-26 14:41:06'),
(2, 'normal',
'$2a$10$Mv1ruD0gHy9Uq73SbfH80ep1McuJNZiJCjM3BpxAJVr9pt34iwWlS',
2, '普通用户', NULL);
COMMIT;后端依赖
<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>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.6</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>同一项目中的 JJWT 模块应使用相同版本。
前端请求拦截器
登录成功后,前端保存访问令牌,并在后续请求的 Authorization 请求头中携带 Bearer Token。
import axios from 'axios'
const service = axios.create({
baseURL: 'http://localhost:8080',
timeout: 5000
})
service.interceptors.request.use(
config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
error => Promise.reject(error)
)
service.interceptors.response.use(
response => response.data,
error => {
const status = error.response?.status
if (status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default serviceHTTP 状态码应与业务响应体中的业务码分开处理:
401 Unauthorized表示未认证、Token 无效或 Token 已过期。403 Forbidden表示已经认证,但没有访问权限。
Security 核心配置
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
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;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.nio.charset.StandardCharsets;
import java.util.List;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private static final String[] WHITE_LIST = {
"/login",
"/doc.html",
"/webjars/**",
"/favicon.ico",
"/druid/**",
"/public/**"
};
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults())
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint((request, response, exception) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(
"{\"code\":401,\"msg\":\"未登录或令牌无效\"}"
);
})
.accessDeniedHandler((request, response, exception) -> {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(
"{\"code\":403,\"msg\":\"没有访问权限\"}"
);
}))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers(WHITE_LIST).permitAll()
.anyRequest().authenticated())
.addFilterBefore(
jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class
);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("http://localhost:5173"));
configuration.setAllowedMethods(List.of(
"GET", "POST", "PUT", "DELETE", "OPTIONS"
));
configuration.setAllowedHeaders(List.of(
HttpHeaders.AUTHORIZATION,
HttpHeaders.CONTENT_TYPE
));
configuration.setExposedHeaders(List.of(HttpHeaders.AUTHORIZATION));
configuration.setAllowCredentials(false);
UrlBasedCorsConfigurationSource source =
new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
}本示例的 Token 只通过 Authorization 请求头传递,且服务端不依赖浏览器 Cookie,因此关闭 CSRF。若改为 Cookie 自动携带凭证,应重新评估并启用合适的 CSRF 防护。
@EnableWebSecurity 用于导入 Spring Security 的 Servlet Web 安全配置。真正的请求规则由 SecurityFilterChain Bean 定义;Spring Boot Starter 负责相关自动配置和基础集成。
生产环境中的 CORS 允许源必须配置为真实前端域名,不应无条件放行任意来源。
生成实体与 Mapper
可使用 MyBatis Plus 逆向工程生成 User、Role、Permission、Menu 及其 Mapper。生成后仍需检查字段类型、表关联查询和空值处理,不能直接把逆向工程结果视为完整业务代码。
实现 UserDetails
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
import java.util.stream.Stream;
public class UserItem implements UserDetails {
private final User user;
private final List<String> permissions;
private final List<GrantedAuthority> authorities;
public UserItem(User user, String roleKey, List<String> permissions) {
this.user = user;
this.permissions = List.copyOf(permissions);
this.authorities = Stream.concat(
Stream.of(new SimpleGrantedAuthority("ROLE_" + roleKey)),
permissions.stream().map(SimpleGrantedAuthority::new)
)
.map(GrantedAuthority.class::cast)
.toList();
}
public User getUser() {
return user;
}
public List<String> getPermissions() {
return permissions;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return user.getFreeze() == null || user.getFreeze() == 0;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}hasRole('ADMIN') 会自动检查 ROLE_ADMIN,因此数据库中的稳定角色标识应保存为 ADMIN,构造权限时再添加 ROLE_ 前缀。
实现 UserDetailsService
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
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;
import java.util.List;
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserMapper userMapper;
private final RoleMapper roleMapper;
private final PermissionMapper permissionMapper;
@Override
public UserDetails loadUserByUsername(String username) {
User user = userMapper.selectOne(
new LambdaQueryWrapper<User>()
.eq(User::getUsername, username)
.last("LIMIT 1")
);
if (user == null) {
throw new UsernameNotFoundException("账号不存在");
}
Role role = roleMapper.selectById(user.getRoleId());
if (role == null) {
throw new UsernameNotFoundException("账号未配置有效角色");
}
List<String> permissions = permissionMapper
.selectPermissionListByRoleId(user.getRoleId())
.stream()
.map(Permission::getPermissionKey)
.toList();
return new UserItem(user, role.getRoleKey(), permissions);
}
}BCrypt 密码编码
BCryptPasswordEncoder 使用带随机盐的自适应单向哈希算法。相同明文每次编码得到的结果通常不同,但都可以通过 matches() 完成校验。
@Test
void shouldEncodeAndMatchPassword() {
PasswordEncoder encoder = new BCryptPasswordEncoder();
String encodedPassword = encoder.encode("123456");
boolean matched = encoder.matches("123456", encodedPassword);
System.out.println(encodedPassword);
System.out.println(matched);
}密码摘要不能被“解密”回明文。登录时应使用 matches(rawPassword, encodedPassword) 校验,而不是再次编码后比较字符串。
统一响应对象
public record Result<T>(int code, T data, String msg, long count) {
public static <T> Result<T> ok(T data, String msg) {
return new Result<>(200, data, msg, 0);
}
public static Result<Void> ok(String msg) {
return new Result<>(200, null, msg, 0);
}
public static Result<Void> fail(int code, String msg) {
return new Result<>(code, null, msg, 0);
}
public static Result<Void> fail(String msg) {
return fail(500, msg);
}
}业务响应码应与 HTTP 状态码保持清晰语义。实际项目可使用 ResponseEntity 或全局异常处理器统一设置 HTTP 状态。
JWT 认证过滤器
自定义登录接口只负责签发 Token。后续每个受保护请求还需要解析 Token,并把认证结果放入当前请求的 SecurityContext。
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String BEARER_PREFIX = "Bearer ";
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authorization == null || !authorization.startsWith(BEARER_PREFIX)) {
filterChain.doFilter(request, response);
return;
}
String token = authorization.substring(BEARER_PREFIX.length()).trim();
try {
Claims claims = jwtUtil.parseToken(token);
if (SecurityContextHolder.getContext().getAuthentication() == null) {
List<?> values = claims.get("permissions", List.class);
List<GrantedAuthority> authorities = values == null
? List.of()
: values.stream()
.filter(String.class::isInstance)
.map(String.class::cast)
.map(SimpleGrantedAuthority::new)
.map(GrantedAuthority.class::cast)
.toList();
UsernamePasswordAuthenticationToken authentication =
UsernamePasswordAuthenticationToken.authenticated(
claims.getSubject(),
null,
authorities
);
SecurityContext context =
SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
filterChain.doFilter(request, response);
} catch (JwtException | IllegalArgumentException exception) {
SecurityContextHolder.clearContext();
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"code\":401,\"msg\":\"令牌无效或已过期\"}"
);
}
}
}该示例直接使用 JWT 中的权限,优点是无需每次查询数据库,缺点是权限变更不会立即影响旧 Token。生产系统可以采用短时效 Token、令牌版本号、黑名单,或每次请求重新查询关键权限。
自定义登录接口
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequiredArgsConstructor
public class LoginController {
private final AuthenticationManager authenticationManager;
private final MenuService menuService;
private final JwtUtil jwtUtil;
@PostMapping("/login")
public Result<Map<String, Object>> login(
@RequestBody LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(
request.username(),
request.password()
)
);
UserItem userItem = (UserItem) authentication.getPrincipal();
User user = userItem.getUser();
List<String> authorities = authentication.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.toList();
String token = jwtUtil.createToken(
user.getId(),
user.getUsername(),
user.getRoleId(),
authorities
);
List<Menu> menuList = menuService.listByRoleId(user.getRoleId());
Map<String, Object> data = new HashMap<>();
data.put("token", token);
data.put("user", user);
data.put("menuList", menuList);
data.put("authorities", authorities);
return Result.ok(data, "登录成功");
}
public record LoginRequest(String username, String password) {
}
}认证异常建议交给全局异常处理器统一转换:
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.LockedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BadCredentialsException.class)
public ResponseEntity<Result<Void>> handleBadCredentials() {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Result.fail(401, "用户名或密码错误"));
}
@ExceptionHandler(LockedException.class)
public ResponseEntity<Result<Void>> handleLocked() {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Result.fail(403, "账号已被冻结"));
}
}不要把密码、密码摘要等敏感字段直接放入登录响应。可以创建专用用户响应对象,只返回前端实际需要的字段。
接口测试
登录成功后,响应中返回 Token、用户基本信息、菜单和权限集合。后续请求应在请求头中携带:
Authorization: Bearer <token>

权限控制实战
开启方法级授权
Spring Security 6 使用 @EnableMethodSecurity 开启方法级授权。
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@SpringBootApplication
@EnableMethodSecurity
public class SecurityApplication {
public static void main(String[] args) {
SpringApplication.run(SecurityApplication.class, args);
}
}spring-boot-starter-security 不会默认开启方法级授权,因此需要显式添加 @EnableMethodSecurity。
使用 @PreAuthorize
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class PermissionController {
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/admin")
public String admin() {
return "管理员接口访问成功";
}
@PreAuthorize("hasAuthority('permission:query')")
@GetMapping("/user/list")
public String userList() {
return "用户列表接口访问成功";
}
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
@GetMapping("/common")
public String common() {
return "通用接口访问成功";
}
}常用表达式如下:
| 表达式 | 含义 |
|---|---|
hasRole('ADMIN') | 是否具有 ROLE_ADMIN |
hasAnyRole('ADMIN', 'USER') | 是否具有任一指定角色 |
hasAuthority('permission:query') | 是否具有指定权限 |
hasAnyAuthority('a', 'b') | 是否具有任一指定权限 |
isAuthenticated() | 当前用户是否已经认证 |
面试题:@PreAuthorize 的执行原理是什么?
启用方法安全后,Spring Security 基于 Spring AOP 为受保护的 Spring Bean 创建代理。方法调用前,AuthorizationManagerBeforeMethodInterceptor 读取 @PreAuthorize 的 SpEL 表达式,并结合当前 Authentication 进行授权判断。
校验通过后才会调用目标方法;校验失败时会抛出 AccessDeniedException。在 HTTP 请求链中,该异常通常被转换为 403 Forbidden 响应。
关键注意事项
- 认证成功不等于拥有所有权限,认证和授权必须分别配置。
401表示未通过认证,403表示已经认证但权限不足。- JWT 过滤器必须先验证签名和有效期,再创建已认证的
Authentication。 - 不要信任客户端直接提交的角色或权限。
- 不要在 Token 或响应体中返回密码、密码摘要和密钥。
- CORS、CSRF、XSS 属于不同安全问题,不能相互替代。
- 生产系统应使用 HTTPS,并配置合理的 Token 有效期、刷新和撤销机制。
- 方法级授权适合保护 Service 层关键操作,不能只依赖前端按钮是否显示。
喜欢的话,留下你的评论吧~