Spring Security + JWT 企业级鉴权实战:从零概念到完整代码实现

阅读前提:本文假设你已经会使用 Spring Boot 写基本的 CRUD 接口。如果你从未接触过 Spring Security,从这篇开始即可。

本文按照"先搞懂概念 → 教程版完整实现 → 生产版逐项升级 → 验证排错“的顺序组织。如果你只想快速跑通一个能用的版本,读完 Part 1 后直接看 Part 2 即可;如果你想理解企业级项目的真实做法,需要完整读完。


Part 1:先搞懂要做什么

在写任何代码之前,先把三个问题搞清楚:认证和授权到底是什么?Spring Security 怎么运作的?JWT 是什么?


一、从一个没有防护的接口说起

假设你用 Spring Boot 写了一个用户管理接口:

@RestController
@RequestMapping("/api/admin")
public class AdminController {

    @GetMapping("/users")
    public List<User> listAllUsers() {
        // 返回系统中所有用户信息
        return userService.findAll();
    }

    @DeleteMapping("/users/{id}")
    public String deleteUser(@PathVariable Long id) {
        userService.deleteById(id);
        return "删除成功";
    }
}

启动项目后,任何人只要知道 URL,就能直接访问这些接口——不需要登录,不需要权限。这在企业生产环境中是不可接受的。

你需要回答两个核心问题:

  1. 你是谁?(认证 Authentication)——访问者是否已经登录?用户名密码是否正确?
  2. 你能做什么?(授权 Authorization)——登录之后,你有没有权限删除用户?还是只能查看?

Spring Security 就是用来解决这两个问题的框架。


二、核心概念:认证与授权

2.1 认证(Authentication)——“你是谁”

认证就是验证用户身份的过程。最常见的认证方式是用户名 + 密码

用户提交用户名密码 → 系统验证是否正确 → 正确则签发凭证(如 JWT) → 用户持凭证访问

认证需要回答的问题:这个人真的是他声称的那个人吗?

业务上常见的认证方式:

方式说明使用场景
用户名 + 密码最基础的认证方式所有系统的标配
手机验证码通过短信验证移动端登录、快速注册
邮箱 + 密码类似用户名密码国际化产品
扫码登录通过已登录设备扫码确认Web 端快速登录
第三方登录OAuth2.0(微信 / 企业微信 / 钉钉)企业办公、社交产品
指纹 / 面容生物识别App 端便捷登录

2.2 授权(Authorization)——“你能做什么”

授权发生在认证之后。系统已经知道你是谁了,现在需要判断你有没有权限做某件事。

授权需要回答的问题:这个人有资格执行这个操作吗?

业务上常见的授权模式:

模式说明示例
角色授权(RBAC)基于角色控制权限ROLE_ADMIN 可以删除用户,ROLE_USER 不行
权限码授权基于细粒度权限码user:delete 权限才能删除用户
资源级授权基于数据归属只能查看自己部门的订单
动态授权权限规则存储在数据库中后台管理员可动态配置角色权限

2.3 认证与授权的关系

flowchart TD
classDef startEnd fill:#701a4c,stroke:#e11d48,stroke-width:2px,color:#fce7f3,font-weight:bold;
classDef condition fill:#2a1147,stroke:#a855f7,stroke-width:1.5px,color:#ede9fe,font-weight:bold;
classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb;
classDef reject fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold;

    START([用户请求接口]) --> COND_AUTH{"是否已认证\n(有合法Token) ?"}
    COND_AUTH -- 否 --> RESP_401([返回 401\n请先登录])
    COND_AUTH -- 是 --> AUTH_DONE[认证通过\n确定用户身份]
    AUTH_DONE --> COND_AUTHZ{"是否有权限\n(角色/权限码) ?"}
    COND_AUTHZ -- 否 --> RESP_403([返回 403\n无权限访问])
    COND_AUTHZ -- 是 --> RESP_OK([正常响应业务数据])

    class START,RESP_401,RESP_OK,RESP_403 startEnd;
    class COND_AUTH,COND_AUTHZ condition;
    class AUTH_DONE process;

关键理解:401(Unauthorized)和 403(Forbidden)的区别——401 是"我不知道你是谁,请先登录”;403 是"我知道你是谁,但你没资格做这件事"。前者是认证问题,后者是授权问题。


三、Spring Security 架构:一张图看懂所有组件

在写代码之前,先理解 Spring Security 的整体结构。不需要看源码,只需要知道每个组件是干什么的。

3.1 过滤器链(Filter Chain)

Spring Security 的核心是一组过滤器链。每个 HTTP 请求都会依次经过这条链上的所有过滤器,每个过滤器负责一件具体的事情。

flowchart LR
classDef root fill:#0f172a,stroke:#3b82f6,stroke-width:2px,color:#bfdbfe,font-weight:bold;
classDef branch fill:#2d1a05,stroke:#f59e0b,stroke-width:2px,color:#fde68a,font-weight:bold;
classDef leaf fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb;
classDef highlight fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold;

    ROOT[Spring Security\n过滤器链]

    ROOT --> B1[认证相关过滤器]
    B1 --> F1["🔐 UsernamePassword\nAuthenticationFilter\n处理用户名密码登录"]
    B1 --> F2["🔑 JwtAuthentication\nFilter 自定义\n从请求头提取JWT\n解析并设置认证状态"]

    ROOT --> B2[授权相关过滤器]
    B2 --> F3["✅ FilterSecurity\nInterceptor\n最终授权决策点\n判断是否有权限访问"]
    B2 --> F4["🛡️ ExceptionTranslation\nFilter\n将认证/授权异常\n转换为HTTP响应"]

    ROOT --> B3[安全基础过滤器]
    B3 --> F5["🧱 SecurityContext\nPersistenceFilter\n安全上下文持久化"]
    B3 --> F6["🚫 CsrfFilter\nCSRF防护\n前后端分离可关闭"]

    ROOT --> B4[核心组件]
    B4 --> C1["🏗️ AuthenticationManager\n认证管理器 接口\n默认实现: ProviderManager"]
    B4 --> C2["👤 UserDetailsService\n加载用户数据的接口\n你需要实现它"]
    B4 --> C3["🔒 PasswordEncoder\n密码加密器\n常用的: BCryptPasswordEncoder"]
    B4 --> C4["📋 SecurityContextHolder\n持有当前请求的\n认证状态 Authentication"]

    class ROOT root;
    class B1,B2,B3,B4 branch;
    class F1,F3,F5,F6,C1,C2,C3,C4 leaf;
    class F2 highlight;

3.2 核心组件一句话介绍

组件作用你需要做什么
SecurityContextHolder持有当前请求用户的认证信息不需要操作,Spring Security 自动管理
Authentication代表"当前用户是谁"及其权限列表登录成功后构造这个对象
UserDetailsService根据用户名从数据库加载用户信息必须实现:写一个类去数据库查用户
AuthenticationManager认证管理器,负责调用 UserDetailsService 验证用户通常不需要自定义,Spring Security 有默认实现
PasswordEncoder密码加密器,存库的密码必须加密配置一个 BCryptPasswordEncoder Bean
SecurityFilterChain过滤器链配置,定义哪些 URL 需要什么权限最重要:自定义安全规则
ProviderManagerAuthenticationManager 的默认实现,管理多个认证方式不需要操作

3.3 一个请求走完过滤器链的全过程

sequenceDiagram
    participant C as 客户端
    participant F1 as SecurityContext\nPersistenceFilter
    participant F2 as JWT认证过滤器\n自定义
    participant F3 as FilterSecurity\nInterceptor
    participant F4 as ExceptionTranslation\nFilter
    participant API as Controller

    C->>F1: HTTP请求
    F1->>F1: 检查SecurityContext\n是否已有认证信息
    F1->>F2: 继续

    alt 请求头带了JWT
        F2->>F2: 1.提取Token\n2.解析JWT获取用户信息\n3.查询用户权限
        F2->>F1: 4.设置Authentication到\nSecurityContextHolder
    else 请求头未带JWT
        F2->>F2: 跳过 匿名访问
    end

    F2->>F3: 继续

    alt URL需要认证
        F3->>F3: 检查SecurityContext\n中是否有认证信息
        alt 未认证
            F3->>F4: 抛出AuthenticationException
            F4->>C: 返回 401 或跳转登录页
        end
    end

    alt URL需要特定权限
        F3->>F3: 检查当前用户是否有\n该URL要求的权限
        alt 权限不足
            F3->>F4: 抛出AccessDeniedException
            F4->>C: 返回 403
        end
    end

    F3->>API: 放行
    API->>C: 正常响应

