【JavaWeb】----- 登录认证 与 统一拦截架构详解

0. 登录功能

0.1 需求分析

登录功能的本质是查询 。前端发送用户名密码,后端根据这两个条件去数据库中匹配:

  • 匹配成功 :说明合法,允许进入系统,并返回该用户的信息
  • 匹配失败 :说明用户名或密码有误,拒绝进入

0.2 接口设计

参照接口文档,定义如下规范:

特性 说明
请求路径 /login
请求方式 POST
请求参数 JSON格式 (username, password)
响应数据 Result<LoginInfo> (包含 id, username, name, token)

0.3 后端代码实现步骤


0.3.1 封装响应结果实体 (DTO)

为了安全和规范,我们通常不会把数据库实体类(如 Emp)直接返回给前端 ,而是专门设计一个 LoginInfo 作为 DTO (数据传输对象)

  • 防泄露 :屏蔽 password 等敏感字段,防止重要数据暴露给前端。
  • 按需给 :只传前端页面需要的字段(如用于展示的 name 和鉴权的 token),减少冗余。
  • 低耦合:以后哪怕数据库里的表结构改了,只要 DTO 不变,前端接口就不受影响。
java 复制代码
/**
 * 登录返回结果实体类
 * 用于封装员工登录成功后,回显给前端的非敏感用户信息及身份令牌
 */
@Data               // 自动生成:Getter、Setter、toString、hashCode、equals 方法
@NoArgsConstructor  // 自动生成:无参构造器(框架进行 JSON 反序列化时必须使用)
@AllArgsConstructor // 自动生成:全参构造器(方便在 Service 层快速构建对象)
public class LoginInfo {

    /**
     * 员工ID:数据库主键,前端常用于获取个人详情或修改资料
     */
    private Integer id;

    /**
     * 用户名:员工登录的唯一标识账号
     */
    private String username;

    /**
     * 姓名:员工真实姓名,用于前端界面展示(如:欢迎您,宋江)
     */
    private String name;

    /**
     * 身份令牌:后端生成的 JWT 字符串
     * [注意] 当前阶段暂存空字符串 "",在后续"登录校验"章节中通过工具类生成
     */
    private String token;
    
}

0.3.2 控制层实现 (Controller)

Controller(控制层)相当于前后端交互的"大门"与调度中心。为了保证三层架构的清晰与职责单一,它的核心工作可以总结为以下三点:

  • 统一入口:负责拦截和接收前端发送的 HTTP 请求,并解析请求体中的数据(如 JSON 格式的账号密码)。
  • 业务分发:绝不"越俎代庖"去写复杂的判断逻辑,而是直接调用 Service 层去执行核心的查询与比对任务。
  • 标准响应 :将 Service 层返回的结果,打包成前后端约定好的统一标准格式(如 Result 对象),再响应给前端,方便前端统一拦截和解析。
java 复制代码
/*
* 登录controller
* */
@Slf4j // 自动生成日志对象 log,方便开发调试和线上问题排查
@RestController // 复合注解:@Controller + @ResponseBody,表示将返回的对象自动转换为 JSON 格式响应给前端
public class LoginController {

    @Autowired // 依赖注入:告诉 Spring 容器自动装配 EmpService 的实现类对象
    private EmpServiceImpl empService;

    /**
     * 员工登录接口
     * * @param emp 接收前端传递的 JSON 格式账号密码数据
     * * @return 统一响应结果对象 Result
     */
    @PostMapping("/login") // 映射 HTTP POST 请求,路径为 /login
    // @RequestBody:将前端 POST 传来的 JSON 请求体,自动反序列化并映射到 Emp 对象的属性中(提取 username 和 password)
    public Result login(@RequestBody Emp emp) {

        // 1. 记录请求日志
        log.info("接收到员工登录请求,当前尝试登录的账号: {}", emp.getUsername());

        // 2. 调用 Service 层执行核心业务逻辑
        LoginInfo loginInfo = empService.login(emp);

        // 3. 判断比对结果,并封装统一响应体
        if (loginInfo != null) {
            // 登录成功:调用 Result.success(),状态码通常为 1,并携带用户信息
            return Result.success(loginInfo);
        }

        // 登录失败:调用 Result.error(),状态码通常为 0,并附带错误提示
        return Result.error("用户名或密码错误");
    }
}

0.3.3 业务层实现 (Service)

业务层(Service)是整个系统的"处理中心",起到承上启下的作用:向上为 Controller 提供业务结果,向下调用 Mapper 操作数据库。

在登录功能中,Service 层的核心职责是:比对数据 并完成数据脱敏 (将包含密码的 Emp 转换为安全的 LoginInfo)。


0.3.3.1 定义接口 (Interface)

首先在 EmpService 接口中声明登录方法,明确业务契约:

java 复制代码
public interface EmpService {
    // ... 其他原有方法

    /**
     * 员工登录验证
     * @param emp 登录信息 (需包含 username 和 password)
     * @return 成功返回用户信息及 Token,失败返回 null
     */
    LoginInfo login(Emp emp);
}

0.3.3.2 编写实现类 (Impl)

EmpServiceImpl 中实现具体的登录逻辑。这里体现了经典的实体类与 DTO 的转换过程。

java 复制代码
@Slf4j // 自动生成日志对象 log,方便开发调试和线上问题排查
@Service // 将该类交由 Spring 容器管理,Controller 才能通过 @Autowired 注入
public class EmpServiceImpl implements EmpService {
		
    @Autowired // 依赖注入:由 Spring 容器自动实例化 EmpMapper 接口的代理对象,并注入到当前类中
    private EmpMapper empMapper;

    // ... 其他原有方法实现

    @Override
    public LoginInfo login(Emp emp) {
        // 1. 调用 Mapper 层,去数据库中精确匹配账号和密码
        Emp loginEmp = empMapper.selectByUsernameAndPassword(emp);
        
        // 2. 判断是否命中数据
        if (loginEmp != null) {
            // 3. 命中数据:进行数据脱敏,将 Emp 转化为 LoginInfo
            return new LoginInfo(
                loginEmp.getId(),       // 只取需要的 ID
                loginEmp.getUsername(), // 只取用户名
                loginEmp.getName(),     // 取真实姓名用于前端展示
                ""                      // Token 占位符(当前暂设为空字符串,后续整合 JWT)
            );
        }
        
        // 4. 未命中数据:说明账号或密码错误,直接返回 null,交由 Controller 处理
        return null;
    }
}

0.3.4 持久层实现 (Mapper)

使用 MyBatis 的 @Select 注解实现根据账号密码查询的功能。

java 复制代码
@Mapper
public interface EmpMapper {
    /**
     * 员工登录校验:根据账号和密码精确查询记录
     * * @param emp 包含前端传入的账号 (username) 和密码 (password) 的实体对象
     * @return Emp 若校验通过,返回包含完整信息的员工实体;若不匹配,返回 null
     */
    @Select("select * from emp where username = #{username} and password = #{password}")
    Emp selectByUsernameAndPassword(Emp emp);
}

0.4 功能测试

开发完成后,建议通过以下步骤验证:

  1. 启动 SpringBoot 服务:检查控制台是否有报错。

  2. 使用 Apifox/Postman 测试

    • 地址:http://localhost:8080/login
    • 方法:POST
  3. 结果验证

    • Body (JSON):{"username":"songjiang", "password":"123456"}

    • 正确账号:应收到 code: 1 和用户信息:

    • Body (JSON):{"username":"songang", "password":"123456"}

    • 错误账号:应收到 code: 0 和 "用户名或密码错误~":

  4. 前后端联调测试

    • 前端输入

      在浏览器中打开系统登录页。在用户名的输入框中填入数据库中已存在的合法账号 songjiang,并在密码框中输入其对应密码。确认输入无误后,点击"登录"按钮。

    • 后端验证与界面跳转

      点击登录后,前端将向后端服务器发送 POST 请求。后端在校验通过后返回成功结果,前端接收到成功信号后会自动跳转至系统的首页(主后台界面)。此时,可以在浏览器顶部中央看到一条绿色的"登录成功"提示框。


1. 登录认证概念引入

1.1 为什么必须做登录认证

在一个完整的 JavaWeb 系统中,登录功能登录认证 从来都不是同一件事。

很多初学者在完成 /login 接口之后,会误以为系统已经具备了安全访问能力。事实上,"能登录" 只代表服务端校验了这一次用户名和密码;"能认证" 才代表后续每一次请求都能识别当前用户身份

如果系统只做了登录接口,却没有做登录校验,会出现非常典型的问题:

  • 用户未登录,也能直接访问后台页面
  • 浏览器刷新页面后,系统无法识别当前用户是否已经登录
  • 任何业务接口都缺乏统一的身份判断入口

本质原因:

HTTP 协议是无状态协议,服务端默认不会记住上一次请求的身份信息。


1.2 登录功能 与 登录认证 的区别

维度 登录功能 登录认证
作用 校验用户名和密码是否正确 校验当前请求是否来自已登录用户
发生时机 用户点击"登录"时 用户访问任意受保护资源时
处理对象 /login 接口 /depts/emps/report 等业务接口
核心问题 账号密码对不对 当前请求是否合法
典型实现 查询数据库 Token 校验 + 统一拦截

一句话总结:

登录功能解决的是"你是谁",登录认证解决的是"你现在有没有权限继续访问系统"。


2. 核心原理

2.1 HTTP 无状态 与 登录校验思路

HTTP 是无状态协议,每次请求都是独立的。

这意味着:

  • 第 1 次请求登录成功
  • 第 2 次请求查询部门列表
  • 第 3 次请求查询员工列表

对于服务端来说,这三次请求默认没有"连续关系"。

所以,要实现登录认证,至少要解决两个问题:

  1. 登录成功后,如何保存"已登录标记"
  2. 后续请求到来时,如何统一判断该标记是否存在且有效

2.2 会话技术 (Session Tracking)

2.2.1 什么是会话?

在日常生活中,"会话"指的是两人之间的一场交谈。

在 Web 开发中,会话(Session) 指的是浏览器与服务器之间建立的一次持续连接过程

会话的生命周期:

  • 开始: 用户打开浏览器,第一次向服务器发送请求。
  • 过程: 在浏览器未关闭期间,发送的多次请求(如登录、查询部门、查询员工)都属于同一次会话
  • 结束: 浏览器关闭(客户端断开),或服务器关机(服务端断开)。

💡 注意:

会话是与浏览器进程 绑定的。打开三个不同的浏览器(如 Chrome、Edge、Firefox)访问同一个网站,服务器会认为这是三个独立的不同会话


2.2.2 为什么需要会话跟踪?

要理解会话跟踪,首先要知道 HTTP 协议的一个"致命缺点":HTTP 是无状态协议 (Stateless)

  • 无状态的意思是: 服务器就像一个"没有记忆的金鱼",它处理完当前请求后,就会瞬间忘记你是谁。当你发起第二次请求时,服务器根本不知道你刚才已经登录过了。

会话跟踪的定义:

服务器用来识别"连续的多次请求是否来自于同一个浏览器"的技术机制。

会话跟踪的终极目的:

为了在同一次会话的多次请求之间共享数据 (比如共享你的"已登录"状态)。


2.2.3 常见的会话跟踪方案

为了解决 HTTP "健忘"的问题,业界演进出了三种主流的会话跟踪技术。我们可以把它们比作去游乐园玩耍时的"验票方式":

