SpringSecurity

发布于 2026-08-03 10:03 更新于 2026-08-03 10:03 5979 字 30 min read ... 访问量

本文介绍了 Spring Security 6 企业级安全框架的核心概念与实现机制,重点讲解了身份认证与访问授权的基本流程、核心组件(如 SecurityFilterChain、AuthenticationManager、UserDetailsService 等)以及在前后端分离场景下的 JWT 认证实践。文章强调了安全配置的正确性、密码编码的规范性、CSRF 与 XSS 防护的必要性,并指出生产环境中应结合威胁模型选择合适的认证存储方式和安全策略。

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 的常见能力包括:

  1. 用户名密码认证、证书认证、一次性令牌认证等身份认证机制。
  2. URL 级别与方法级别的访问授权。
  3. 密码编码与校验。
  4. 会话管理、固定会话攻击防护和并发会话控制。
  5. CSRF 防护、安全响应头等 Web 安全能力。
  6. 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 防护。

核心架构

image-001
image-001

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 应用启用默认安全配置,并显示登录页面。

image-002
image-002

默认用户

当项目中没有自定义 UserDetailsServiceAuthenticationProvider 等认证配置时,Spring Boot 通常会创建默认用户:

username: user
password: 启动时随机生成

随机密码会输出到控制台。

image-003
image-003

自定义默认用户名和密码

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 核心原理

学习目标

完成本章后,应能够:

  1. 理解 Spring Security 的整体请求流程。
  2. 掌握 SecurityFilterChain 的工作机制。
  3. 理解 Authentication 的两种主要用途。
  4. 掌握 AuthenticationManagerAuthenticationProvider 的关系。
  5. 理解 SecurityContextSecurityContextHolder 的职责。
  6. 分析用户名密码认证的主要执行步骤。

基于 Filter 的安全处理

在传统 Spring MVC 应用中,请求流程可以简化为:

客户端请求
→ Servlet Filter
→ DispatcherServlet
→ HandlerInterceptor
→ Controller
→ Service
→ DAO
→ Database

Spring Security 选择过滤器作为 Servlet 安全入口,使认证和授权可以在业务代码执行前完成。

例如,用户请求 GET /api/user/delete/1 时,安全过滤器链会先判断用户是否已经认证以及是否具有删除权限。校验失败时,请求不会进入目标控制器。

SecurityFilterChain

SecurityFilterChain 表示一组用于处理安全任务的过滤器。一个应用可以声明多个安全过滤器链,每条链负责不同的请求范围。

image-004
image-004

DelegatingFilterProxy 与 FilterChainProxy

Spring Security 的 Servlet 请求转发关系可以概括为:

DelegatingFilterProxy
→ FilterChainProxy
→ 匹配到的 SecurityFilterChain
→ 链内的 Security Filter
image-005
image-005
  1. DelegatingFilterProxy 注册在 Servlet 容器中,负责把过滤工作委托给 Spring 容器管理的 Filter Bean。
  2. FilterChainProxy 是 Spring Security 的核心入口,会根据当前请求匹配合适的 SecurityFilterChain
  3. 被选中的 SecurityFilterChain 按顺序执行其中的安全过滤器。

过滤器的具体组成取决于配置。常见过滤器包括:

  • SecurityContextHolderFilter:加载并管理安全上下文。
  • CsrfFilter:启用 CSRF 时校验 CSRF Token。
  • UsernamePasswordAuthenticationFilter:启用表单登录时处理用户名密码登录请求。
  • BearerTokenAuthenticationFilter:配置 OAuth 2.0 资源服务器时处理 Bearer Token。
  • AnonymousAuthenticationFilter:为匿名请求提供匿名 Authentication
  • ExceptionTranslationFilter:把认证和授权异常转换为相应处理流程。
  • AuthorizationFilter:执行请求级授权判断。

部分旧版架构图中会出现 FilterSecurityInterceptor。在 Spring Security 6 的常用配置中,请求授权通常由 AuthorizationFilter 完成。实际过滤器列表应以项目启动日志或调试输出为准。

Authentication 认证模型

image-006
image-006

Authentication 的作用

Authentication 有两种主要用途:

  1. 作为待认证凭证提交给 AuthenticationManager
  2. 表示当前已经通过认证的主体,并保存到 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 实例。

前后端分离身份认证

image-007
image-007

本章采用以下方案:

用户名密码登录
→ AuthenticationManager 校验
→ 服务端签发 JWT
→ 客户端使用 Authorization 请求头携带 JWT
→ JWT 过滤器验签并建立 SecurityContext
→ Spring Security 执行访问授权

数据库表结构

下面的示例保留用户、角色、权限和菜单关系。角色表增加稳定的英文 role_key,用于生成 ROLE_ADMINROLE_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 service

HTTP 状态码应与业务响应体中的业务码分开处理:

  • 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 逆向工程生成 UserRolePermissionMenu 及其 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>
image-008
image-008

权限控制实战

开启方法级授权

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 响应。

关键注意事项

  1. 认证成功不等于拥有所有权限,认证和授权必须分别配置。
  2. 401 表示未通过认证,403 表示已经认证但权限不足。
  3. JWT 过滤器必须先验证签名和有效期,再创建已认证的 Authentication
  4. 不要信任客户端直接提交的角色或权限。
  5. 不要在 Token 或响应体中返回密码、密码摘要和密钥。
  6. CORS、CSRF、XSS 属于不同安全问题,不能相互替代。
  7. 生产系统应使用 HTTPS,并配置合理的 Token 有效期、刷新和撤销机制。
  8. 方法级授权适合保护 Service 层关键操作,不能只依赖前端按钮是否显示。

喜欢的话,留下你的评论吧~

... 访问量
© 2026 跨越星轨的客 @Hoshiumi
Powered by theme astro-koharu · Inspired by Shoka