四、JWT 详解:前后端分离的凭证方案

JWT(JSON Web Token)是当前前后端分离项目中最主流的认证凭证格式。Spring Security 默认使用 Session 机制,但在前后端分离架构中,JWT 是完全替代 Session 的方案。

4.1 JWT 是什么

JWT 是一串经过签名(Signature)的字符串,用来在两个系统之间安全地传递信息。在鉴权场景中:

  • 登录成功后,服务端生成一个 JWT 返回给客户端
  • 客户端将它存储起来(通常是 localStorageCookie
  • 后续每次请求都在 HTTP Header 中携带这个 JWT
  • 服务端验证 JWT 的签名,从中解析出用户身份

4.2 JWT 的结构

一个 JWT 字符串长这样:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMDAxIiwiaWF0IjoxNjYwMTIzNDAwfQ.s5VqEeFv3k0KxJF8wYz7rB1tQpU2hHnOwDcMiLgA9X4

. 分割成三段:

Header.Payload.Signature
flowchart TD
classDef startEnd fill:#701a4c,stroke:#e11d48,stroke-width:2px,color:#fce7f3,font-weight:bold;
classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb;
classDef data fill:#052e16,stroke:#16a34a,stroke-width:1.5px,color:#bbf7d0,font-weight:bold;

    JWT[完整的JWT Token]

    JWT --> HEADER[Header 头部]
    HEADER --> H1["alg: HS256\n签名算法"]
    HEADER --> H2["typ: JWT\n令牌类型"]

    JWT --> PAYLOAD[Payload 载荷]
    PAYLOAD --> P1["sub: 用户ID\n主题,标准字段"]
    PAYLOAD --> P2["iat: 签发时间\n标准时间字段"]
    PAYLOAD --> P3["exp: 过期时间\n标准时间字段"]
    PAYLOAD --> P4["自定义字段\n如: username, roles"]

    JWT --> SIG[Signature 签名]
    SIG --> S1["HMACSHA256 \n  base64Url header\n  + '.' +\n  base64Url payload\n  secret\n"]

    class JWT startEnd;
    class HEADER,PAYLOAD,SIG process;
    class H1,H2,P1,P2,P3,P4,S1 data;

三部分的详细说明

部分内容是否加密说明
Header签名算法 + Token 类型否(仅 Base64 编码)指明用 HS256 还是 RS256
Payload用户信息 + 过期时间 + 自定义数据否(仅 Base64 编码)绝对不能放密码等敏感信息,Base64 只是编码不是加密
Signature签名(防篡改)签名校验用 Header 指定的算法,将 Header + Payload + 密钥一起签名。任何人修改 Payload 都会导致签名不匹配

4.3 JWT 的验证原理

服务端收到 JWT 后:

  1. . 分割出 Header、Payload、Signature
  2. 用相同的算法和密钥,对 Header.Payload 重新计算签名
  3. 对比计算出的签名和收到的 Signature 是否一致
  4. 一致 → 数据未被篡改,可以信任 Payload 中的用户信息
  5. 不一致 → 数据被篡改过,拒绝请求

关键设计:JWT 不是用来隐藏数据的(Payload 任何人 Base64 解码就能看到),而是用来防止数据被篡改的。因此 Payload 中绝不能放密码等敏感信息。

4.4 JWT vs Session 对比

对比维度Session(传统方案)JWT(前后端分离方案)
存储位置服务端内存 / Redis客户端(localStorage / Cookie)
服务端状态有状态——服务端必须保存 Session无状态——服务端不保存,密钥验证即可
扩展性多服务器需要共享 Session(Redis)天然支持多服务器,任何服务器都能验证
注销方式删除服务端 Session 即可需要客户端删除 Token,或服务端维护黑名单
跨域Cookie 跨域受限Header 携带,无跨域问题
适用场景服务端渲染(JSP / Thymeleaf)、单体应用前后端分离、微服务、移动 App

4.5 Access Token + Refresh Token 双令牌机制

企业生产中,JWT 通常采用双令牌机制:

令牌有效期存储位置作用
Access Token短(15 ~ 30 分钟)客户端内存每次请求携带,用于认证
Refresh Token长(7 天 ~ 30 天)HttpOnly Cookie 或安全存储Access Token 过期后,用它换取新的 Access Token
sequenceDiagram
    participant C as 客户端
    participant S as 服务端

    Note over C,S: 阶段一:登录
    C->>S: POST /api/auth/login\n用户名 + 密码
    S->>S: 验证用户名密码
    S->>C: 返回 Access Token 30分钟\n+ Refresh Token 7天

    Note over C,S: 阶段二:正常请求
    C->>S: GET /api/users\nHeader: Authorization Bearer {AccessToken}
    S->>S: 验证Access Token签名
    S->>C: 正常响应

    Note over C,S: 阶段三:Access Token过期
    C->>S: GET /api/users\nHeader: Authorization Bearer {AccessToken}
    S->>C: 401 Token Expired

    Note over C,S: 阶段四:刷新Token
    C->>S: POST /api/auth/refresh\n携带Refresh Token
    S->>S: 验证Refresh Token
    S->>C: 返回新的Access Token + 新的Refresh Token

    Note over C,S: 阶段五:Refresh Token也过期
    C->>S: POST /api/auth/refresh\n携带Refresh Token
    S->>C: 401 请重新登录

为什么需要双令牌? Access Token 短有效期是为了安全——即使泄露,攻击者也只能在 30 分钟内使用;Refresh Token 长有效期是为了用户体验——用户不用频繁输入密码重新登录。Refresh Token 通常存储在 HttpOnly Cookie 中(JS 无法读取),降低被 XSS 攻击窃取的风险。


Part 2:教程版 —— 从零搭建一个能跑的鉴权系统

Part 1 把概念、架构、JWT 原理都讲清楚了。从现在开始,你只需要做一件事:跟着写代码。下面每一节都给出了完整的、可运行的代码,你按顺序复制粘贴就能跑通。

教程版的技术选型:

  • Hutool JWT 生成和解析 Token
  • OncePerRequestFilter 实现 JWT 认证过滤器
  • 单表 t_user 存储用户和角色
  • 用户名 + 密码 单一认证方式
  • Token Payload 直接存用户信息(userId + username + role)

五、教程版完整实现

5.1 项目依赖(pom.xml)

<dependencies>
    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

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

    <!-- Spring Boot Validation -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

    <!-- Hutool 工具库(包含 JWT 模块) -->
    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>5.8.25</version>
    </dependency>

    <!-- Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>

    <!-- MyBatis-Plus(数据库操作) -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.5.5</version>
    </dependency>

    <!-- MySQL 驱动 -->
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

5.2 配置文件(application.yml)

server:
  port: 8080

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/mall?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver

# JWT 配置
jwt:
  secret: my-super-secret-key-for-jwt-signing-2024-please-change-in-production
  access-token-expire: 1800000      # 30分钟(毫秒)
  refresh-token-expire: 604800000   # 7天(毫秒)

5.3 数据库表结构

CREATE TABLE `t_user` (
    `id`          BIGINT       NOT NULL AUTO_INCREMENT COMMENT '用户ID',
    `username`    VARCHAR(50)  NOT NULL COMMENT '用户名',
    `password`    VARCHAR(200) NOT NULL COMMENT '加密后的密码',
    `role`        VARCHAR(50)  NOT NULL DEFAULT 'ROLE_USER' COMMENT '角色',
    `enabled`     TINYINT(1)   NOT NULL DEFAULT 1 COMMENT '是否启用 1:是 0:否',
    `create_time` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

-- 插入测试用户(密码都是 123456,BCrypt 加密后的结果)
INSERT INTO t_user (username, password, role) VALUES
('admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt6Z5Eh', 'ROLE_ADMIN'),
('zhangsan', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt6Z5Eh', 'ROLE_USER');

5.4 代码结构总览

src/main/java/com/mallshop/mallsecurity/
├── config/
│   ├── SecurityConfig.java          # Spring Security 核心配置
│   └── JwtConfig.java               # JWT 配置属性
├── controller/
│   └── AuthController.java          # 登录/刷新Token 接口
├── entity/
│   └── User.java                    # 用户实体
├── filter/
│   └── JwtAuthenticationFilter.java # JWT 认证过滤器
├── mapper/
│   └── UserMapper.java              # 数据库访问
├── service/
│   └── UserService.java             # 用户服务
└── util/
    └── JwtUtil.java                 # JWT 工具类(Hutool封装)

5.5 实体类

package com.mallshop.mallsecurity.entity;

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

@Data
@TableName("t_user")
public class User {
    private Long id;
    private String username;
    private String password;  // BCrypt加密后的密码
    private String role;      // ROLE_ADMIN 或 ROLE_USER
    private Boolean enabled;  // 是否启用
}

5.6 JWT 工具类(Hutool 封装)

这是教程版的核心工具类——用 Hutool 生成和解析 JWT,Payload 中 直接存放 userId、username、role

package com.mallshop.mallsecurity.util;

import cn.hutool.jwt.JWT;
import cn.hutool.jwt.JWTUtil;
import java.util.HashMap;
import java.util.Map;

public class JwtUtil {

    // 密钥——教程版直接写在代码里,生产版应放在配置文件中
    private static final String SECRET = "my-super-secret-key-for-jwt-signing-2024-please-change-in-production";

    /**
     * 生成 Access Token(30分钟有效)
     */
    public static String createAccessToken(Long userId, String username, String role) {
        Map<String, Object> payload = new HashMap<>();
        payload.put("userId", userId);
        payload.put("username", username);
        payload.put("role", role);
        // 过期时间:当前时间 + 30分钟
        payload.put("exp", System.currentTimeMillis() + 30 * 60 * 1000);
        return JWTUtil.createToken(payload, SECRET.getBytes());
    }

    /**
     * 生成 Refresh Token(7天有效)
     */
    public static String createRefreshToken(Long userId, String username) {
        Map<String, Object> payload = new HashMap<>();
        payload.put("userId", userId);
        payload.put("username", username);
        payload.put("type", "refresh");
        payload.put("exp", System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000);
        return JWTUtil.createToken(payload, SECRET.getBytes());
    }

    /**
     * 验证 Token 是否有效(签名正确 + 未过期)
     */
    public static boolean verify(String token) {
        return JWTUtil.verify(token, SECRET.getBytes());
    }

    /**
     * 从 Token 中解析 JWT 对象
     */
    public static JWT parseToken(String token) {
        return JWTUtil.parseToken(token);
    }

    /**
     * 从 Token 中获取用户ID
     */
    public static Long getUserId(String token) {
        JWT jwt = parseToken(token);
        return Long.valueOf(jwt.getPayload("userId").toString());
    }

    /**
     * 从 Token 中获取用户名
     */
    public static String getUsername(String token) {
        JWT jwt = parseToken(token);
        return (String) jwt.getPayload("username");
    }

    /**
     * 从 Token 中获取角色
     */
    public static String getRole(String token) {
        JWT jwt = parseToken(token);
        return (String) jwt.getPayload("role");
    }
}

Hutool JWT 常用 API 速查

方法作用
JWTUtil.createToken(map, key)生成 JWT
JWTUtil.verify(token, key)验证 JWT 签名和有效期
JWTUtil.parseToken(token)解析 JWT,读取 Payload
jwt.getPayload("key")读取 Payload 中指定字段的值

5.7 JWT 认证过滤器

每当请求到达时,这个过滤器从 Header 中提取 Token,验证并解析,然后将用户信息设置到 Spring Security 的上下文中。

package com.mallshop.mallsecurity.filter;

import cn.hutool.jwt.JWT;
import com.mallshop.mallsecurity.util.JwtUtil;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;

public class JwtAuthenticationFilter extends OncePerRequestFilter {

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

        // 1. 从请求头中提取 Token
        String authHeader = request.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            // 没有 Token,直接放行(后续 SecurityConfig 中的 URL 规则会拦截)
            filterChain.doFilter(request, response);
            return;
        }

        String token = authHeader.substring(7); // 去掉 "Bearer " 前缀

        // 2. 验证 Token
        if (!JwtUtil.verify(token)) {
            filterChain.doFilter(request, response);
            return;
        }

        // 3. 从 Token Payload 中解析用户信息
        String username = JwtUtil.getUsername(token);
        String role = JwtUtil.getRole(token);

        // 4. 构造 Authentication 对象,设置到 SecurityContext
        UsernamePasswordAuthenticationToken authentication =
                new UsernamePasswordAuthenticationToken(
                        username, null,
                        Collections.singletonList(new SimpleGrantedAuthority(role)));
        SecurityContextHolder.getContext().setAuthentication(authentication);

        // 5. 继续过滤器链
        filterChain.doFilter(request, response);
    }
}

关键理解:这个过滤器只负责"从 Token 中认出你是谁",不负责"拒绝没 Token 的请求"。拒绝操作由后面的 SecurityConfig 里的 URL 规则完成。这叫关注点分离——过滤器做认证,配置做授权。

5.8 Spring Security 核心配置

package com.mallshop.mallsecurity.config;

import com.mallshop.mallsecurity.filter.JwtAuthenticationFilter;
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.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;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

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

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // JWT 方案不需要 CSRF 保护
            .csrf().disable()
            // 不创建 Session(无状态)
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            // URL 授权规则
            .authorizeRequests()
            // 登录和刷新 Token 接口放行
            .antMatchers("/api/auth/login", "/api/auth/refresh").permitAll()
            // 管理员接口需要 ADMIN 角色
            .antMatchers("/api/admin/**").hasRole("ADMIN")
            // 其余所有请求需要认证
            .anyRequest().authenticated()
            .and()
            // 把自定义 JWT 过滤器加到 UsernamePasswordAuthenticationFilter 之前
            .addFilterBefore(new JwtAuthenticationFilter(),
                    UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

5.9 UserDetailsService 实现

Spring Security 需要知道从哪里加载用户数据。UserDetailsService 只有一个方法 loadUserByUsername——根据用户名从数据库查用户,返回 Spring Security 能理解的 UserDetails 对象。

package com.mallshop.mallsecurity.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.mallshop.mallsecurity.entity.User;
import com.mallshop.mallsecurity.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException {
        // 1. 从数据库查用户
        User user = userMapper.selectOne(
                new LambdaQueryWrapper<User>()
                        .eq(User::getUsername, username));

        if (user == null) {
            throw new UsernameNotFoundException("用户不存在: " + username);
        }

        // 2. 转换为 Spring Security 的 UserDetails
        return org.springframework.security.core.userdetails.User
                .withUsername(user.getUsername())
                .password(user.getPassword())
                .roles(user.getRole().replace("ROLE_", ""))
                .disabled(!user.getEnabled())
                .build();
    }
}

5.10 登录接口

package com.mallshop.mallsecurity.controller;

import com.mallshop.mallsecurity.util.JwtUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import java.util.HashMap;
import java.util.Map;

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

    @Autowired
    private AuthenticationManager authenticationManager;

    @PostMapping("/login")
    public Map<String, Object> login(@Valid @RequestBody LoginRequest request) {
        // 1. 调用 Spring Security 认证
        UsernamePasswordAuthenticationToken authToken =
                new UsernamePasswordAuthenticationToken(
                        request.getUsername(), request.getPassword());
        Authentication authentication =
                authenticationManager.authenticate(authToken);

        // 2. 认证成功,从数据库查用户信息
        org.springframework.security.core.userdetails.User userDetails =
                (org.springframework.security.core.userdetails.User)
                        authentication.getPrincipal();
        String role = userDetails.getAuthorities().iterator().next().getAuthority();

        // 3. 生成双令牌
        String accessToken = JwtUtil.createAccessToken(
                1L, userDetails.getUsername(), role);
        String refreshToken = JwtUtil.createRefreshToken(
                1L, userDetails.getUsername());

        // 4. 返回
        Map<String, Object> result = new HashMap<>();
        result.put("accessToken", accessToken);
        result.put("refreshToken", refreshToken);
        result.put("tokenType", "Bearer");
        result.put("expiresIn", 1800);
        return result;
    }

    @PostMapping("/refresh")
    public Map<String, Object> refresh(@RequestBody Map<String, String> body) {
        String refreshToken = body.get("refreshToken");
        if (!JwtUtil.verify(refreshToken)) {
            throw new RuntimeException("Refresh Token 无效或已过期");
        }
        // 用 Refresh Token 中的信息生成新的 Access Token
        String username = JwtUtil.getUsername(refreshToken);
        String role = JwtUtil.getRole(refreshToken);
        Long userId = JwtUtil.getUserId(refreshToken);

        String newAccessToken = JwtUtil.createAccessToken(userId, username, role);
        String newRefreshToken = JwtUtil.createRefreshToken(userId, username);

        Map<String, Object> result = new HashMap<>();
        result.put("accessToken", newAccessToken);
        result.put("refreshToken", newRefreshToken);
        result.put("tokenType", "Bearer");
        result.put("expiresIn", 1800);
        return result;
    }
}