技术方案 数据存储位置 原理比喻 (游乐园验票)
1. Cookie 客户端 (浏览器) 游乐园在你手背上盖了个隐形章,每次玩项目工作人员都要看你的手背。
2. Session 服务端 (服务器内存) 游乐园把你的名字记在了后台系统的账本上,并给你一个储物柜钥匙(JSESSIONID)。
3. 令牌技术 (Token/JWT) 客户端 (常存于 LocalStorage) 游乐园给你发了一张加密的实体通票(如手环),通票上写明了你能玩什么,工作人员只认票不查账本。

(注:在目前主流的前后端分离架构中,第 3 种"令牌技术 / JWT"是最为推荐和常用的方案,也就是我们接下来要重点学习的内容。)


2.3 会话跟踪技术方案对比(Cookie vs Session vs Token)

2.3.1 时代眼泪:Cookie(客户端存储)

2.3.1.1 技术介绍

Cookie 是最古老也是最基础的会话跟踪技术,它的核心思想是 "把凭证发给客户端,让客户端自己收好"

它的工作流主打一个"全自动":

  • 自动下发 :登录成功后,服务端通过响应头 Set-Cookie 将身份数据发给浏览器。
  • 自动保存:浏览器收到后,默默存入本地。
  • 自动携带 :后续的每次请求,浏览器都会通过请求头 Cookie 自动带上这些数据。
http 复制代码
# HTTP 响应头 (服务端 -> 客户端)
Set-Cookie: name = value

# HTTP 请求头 (客户端 -> 服务端)
Cookie: name = value

2.3.1.2 代码测试
java 复制代码
/**
 * HttpSession演示
 */
@Slf4j
@RestController
public class SessionController {

    //设置Cookie(即服务器端 向前端响应cookie)
    @GetMapping("/c1")
    public Result cookie1(HttpServletResponse response){
        // 创建名为 "login_username" 的 Cookie,值为 "zhangfei",并添加到 HTTP 响应中
        // 浏览器接收到响应后会将此 Cookie 保存在客户端,后续请求会自动携带
        response.addCookie(new Cookie("login_username","zhangfei")); //设置Cookie/响应Cookie
        return Result.success();
    }

    //获取Cookie
    @GetMapping("/c2")
    public Result cookie2(HttpServletRequest request){
        // 从 HTTP 请求中获取客户端携带的所有 Cookie
        Cookie[] cookies = request.getCookies();

        // 遍历 Cookie 数组,查找特定名称的 Cookie
        for (Cookie cookie : cookies) {
            if(cookie.getName().equals("login_username")){
                // 找到名为 "login_username" 的 Cookie,输出其值
                System.out.println("login_username: "+cookie.getValue()); //输出name为login_username的cookie
            }
        }
        return Result.success();
    }
}

步骤 A:测试设置 Cookie

  • 操作 :浏览器访问 http://localhost:8080/c1
  • 现象 :服务端通过 Set-Cookie 响应头 将数据发送给浏览器,浏览器接收后自动将其持久化到本地。

步骤 B:测试获取 Cookie

  • 操作 :继续访问 http://localhost:8080/c2
  • 现象 :基于 HTTP 协议特性,浏览器在发起本次请求时,会自动通过 Cookie 请求头 将本地存储的 Cookie 携带到服务端,完成数据的跨请求共享。
2.3.1.3 利弊分析
2.3.1.3.1 核心优势
  • HTTP 协议原生支持 :这是 Cookie 最主要的优势。由于其属于 HTTP 标准规范,主流浏览器均实现了对 Cookie 的全自动接管(自动解析 Set-Cookie 并自动携带 Cookie 请求头)。前端开发者无需编写额外的凭证管理代码,接入成本极低。
  • 服务端无状态化:会话状态被直接下发并持久化在客户端本地,服务端无需消耗内存等资源去维持海量用户的会话状态,具有一定的轻量化特征。

2.3.1.3.2 典型劣势
  • 移动端兼容性差:原生移动端应用(Android、iOS)并未内置类似浏览器的 Cookie 自动管理机制。若强制在移动端使用 Cookie 方案,需开发者手动实现拦截、解析和存储,大幅增加客户端的开发与维护成本。
  • 安全性较低:数据存储在客户端本地,不仅面临被伪造和窃取的风险(如 XSS 攻击),且用户可在浏览器端随时手动清空或禁用 Cookie。一旦禁用,系统的认证逻辑将直接失效。
  • 不支持跨域:这是导致 Cookie 难以胜任现代前后端分离架构的最根本原因。

2.3.1.4 什么是跨域

在传统的单体架构中,前端页面与后端接口同源部署,Cookie 能够稳定运行。但在现代企业级开发中,前后端通常分离独立部署(例如前端部署于 Nginx,后端部署于独立的微服务节点),这必然触发跨域请求。

跨域的判定标准:

根据浏览器的同源安全策略(Same-Origin Policy),以下三个维度中任意一个不一致,即被判定为跨域:

  1. 协议不同 (如 httphttps
  2. IP / 域名不同 (如 192.168.150.90192.168.150.100
  3. 端口不同 (如 808080

场景推演:

假设前端部署于 http://192.168.150.90(默认 80 端口),

后端 API 部署于 http://192.168.150.100:8080

当用户在前端发起登录请求后,后端成功返回了包含身份凭证的 Cookie。然而,当浏览器准备发起第二次业务接口请求时,由于检测到跨域行为(IP 和端口均不一致),出于安全策略限制,浏览器会直接拒绝将该 Cookie 携带并发送给后端。凭证无法传递,用户的登录状态自然无法维持。

结论:

综合而言,Cookie 胜在协议级的原生支持与便捷性,但由于其在多端适配和跨域调用场景下的严重缺陷,目前已不再适合作为现代前后端分离架构下的核心鉴权凭证。


2.3.2 传统单体王者:Session(服务端存储)

2.3.2.1 技术介绍

为了弥补 Cookie 将敏感数据暴露在客户端的安全隐患,Session 技术应运而生。它的核心思想是:"重要数据保存在服务器内存中,只给客户端发放一把唯一且无意义的钥匙"

工作流解析:

  1. 开辟空间 :用户首次成功登录后,服务端会在其内存中开辟一块专属空间(即 Session 对象),并自动生成一个唯一的标识符JSESSIONID,相当于钥匙)。

  2. 下发钥匙 :服务端底层依然借助 HTTP 的 Set-Cookie 响应头,将这把 JSESSIONID 钥匙下发给浏览器。

  3. 携带钥匙 :浏览器后续发起请求时,自动通过 Cookie 请求头带上 JSESSIONID

  4. 对号入座 :服务端拦截到请求后,提取出 JSESSIONID,在内存中精准找到对应的 Session 空间,从而获取用户的登录状态与信息。


2.3.2.2 代码测试
java 复制代码
/**
 * HttpSession演示
 */
@Slf4j
@RestController
public class SessionController {

    @GetMapping("/s1")
    public Result session1(HttpSession session){
        // 输出当前 HttpSession 对象的哈希码,用于验证两次请求是否使用同一个 Session
        log.info("HttpSession-s1: {}", session.hashCode());

        // 将键值对 "loginUser" -> "tom" 存储到 Session 中
        // Session 数据保存在服务器端,同一会话的后续请求可以共享此数据
        session.setAttribute("loginUser", "tom"); //往session中存储数据
        return Result.success();
    }

    @GetMapping("/s2")
    public Result session2(HttpSession session){
        // 输出当前 HttpSession 对象的哈希码,与 /s1 对比验证是否为同一 Session
        log.info("HttpSession-s2: {}", session.hashCode());

        // 从 Session 中获取之前存储的 "loginUser" 属性值
        // 如果 Session 存在且包含该属性,则返回对应的值;否则返回 null
        Object loginUser = session.getAttribute("loginUser"); //从session中获取数据
        log.info("loginUser: {}", loginUser);
        return Result.success(loginUser);
    }
}

步骤 A:测试设置 Session

  • 操作 :浏览器访问 http://localhost:8080/s1

  • 现象 :请求完成后,查看浏览器的开发者工具,可以发现在响应头中包含了一个 Set-Cookie 指令,其值固定为 JSESSIONID=xxxxxx。这正是服务端下发的"钥匙"。

    步骤 B:测试获取 Session

  • 操作 :继续访问 http://localhost:8080/s2

  • 现象 :浏览器自动携带了包含 JSESSIONID 的 Cookie。查看 IDEA 控制台日志,两次请求打印的 Session hashCode 完全一致,且成功获取到了值为 tom 的数据,证明会话跟踪成功。


2.3.2.3 利弊分析
2.3.2.3.1 核心优势
  • 安全性高:用户的敏感信息(如密码、权限等)全部存储在后端的服务器内存中,客户端只能拿到一串无意义的随机字符(ID),极大地降低了数据被篡改或窃取的风险。
  • 开发成本极低 :在 Java Web 生态(如 Spring 框架)中,Session 的创建、ID 的下发与解析均被底层 Servlet 容器全自动接管,开发者只需调用简单的 setAttribute 即可完成状态管理。
2.3.2.3.2 典型劣势
  • 底层依然受限于 Cookie :既然 Session 传递 JSESSIONID 需要依赖 Cookie,那么 Cookie 所具备的移动端兼容性差不支持跨域等先天缺陷,Session 照单全收。
  • 极度消耗服务器资源:当系统在线用户量达到万级甚至十万级时,服务器内存中需要维持海量的 Session 对象,极易引发内存溢出(OOM)。
  • 架构死穴:集群环境下的"失忆症"

2.3.2.4 集群环境下的"失忆症"

Session 在传统的单体架构下单机运行非常完美。但在现代企业级开发中,为了解决高并发和单点故障问题,后端服务通常会进行多机集群部署

场景推演:

假设我们的后端系统进行了集群部署(包含节点 A、节点 B 等多台服务器),并在最前端接入了负载均衡器(Nginx)


服务器 B 服务器 A 负载均衡器 (Nginx) 服务器 B 服务器 A 负载均衡器 (Nginx) 阶段一:用户发起登录请求 阶段二:用户发起业务请求 (触发失忆症) 浏览器 (用户) POST /login (账号密码) 1 轮询算法分发至【服务器 A】 2 校验成功,在内存创建 Session 对象 3 响应成功,并通过 Set-Cookie 下发 JSESSIONID 4 GET /dept/list + 携带 Cookie: JSESSIONID 5 轮询算法分发至【服务器 B】 6 根据 JSESSIONID 在自己内存中寻址 7 ❌ 寻址失败 (内存中没有该 Session) 8 判定未登录,踢回登录页面 (HTTP 401) 9 浏览器 (用户)


  1. 首次登录 :用户发起 /login 请求,负载均衡器将请求分发到了服务器 A 。服务器 A 校验通过,在自己的内存中创建了 Session,并将 JSESSIONID 传给用户。
  2. 再次请求 :用户点击页面,发起查询部门列表的请求。此时负载均衡器根据轮询算法,将该请求分发到了集群中的另一台机器(如服务器 B)。
  3. 寻址失败 :服务器 B 接收到了用户传来的 JSESSIONID,但在自己的内存中根本找不到对应的 Session 对象(因为该对象孤立地存在于服务器 A 上)。
  4. 验证阻断:服务器 B 判定用户未登录,无情地将用户踢回登录页面。这就是臭名昭著的 Session 集群"失忆症"。

(注:虽然可以通过引入 Redis 实现 Session 统一共享来解决此问题,但这会大幅增加系统的运维复杂度和耦合度。)


2.3.3 现代架构首选:Token / JWT(无状态令牌)

2.3.3.1 技术介绍

面对前后端分离带来的 "跨域限制" 以及微服务架构带来的 "集群共享痛点"Token(令牌)技术 成为了现代企业级开发的标准答案。其最具代表性的落地实现便是 JWT (JSON Web Token)

Token 的核心架构理念极具颠覆性:服务端彻底走向无状态化(Stateless),不再保存任何用户的登录状态,只负责鉴权凭证的签发与验签。

现代化的验票流程:

  1. 签发令牌 :用户登录成功后,服务端使用私有密钥,将用户信息加密生成一串具有防篡改特性的字符串(Token),并直接响应给前端。
  2. 前端自治 :前端(如 Vue、React 或原生 App)接收到 Token 后,自行负责将其持久化存储(通常存入 LocalStorage 甚至内存中)。
  3. 主动携带 :在后续的每次业务请求中,前端通过拦截器主动将 Token 注入到 HTTP 请求头的 Authorization 字段中。
  4. 无状态验签 :服务端接收到请求后,利用同一套密钥对 Token 进行解密与校验。只要解析成功且未过期,即视为合法用户。

2.3.3.2 利弊分析
2.3.3.2.1 核心优势
  • 天生免疫跨域:Token 是由前端代码手动获取并放入请求头的,彻底摆脱了浏览器对 Cookie 的同源策略限制,完美适配前后端分离部署。
  • 完美适配多端:无论是 Web 网页、微信小程序,还是 Android / iOS 原生 App,均具备操作 HTTP 请求头的能力,接入标准高度统一。
  • 微服务的最爱:由于服务端不再占用内存存储会话状态,减轻了服务器的存储压力。更重要的是,彻底根治了集群的"失忆症"
2.3.3.2.2 典型劣势
  • 全手动实现:失去了 Cookie 的"全自动"光环,从令牌的生成、前端的存储与携带,到后端的拦截与校验,都需要开发者自行编写代码实现。
  • 状态不可控:由于服务端无状态,一旦 Token 签发,在其过期之前默认始终有效。如果发生令牌泄露,服务端无法像 Session 那样直接在内存中将其销毁(通常需要借助 Redis 引入黑名单机制来弥补)。

2.3.3.3 集群环境下的"无状态"突围

为了直观体会 Token 技术为何被称为"微服务的最爱",我们再次推演与 Session 章节相同的负载均衡集群场景。你会发现,JWT 凭借其无状态的特性,完美规避了寻址失败的痛点。

场景推演:
服务器 B 服务器 A 负载均衡器 (Nginx) 服务器 B 服务器 A 负载均衡器 (Nginx) 阶段一:用户发起登录请求 (签发令牌) 阶段二:用户发起业务请求 (无状态验签) 浏览器 (用户) POST /login (账号密码) 1 轮询算法分发至【服务器 A】 2 校验成功,通过算法生成 JWT 令牌 3 响应成功,直接返回 JWT 字符串 (不依赖 Cookie) 4 前端代码将 JWT 存入 LocalStorage 5 GET /dept/list + 请求头携带 Token 6 轮询算法分发至【服务器 B】 7 提取 Token,使用本地共享密钥校验数字签名 8 ✅ 验签成功,直接从 Token 中提取用户身份 9 身份合法,成功返回部门列表数据 (HTTP 200) 10 浏览器 (用户)

  1. 签发阶段 :请求落在服务器 A,A 生成 JWT 后直接返回。A 的内存中不保存任何记录
  2. 验签阶段 :业务请求落在服务器 B,B 不需要去任何地方"寻址"。只要 B 拥有和 A 一样的解密密钥,就能直接从用户携带的 JWT 中验证身份真伪。各个节点相互独立,真正实现了高可用。

2.3.4 会话跟踪方案总结

评估维度 Cookie (客户端) Session (服务端) Token / JWT (无状态) 🏆
存储位置 浏览器本地缓存 服务器内存 客户端 (如 LocalStorage)
安全性 极低(易被拦截篡改) 高(敏感数据不出域) 高(自带数字签名防篡改)
跨域支持 ❌ 极差 ❌ 极差 (因底层依赖 Cookie) ✅ 完美支持
集群扩展性 ✅ 支持 ❌ 极差 (需 Redis 介入解决同步) ✅ 完美支持 (服务端无状态)
自动化程度 全自动保存与携带 全自动携带 ID 需前端代码手动干预携带

2.4 JWT 令牌技术落地

2.4.1 JWT 核心概念与底层结构

JWT 全称 JSON Web Token (官网:jwt.io),定义了一种简洁的、自包含的开放标准(RFC 7519),用于在通信双方之间以 JSON 对象的形式安全地传输信息。

在 Web 登录认证场景中,JWT 具备以下三大核心特征:

  • 简洁:本质上是一个较长的字符串,非常适合在 HTTP 请求头(Header)或 URL 参数中传输。
  • 自包含:允许在令牌内部封装非敏感的业务数据(如用户 ID、用户名等),服务端解密后即可直接使用,无需二次查询数据库。
  • 防篡改:通过不可逆的密码学摘要算法生成数字签名,确保数据在传输过程中不被非法修改。

JWT 的三段式组成结构

一个标准的 JWT 令牌由三个部分组成,各部分之间通过英文句号 . 进行分割:Header.Payload.Signature

组成部分 英文全称 核心作用 数据示例
头部 Header 记录令牌类型(JWT)及所使用的签名算法(如 HS256)。 {"alg":"HS256","typ":"JWT"}
有效载荷 Payload 存储业务自定义数据(如用户信息)以及默认声明(如过期时间 exp)。 {"id":1,"username":"Tom"}
签名 Signature 将前两部分通过 Base64Url 编码后,拼接指定的服务端私有秘钥 (Secret),通过 Header 中声明的算法计算得出的结果。 fHi0Ub8npbyt71...

⚠️ 安全警示:Base64 不是加密

JWT 的第一部分(Header)和第二部分(Payload)仅仅是进行了 Base64 编码 ,任何人只要拿到 Token 都可以轻易解码出其中的 JSON 数据。因此,绝对严禁在 Payload 中存放密码等敏感信息。JWT 的安全性仅仅体现在"防篡改"(第三部分的数字签名),而不保证数据的"机密性"。


2.4.2 核心依赖与工具类封装

在企业级 Java 项目中,我们通常不会在业务代码里到处编写 JWT 的生成与解析逻辑,而是将其封装为全局工具类。

2.4.2.1 引入 Maven 依赖
xml 复制代码
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>
2.4.2.2 编写 JwtUtils 工具类

在项目中创建统一的 JWT 操作工具类,集中管理签名秘钥过期时间

java 复制代码
package com.example.tliaswebmanagement.utils;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import java.util.Date;
import java.util.Map;

/**
 * JWT令牌操作工具类
 */
public class JwtUtils {
    
    // 签名密钥
    private static final String KEY = "aXRjYXRz";
    // 令牌过期时间
    private static final Date EXPIRATION = new Date(System.currentTimeMillis() + 12 * 3600 * 1000);
    
    /**
     * 生成JWT令牌
     * 
     * @param claims 自定义声明数据(如用户ID、用户名等)
     * @return JWT令牌字符串
     */
    public static String generateJwt(Map<String, Object> claims) {
        return Jwts.builder()
                // 设置签名算法和密钥(HS256)
                .signWith(SignatureAlgorithm.HS256, KEY)
                // 添加自定义声明
                .addClaims(claims)
                // 设置过期时间为12小时
                .setExpiration(EXPIRATION)
                // 生成令牌
                .compact();
    }
    
    /**
     * 解析JWT令牌
     * 
     * @param token JWT令牌字符串
     * @return Claims对象,包含令牌中的所有数据
     */
    public static Claims parseJwt(String token) {
        return Jwts.parser()
                // 设置签名密钥(必须与生成时一致)
                .setSigningKey(KEY)
                // 解析并验证令牌
                .parseClaimsJws(token)
                // 获取Payload部分
                .getBody();
    }
}

异常处理机制:

在调用 parseJWT 进行解析时,如果传入的 JWT 字符串被篡改 ,或者已经超时过期 ,底层 API 会直接抛出 SignatureExceptionExpiredJwtException 异常。我们只需捕获异常即可判定令牌非法。


2.4.2.3 签名秘钥的设置与 Base64 编码

在前文的 JWT 生成与解析代码中,我们在指定签名算法(HS256)时,传入了一个看似无规律的字符串 "aXRjYXN0" 作为签名秘钥(Secret Key)。实际上,这并非随机生成的乱码,而是经过 Base64 编码 后的产物。

  • 秘钥生成的底层逻辑

    JWT 的第三部分(Signature)是对头部和载荷进行摘要计算的结果。底层加密算法通常要求秘钥具备一定的复杂度和长度。为了在代码中安全、规范地传输和配置这些秘钥(避免因为特殊字符导致解析错误),业界通用的做法是将原始秘钥转换为 Base64 格式的字符串 进行配置。

  • 结合演示工具解析

    如图中所示的 Base64 在线编码工具,代码中的 "aXRjYXN0" 其实就是原始明文单词 "itcats" 经过 Base64 编码后的结果:

    • 明文秘钥itcats
    • Base64 编码后aXRjYXN0

在开发测试阶段,我们可以借助此类在线工具(如 base64.us),快速将自定义的字符串转化为 Base64 格式,并填入工具类中作为 JWT 的签名秘钥。


2.4.3 JWT 核心 API 与单元测试

在将其整合进实际业务之前,我们需要先通过 JUnit 单元测试来剖析 jjwt 库的核心 API,并验证 JWT 的防篡改超时失效机制。

2.4.3.1 编写测试用例

在测试类中,我们将模拟 JWT 的"签发 "与"验签"两个核心动作:

java 复制代码
package com.example.tliaswebmanagement;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.junit.jupiter.api.Test;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;

public class JwtTest {
    /*
     * 生成JWT令牌
     * */
    @Test
    public void testGenerateJwt(){
        // 创建 Map 集合,用于存储需要嵌入到 JWT 中的自定义数据(Claims)
        // 这些数据会在令牌验证后被提取出来,用于识别用户身份和权限
        Map<String, Object> dataMap = new HashMap<>();
        dataMap.put("id",1);              // 用户ID,用于唯一标识登录用户
        dataMap.put("username","admin");  // 用户名,用于后续业务逻辑中的身份识别
        // 使用 Jwts.builder() 构建 JWT 令牌,包含以下关键组成部分:
        String jwt = Jwts.builder()
                // 1. 设置签名算法和密钥:使用 HS256(HMAC-SHA256)算法对令牌进行签名
                //    签名确保令牌内容不被篡改,"aXRjYXRz" 是签名密钥(实际项目中应使用更复杂的密钥)
                .signWith(SignatureAlgorithm.HS256,"aXRjYXRz")
                // 2. 添加自定义声明(Claims):将上面创建的 dataMap 中的数据嵌入到 JWT 的 Payload 部分
                //    这些 claims 可以是用户ID、用户名、角色等任何需要在多个请求间传递的信息
                .addClaims(dataMap)
                // 3. 设置令牌过期时间:当前时间 + 3600秒(1小时)
                //    过期时间用于控制令牌的有效期,超过此时间后令牌将失效,需要重新登录
                //    System.currentTimeMillis() 获取当前毫秒时间戳,3600*1000 表示 1 小时的毫秒数
                .setExpiration(new Date(System.currentTimeMillis()+3600*1000))
                // 4.  compact() 方法将上述所有信息组合并压缩成最终的 JWT 字符串
                //    生成的 JWT 格式为:Header.Payload.Signature(三部分用点号分隔)
                .compact();
        // 输出生成的 JWT 令牌字符串,用于测试
        System.out.println(jwt);
    }


    /*
    *解析JWT令牌
    * */
    @Test
    public void testParseJwt(){
        // 待解析的 JWT 令牌字符串(由 testGenerateJwt 方法生成)
        // JWT 格式:Header.Payload.Signature,包含用户身份信息和签名
        String token = "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsImV4cCI6MTc3NjI2NDM1Nn0.TxAFDc1VZWtjMb5TX_GwHXhPUEQz2zzBRKowHoxc4c4";
        // 使用 Jwts.parser() 解析 JWT 令牌,验证签名并提取其中的数据
        Claims claims = Jwts.parser()
                // 设置签名密钥,必须与生成令牌时使用的密钥一致
                // 如果密钥不匹配或令牌被篡改,parseClaimsJws() 会抛出异常
                .setSigningKey("aXRjYXRz")
                // 解析并验证 JWT 令牌:
                // 1. 验证签名是否有效(确保令牌未被篡改)
                // 2. 检查令牌是否过期(验证 exp 声明)
                // 3. 解析 Payload 部分,提取自定义声明(Claims)
                .parseClaimsJws(token)
                // 获取 JWT 的 Body 部分(即 Payload),返回 Claims 对象
                // Claims 是一个 Map 结构,包含所有嵌入的用户数据(如 id、username、exp 等)
                .getBody();
        // 输出解析后的 Claims 数据,可以看到之前嵌入的用户ID、用户名和过期时间等信息
        System.out.println( claims);
    }


}

2.4.3.2 安全机制深度验证与测试报告

在验证了 JWT 的基础签发与解析功能后,我们需要进一步通过破坏性测试,来从底层剖析 JWT 的编码特性与安全防御机制。

2.4.3.2.1 基础功能验证(IDEA 控制台解析)

在编写完上述测试用例后,我们分别运行这两个方法,通过 IDEA 控制台的输出结果来验证 JWT 的基础签发与验签逻辑。

  • 步骤一:运行签发测试 (testGenerateJwt)
    执行生成方法后,控制台成功打印出了一串由英文句号 . 分隔的三段式字符串。

    这就是服务端生成的最终凭证。它将代替传统的 Session ID,下发给客户端保存。

  • 步骤二:运行验签测试 (testParseJwt)

    将上述生成的令牌字符串填入 testParseJwt 方法的 token 变量中并执行。程序底层会使用相同的秘钥 aXRjYXRz 进行解密和数字签名校验。

  • IDEA 结果剖析:

    控制台打印出的这个 Map 格式的数据(即 Claims 对象),证明了我们的解析是完全成功的。

    • 验签通过:程序没有抛出任何异常,说明该令牌的数字签名是合法的,未被篡改。
    • 载荷还原 :清晰可见之前存入的业务数据 id=1username=admin。在实际企业开发中,后端拦截器拿到该结果后,可直接将用户信息存入当前线程(如 ThreadLocal),供后续 Controller 直接使用,免去了查库开销。
    • 有效时间exp=1776264356 为时间戳,代表该令牌的具体过期时间。

2.4.3.2.2 编码结构可视化验证 (jwt.io)

在进行破坏性测试前,我们将 IDEA 控制台生成的 JWT 字符串复制,并粘贴至 JWT 官网(https://jwt.io/)的解码器中进行观察:

结构与现象剖析:

  • Header(红色)与 Payload(紫色) :官网解码器无需任何秘钥,即可直接将其还原为明文的 JSON 结构(如 "id": 1, "username": "admin")。这再次印证了前文的理论:JWT 的前两部分仅采用了 Base64 编码,绝非加密。
  • Signature(蓝色) :官网无法将其逆向还原。因为它是通过服务端私有秘钥(SIGN_KEY)结合 HS256 算法计算出的哈希签名,具有不可逆转的特性。

2.4.3.2.3 场景一:防篡改测试(验证数字签名机制)
  • 测试操作 :我们在 testParseJwt() 方法中,故意将原正确 token 字符串的头部部分修改任意一个字符(例如将 eyJi... 修改为 ayJi...),随后执行解析操作。

  • 运行结果 :IDEA 控制台将直接抛出 io.jsonwebtoken.MalformedJwtException异常,解析阻断。

  • 机制解析 :当数据被人为篡改后,JJWT 引擎在验签时,会利用配置的 SIGN_KEY 对篡改后的 Header 和 Payload 重新计算签名。由于计算出的新签名与 Token 末尾携带的旧签名完全不匹配,底层直接判定该令牌为非法伪造,从而保证了数据的绝对完整性。

2.4.3.2.4 场景二:时效性测试(验证生命周期机制)
  • 测试操作 :返回 testGenerateJwt() 方法,将 .setExpiration() 的过期时间强行缩短为 1000 毫秒(即 1 秒)。重新生成令牌后,我们在代码中使线程休眠(或手动等待)超过 1 秒钟,再次将其传入 testParseJwt() 进行解析。

  • 运行结果 :IDEA 控制台将抛出 io.jsonwebtoken.ExpiredJwtException 异常。

  • 机制解析 :在验签流程中,JJWT 引擎不仅会核对签名,还会强制提取 Payload 中的 exp(Expiration Time)声明,并与服务器当前系统时间进行比对。一旦确认超时,无论签名是否合法,都会直接抛出异常。这一机制有效降低了令牌泄露后被长期恶意冒用的风险。

核心结论总结:

  1. 敏感数据隔离:绝对禁止在 JWT 的 Payload 中存入密码等敏感信息。
  2. 鉴权放行标准 :在实际的业务拦截器中,只要 parseClaimsJws(token) 方法能够无异常地正常执行完毕 ,即代表该请求携带的令牌满足"未被篡改 "且"在有效期内"的双重标准,可安全放行。

2.4.4 业务落地:登录成功下发令牌

掌握了底层机制后,我们将其集成到实际的登录业务线中。

标准前后端分离鉴权流程:
业务层 (EmpServiceImpl) 登录接口 (LoginController) 前端 (浏览器) 业务层 (EmpServiceImpl) 登录接口 (LoginController) 前端 (浏览器) 1. POST /login 提交账号密码 2. 调度登录业务逻辑 3. 查询数据库比对账号密码 4. 验证通过,调用 JwtUtils 生成 Token 5. 返回封装好的 LoginInfo (包含 Token) 6. 返回 Result 统一响应报文 7. 前端提取 Token,持久化至 LocalStorage 8. 后续请求将 Token 注入 Header 携带

2.4.4.1 工具类准备 (JwtUtils)

此处直接复用我们在 2.4.2.2 节中封装好的标准化代码:

在项目中创建统一的 JWT 操作工具类,集中管理签名秘钥过期时间

java 复制代码
package com.example.tliaswebmanagement.utils;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import java.util.Date;
import java.util.Map;

/**
 * JWT令牌操作工具类
 */
public class JwtUtils {
    
    // 签名密钥
    private static final String KEY = "aXRjYXRz";
    // 令牌过期时间
    private static final Date EXPIRATION = new Date(System.currentTimeMillis() + 12 * 3600 * 1000);
    
    /**
     * 生成JWT令牌
     * 
     * @param claims 自定义声明数据(如用户ID、用户名等)
     * @return JWT令牌字符串
     */
    public static String generateJwt(Map<String, Object> claims) {
        return Jwts.builder()
                // 设置签名算法和密钥(HS256)
                .signWith(SignatureAlgorithm.HS256, KEY)
                // 添加自定义声明
                .addClaims(claims)
                // 设置过期时间为12小时
                .setExpiration(EXPIRATION)
                // 生成令牌
                .compact();
    }
    
    /**
     * 解析JWT令牌
     * 
     * @param token JWT令牌字符串
     * @return Claims对象,包含令牌中的所有数据
     */
    public static Claims parseJwt(String token) {
        return Jwts.parser()
                // 设置签名密钥(必须与生成时一致)
                .setSigningKey(KEY)
                // 解析并验证令牌
                .parseClaimsJws(token)
                // 获取Payload部分
                .getBody();
    }
}

2.4.4.2 服务端核心业务改造 (EmpServiceImpl)

重构 login 方法。在数据库校验账号密码成功后,动态生成 JWT 并封装入返回体 DTO 中。

java 复制代码
/**
 * 员工登录逻辑处理
 * * 接收前端传入的登录凭证,验证通过后生成并下发 JWT 令牌。
 *
 * @param emp 包含登录凭证(主要是用户名和密码)的员工实体对象
 * @return LoginInfo 登录成功返回包含 JWT 令牌及用户基础信息的对象;若用户名或密码错误(即员工不存在)则返回 null
 */
 @Override
 public LoginInfo login(Emp emp){
     // 1. 调用 Mapper 层,根据传入的用户名和密码在数据库中查询对应的员工记录
     Emp e = empMapper.selectByUsernameAndPassword(emp);

     // 2. 校验查询结果:如果对象不为空,说明数据库中存在匹配的记录,账号密码验证通过
     if(e != null){
         // 记录登录成功的日志信息
         log.info("登录成功,员工信息:{}", e);

         // 3. 准备组装 JWT 令牌的载荷(Claims)数据
         // 通常将能够唯一标识用户的非敏感信息存入令牌中,便于后续接口解析并识别用户身份
         Map<String, Object> claims = new HashMap<>();
         claims.put("id", e.getId());
         claims.put("username", e.getUsername());

         // 4. 调用自定义的 JWT 工具类,根据载荷生成加密的 JWT 字符串
         String jwt = JwtUtils.generateJwt(claims);

         // 5. 将用户的核心信息以及生成的 JWT 令牌封装成 LoginInfo 对象并返回给控制层
         return new LoginInfo(e.getId(), e.getUsername(), e.getName(), jwt);
     }

     // 6. 如果 e 为 null,说明用户名不存在或密码错误,直接返回 null 交由控制层处理(例如抛出异常或返回错误状态码)
     return null;
}

2.4.4.3 前后端全链路验证

代码改造完成后,我们需要通过接口测试工具和浏览器控制台,抓取真实的报文来验证整个鉴权链路。


第一环:接口响应验证(下发令牌)

通过 Apifox 发起 POST 登录请求。验证成功后,可以看到后端响应的 JSON 报文中,data.token 字段已成功携带了生成的 JWT 字符串。


第二环:前端存储验证(保存令牌)

在真实的前后端联调环境中,前端(Vue 项目)接收到上述成功响应后,会利用 JavaScript 将 Token 提取出来,并持久化保存。


第三环:后续请求携带验证(使用令牌)

当用户登录成功并跳转到系统主页后,每次点击菜单发起新的业务请求(例如查询班级列表),前端的 Axios 拦截器会自动提取 LocalStorage 中的 Token,并将其注入到 HTTP 请求头中。

通过开发者工具的 Network 面板查看具体请求的 Headers,可以清晰地看到前端主动携带了名为 Token 的请求头,这正是后续服务端进行无状态拦截与验签的关键所在。


2.5 统一拦截架构设计:Filter 与 Interceptor

在完成了 JWT 令牌的签发与保存后,目前的登录校验方式还有一个明显的问题:校验代码和业务代码紧紧绑在一起,耦合度太高

2.5.1 横切关注点与统一拦截的必要性

如果按照早期的粗放式开发模式,在每个 Controller 接口里面手动写一遍"提取 Token 并验证"的代码,会带来严重的工程问题:

  • 代码大量重复:相同的校验代码散落在系统的成百上千个接口中,只要写错一个地方就会出问题。
  • 修改维护困难:业务逻辑与安全校验深度绑定。如果以后换了鉴权方式(比如不用 JWT 了),或者要修改哪些接口不需要登录,就不得不去修改海量的业务代码。
  • 容易产生安全漏洞:在团队协作开发中,很容易因为新手的疏忽,忘记给某些敏感接口(如导出数据)加上校验,从而造成严重的安全事故。

架构层面的解决方案:登录校验日志记录全局异常处理"这种每个模块都需要,但又和具体业务无关的公共功能 ,在软件工程中被称为横切关注点 (Cross-cutting Concerns) 。处理它们的最佳方案就是采用统一拦截机制

在目前的 JavaWeb / SpringBoot 体系中,主要有两种核心技术来实现统一拦截:Filter(过滤器)Interceptor(拦截器)


2.5.2 Filter 底层机制与快速入门

Filter(过滤器) 是 Java Web 三大核心组件(Servlet、Filter、Listener)之一。它就像是 Web 服务器具体业务代码之间的一道安检闸机,能够把所有进来的请求和出去的响应都拦截下来进行统一处理。


2.5.2.1 核心机制与生命周期

开发一个标准的 Filter 需要实现 jakarta.servlet.Filter 接口。它的生命周期完全由 Tomcat 等 Web 容器来管理,主要包含三个核心方法:

核心方法 触发时机与作用 调用频率
init(FilterConfig) 初始化阶段:项目启动时,Web 容器创建 Filter 对象时自动调用。常用来做一些前置准备工作。 只执行一次
doFilter(ServletRequest, ServletResponse, FilterChain) 拦截处理阶段 :每次客户端发送请求被拦截到时触发。在这里决定是拦截报错,还是放行让请求继续往下走。 每次拦截到请求均执行
destroy() 销毁阶段:项目关闭、Web 容器停止时调用。常用于释放一些占用的资源。 只执行一次

2.5.2.2 基础拦截代码实现 (DemoFilter)

为了直观感受,我们先来写一个最简单的全局过滤器,看看它是怎么运行的。


2.5.2.2.1 定义过滤器组件

实现 Filter 接口,并在类上加上 @WebFilter 注解声明要拦截的路径(/* 代表拦截所有请求)。

java 复制代码
package com.example.tliaswebmanagement.filter;

import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import lombok.extern.slf4j.Slf4j;

import java.io.IOException;

/**
 * 示例过滤器
 * <p>
 * 用于演示 Servlet 过滤器的基本使用方式,拦截所有请求并记录日志信息。
 * 实际项目中可用于实现权限校验、请求日志记录、编码统一设置等功能。
 * </p>
 */
@WebFilter(urlPatterns = "/*")//拦截所有请求
@Slf4j
public class DemoFilter implements Filter {

    /**
     * 过滤器初始化方法
     * <p>
     * 在 Web 应用启动时由容器自动调用,仅执行一次。
     * 可在此处进行资源初始化、配置加载等操作。
     * </p>
     *
     * @param filterConfig 过滤器配置对象,包含初始化参数等信息
     * @throws ServletException 当初始化过程发生错误时抛出
     */
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("初始化过滤器...");
    }

    /**
     * 过滤器核心处理方法
     * <p>
     * 每次有请求匹配到过滤器的 URL 模式时都会执行此方法。
     * 可以在请求到达目标资源之前或响应返回客户端之前执行自定义逻辑。
     * </p>
     *
     * @param servletRequest  请求对象,封装了客户端发送的 HTTP 请求信息
     * @param servletResponse 响应对象,用于向客户端发送响应数据
     * @param filterChain     过滤器链对象,调用 doFilter() 方法将请求传递给下一个过滤器或目标资源
     * @throws IOException      当处理请求/响应过程中发生 I/O 错误时抛出
     * @throws ServletException 当处理过程中发生 Servlet 相关错误时抛出
     */
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        log.info("开始执行过滤器...拦截到了请求");
        // 放行:将请求传递给过滤器链中的下一个组件(下一个过滤器或目标 Servlet)
        filterChain.doFilter(servletRequest,servletResponse);
    }

    /**
     * 过滤器销毁方法
     * <p>
     * 在 Web 应用停止或重启时由容器自动调用,仅执行一次。
     * 可在此处释放资源、关闭连接等清理工作。
     * </p>
     */
    @Override
    public void destroy() {
        log.info("销毁过滤器...");
    }
}

2.5.2.2.2 开启 Servlet 组件扫描

在 Spring Boot 项目中,原生的 Servlet 组件默认是不会被识别的。必须在主启动类 上加上 @ServletComponentScan 注解开启扫描。

java 复制代码
package com.example.tliaswebmanagement;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.ServletComponentScan;

@ServletComponentScan// 开启 Servlet 组件扫描。使其能够扫描并注册项目中的 @WebServlet、@WebFilter 和 @WebListener
@SpringBootApplication// Spring Boot 核心注解:标志这是一个启动类,并自动开启组件扫描、自动配置和属性配置
public class TliasWebManagementApplication {

    public static void main(String[] args) {
        // 启动 Spring 应用,底层会初始化 Spring 容器(ApplicationContext),并启动内嵌的 Tomcat 服务器
        SpringApplication.run(TliasWebManagementApplication.class, args);
    }
}

2.5.2.2.3 启动测试与效果验证

完成上述配置后,重启 Spring Boot 服务。我们通过前端页面发起一次实际的业务请求(登录请求)。

控制台日志剖析:

观察 IDEA 控制台的输出链路,可以清晰地印证 Filter 的底层运行机制:

  1. 拦截前置 :请求首先触发了 DemoFilterdoFilter 方法,控制台率先打印出"开始执行过滤器...拦截到了请求"。
  2. 链式放行 :随着代码执行到 filterChain.doFilter(),拦截器将请求予以放行。
  3. 业务触达 :放行后,目标接口 DeptController 才真正接收到请求并开始处理查询业务。

2.5.3 业务落地:基于 Filter 的 JWT 登录校验拦截器

理论讲完了,现在我们把学到的东西用到实战中:使用 Filter 拦截所有的请求,提取 Token 并判断当前用户是否合法。

2.5.3.1 登录校验的具体步骤分析

结合前面的业务需求,我们在过滤器里需要完成以下几步逻辑:

  1. 获取请求路径:拿到当前请求的 URL 地址。
  2. 判断是否放行 :看看 URL 中是否包含 /login。如果是登录操作,不需要校验 Token,直接放行。
  3. 获取令牌 :从 HTTP 请求头 (Header) 中取出 token 字符串。
  4. 初步校验 :如果 token 不存在或为空串,说明用户没登录,直接拦截,并返回 HTTP 401 (未授权) 错误码。
  5. 解析校验 :调用写好的 JwtUtils.parseJwt(token) 解析令牌,验证是否被篡改或已过期。如果解析报错,同样拦截并返回 401 错误码。
  6. 最终放行 :如果以上考验都通过了,说明是一个合法的登录用户,调用 chain.doFilter 让他去访问 Controller。

流程图解示例




失败
成功
请求
获取请求路径

http://localhost:8080/login

http://localhost:8080/emps
判断是否是

登录请求
放行
获取请求头token
判断是否有

token
响应401
解析token


2.5.3.2 核心代码实现 (TokenFilter)
java 复制代码
package com.example.tliaswebmanagement.filter;

import com.example.tliaswebmanagement.utils.JwtUtils;
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.HttpStatus;

import java.io.IOException;

/**
 * JWT 令牌验证过滤器
 * <p>
 * 用于拦截所有请求并验证 JWT 令牌的有效性,确保只有携带合法令牌的请求才能访问受保护的资源。
 * 登录请求会被直接放行,其他请求必须携带有效的 JWT 令牌,否则返回 401 未授权状态码。
 * </p>
 */
@WebFilter(urlPatterns = "/*")
@Slf4j
public class TokenFilter implements Filter {

    /**
     * 过滤器核心处理方法
     * <p>
     * 对每个请求执行以下验证流程:
     * <ol>
     *   <li>判断是否为登录请求,如果是则直接放行</li>
     *   <li>从请求头中获取 JWT 令牌</li>
     *   <li>验证令牌是否存在且非空,不存在则返回 401 状态码</li>
     *   <li>解析并验证令牌的有效性,解析失败则返回 401 状态码</li>
     *   <li>令牌验证通过,放行请求到目标资源</li>
     * </ol>
     * </p>
     *
     * @param servletRequest  请求对象,封装了客户端发送的 HTTP 请求信息
     * @param servletResponse 响应对象,用于向客户端发送响应数据
     * @param filterChain     过滤器链对象,调用 doFilter() 方法将请求传递给下一个过滤器或目标资源
     * @throws IOException      当处理请求/响应过程中发生 I/O 错误时抛出
     * @throws ServletException 当处理过程中发生 Servlet 相关错误时抛出
     */
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        //1. 将 ServletRequest 和 ServletResponse 转换为 HTTP 类型,以便获取 HTTP 特有的方法和属性
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        //2. 获取请求的 URI,用于判断请求类型(如登录请求)
        String uri = request.getRequestURI();// /api/users

        //3. 判断是否为登录请求,登录接口不需要令牌验证,直接放行
        if(uri.contains("login")){
            log.info("登录请求,直接放行");
            filterChain.doFilter(request,response);
            return;
        }

        //4. 从请求头中获取 JWT 令牌,客户端需在 Header 中携带 token 字段
        String token = request.getHeader("token");

        //5. 验证令牌是否存在且非空
        if(token == null || token.isEmpty()){
            log.info("获取到jwt令牌为空,返回错误结果");
            response.setStatus(HttpStatus.SC_UNAUTHORIZED);//401 未授权
            return;
        }

        //6. 尝试解析 JWT 令牌,验证其有效性和完整性
        try{
            JwtUtils.parseJwt(token);
        }
        catch (Exception e){
            log.info("解析jwt令牌失败,返回错误结果");
            response.setStatus(HttpStatus.SC_UNAUTHORIZED);//401 未授权
            return;
        }

        //7. 令牌验证通过,记录日志并放行请求到目标资源
        log.info("令牌合法, 放行");
        filterChain.doFilter(request , response);
    }
}

2.5.3.3 整体功能测试与验证

场景一:未登录直接访问 (成功拦截)

关闭浏览器清空 LocalStorage,随后直接在地址栏尝试访问受保护的 /emps 部门列表接口。 后台的 TokenFilter 会发现 Request Header 里根本没有 token 字段,直接返回 401 错误码。前端页面收到这个 401 错误后,会自动跳转回登录页面,不允许越权查看。


场景二:先登录再正常操作 (成功放行)

用户首先访问 /login 登录。TokenFilter 发现路径里带了 login,直接放行:


随后,在前端界面点击部门管理模块,前端自动把 Token 塞到请求头里。过滤器校验通过,可以看到这里成功访问到了相关的数据。



2.5.4 Filter 进阶:执行流程、拦截路径 与 责任链

2.5.4.1 执行流程:U型回溯机制

doFilter 方法的执行并不是一条路走到黑的,它是一个 "U型"的回溯流程

一句 filterChain.doFilter() 代码将过滤器的处理逻辑分成了两半:

  • 放行前逻辑 (请求进门时):在请求到达 Controller 之前执行。适合做:校验 Token、提取参数等。
  • 放行后逻辑 (响应出门时) :当 Controller 业务处理完毕并生成返回数据后,代码会回过头来继续执行 doFilter 之后的代码。适合做:统一记录请求耗时、给响应数据加密等。

2.5.4.2 拦截路径规则 (urlPatterns)

通过配置 @WebFilterurlPatterns 属性,我们可以精确控制到底要拦截哪些请求:

拦截匹配策略 urlPatterns 示例 触发场景解析
精确拦截 /login 只有当请求路径完完全全是 /login 时才会被拦截。
目录拦截 /emps/* 拦截 /emps 下面的所有请求(比如 /emps/1, /emps/list)。
全局拦截 /* 拦截进入应用的所有请求,通常用于做第一道安全屏障(如全局登录校验)。

2.5.4.3 过滤器链 (Filter Chain) 与执行顺序

在一个成熟的项目中,往往会有多个过滤器各司其职(比如:一个负责处理乱码,一个负责校验 JWT,一个负责跨域)。这些过滤器串联在一起,就组成了过滤器链 (Filter Chain)

谁先执行? 基于 @WebFilter 注解配置的过滤器,默认是按照类名的字符串自然排序来决定优先级的。

举例:如果有 AbcFilterDemoFilter 两个类,请求进来时 AbcFilter 会先执行;但等响应回去的时候,顺序会反过来,DemoFilter 先执行后续逻辑。


2.5.5 Interceptor 底层机制与快速入门

如果说 Filter 是 Servlet 容器(如 Tomcat)级别的"安检大门",那么 Interceptor(拦截器) 就是 Spring 框架内部专属的"内卫保安"。

它是 Spring MVC 框架提供的一种动态拦截机制,专门用于拦截对 Controller 控制器方法的访问。在实际的企业开发中,由于绝大多数项目都基于 Spring Boot 构建,Interceptor 往往比 Filter 更加常用,因为它可以更精细地融入 Spring 的生态环境中


2.5.5.1 核心机制与生命周期

开发一个标准的 Interceptor 需要实现 org.springframework.web.servlet.HandlerInterceptor 接口。它提供了三个不同执行时机的核心方法:

核心方法 触发时机与作用 返回值说明
preHandle(...) 前置拦截 :在目标 Controller 方法执行之前 触发。这是最常用的方法,主要用于权限校验(如 JWT 鉴权)。 返回 true 代表放行;返回 false 代表拦截,后续逻辑不再执行。
postHandle(...) 后置处理:在目标 Controller 方法执行完毕,但视图(View)渲染之前触发。 void(无返回值)
afterCompletion(...) 完成清理:整个请求处理完毕(包含视图渲染完毕)后触发。常用于释放资源、清理 ThreadLocal 等操作。 void(无返回值)

2.5.5.2 基础拦截代码实现 (DemoInterceptor)

拦截器的使用通常分为两步:定义拦截器注册配置拦截器

2.5.5.2.1 定义拦截器组件

创建一个类实现 HandlerInterceptor 接口,并加上 @Component 注解将其交给 Spring 容器管理。

java 复制代码
package com.example.tliaswebmanagement.interceptor;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;


/**
 * 示例拦截器
 * <p>
 * 用于演示 Spring MVC 拦截器的基本使用方式和执行流程。
 * 拦截器工作在 DispatcherServlet 之后,可以拦截控制器方法的执行,
 * 常用于实现权限校验、日志记录、性能监控等功能。
 * </p>
 */
@Slf4j
@Component
public class DemoInterceptor implements HandlerInterceptor {

    /**
     * 前置处理方法:在目标控制器方法执行之前调用
     * <p>
     * 此方法在请求被分配到处理器(Controller)之后、实际处理方法执行之前调用。
     * 可以在此处进行权限验证、参数校验、日志记录等预处理操作。
     * </p>
     *
     * @param request  当前 HTTP 请求对象
     * @param response 当前 HTTP 响应对象
     * @param handler  被调用的处理器对象(通常是 Controller 中的方法)
     * @return true 表示放行,继续执行后续拦截器和目标方法;false 表示拦截,不再继续执行
     * @throws Exception 处理过程中可能抛出的异常
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        log.info("preHandle .... ");

        return true; //true表示放行
    }

    /**
     * 后置处理方法:在目标控制器方法执行之后、视图渲染之前调用
     * <p>
     * 此方法在处理器方法执行完成后、视图渲染之前调用。
     * 可以在此处对模型数据进行修改、添加公共属性、记录处理结果等。
     * 只有当 preHandle() 返回 true 时才会执行此方法。
     * </p>
     *
     * @param request      当前 HTTP 请求对象
     * @param response     当前 HTTP 响应对象
     * @param handler      被调用的处理器对象
     * @param modelAndView 模型和视图对象,包含处理结果数据和视图信息,可在此处进行修改
     * @throws Exception 处理过程中可能抛出的异常
     */
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        log.info("postHandle ... ");
    }

    /**
     * 完成处理方法:在整个请求处理完成后(视图渲染完毕后)调用
     * <p>
     * 此方法在整个请求处理流程结束后调用,包括视图渲染完成之后。
     * 无论请求是否成功、是否发生异常,此方法都会被执行。
     * 通常用于资源清理、性能统计、日志记录等收尾工作。
     * </p>
     *
     * @param request  当前 HTTP 请求对象
     * @param response 当前 HTTP 响应对象
     * @param handler  被调用的处理器对象
     * @param ex       请求处理过程中发生的异常,如果没有异常则为 null
     * @throws Exception 处理过程中可能抛出的异常
     */
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        log.info("afterCompletion .... ");
    }
}
2.5.5.2.2 注册配置拦截器