// 登录请求体
class LoginRequest {
    private String username;
    private String password;
    // getters & setters...
}

5.11 测试接口

@RestController
@RequestMapping("/api/admin")
public class AdminController {

    @GetMapping("/dashboard")
    public String dashboard() {
        // 只有 ROLE_ADMIN 角色能访问
        return "管理员仪表盘——敏感数据";
    }
}

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

    @GetMapping("/info")
    public String userInfo() {
        // 登录用户均可访问
        return "用户个人信息";
    }
}

5.12 教程版小结

到这里,你已经有了一个完整可跑的鉴权系统。核心流程是:

登录 → 验证用户名密码 → 生成 JWT(Payload 里存 userId+username+role)→ 返回给客户端
请求 → JwtAuthFilter 提取 Token → 解析 Payload 拿角色 → 设置 SecurityContext → SecurityConfig 判断 URL 权限

教程版的问题——也是你必须继续读 Part 3 的原因:

问题后果
Token Payload 存了 userId+role,Base64 任何人可解码用户信息泄露
Token 签发后无法主动失效管理员改了权限,旧 Token 仍然有效
角色直接存在 t_user.role 字段无法支持"一个用户多个角色"
登录接口在 SecurityConfig 里硬编码放行每加一个公开接口都要改配置
Filter 中异常直接写 response错误格式不统一,前端要处理两套
密码明文传输HTTP 中间人攻击风险

这 6 个问题,正是 Part 3 要逐一解决的。


Part 3:生产版 —— 从教程到企业级的 6 个升级

下面每个升级都是独立的:教程版做了 X → 生产版改为 Y → 原因是 Z。你可以按顺序逐个应用到自己的项目里。


六、升级一:JJWT + Redis 双 key Token 方案

痛点:教程版用 Hutool 把 userId、username、role 全部塞进 Token Payload。任何人拿到 Token,Base64 解码就能看到这些信息。而且 Token 一旦签发,在过期之前无法让它失效。

6.1.1 核心思路

生产版的做法:Token 里只存最少信息(username),完整的用户详情存在 Redis 里。

Token Payload: { sub: "zhangsan", exp: 1234567890 }
Redis token:zhangsan → JWT 字符串(用于对比验证)
Redis user:zhangsan  → JwtUserEntity 的 JSON(完整用户信息)

6.1.2 为什么选择 JJWT 而不是 Hutool

Hutool JWTJJWT(io.jsonwebtoken
引入方式cn.hutool:hutool-allio.jsonwebtoken:jjwt
功能定位工具库附带(JWT 只是 Hutool 200+ 模块之一)专注 JWT——完整的构建/解析/验证 API
签名算法默认 HS256,切换不便显式指定 SignatureAlgorithm.HS512
Token 构建JWTUtil.createToken(map, key)Jwts.builder().setSubject().setExpiration().signWith().compact()——链式调用,每一步语义清晰
Payload 策略把 userId、role、username 全塞进去只存 sub(用户名)和 exp(过期时间)——用户详情放 Redis

6.1.3 添加 JJWT 依赖

<!-- JJWT -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

6.1.4 TokenHelper 完整代码

@Slf4j
@Component
public class UserTokenHelper {

    private static final String TOKEN_PREFIX = "token:";
    private static final String USER_PREFIX = "user:";

    @Getter
    @Value("${mall.mgt.tokenSecret:123456test}")
    private String tokenSecret;
    @Value("${mall.mgt.tokenExpireTimeInRecord:3600}")
    private int tokenExpireTimeInRecord;

    @Autowired
    protected RedisUtil redisUtil;

    /**
     * 生成 Token 并存入 Redis
     * @param username  用户名(作为 Token 的 subject)
     * @param json      用户完整信息的 JSON(存入 Redis)
     */
    public String generateToken(String username, String json) {
        // 生成 JWT——只存 sub + exp,不存任何业务字段
        String token = Jwts.builder()
                .setSubject(username)
                .setExpiration(new Date(System.currentTimeMillis()
                        + tokenExpireTimeInRecord * 1000))
                .signWith(SignatureAlgorithm.HS512, tokenSecret)
                .compact();

        // 双 key 写入 Redis——TTL 与 Token 一致
        redisUtil.set(getTokenKey(username), token, tokenExpireTimeInRecord);
        redisUtil.set(getUserKey(username), json, tokenExpireTimeInRecord);
        return token;
    }

    /**
     * 从 Token 中解析用户名
     */
    public String getUsernameFromToken(String token) {
        Claims claims = getClaimsFromToken(token);
        if (Objects.isNull(claims)) {
            return null;
        }
        return claims.getSubject();
    }

    /**
     * 解析 JWT Claims——验证签名 + 检查过期
     */
    public Claims getClaimsFromToken(String token) {
        Claims claims;
        try {
            claims = Jwts.parser()
                    .setSigningKey(getTokenSecret())
                    .parseClaimsJws(token)
                    .getBody();
        } catch (Exception e) {
            // 签名无效/过期 → 直接抛 BusinessException → GlobalExceptionHandler 统一处理
            throw new BusinessException(HttpStatus.FORBIDDEN.value(), "请先登录");
        }
        return claims;
    }

    protected String getTokenKey(String username) {
        return String.format("%s%s", TOKEN_PREFIX, username);
    }

    protected String getUserKey(String username) {
        return String.format("%s%s", USER_PREFIX, username);
    }
}

继承自 UserTokenHelper 的业务层 TokenHelper

@Slf4j
@Component
public class TokenHelper extends UserTokenHelper {

    /**
     * 生成 Token——调用父类方法,传入 UserDetails 序列化后的 JSON
     */
    public String generateToken(UserDetails userDetails) {
        return super.generateToken(
                userDetails.getUsername(),
                JSON.toJSONString(userDetails)  // FastJSON 序列化整个 UserDetails
        );
    }

    /**
     * 从 Redis 中获取用户详情——供 JwtTokenFilter 使用
     */
    public UserDetails getUserDetailsFromUsername(String username) {
        String userDetailJson = redisUtil.get(getUserKey(username));
        if (!StringUtils.hasLength(userDetailJson)) {
            return null;
        }
        return JSON.parseObject(userDetailJson, JwtUserEntity.class);
    }
}

6.1.5 Redis 双 key 的作用

Redis KeyValue作用
token:zhangsanJWT 字符串支持"踢人下线"——删掉这个 key,Token 就失效了
user:zhangsanJwtUserEntity 的 JSON过滤器拿到用户名后,从这里取完整的用户信息(id、roles、authorities)

注销(logout)的实现

public void delToken(String token) {
    String username = getUsernameFromToken(token);
    redisUtil.del(getTokenKey(username));   // 删除 token → Token 验证失败
    redisUtil.del(getUserKey(username));   // 删除 user → 用户信息丢失
}

两个 key 一起删——不管 JWT 本身的 exp 还有多久,Redis 里没有就是没有。不需要黑名单、不需要 JWT 版本号,Redis 的 TTL 就是 Token 的生命周期。

6.1.6 教程版 vs 生产版对比

教程版(Hutool)生产版(JJWT + Redis)
JWT 库Hutool JWTUtilJJWT Jwts.builder()
Token 里存什么userId + username + role + type + exp只存 sub(username) + exp
用户信息在哪Token Payload(Base64,可解码)Redis user:{username} key
主动失效需要额外维护黑名单删除 Redis key 即刻失效
过期控制JWT exp 字段JWT exp + Redis key TTL(双重控制)
防篡改签名校验签名校验 + Token 值对比(Redis 中存了一份正确的)

七、升级二:SecurityConfig —— @NoLogin 自动扫描 + 双 Provider + 方法级权限

痛点:教程版的 SecurityConfig 只有 4 行 permitAll,每次新增公开接口都要手动加 .antMatchers()。而且不支持 @PreAuthorize 方法级权限注解。

7.1 生产版 SecurityConfig 完整代码

@Configuration(proxyBeanMethods = false)
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)  // ① 开启方法级权限注解
public class SpringSecurityConfig implements ApplicationContextAware {