不同于 Filter 的 @WebFilter 注解,Interceptor 必须通过 Spring MVC 的配置类来进行显式注册。我们需要实现 WebMvcConfigurer 接口。

java 复制代码
package com.example.tliaswebmanagement.interceptor;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;


/**
 * 示例拦截器
 * <p>
 * 用于演示 Spring MVC 拦截器的基本使用方式和执行流程。
 * 拦截器工作在 DispatcherServlet 之后,可以拦截控制器方法的执行,
 * 常用于实现权限校验、日志记录、性能监控等功能。
 * </p>
 */
@Slf4j
@Component
public class DemoInterceptor implements HandlerInterceptor {

    /**
     * 前置处理方法:在目标控制器方法执行之前调用
     * <p>
     * 此方法在请求被分配到处理器(Controller)之后、实际处理方法执行之前调用。
     * 可以在此处进行权限验证、参数校验、日志记录等预处理操作。
     * </p>
     *
     * @param request  当前 HTTP 请求对象
     * @param response 当前 HTTP 响应对象
     * @param handler  被调用的处理器对象(通常是 Controller 中的方法)
     * @return true 表示放行,继续执行后续拦截器和目标方法;false 表示拦截,不再继续执行
     * @throws Exception 处理过程中可能抛出的异常
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        log.info("preHandle .... ");

        return true; //true表示放行
    }

    /**
     * 后置处理方法:在目标控制器方法执行之后、视图渲染之前调用
     * <p>
     * 此方法在处理器方法执行完成后、视图渲染之前调用。
     * 可以在此处对模型数据进行修改、添加公共属性、记录处理结果等。
     * 只有当 preHandle() 返回 true 时才会执行此方法。
     * </p>
     *
     * @param request      当前 HTTP 请求对象
     * @param response     当前 HTTP 响应对象
     * @param handler      被调用的处理器对象
     * @param modelAndView 模型和视图对象,包含处理结果数据和视图信息,可在此处进行修改
     * @throws Exception 处理过程中可能抛出的异常
     */
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        log.info("postHandle ... ");
    }

    /**
     * 完成处理方法:在整个请求处理完成后(视图渲染完毕后)调用
     * <p>
     * 此方法在整个请求处理流程结束后调用,包括视图渲染完成之后。
     * 无论请求是否成功、是否发生异常,此方法都会被执行。
     * 通常用于资源清理、性能统计、日志记录等收尾工作。
     * </p>
     *
     * @param request  当前 HTTP 请求对象
     * @param response 当前 HTTP 响应对象
     * @param handler  被调用的处理器对象
     * @param ex       请求处理过程中发生的异常,如果没有异常则为 null
     * @throws Exception 处理过程中可能抛出的异常
     */
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        log.info("afterCompletion .... ");
    }
}

2.5.5.2.3 启动测试与效果验证

完成上述配置后,重启 Spring Boot 服务。我们通过接口测试工具(如 Apifox)发起一次实际的业务请求(登录请求)。

控制台日志剖析:

观察 IDEA 控制台的输出链路,可以清晰地印证 Interceptor 的底层运行机制与三个核心方法的执行时机:

  • 拦截前置 (preHandle) :请求进入 Spring 环境后,首先触发了 DemoInterceptorpreHandle 方法。控制台率先打印出前置拦截日志,由于代码中返回了 true,拦截器将请求予以放行。
  • 业务触达 (Controller) :放行后,目标接口 LoginController 才真正接收到请求,并开始往下调用 Service 层执行数据库查询与核心登录逻辑。
  • 后置处理 (postHandle) :当 Controller 业务逻辑执行完毕并生成响应结果后,执行流程开始回溯,触发并打印了 postHandle 的日志。
  • 完成清理 (afterCompletion) :最后,在整个请求完全处理结束(响应数据准备写回给浏览器)时,触发并打印了 afterCompletion 的日志。

2.5.6 业务落地:基于 Interceptor 的 JWT 登录校验

我们已经掌握了 Interceptor 的原理,现在就可以将原本写在 Filter 中的 JWT 校验逻辑,平滑迁移 到 Spring 原生的 Interceptor 中。