    private ApplicationContext applicationContext;

    @Override
    public void setApplicationContext(ApplicationContext ctx) {
        this.applicationContext = ctx;
    }

    // ===== 认证提供者 =====

    @Bean
    public SmsAuthenticationProvider smsAuthenticationProvider() {
        return new SmsAuthenticationProvider(
                applicationContext.getBean(UserDetailsServiceImpl.class),
                applicationContext.getBean(RedisUtil.class),
                applicationContext.getBean(UserMapper.class));
    }

    @Bean
    public DaoAuthenticationProvider daoAuthenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setPasswordEncoder(passwordEncoder());
        provider.setUserDetailsService(
                applicationContext.getBean(UserDetailsServiceImpl.class));
        return provider;
    }

    // ② AuthenticationManager 管理两个 Provider——密码登录 + 短信登录
    @Bean
    public AuthenticationManager authenticationManager() {
        List<AuthenticationProvider> providers = new ArrayList<>();
        providers.add(smsAuthenticationProvider());   // 短信验证码登录
        providers.add(daoAuthenticationProvider());   // 用户名密码登录
        return new ProviderManager(providers);
    }

    // ③ 去掉 ROLE_ 前缀——权限码直接是 "admin:user:delete" 而不是 "ROLE_admin:user:delete"
    @Bean
    public GrantedAuthorityDefaults grantedAuthorityDefaults() {
        return new GrantedAuthorityDefaults("");
    }

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

    // ===== 安全过滤器链 =====

    @Bean
    SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        // ④ 启动时扫描所有 @NoLogin 注解,构建免登录 URL 集合
        initNoLogin(applicationContext);

        return httpSecurity
                .csrf().disable()           // JWT 方案不需要 CSRF
                .headers().frameOptions().disable()  // 允许 iframe(Druid 监控页需要)
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                .authorizeRequests()
                // 静态资源
                .antMatchers(HttpMethod.GET,
                        "/*.html", "/**/*.html", "/**/*.css", "/**/*.js",
                        "/websocket/**", "/job/**", "/init/**").permitAll()
                // Swagger 文档
                .antMatchers("/swagger-ui.html", "/swagger-resources/**",
                        "/webjars/**", "/*/api-docs").permitAll()
                // Druid 监控 + 头像
                .antMatchers("/druid/**", "/avatar/**").permitAll()
                // OPTIONS 预检请求
                .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                // ⑤ @NoLogin 注解标记的接口——运行时动态构建的 permitAll 列表
                .antMatchers(NoLoginMap.getNoLoginUrlSet()
                        .toArray(new String[0])).permitAll()
                // 其余所有请求需要认证
                .anyRequest().authenticated()
                .and()
                .apply(new JwtTokenConfigurer())  // ⑥ 注册自定义 JWT 过滤器
                .and()
                .build();
    }

    /**
     * ⑦ 自动扫描:启动时遍历所有 @RequestMapping,收集标了 @NoLogin 的 URL
     */
    private void initNoLogin(ApplicationContext applicationContext) {
        RequestMappingHandlerMapping mapping = applicationContext
                .getBean(RequestMappingHandlerMapping.class);
        Map<RequestMappingInfo, HandlerMethod> handlerMethods =
                mapping.getHandlerMethods();

        Set<String> noLoginUrls = new HashSet<>();
        for (Map.Entry<RequestMappingInfo, HandlerMethod> entry
                : handlerMethods.entrySet()) {
            HandlerMethod handlerMethod = entry.getValue();
            NoLogin noLogin = handlerMethod.getMethodAnnotation(NoLogin.class);
            if (null != noLogin) {
                noLoginUrls.addAll(entry.getKey()
                        .getPatternsCondition().getPatterns());
            }
        }
        NoLoginMap.initSet(noLoginUrls);
    }
}

7.2 五个独特设计逐一解释

设计教程版生产版为什么
① @EnableGlobalMethodSecurity未启用prePostEnabled = true开启后可以在 Controller 方法上用 @PreAuthorize("hasAuthority('user:delete')") 做细粒度权限控制——不在 SecurityConfig 里写死角色规则
② 双 AuthenticationProvider只有一个默认的 DaoAuthenticationProviderSmsAuthenticationProvider + DaoAuthenticationProvider支持两种登录方式——短信验证码和用户名密码。ProviderManager 按注册顺序依次尝试,哪个 supports() 返回 true 就用哪个
③ GrantedAuthorityDefaults("")没有配置(默认 “ROLE_")显式去前缀hasRole("ADMIN") 背后会自动加 ROLE_ → 实际校验的权限码是 ROLE_ADMIN。去掉前缀后权限码直接是 admin:user:delete 格式——和数据库中存的完全一致,不混淆
④ initNoLogin() 自动扫描SecurityConfig 中硬编码 .antMatchers("/api/auth/login").permitAll()启动时扫描所有 @RequestMapping,找出标了 @NoLogin新增一个公开接口不需要改 SecurityConfig——只需要在方法上加 @NoLogin。符合开闭原则(对扩展开放、对修改关闭)
⑤ 7 类 permitAll 规则只有登录 + 刷新接口静态资源 + Swagger + Druid + WebSocket + OPTIONS + Job 回调 + NoLogin真实生产项目不仅有业务接口——监控、文档、WebSocket 都要在安全配置里声明。OPTIONS 预检请求必须放行否则前端 CORS 失败

八、升级三:JWT Filter —— 异常桥接 + NoLoginMap 跳过

痛点:教程版的 OncePerRequestFilter 在处理异常时只能直接在 response 上写 JSON。但这样做绕过了 @RestControllerAdvice,导致错误响应格式和正常接口不一致——前端要处理两套错误格式。

8.1 生产版 JwtTokenFilter

public class JwtTokenFilter extends GenericFilterBean {

    public final static String FILTER_ERROR = "filterError";
    public final static String FILTER_ERROR_PATH = "/throw-error";

    @Override
    public void doFilter(ServletRequest servletRequest,
            ServletResponse servletResponse, FilterChain filterChain)
            throws IOException, ServletException {

        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;

        // ① NoLoginMap 判断:标了 @NoLogin 的接口 → 直接放行
        if (!NoLoginMap.notExist(httpServletRequest.getRequestURI())) {
            filterChain.doFilter(httpServletRequest, servletResponse);
            return;
        }

        // ② 从 Authorization 头提取 Token
        String token = TokenUtil.getTokenForAuthorization(httpServletRequest);

        if (Objects.isNull(token)) {
            if (NoLoginMap.notExist(httpServletRequest.getRequestURI())) {
                // ③ 需要登录但没有 Token → 转发给 FilterExceptionController
                handleException((HttpServletRequest) servletRequest,
                        (HttpServletResponse) servletResponse,
                        new BusinessException(HttpStatus.FORBIDDEN.value(), "请先登录"));
            } else {
                filterChain.doFilter(httpServletRequest, servletResponse);
            }
            return;
        }

        // ④ 通过 SpringBeanUtil 获取 TokenHelper(Filter 不是 Spring Bean,不能 @Autowired)
        TokenHelper tokenHelper = SpringBeanUtil.getBean("tokenHelper");

        if (Objects.nonNull(tokenHelper)) {
            try {
                // ⑤ 从 Token 解析用户名 → 从 Redis 获取完整 UserDetails
                String username = tokenHelper.getUsernameFromToken(token);
                if (StringUtils.hasLength(username)
                        && SecurityContextHolder.getContext().getAuthentication() == null) {
                    UserDetails userDetails =
                            tokenHelper.getUserDetailsFromUsername(username);  // 从 Redis 取
                    if (Objects.nonNull(userDetails)) {
                        UsernamePasswordAuthenticationToken authentication =
                                new UsernamePasswordAuthenticationToken(
                                        userDetails, null,
                                        userDetails.getAuthorities());
                        authentication.setDetails(
                                new WebAuthenticationDetailsSource()
                                        .buildDetails(httpServletRequest));
                        SecurityContextHolder.getContext()
                                .setAuthentication(authentication);
                    }
                }
                filterChain.doFilter(httpServletRequest, servletResponse);
            } catch (BusinessException e) {
                // ⑥ Token 解析异常 → 转发给 FilterExceptionController
                handleException((HttpServletRequest) servletRequest,
                        (HttpServletResponse) servletResponse, e);
            }
        } else {
            filterChain.doFilter(httpServletRequest, servletResponse);
        }
    }

    /**
     * ⑦ Filter 不能直接返回 JSON——通过 forward 交给 Controller
     */
    private void handleException(HttpServletRequest request,
            HttpServletResponse response, BusinessException e)
            throws ServletException, IOException {
        request.setAttribute(FILTER_ERROR, e);
        request.getRequestDispatcher(FILTER_ERROR_PATH).forward(request, response);
    }
}

8.2 Filter 注册方式:JwtTokenConfigurer

public class JwtTokenConfigurer
        extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    @Override
    public void configure(HttpSecurity httpSecurity) {
        JwtTokenFilter jwtTokenFilter = new JwtTokenFilter();
        // 插在 UsernamePasswordAuthenticationFilter 之前
        httpSecurity.addFilterBefore(jwtTokenFilter,
                UsernamePasswordAuthenticationFilter.class);
    }
}

SecurityConfig 中通过 .apply(new JwtTokenConfigurer()) 注册——而不是 @Component 自动注入。这样保证 Filter 在 Spring Security 链中正确初始化。

8.3 FilterExceptionController —— 异常桥接

@Slf4j
@RestController
public class FilterExceptionController {

    @RequestMapping(FILTER_ERROR_PATH)  // "/throw-error"
    public void handleException(HttpServletRequest request) {
        Object exception = request.getAttribute(FILTER_ERROR);
        if (exception instanceof BusinessException) {
            BusinessException businessException = (BusinessException) exception;
            throw businessException;  // 重新抛出 → GlobalExceptionHandler 捕获
        }
        throw new BusinessException(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "服务器内部错误,请联系系统管理员!");
    }
}

8.4 教程版 vs 生产版对比

差异教程版生产版为什么
Filter 基类OncePerRequestFilterGenericFilterBean更灵活的控制——不强制每次请求只执行一次
Filter 注册方式@Component 自动注入后 addFilterBefore(jwtFilter, ...)JwtTokenConfigurernew JwtTokenFilter() 手动 new保证初始化顺序——避免 @Component 在其他 FilterConfig 之前加载导致注入问题
UserDetails 来源Token Payload 中解析 roleRedis user:{username} key 中取完整 JwtUserEntity权限可能在 Token 有效期内被管理员修改——从 Redis 取保证实时性
NoLoginMap 跳过没有这层——Filter 对每个请求都检查 TokenNoLoginMap.notExist(uri) 先判断是否需要登录@NoLogin 注解的方法应该在 Filter 层就跳过 JWT 校验
异常处理catch 块直接 response.getWriter().write("{\"code\":401}")forward 到 FilterExceptionController → Controller 重新 throw → @RestControllerAdvice 捕获Filter 不在 Spring MVC 上下文中,@RestControllerAdvice 无法捕获 Filter 中抛出的异常!forward 桥接是标准做法

⚠️ 新手提示:这是 Spring Security + 统一 JSON 返回格式组合中最容易踩的坑。直接 response.getWriter().write(...) 也能用,但 不走 GlobalExceptionHandler 的响应格式跟其他接口不一致,前端要处理两套错误格式。


九、升级四:RBAC 三表联查权限

痛点:教程版从 t_user.role 一个字段拿角色。真实系统是 RBAC 模型——用户 → 角色 → 菜单权限,权限来自三张表。

9.1 三表关系

t_user ──< t_user_role >── t_role ──< t_role_menu >── t_menu
                                  │
                                  └── permission 字段(角色自身的权限码)
                                                        │
                                  t_menu.permission 字段(菜单关联的权限码)

9.2 UserDetailsService 实现

@Service("userDetailsService")
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;
    @Autowired
    private RoleMapper roleMapper;
    @Autowired
    private MenuMapper menuMapper;

    @Override
    public UserDetails loadUserByUsername(String username) {
        // ① 查用户
        UserEntity userEntity = userMapper.findByUserName(username);
        if (Objects.isNull(userEntity)) {
            return null;  // 返回 null → DaoAuthenticationProvider 抛出 BadCredentialsException
        }

        // ② 查用户拥有的角色 → 查角色关联的菜单权限
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        fillUserAuthority(userEntity, authorities);

        // ③ 提取角色名列表(用于前端菜单展示)
        List<String> roles = authorities.stream()
                .map(SimpleGrantedAuthority::getAuthority)
                .collect(Collectors.toList());

        // ④ 返回自定义 UserDetails——除了权限,还带了 userId 和 roles
        return new JwtUserEntity(userEntity.getId(), username,
                userEntity.getPassword(), authorities, roles);
    }

    private void fillUserAuthority(UserEntity userEntity,
            List<SimpleGrantedAuthority> authorities) {
        // ⑤ 查用户关联的角色
        List<RoleEntity> roleEntities =
                roleMapper.findRoleByUserId(userEntity.getId());
        if (CollectionUtils.isEmpty(roleEntities)) {
            return;
        }

        // ⑥ 收集角色自身的权限码(如 "admin:user:list")
        Set<String> permissionSet = roleEntities.stream()
                .filter(x -> StringUtils.hasLength(x.getPermission()))
                .map(RoleEntity::getPermission)
                .collect(Collectors.toSet());

        // ⑦ 查角色关联的菜单 → 收集菜单的权限码(如 "user:delete")
        fillRoleMenu(roleEntities, permissionSet);

        if (CollectionUtils.isNotEmpty(permissionSet)) {
            authorities.addAll(permissionSet.stream()
                    .map(SimpleGrantedAuthority::new)
                    .collect(Collectors.toList()));
        }
    }

    private void fillRoleMenu(List<RoleEntity> roleEntities,
            Set<String> permissionSet) {
        List<Long> roleIdList = roleEntities.stream()
                .map(RoleEntity::getId).collect(Collectors.toList());
        List<MenuEntity> menuList =
                menuMapper.findMenuByRoleIdList(roleIdList);
        if (CollectionUtils.isEmpty(menuList)) {
            return;
        }
        for (MenuEntity menuEntity : menuList) {
            if (StringUtils.hasLength(menuEntity.getPermission())) {
                // ⑧ 菜单权限可能是逗号分隔的多个权限码,如 "user:add,user:delete"
                Set<String> menuPermSet = Arrays
                        .stream(menuEntity.getPermission().split(","))
                        .collect(Collectors.toSet());
                permissionSet.addAll(menuPermSet);
            }
        }
    }
}

9.3 自定义 UserDetails —— JwtUserEntity

@Data
@NoArgsConstructor
@AllArgsConstructor
public class JwtUserEntity implements UserDetails {

    private Long id;                              // 用户ID——后续填充审计字段时用
    private String username;
    @JsonIgnore
    private String password;                      // 密码不序列化到 Redis 的 JSON 中
    private List<SimpleGrantedAuthority> authorities;  // 权限码集合
    private List<String> roles;                   // 角色名集合(前端菜单用)

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

比教程版多出来的关键字段是 idroles——id 用于填充数据库审计字段(createUserId/updateUserId),roles 用于前端根据角色展示不同菜单。


十、升级五:双认证 Provider(密码 + 短信)

痛点:教程版只有用户名密码一种登录方式。真实项目通常需要支持短信验证码登录。

10.1 短信验证码登录的 SmsAuthenticationProvider

public class SmsAuthenticationProvider implements AuthenticationProvider {

    @Override
    public Authentication authenticate(Authentication authentication) {
        String phone = (String) authentication.getPrincipal();
        String captcha = (String) authentication.getCredentials();

        // ① 验证短信验证码
        String smsCodeKey = getSmsCodePrefixKey(phone, SmsTypeEnum.LOGIN);
        String smsCode = redisUtil.get(smsCodeKey);
        AssertUtil.hasLength(smsCode, "该短信验证码已失效");
        AssertUtil.isTrue(smsCode.trim().equals(captcha), "短信验证码错误");

        try {
            // ② 根据手机号查用户——没有就自动注册
            List<UserEntity> userEntities = userMapper.searchByPhone(phone);
            UserEntity userEntity;
            if (CollectionUtils.isEmpty(userEntities)) {
                userEntity = registerUser(phone);  // 自动注册
            } else {
                userEntity = userEntities.get(0);
            }

            // ③ 走正常的 UserDetailsService 加载权限
            UserDetails userDetails = userDetailsService
                    .loadUserByUsername(userEntity.getUserName());

            // ④ 返回已认证的 Token
            return new SmsAuthenticationToken(
                    userDetails, null, userDetails.getAuthorities());
        } finally {
            redisUtil.del(smsCodeKey);  // 用完即删
        }
    }