2.5.6.1 核心逻辑实现 (TokenInterceptor)

创建一个真正的鉴权拦截器。核心验证逻辑(提取 Header、校验判空、JWT 验签)与 Filter 完全一致:

java 复制代码
package com.example.tliaswebmanagement.interceptor;

import com.example.tliaswebmanagement.utils.JwtUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

/**
 * JWT 令牌验证拦截器
 * <p>
 * 用于在 Spring MVC 层面拦截请求并验证 JWT 令牌的有效性,确保只有携带合法令牌的请求才能访问受保护的控制器方法。
 * 与 Filter 不同,拦截器工作在 DispatcherServlet 之后,可以访问 Controller 方法和模型数据,更适合实现业务级别的权限控制。
 * </p>
 */
@Slf4j
@Component
public class TokenInterceptor implements HandlerInterceptor {

    /**
     * 前置处理方法:在目标控制器方法执行之前进行 JWT 令牌验证
     * <p>
     * 对每个请求执行以下验证流程:
     * <ol>
     *   <li>获取请求 URI,判断是否为登录等无需验证的接口</li>
     *   <li>从请求头中获取 JWT 令牌</li>
     *   <li>验证令牌是否存在且非空,不存在则返回 401 状态码并拦截请求</li>
     *   <li>解析并验证令牌的有效性和完整性,解析失败则返回 401 状态码并拦截请求</li>
     *   <li>令牌验证通过,放行请求到目标控制器方法</li>
     * </ol>
     * </p>
     *
     * @param request  当前 HTTP 请求对象,用于获取请求 URI 和请求头中的令牌
     * @param response 当前 HTTP 响应对象,用于设置响应状态码
     * @param handler  被调用的处理器对象(通常是 Controller 中的方法)
     * @return true 表示令牌验证通过或为白名单请求,放行继续执行;false 表示令牌验证失败,拦截请求不再继续执行
     * @throws Exception 处理过程中可能抛出的异常
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        // 注意:以下代码已被 WebConfig 中的 excludePathPatterns 配置替代
        // 原逻辑:在拦截器内部通过 URI 判断来放行登录请求
        // 优化后:在 WebConfig 配置类中统一配置排除路径,使代码更清晰、更易维护
        //
        // 原实现方式:
        //1. 获取请求的 URI,用于判断是否为登录等无需令牌验证的请求
        // String uri = request.getRequestURI();// /api/users
        //
        //2. 判断是否为登录请求,登录接口不需要令牌验证,直接放行
        // if(uri.contains("login")){
        //     log.info("登录请求,直接放行");
        //     return true;
        // }

        // 对应的配置方式(在 WebConfig.addInterceptors 方法中配置):
        //.excludePathPatterns("/login");//设置不拦截的请求路径( 这里仅放行登录请求 )



        //3. 从请求头中获取 JWT 令牌,客户端需在 Header 中携带 token 字段
        String token = request.getHeader("token");

        //4. 验证令牌是否存在且非空,为空则拒绝访问
        if(token == null || token.isEmpty()){
            log.info("获取到jwt令牌为空,返回错误结果");
            response.setStatus(HttpStatus.SC_UNAUTHORIZED);//401 未授权
            return false;
        }

        //5. 尝试解析 JWT 令牌,验证其有效性和完整性
        try{
            JwtUtils.parseJwt(token);
        }
        catch (Exception e){
            log.info("解析jwt令牌失败,返回错误结果");
            response.setStatus(HttpStatus.SC_UNAUTHORIZED);//401 未授权
            return false;
        }

        //6. 令牌验证通过,记录日志并放行请求到目标控制器方法
        log.info("令牌合法, 放行");
        return true;
    }
}

2.5.6.2 注册拦截器与路径排除 (Exclude)

相较于 Filter 只能在代码里写 if(uri.contains("login")) 进行硬编码排除,Interceptor 提供了一种更加优雅的路由配置方式:白名单机制 (excludePathPatterns)

java 复制代码
package com.example.tliaswebmanagement.config;

import com.example.tliaswebmanagement.interceptor.DemoInterceptor;
import com.example.tliaswebmanagement.interceptor.TokenInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * Web MVC 配置类
 * <p>
 * 用于配置 Spring MVC 的相关组件,包括拦截器、资源处理器、消息转换器等。
 * 通过实现 WebMvcConfigurer 接口,可以自定义 Web 应用的行为,而无需覆盖默认的 Spring MVC 配置。
 * </p>
 */
@Configuration
public class WebConfig implements WebMvcConfigurer {