    @Override
    public boolean supports(Class<?> authentication) {
        // ⑤ 只有 SmsAuthenticationToken 才交给我处理
        return SmsAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

SmsAuthenticationToken 继承自 AbstractAuthenticationToken——和 UsernamePasswordAuthenticationToken 是平级关系。ProviderManager 按注册顺序依次询问每个 Provider 的 supports(),谁支持就交给谁处理。

10.2 ProviderManager 的调度逻辑

登录请求 → ProviderManager.authenticate()
  → 问 SmsAuthenticationProvider.supports() → 是 SmsAuthenticationToken 吗?
    → 是 → SmsAuthenticationProvider.authenticate() 处理
    → 否 → 问 DaoAuthenticationProvider.supports() → 是 UsernamePasswordAuthenticationToken 吗?
      → 是 → DaoAuthenticationProvider.authenticate() 处理
      → 否 → 抛出 ProviderNotFoundException

十一、升级六:完整登录流程安全加固

痛点:教程版的登录只做了用户名密码验证。生产环境还需要:验证码防刷、RSA 传输加密、异地登录检测、登录错误锁定。

11.1 完整登录代码

@Slf4j
@Service
public class UserAuthService {

    @Autowired
    private UserMapper userMapper;
    @Autowired
    private TokenHelper tokenHelper;
    @Autowired
    private PasswordUtil passwordUtil;        // RSA 密码解密
    @Autowired
    private RedisUtil redisUtil;
    @Autowired
    private AuthenticationManager authenticationManager;
    @Autowired
    private GeoIpHelper geoIpHelper;          // IP 归属地查询

    @Value("${mall.mgt.tokenExpireTimeInRecord:3600}")
    private int tokenExpireTimeInRecord;

    /**
     * 用户名密码登录
     */
    public TokenEntity login(AuthUserEntity authUserEntity) {
        // ① 登录锁定检查
        checkUserIsLocked(authUserEntity.getUsername());

        try {
            // ② 验证图形验证码
            String code = redisUtil.get(getCaptchaKey(authUserEntity.getUuid()));
            AssertUtil.hasLength(code, "该验证码已失效");
            AssertUtil.isTrue(code.trim()
                    .equals(authUserEntity.getCode().trim()), "验证码错误");

            // ③ RSA 解密前端传来的加密密码
            String decodePassword = passwordUtil
                    .decodeRsaPassword(authUserEntity);

            // ④ 调用 Spring Security 认证链路
            UsernamePasswordAuthenticationToken authToken =
                    new UsernamePasswordAuthenticationToken(
                            authUserEntity.getUsername(), decodePassword);
            Authentication authentication =
                    authenticationManager.authenticate(authToken);
            SecurityContextHolder.getContext()
                    .setAuthentication(authentication);

            JwtUserEntity jwtUserEntity =
                    (JwtUserEntity) authentication.getPrincipal();

            // ⑤ 异地登录检测——如果本次登录城市与上次不同,发告警邮件
            HttpServletRequest request = ((ServletRequestAttributes)
                    RequestContextHolder.getRequestAttributes()).getRequest();
            String ip = IpUtil.getIpAddr(request);
            CityDTO cityDTO = geoIpHelper.getCity(ip);
            if (Objects.nonNull(cityDTO)) {
                userGeoIpService.validateRemoteLogin(
                        jwtUserEntity.getId(), cityDTO.getCity());
            }

            // ⑥ 生成 JWT + 存入 Redis——通过升级一的 TokenHelper
            String token = tokenHelper.generateToken(jwtUserEntity);
            redisUtil.del(getCaptchaKey(authUserEntity.getUuid()));  // 用完即删

            // ⑦ 返回 TokenEntity(含 username、token、roles、过期时间)
            List<String> roleList = jwtUserEntity.getAuthorities().stream()
                    .map(SimpleGrantedAuthority::getAuthority)
                    .collect(Collectors.toList());
            return new TokenEntity(jwtUserEntity.getUsername(),
                    token, roleList, tokenExpireTimeInRecord);

        } catch (Exception e) {
            // ⑧ 登录失败 → 记录错误次数 → 超过阈值锁定账户
            if (e instanceof BusinessException
                    || e instanceof BadCredentialsException) {
                recordLoginErrorUser(authUserEntity.getUsername());
            }
            throw new BusinessException("用户名或密码错误");
        }
    }

    // ⑨ Redis INCR 原子递增登录错误次数
    private void recordLoginErrorUser(String key) {
        String loginErrorKey = LOGIN_ERROR_USER_PREFIX + key;
        Long count = redisUtil.increment(loginErrorKey);  // 原子操作
        if (count == 1) {
            redisUtil.expire(loginErrorKey, lockedUserTime);
        }
        if (count > MAX_LOGIN_ERROR_COUNT) {
            redisUtil.set(LOCKED_USER_PREFIX + key, "true", lockedUserTime);
            throw new BusinessException("该用户已被锁定");
        }
    }

    private void checkUserIsLocked(String key) {
        String value = redisUtil.get(LOCKED_USER_PREFIX + key);
        if (StringUtils.hasLength(value)) {
            throw new BusinessException("该用户已被锁定");
        }
    }

    /**
     * 注销——删除 Redis 中的 Token 和用户信息
     */
    public void logout(HttpServletRequest request) {
        String token = TokenUtil.getTokenForAuthorization(request);
        AssertUtil.hasLength(token, "请重新登录");
        tokenHelper.delToken(token);  // 同时删除 token:xxx 和 user:xxx
    }
}

11.2 登录流程 6 个安全环节

环节教程版生产版作用
密码传输明文密码RSA 加密传输 → 后端私钥解密防止 HTTP 明文传输密码被中间人截获
验证码算术验证码 + Redis TTL 保证时效性防止暴力破解、机器人登录
认证方式仅用户名密码用户名密码 + 短信验证码(双 Provider)支持多种登录场景
安全防护Redis INCR 登录错误计数 → 超过阈值自动锁定防止暴力破解
风控IP 归属地 → 异地登录检测账号异常登录告警
Token 存储仅生成返回Redis 双 key 存储 → 支持主动失效管理员改权限立即生效、支持踢人下线

Part 4:验证与排错


十二、完整鉴权流程时序图

整合了上述所有组件,一个完整的请求鉴权流程如下:

sequenceDiagram
    participant C as 客户端
    participant JWT as JwtAuthFilter
    participant SEC as SecurityConfig\nURL规则
    participant AM as AuthManager
    participant UDS as UserDetailsService
    participant DB as 数据库
    participant API as Controller

    Note over C,API: ====== 登录流程 ======
    C->>API: POST /api/auth/login\n{username, password}
    API->>AM: authenticate username, password
    AM->>UDS: loadUserByUsername username
    UDS->>DB: SELECT * FROM t_user WHERE username=?
    DB-->>UDS: 用户数据
    UDS-->>AM: UserDetails 含加密密码
    AM->>AM: PasswordEncoder.matches\n输入的密码, 库中的密码
    AM-->>API: 认证成功 → Authentication对象
    API->>API: 生成AccessToken + RefreshToken
    API-->>C: {accessToken, refreshToken}

    Note over C,API: ====== 带Token的业务请求 ======
    C->>JWT: GET /api/admin/dashboard\nHeader: Bearer xxx.yyy.zzz
    JWT->>JWT: 提取Token → 验证签名 → 解析Payload
    JWT->>JWT: 构造Authentication对象\nusername, role
    JWT->>JWT: SecurityContextHolder\n.setAuthentication(...)
    JWT->>SEC: 继续过滤器链

    SEC->>SEC: 检查URL规则\n/api/admin/** → hasRole ADMIN
    SEC->>SEC: 从SecurityContext\n获取当前用户role
    alt 角色匹配
        SEC->>API: 放行
        API-->>C: 200 正常数据
    else 角色不匹配
        SEC-->>C: 403 Forbidden
    end

    Note over C,API: ====== 无Token的请求 ======
    C->>JWT: GET /api/user/info\n无 Authorization Header
    JWT->>JWT: 无Token, 直接放行
    JWT->>SEC: 继续过滤器链
    SEC->>SEC: 检查URL规则\nanyRequest().authenticated()
    SEC->>SEC: SecurityContext中\n无认证信息
    SEC-->>C: 401 Unauthorized

十三、接口测试

13.1 无 Token 访问受保护接口 → 401

# 请求
curl -X GET http://localhost:8080/api/user/info

# 响应
HTTP 401 Unauthorized

13.2 登录获取 Token

# 请求
curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"123456"}'

# 响应
{
  "accessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
  "refreshToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
  "tokenType": "Bearer",
  "expiresIn": 1800
}

13.3 携带 Token 访问受保护接口 → 200

# 请求
curl -X GET http://localhost:8080/api/user/info \
  -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."

# 响应
HTTP 200
"用户个人信息"

13.4 普通用户访问管理员接口 → 403

# 用 zhangsan(ROLE_USER) 的 Token 访问管理员接口
curl -X GET http://localhost:8080/api/admin/dashboard \
  -H "Authorization: Bearer {zhangsan的Token}"

# 响应
HTTP 403 Forbidden

13.5 Token 过期后刷新

# 请求
curl -X POST http://localhost:8080/api/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."}'

# 响应
{
  "accessToken": "新的AccessToken",
  "refreshToken": "新的RefreshToken",
  "tokenType": "Bearer",
  "expiresIn": 1800
}

十四、补充配置

14.1 自定义认证失败和权限不足的响应

Spring Security 默认的 401 和 403 响应是 HTML 页面或很简略的文本,前后端分离项目中需要返回统一的 JSON 格式。

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // 自定义认证失败处理(未登录)
            .exceptionHandling()
            .authenticationEntryPoint((request, response, authException) -> {
                response.setContentType("application/json;charset=UTF-8");
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                response.getWriter().write(
                    "{\"code\":401,\"message\":\"请先登录\"}");
            })
            // 自定义授权失败处理(权限不足)
            .accessDeniedHandler((request, response, accessDeniedException) -> {
                response.setContentType("application/json;charset=UTF-8");
                response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                response.getWriter().write(
                    "{\"code\":403,\"message\":\"权限不足, 无法访问\"}");
            });

        return http.build();
    }
}

14.2 允许跨域(CORS)

前后端分离项目中,前端(如 localhost:3000)和后端(localhost:8080)不在同一个端口,需要配置跨域:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // 开启跨域
            .cors().configurationSource(corsConfigurationSource())
            .and()
            // ... 其他配置 ...;

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(Arrays.asList("http://localhost:3000"));
        config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(Arrays.asList("*"));
        config.setAllowCredentials(true);

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

14.3 密码加密工具(生成 BCrypt 密文)

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

public class PasswordGenerator {
    public static void main(String[] args) {
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
        // 每次生成的密文不同,但都能匹配"123456"
        String encoded = encoder.encode("123456");
        System.out.println(encoded);
    }
}

14.4 忽略某些 URL(如 Swagger、静态资源)

@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
    // 完全忽略某些 URL(不经过过滤器链)
    return (web) -> web.ignoring()
        .antMatchers("/favicon.ico", "/error");
}

十五、常见问题排查

问题现象可能原因解决方向
Filter 中抛出的异常没有被 @RestControllerAdvice 捕获Filter 在 Servlet 容器层,不在 Spring MVC 上下文参考第八章的 FilterExceptionController 桥接模式——forward 到 Controller 再重新 throw
所有请求都返回 401JWT 过滤器没有正确设置 SecurityContext检查 SecurityContextHolder.getContext().setAuthentication() 是否执行;另外检查 Redis 中的 user:{username} key 是否存在
@Async 方法中获取不到当前用户SecurityContextHolder 默认是线程绑定的子线程需要手动传递 SecurityContext
登录接口返回 403 或 401登录接口需要 .permitAll(),或 CSRF 未关闭SecurityFilterChain 中放行;如果用 @NoLogin 注解,确认 initNoLogin() 在启动时执行了
Token 解析报错密钥不一致、Token 格式错误、Redis 中 Token 已过期Token 解析失败可能是因为 Redis TTL 到期导致 token:{username} key 被自动删除了——此时虽然 JWT 本身未过期但在 Redis 找不到了
角色校验不生效hasRole("ADMIN") 会自动加 ROLE_ 前缀如果用了 GrantedAuthorityDefaults("")(见第七章),已去掉 ROLE_ 前缀——此时应该用 hasAuthority("admin:user:delete") 而不是 hasRole("ADMIN")
@PreAuthorize 注解不生效没有开启 @EnableGlobalMethodSecurity在 SecurityConfig 上加 @EnableGlobalMethodSecurity(prePostEnabled = true)(见第七章)
新增了公开接口但仍要求登录忘记加 @NoLogin 注解,或 initNoLogin() 扫描时机问题确认方法上有 @NoLogin 注解;如果是动态注册的 Controller,可能不会被扫描到——改为显式 .antMatchers() 配置

十六、总结

本文按照”先搞懂概念 → 教程版完整实现 → 生产版逐项升级 → 验证排错“的顺序,完整讲解了 Spring Security + JWT 企业级鉴权体系。

16.1 教程版 vs 生产版 全貌对比

模块教程做法生产做法为什么不同
JWT 工具Hutool JWTUtil.createToken(payload, key)JJWT Jwts.builder().signWith(HS512) + Redis 双 keyToken Payload 只存 username——安全性;Redis 存储完整 UserDetails——支持主动失效
Token 主动失效没有(依赖 JWT 自然过期)删 Redis key 即刻失效支持"踢人下线”——管理后台修改角色后立即生效
JWT FilterOncePerRequestFilter + 直接写 responseGenericFilterBean + FilterExceptionController forward 桥接Filter 异常不被 @ControllerAdvice 捕获——forward 到 Controller 重新 throw 以输出统一 JSON
SecurityConfig4 行 permitAll7 类 permitAll + @NoLogin 自动扫描 + @EnableGlobalMethodSecurity生产项目需要:监控页放行、预检放行、动态 URL 放行、方法级权限注解
权限加载t_user.role 字段User → Role → Menu 三表联查真实 RBAC 模型——权限码来自角色的 permission 字段 + 菜单的 permission 字段
登录流程用户名密码明文 → 签发 Token验证码校验 + RSA 解密 + 短信双认证 + 异地登录检测 + 登录锁定安全不是"有没有登录",而是"登录的人是不是真的是他"
密码加密仅 BCrypt 存储BCrypt 存储 + RSA 传输加密防止 HTTP 明文传输密码被中间人截获

16.2 关键配置速查(生产版)

配置项代码作用
关闭 Session.sessionManagement().sessionCreationPolicy(STATELESS)JWT 方案核心
关闭 CSRF.csrf().disable()前后端分离不需要 CSRF
去除 ROLE_ 前缀new GrantedAuthorityDefaults("")权限码和数据库一致,不混淆
开启方法级权限@EnableGlobalMethodSecurity(prePostEnabled = true)Controller 方法上可用 @PreAuthorize
自动扫描免登录initNoLogin(applicationContext)@NoLogin 注解即放行,不改 SecurityConfig
注册自定义 Filter.apply(new JwtTokenConfigurer())在 SecurityConfigurerAdapter 中 new Filter 保证初始化顺序
Filter 异常转发request.setAttribute("filterError", e) + forwardFilter 异常 → Controller 重新 throw → 统一 JSON 输出

16.3 学习路径建议

  1. 读 Part 1,理解认证/授权的两个核心问题(“你是谁” + “你能做什么”)
  2. 读 Part 2,跟着教程版代码从头写一遍,跑通 401、403、200 三种响应
  3. 读 Part 3,理解教程版与生产版的 6 个差异,按顺序逐个升级——建议从升级一(JJWT+Redis)开始
  4. 加上 @NoLogin 注解 + initNoLogin() 自动扫描,体会"开闭原则"的实际应用
  5. 最后加上 FilterExceptionController 桥接——你会发现"统一 JSON 返回格式"的最后一公里终于打通了