    /**
     * JWT 令牌验证拦截器实例
     * <p>
     * 由 Spring 容器自动注入 TokenInterceptor bean,用于在控制器方法执行前验证请求携带的 JWT 令牌有效性,
     * 实现基于令牌的访问控制,确保只有认证通过的请求才能访问受保护的资源。
     * </p>
     */
    @Autowired
    private TokenInterceptor tokenInterceptor;


    /**
     * 注册拦截器配置
     * <p>
     * 此方法在 Spring MVC 初始化时被调用,用于将自定义拦截器注册到拦截器链中。
     * 可以配置多个拦截器,并分别为每个拦截器设置拦截路径和排除路径。
     * </p>
     *
     * @param registry 拦截器注册表,用于添加和管理拦截器配置
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //注册自定义拦截器对象,并配置拦截路径
        registry.addInterceptor(tokenInterceptor)
                .addPathPatterns("/**")//设置拦截器拦截的请求路径( /** 表示拦截所有请求)
                .excludePathPatterns("/login");//设置不拦截的请求路径( 这里仅放行登录请求 )
    }
}

(配置完成后,切记将之前的 TokenFilter 上的 @WebFilter 注释掉,避免 Filter 和 Interceptor 同时执行导致二次拦截。)


2.5.6.3 整体功能测试与验证

场景一:未登录直接访问 (成功拦截)

在浏览器未登录(无 Token)的状态下,直接尝试访问后台主页。Interceptor 验签失败直接返回 401 状态码,前端侦测到异常后,自动强制跳转回登录界面,并弹出"登录失效"提示。

场景二:正常登录与业务访问 (全链路放行)

  • 登录请求放行 :通过前端或接口工具发起 /login 请求,得益于 excludePathPatterns 白名单配置,请求被直接放行并成功下发 Token。

  • 业务成功触达 :随后携带合法的 Token 访问"员工信息统计"模块,拦截器 preHandle 验签通过,后端成功返回数据,前端页面顺利渲染出图表。


2.5.7 Interceptor 进阶:执行流程、路径匹配与技术对比

2.5.7.1 拦截路径匹配规则总结

在 Spring Boot 中,路径配置语法与 Servlet Filter 存在细微差别。特别要注意 星号 (*) 的数量

拦截路径配置 含义说明 匹配示例 不匹配示例
/* 一级路径拦截 /depts, /emps, /login /depts/1 (包含多级路径则不匹配)
/** 任意级路径拦截 (最常用) /depts, /depts/1, /depts/1/2 无(通杀所有请求)
/depts/* /depts 下的一级子路径 /depts/1, /depts/add /depts/1/delete, /emps
/depts/** /depts 下的任意多级子路径 /depts, /depts/1, /depts/1/2 /emps/1

2.5.7.2 宏观架构:执行流程深度剖析

既然系统里既可以有 Filter 也可以有 Interceptor,如果两者同时存在,它们的先后顺序是怎样的?

Tomcat 容器并不识别 Spring 定义的 Controller,所有的请求在进入 Controller 前,都必须先经过 Spring 的中央调度器:DispatcherServlet(前端控制器)。这就注定了两者的作用域完全不同。
Controller (业务控制器) Interceptor (拦截器) DispatcherServlet (Spring 核心) Filter (过滤器) 浏览器 (客户端) Controller (业务控制器) Interceptor (拦截器) DispatcherServlet (Spring 核心) Filter (过滤器) 浏览器 (客户端) 1. 发起 HTTP 请求 2. 放行 (执行 doFilter 前置逻辑) 3. 分发请求 4. preHandle (返回 true 放行) 5. 执行完业务逻辑,返回数据 6. 依次执行 postHandle & afterCompletion 7. 返回 Servlet 响应 8. 执行 doFilter 后置逻辑,响应到达浏览器

从图中可以清晰地看出:Filter 是将大门的门卫,而 Interceptor 是核心办公区前台的内卫。 无论请求到达多深,执行完毕后原路返回的顺序始终是严格对称的(U型回溯)。


2.5.8 Filter 与 Interceptor 的核心区别

在实际的企业级开发中,我们应该如何在这两者之间做选型?只需记住以下两点核心区别:

  • 接口规范与出身不同
    • Filter 隶属于 JavaEE 规范 (Servlet 标准),由 Web 服务器(如 Tomcat)原生支持。
    • Interceptor 隶属于 Spring Framework,只能在引入了 Spring 环境的项目中使用。
  • 拦截的覆盖范围不同
    • Filter 是真正意义上的"全局拦截",它可以拦截发往 Web 服务器的所有资源(包括静态资源 HTML、CSS、JS 等)。
    • Interceptor 由于作用在 DispatcherServlet 之后,它只会拦截 Spring 环境中的 Controller 接口调用

3. 完整代码一览

3.1 登录接口设计

3.1.1 请求参数

前端发起 POST 请求,提交 JSON 格式的账号密码:

json 复制代码
{
  "username": "admin",
  "password": "123456"
}

3.1.2 响应参数

后端返回统一响应对象 Result<LoginInfo>

json 复制代码
{
  "code": 1,
  "msg": "success",
  "data": {
    "id": 1,
    "username": "songjiang",
    "name": "宋江",
    "token": "eyJhbGciOiJIUzI1NiJ9..."
  }
}

3.1.3 登录成功返回对象 (DTO)

java 复制代码
/**
 * 登录返回结果实体类
 * 用于封装员工登录成功后,回显给前端的非敏感用户信息及身份令牌
 */
@Data               // 自动生成:Getter、Setter、toString、hashCode、equals 方法
@NoArgsConstructor  // 自动生成:无参构造器(框架进行 JSON 反序列化时必须使用)
@AllArgsConstructor // 自动生成:全参构造器(方便在 Service 层快速构建对象)
public class LoginInfo {

    /**
     * 员工ID:数据库主键,前端常用于获取个人详情或修改资料
     */
    private Integer id;

    /**
     * 用户名:员工登录的唯一标识账号
     */
    private String username;

    /**
     * 姓名:员工真实姓名,用于前端界面展示(如:欢迎您,宋江)
     */
    private String name;

    /**
     * 身份令牌:后端生成的 JWT 字符串
     * [注意] 当前阶段暂存空字符串 "",在后续"登录校验"章节中通过工具类生成
     */
    private String token;
    
}

3.2 数据访问层 (Mapper)

登录的本质,就是根据用户名和密码查询员工信息

java 复制代码
@Mapper
public interface EmpMapper {
    /**
     * 员工登录校验:根据账号和密码精确查询记录
     * * @param emp 包含前端传入的账号 (username) 和密码 (password) 的实体对象
     * @return Emp 若校验通过,返回包含完整信息的员工实体;若不匹配,返回 null
     */
    @Select("select * from emp where username = #{username} and password = #{password}")
    Emp selectByUsernameAndPassword(Emp emp);
}

3.3 业务层 (Service)

3.3.1 Service 接口

java 复制代码
public interface EmpService {
    // ... 其他原有方法

    /**
     * 员工登录验证
     * @param emp 登录信息 (需包含 username 和 password)
     * @return 成功返回用户信息及 Token,失败返回 null
     */
    LoginInfo login(Emp emp);
}

3.3.2Service 实现类

java 复制代码
/**
 * 员工登录逻辑处理
 * * 接收前端传入的登录凭证,验证通过后生成并下发 JWT 令牌。
 *
 * @param emp 包含登录凭证(主要是用户名和密码)的员工实体对象
 * @return LoginInfo 登录成功返回包含 JWT 令牌及用户基础信息的对象;若用户名或密码错误(即员工不存在)则返回 null
 */
 @Override
 public LoginInfo login(Emp emp){
     // 1. 调用 Mapper 层,根据传入的用户名和密码在数据库中查询对应的员工记录
     Emp e = empMapper.selectByUsernameAndPassword(emp);

     // 2. 校验查询结果:如果对象不为空,说明数据库中存在匹配的记录,账号密码验证通过
     if(e != null){
         // 记录登录成功的日志信息
         log.info("登录成功,员工信息:{}", e);

         // 3. 准备组装 JWT 令牌的载荷(Claims)数据
         // 通常将能够唯一标识用户的非敏感信息存入令牌中,便于后续接口解析并识别用户身份
         Map<String, Object> claims = new HashMap<>();
         claims.put("id", e.getId());
         claims.put("username", e.getUsername());

         // 4. 调用自定义的 JWT 工具类,根据载荷生成加密的 JWT 字符串
         String jwt = JwtUtils.generateJwt(claims);

         // 5. 将用户的核心信息以及生成的 JWT 令牌封装成 LoginInfo 对象并返回给控制层
         return new LoginInfo(e.getId(), e.getUsername(), e.getName(), jwt);
     }

     // 6. 如果 e 为 null,说明用户名不存在或密码错误,直接返回 null 交由控制层处理(例如抛出异常或返回错误状态码)
     return null;
}

3.4 控制层 (Controller)

java 复制代码
/*
* 登录controller
* */
@Slf4j // 自动生成日志对象 log,方便开发调试和线上问题排查
@RestController // 复合注解:@Controller + @ResponseBody,表示将返回的对象自动转换为 JSON 格式响应给前端
public class LoginController {

    @Autowired // 依赖注入:告诉 Spring 容器自动装配 EmpService 的实现类对象
    private EmpServiceImpl empService;

    /**
     * 员工登录接口
     * * @param emp 接收前端传递的 JSON 格式账号密码数据
     * * @return 统一响应结果对象 Result
     */
    @PostMapping("/login") // 映射 HTTP POST 请求,路径为 /login
    // @RequestBody:将前端 POST 传来的 JSON 请求体,自动反序列化并映射到 Emp 对象的属性中(提取 username 和 password)
    public Result login(@RequestBody Emp emp) {

        // 1. 记录请求日志
        log.info("接收到员工登录请求,当前尝试登录的账号: {}", emp.getUsername());

        // 2. 调用 Service 层执行核心业务逻辑
        LoginInfo loginInfo = empService.login(emp);

        // 3. 判断比对结果,并封装统一响应体
        if (loginInfo != null) {
            // 登录成功:调用 Result.success(),状态码通常为 1,并携带用户信息
            return Result.success(loginInfo);
        }

        // 登录失败:调用 Result.error(),状态码通常为 0,并附带错误提示
        return Result.error("用户名或密码错误");
    }
}

3.5 JWT 工具类(utils)

java 复制代码
package com.example.tliaswebmanagement.utils;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import java.util.Date;
import java.util.Map;

/**
 * JWT令牌操作工具类
 */
public class JwtUtils {
    
    // 签名密钥
    private static final String KEY = "aXRjYXRz";
    // 令牌过期时间
    private static final Date EXPIRATION = new Date(System.currentTimeMillis() + 12 * 3600 * 1000);
    
    /**
     * 生成JWT令牌
     * 
     * @param claims 自定义声明数据(如用户ID、用户名等)
     * @return JWT令牌字符串
     */
    public static String generateJwt(Map<String, Object> claims) {
        return Jwts.builder()
                // 设置签名算法和密钥(HS256)
                .signWith(SignatureAlgorithm.HS256, KEY)
                // 添加自定义声明
                .addClaims(claims)
                // 设置过期时间为12小时
                .setExpiration(EXPIRATION)
                // 生成令牌
                .compact();
    }
    
    /**
     * 解析JWT令牌
     * 
     * @param token JWT令牌字符串
     * @return Claims对象,包含令牌中的所有数据
     */
    public static Claims parseJwt(String token) {
        return Jwts.parser()
                // 设置签名密钥(必须与生成时一致)
                .setSigningKey(KEY)
                // 解析并验证令牌
                .parseClaimsJws(token)
                // 获取Payload部分
                .getBody();
    }
}

3.6 登录校验实现

3.6.1 方案一:Filter 版本

java 复制代码
package com.example.tliaswebmanagement.filter;

import com.example.tliaswebmanagement.utils.JwtUtils;
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.HttpStatus;

import java.io.IOException;

/**
 * JWT 令牌验证过滤器
 * <p>
 * 用于拦截所有请求并验证 JWT 令牌的有效性,确保只有携带合法令牌的请求才能访问受保护的资源。
 * 登录请求会被直接放行,其他请求必须携带有效的 JWT 令牌,否则返回 401 未授权状态码。
 * </p>
 */
@WebFilter(urlPatterns = "/*")
@Slf4j
public class TokenFilter implements Filter {

    /**
     * 过滤器核心处理方法
     * <p>
     * 对每个请求执行以下验证流程:
     * <ol>
     *   <li>判断是否为登录请求,如果是则直接放行</li>
     *   <li>从请求头中获取 JWT 令牌</li>
     *   <li>验证令牌是否存在且非空,不存在则返回 401 状态码</li>
     *   <li>解析并验证令牌的有效性,解析失败则返回 401 状态码</li>
     *   <li>令牌验证通过,放行请求到目标资源</li>
     * </ol>
     * </p>
     *
     * @param servletRequest  请求对象,封装了客户端发送的 HTTP 请求信息
     * @param servletResponse 响应对象,用于向客户端发送响应数据
     * @param filterChain     过滤器链对象,调用 doFilter() 方法将请求传递给下一个过滤器或目标资源
     * @throws IOException      当处理请求/响应过程中发生 I/O 错误时抛出
     * @throws ServletException 当处理过程中发生 Servlet 相关错误时抛出
     */
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        //1. 将 ServletRequest 和 ServletResponse 转换为 HTTP 类型,以便获取 HTTP 特有的方法和属性
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        //2. 获取请求的 URI,用于判断请求类型(如登录请求)
        String uri = request.getRequestURI();// /api/users

        //3. 判断是否为登录请求,登录接口不需要令牌验证,直接放行
        if(uri.contains("login")){
            log.info("登录请求,直接放行");
            filterChain.doFilter(request,response);
            return;
        }

        //4. 从请求头中获取 JWT 令牌,客户端需在 Header 中携带 token 字段
        String token = request.getHeader("token");

        //5. 验证令牌是否存在且非空
        if(token == null || token.isEmpty()){
            log.info("获取到jwt令牌为空,返回错误结果");
            response.setStatus(HttpStatus.SC_UNAUTHORIZED);//401 未授权
            return;
        }

        //6. 尝试解析 JWT 令牌,验证其有效性和完整性
        try{
            JwtUtils.parseJwt(token);
        }
        catch (Exception e){
            log.info("解析jwt令牌失败,返回错误结果");
            response.setStatus(HttpStatus.SC_UNAUTHORIZED);//401 未授权
            return;
        }

        //7. 令牌验证通过,记录日志并放行请求到目标资源
        log.info("令牌合法, 放行");
        filterChain.doFilter(request , response);
    }
}

3.6.2 方案二:Interceptor 版本 (推荐)

java 复制代码
package com.example.tliaswebmanagement.interceptor;

import com.example.tliaswebmanagement.utils.JwtUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

/**
 * JWT 令牌验证拦截器
 * <p>
 * 用于在 Spring MVC 层面拦截请求并验证 JWT 令牌的有效性,确保只有携带合法令牌的请求才能访问受保护的控制器方法。
 * 与 Filter 不同,拦截器工作在 DispatcherServlet 之后,可以访问 Controller 方法和模型数据,更适合实现业务级别的权限控制。
 * </p>
 */
@Slf4j
@Component
public class TokenInterceptor implements HandlerInterceptor {

    /**
     * 前置处理方法:在目标控制器方法执行之前进行 JWT 令牌验证
     * <p>
     * 对每个请求执行以下验证流程:
     * <ol>
     *   <li>获取请求 URI,判断是否为登录等无需验证的接口</li>
     *   <li>从请求头中获取 JWT 令牌</li>
     *   <li>验证令牌是否存在且非空,不存在则返回 401 状态码并拦截请求</li>
     *   <li>解析并验证令牌的有效性和完整性,解析失败则返回 401 状态码并拦截请求</li>
     *   <li>令牌验证通过,放行请求到目标控制器方法</li>
     * </ol>
     * </p>
     *
     * @param request  当前 HTTP 请求对象,用于获取请求 URI 和请求头中的令牌
     * @param response 当前 HTTP 响应对象,用于设置响应状态码
     * @param handler  被调用的处理器对象(通常是 Controller 中的方法)
     * @return true 表示令牌验证通过或为白名单请求,放行继续执行;false 表示令牌验证失败,拦截请求不再继续执行
     * @throws Exception 处理过程中可能抛出的异常
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        // 注意:以下代码已被 WebConfig 中的 excludePathPatterns 配置替代
        // 原逻辑:在拦截器内部通过 URI 判断来放行登录请求
        // 优化后:在 WebConfig 配置类中统一配置排除路径,使代码更清晰、更易维护
        //
        // 原实现方式:
        //1. 获取请求的 URI,用于判断是否为登录等无需令牌验证的请求
        // String uri = request.getRequestURI();// /api/users
        //
        //2. 判断是否为登录请求,登录接口不需要令牌验证,直接放行
        // if(uri.contains("login")){
        //     log.info("登录请求,直接放行");
        //     return true;
        // }

        // 对应的配置方式(在 WebConfig.addInterceptors 方法中配置):
        //.excludePathPatterns("/login");//设置不拦截的请求路径( 这里仅放行登录请求 )



        //3. 从请求头中获取 JWT 令牌,客户端需在 Header 中携带 token 字段
        String token = request.getHeader("token");

        //4. 验证令牌是否存在且非空,为空则拒绝访问
        if(token == null || token.isEmpty()){
            log.info("获取到jwt令牌为空,返回错误结果");
            response.setStatus(HttpStatus.SC_UNAUTHORIZED);//401 未授权
            return false;
        }

        //5. 尝试解析 JWT 令牌,验证其有效性和完整性
        try{
            JwtUtils.parseJwt(token);
        }
        catch (Exception e){
            log.info("解析jwt令牌失败,返回错误结果");
            response.setStatus(HttpStatus.SC_UNAUTHORIZED);//401 未授权
            return false;
        }

        //6. 令牌验证通过,记录日志并放行请求到目标控制器方法
        log.info("令牌合法, 放行");
        return true;
    }
}

注册拦截器:

java 复制代码
package com.example.tliaswebmanagement.config;

import com.example.tliaswebmanagement.interceptor.DemoInterceptor;
import com.example.tliaswebmanagement.interceptor.TokenInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * Web MVC 配置类
 * <p>
 * 用于配置 Spring MVC 的相关组件,包括拦截器、资源处理器、消息转换器等。
 * 通过实现 WebMvcConfigurer 接口,可以自定义 Web 应用的行为,而无需覆盖默认的 Spring MVC 配置。
 * </p>
 */
@Configuration
public class WebConfig implements WebMvcConfigurer {

    /**
     * JWT 令牌验证拦截器实例
     * <p>
     * 由 Spring 容器自动注入 TokenInterceptor bean,用于在控制器方法执行前验证请求携带的 JWT 令牌有效性,
     * 实现基于令牌的访问控制,确保只有认证通过的请求才能访问受保护的资源。
     * </p>
     */
    @Autowired
    private TokenInterceptor tokenInterceptor;


    /**
     * 注册拦截器配置
     * <p>
     * 此方法在 Spring MVC 初始化时被调用,用于将自定义拦截器注册到拦截器链中。
     * 可以配置多个拦截器,并分别为每个拦截器设置拦截路径和排除路径。
     * </p>
     *
     * @param registry 拦截器注册表,用于添加和管理拦截器配置
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //注册自定义拦截器对象,并配置拦截路径
        registry.addInterceptor(tokenInterceptor)
                .addPathPatterns("/**")//设置拦截器拦截的请求路径( /** 表示拦截所有请求)
                .excludePathPatterns("/login");//设置不拦截的请求路径( 这里仅放行登录请求 )
    }
}

相关推荐
float_com12 天前
【JavaWeb】----- Linux基础入门
linux·javaweb
夹芯饼干1 个月前
JavaWeb 核心:Request 与 Response 对象全解析与实战
javaweb·重定向·request对象·response对象
一只大袋鼠1 个月前
JavaWeb ——Cookie 对象
java·servlet·javaweb·cookie·小蛋糕
一只大袋鼠1 个月前
请求转发vs重定向、同源策略与跨域
java·javaweb·同源策略·请求转发·重定向
为美好的生活献上中指1 个月前
*Java 沉淀重走长征路*之——《Java Web 应用开发完全指南:从零到企业实战(两万字深度解析)》
java·开发语言·前端·html·javaweb·js
2301_780669862 个月前
MyBatis(配置,增删改查,注解与XML两种开发方式)、SpringBoot配置文件(yml简化properties)
xml·spring boot·mybatis·javaweb
四谎真好看3 个月前
JavaWeb学习笔记(Day14)
笔记·学习·学习笔记·javaweb
四谎真好看3 个月前
JavaWeb学习笔记(Day13)
笔记·学习·学习笔记·javaweb
四谎真好看3 个月前
JavaWeb学习笔记(Day12)
笔记·学习·学习笔记·javaweb