13-[API安全] 拦截器与 Token 认证

6. [API安全] 拦截器与 Token 认证

摘要: 到目前为止,我们的 API 功能已经非常完备,但它正处于“不设防”的状态,任何人都可以随意调用所有接口。这在真实世界中是绝对不可接受的。本章,我们将聚焦于 API 的核心安全问题,引入 Spring MVC 强大的**拦截器(Interceptor)**机制,并结合 JWT (JSON Web Token) 这一现代化的认证方案,为我们的 API 构建一套专业、无状态的用户认证和权限校验体系。

6.1. 核心技术:HandlerInterceptor 详解

1. 痛点:重复的通用逻辑

随着项目发展,我们可能会遇到一些需要对多个接口生效的通用需求,例如:

  • 权限校验:某些接口(如修改、删除用户)必须在用户登录后才能调用。
  • 日志记录:需要记录每个接口的请求路径、执行耗时等信息,用于监控和性能分析。
  • 通用处理:为所有请求注入一些通用的上下文信息。

我们当然不希望在每个 Controller 的每个方法里都重复编写这些逻辑,这会造成大量的代码冗余。我们需要一种能够在请求处理流程中“切入”的机制,这正是 Spring MVC 提供的拦截器 (Interceptor)

拦截器是一种强大的 AOP (面向切面编程) 的体现,它允许我们在请求进入 Controller 方法之前、方法执行之后以及整个请求处理完毕之后这三个关键节点,执行我们自定义的通用逻辑。

2. HandlerInterceptor 接口

要创建一个拦截器,我们需要实现 org.springframework.web.servlet.HandlerInterceptor 接口。这个接口定义了三个核心方法(在 Java 8 之后,它们都是 default 方法,我们只需按需重写即可):

方法 (Method)执行时机核心作用
preHandleController 方法执行请求拦截。返回 false 可中断请求。
postHandleController 方法执行,视图渲染修改响应。可以修改 ModelAndView。(API开发中较少使用)
afterCompletion整个请求处理完毕资源清理。无论是否发生异常都会执行。

3. 实战:创建并注册一个日志拦截器

为了直观地感受拦截器的工作流程,我们先来创建一个简单的日志拦截器,用于计算并打印每个请求的处理耗时。

第一步:创建拦截器实现类

文件路径: src/main/java/com/example/springbootdemo/interceptor/LogInterceptor.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package com.example.springbootdemo.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;

@Slf4j
@Component
public class LogInterceptor implements HandlerInterceptor {

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 记录请求开始时间
long startTime = System.currentTimeMillis();
request.setAttribute("startTime", startTime);

log.info("开始处理请求: {} {}", request.getMethod(), request.getRequestURI());

// 返回 true 表示继续执行后续的拦截器和 Controller
return true;
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 从请求中获取开始时间
long startTime = (Long) request.getAttribute("startTime");
long endTime = System.currentTimeMillis();
long duration = endTime - startTime;

log.info("请求处理完毕: {} {}, 耗时: {}ms", request.getMethod(), request.getRequestURI(), duration);
}
}

第二步:注册拦截器
我们需要在 WebConfig 中,将我们创建的拦截器注册到 Spring MVC 的拦截器链中。

文件路径: src/main/java/com/example/springbootdemo/config/WebConfig.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.example.springbootdemo.config;

import com.example.springbootdemo.interceptor.LogInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

private final LogInterceptor logInterceptor; // 通过构造函数注入

// ... 已有的 addResourceHandlers 和 addCorsMappings 方法 ...

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(logInterceptor) // 注册我们编写的日志拦截器
.addPathPatterns("/**"); // 指定拦截所有路径
}
}

代码解析:

  • addInterceptor(logInterceptor): 将我们的 LogInterceptor Bean 注册到拦截器注册表中。
  • .addPathPatterns("/**"): 指定这个拦截器要拦截的 URL 模式。/** 是一个通配符,表示拦截所有进入应用的请求。与之相对的,还有一个 .excludePathPatterns("/login") 方法,用于指定需要排除的路径。

重启应用,并使用 SpringDoc 或 cURL 调用任意一个 /users 接口(例如 GET /users/1)。然后观察您的应用控制台日志

1
2
3
... INFO com.e.s.interceptor.LogInterceptor  : 开始处理请求: GET /users/1
... (省略 Controller 和 Service 的日志)
... INFO com.e.s.interceptor.LogInterceptor : 请求处理完毕: GET /users/1, 耗时: 18ms

这证明我们的拦截器已经成功地“切入”了请求处理流程。

通过这个简单的日志拦截器,我们已经掌握了拦截器的基本创建和注册流程。preHandle 方法的 boolean 返回值是实现权限校验的关键。在下一节,我们将利用这一点,结合 JWT 技术,来构建一个真正的用户认证拦截器。


6.2. 实战:实现基于 JWT 的 Token 认证

现在我们知道了拦截器是进行权限校验的“关卡”,那么下一个问题就是:我们用什么作为“通行凭证”呢?

6.2.1. 认证方案选择:为什么是 JWT?

在传统的 Web 应用中,我们常用 Session-Cookie 机制来管理用户状态。但它在现代前后端分离、分布式、移动优先的架构下,暴露了一些弊端:

  • 服务端状态化:服务器需要为每个登录用户维护一份 Session 数据,当在线用户量巨大时,会消耗大量内存。
  • 扩展性差:Session 数据默认存储在单台服务器上。在多台服务器做负载均衡时,需要额外处理 Session 共享问题(如使用 Sticky Session 或 Session 复制),增加了架构复杂度。
  • 跨域与移动端不友好:基于 Cookie 的 Session 机制在跨域场景和非浏览器客户端(如手机 App)上处理起来较为棘手。

为了解决这些问题,基于 Token 的无状态认证 方案应运而生,而 JWT (JSON Web Token) 是其中最主流、最优秀的事实标准。

JWT 的核心思想:服务器在用户登录成功后,不再保存任何 Session 信息,而是根据用户信息生成一个加密签名的、自包含的字符串(即 Token),返还给客户端。客户端在后续的每次请求中,都需要在请求头里携带这个 Token。服务器收到请求后,只需验证 Token 签名的合法性,即可确认用户的身份,无需查询数据库或任何会话存储。

特性Session-Cookie (传统方案)JWT Token (现代方案)
状态有状态 (Stateful)无状态 (Stateless)
存储服务端存储 Session客户端存储 Token
扩展性差,依赖 Session 共享,天然支持分布式部署
适用性仅限浏览器通用,浏览器、App、小程序均适用

6.2.2. 完整的认证授权流程

一个标准的 Token 认证流程包含以下三个步骤,我们必须在脑海中建立起这个闭环:

客户端(前端)使用用户名和密码调用登录接口 (/auth/login)。

服务器验证身份成功后,使用 Hutool-JWT 生成一个包含用户信息的 Token,并将其返回给客户端。

客户端将获取到的 Token 存储起来(例如,在 localStorage 中)。在后续访问所有受保护的接口(如 /users)时,必须在 HTTP 请求头 Authorization 中携带这个 Token,格式通常为 Bearer <token>

服务器端的拦截器会捕获每一个请求,检查 Authorization 头是否存在且 Token 是否合法有效。

如果 Token 有效,则放行请求至 Controller;如果 Token 无效或不存在,则拦截请求并返回 401 Unauthorized 错误。


6.3. 实战:登录接口与 Token 校验

6.3.1. 准备工作:添加 JWT 配置

文件路径: src/main/resources/application.yml (修改)

1
2
3
4
5
6
7
# ... 已有配置 ...
app:
# ...
# JWT Settings
jwt:
# 签名密钥,必须足够复杂
secret: your-super-strong-and-long-secret-key-for-hs256-jwt

安全警告: JWT 的安全性完全依赖于密钥的保密性。在生产环境中,绝对不能 将密钥硬编码在配置文件中。最佳实践是将其配置在环境变量或专门的密钥管理服务中。

文件路径: com/example/springbootdemo/dto/auth/LoginDTO.java (新增)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.example.springbootdemo.dto.auth;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;

@Data
@Schema(description = "登录数据传输对象")
public class LoginDTO {
@Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "admin")
@NotBlank(message = "用户名不能为空")
private String username;

@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
@NotBlank(message = "密码不能为空")
private String password;
}

6.3.2. 实现登录接口 (签发 Token)

我们将重构 AuthController,使用 Hutool 的 JWTUtil 来生成 Token。

文件路径: src/main/java/com/example/springbootdemo/controller/AuthController.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package com.example.springbootdemo.controller;

import cn.hutool.jwt.JWTUtil;
import com.example.springbootdemo.common.Result;
import com.example.springbootdemo.common.ResultCode;
import com.example.springbootdemo.dto.auth.LoginDTO;
import com.example.springbootdemo.exception.BusinessException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;

@Tag(name = "认证管理", description = "提供用户登录认证接口")
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
@Validated
public class AuthController {
@Value("${app.jwt.secret}")
private String jwtSecret;

@Operation(summary = "用户登录")
@PostMapping("/login")
public ResponseEntity<Result<String>> login(@Validated @RequestBody LoginDTO loginDTO) {
// 模拟验证用户名密码
if ("admin".equals(loginDTO.getUsername()) && "123456".equals(loginDTO.getPassword())) {
// 使用 Hutool 创建 Token
HashMap<String, Object> payload = new HashMap<>();
// 在 payload 中放入基本信息
payload.put("username", loginDTO.getUsername());
// 可以在此放入用户角色、权限等信息
String token = JWTUtil.createToken(payload, jwtSecret.getBytes());
return ResponseEntity.ok(
Result.success(token)
);

} else {
throw new BusinessException(ResultCode.UNAUTHORIZED);
}
}
}

6.3.3. 编写 Token 校验拦截器

现在我们来创建真正的“门卫”——AuthInterceptor

文件路径: src/main/java/com/example/springbootdemo/interceptor/AuthInterceptor.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package com.example.springbootdemo.interceptor;

import cn.hutool.core.util.StrUtil;
import cn.hutool.jwt.JWTValidator;
import com.example.springbootdemo.common.ResultCode;
import com.example.springbootdemo.exception.BusinessException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

@Component
@RequiredArgsConstructor
public class AuthInterceptor implements HandlerInterceptor {
@Value("${app.jwt.secret}")
private String jwtSecret;

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("Authorization");
try {
if (StrUtil.isBlank(token) || !token.startsWith("Bearer ")) {
throw new BusinessException(ResultCode.UNAUTHORIZED);
}
// 固定截取 Bearer 后面的字符
token = token.substring(7);
// 2. 使用 Hutool 验证 Token 中的载荷(例如:过期时间)
// validate 方法会检查 iat, exp, nbf 等时间戳
JWTValidator.of(token).validateDate();
} catch (Exception e) {
// 捕获所有可能的异常,统一返回错误
throw new BusinessException(ResultCode.ERROR);
}
return true;
}
}

6.3.4. 注册拦截器

文件路径: src/main/java/com/example/springbootdemo/config/WebConfig.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// ... imports ...
import com.example.springbootdemo.interceptor.AuthInterceptor;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

// 通过构造函数注入依赖
private final AuthInterceptor authInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
// ... 已有的日志拦截器 ...

// 认证拦截器
registry.addInterceptor(authInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/auth/login",
"/files/**",
"/springboot-uploads/**",
"/swagger-ui/**",
"/v3/api-docs/**"
);
}
}

6.4. 联动:配置 SpringDoc 支持 JWT 认证

我们已经创建了登录接口和拦截器,但 Swagger UI 并不知道我们的 /users 接口需要一个 Authorization 请求头。因此,它既不会在接口上显示需要认证的“小锁”图标,也没有提供地方让我们输入 Token。这使得我们无法通过这个便捷的工具来测试受保护的接口。

为了解决这个问题,我们需要通过注解,明确地告诉 SpringDoc 我们项目所采用的认证方案。

1. 定义安全方案 (@SecurityScheme)

首先,我们需要定义一个全局的安全方案,告诉 SpringDoc 我们使用的是基于 HTTP 的 Bearer Token 认证。最佳实践是创建一个专门的配置类来做这件事。

文件路径: src/main/java/com/example/springbootdemo/config/SpringDocConfig.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.example.springbootdemo.config;

import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import org.springframework.context.annotation.Configuration;

@Configuration
@SecurityScheme(
name = "bearerAuth", // 这是安全方案的唯一名称,后续将通过此名称引用
type = SecuritySchemeType.HTTP, // 认证类型为 HTTP
scheme = "bearer", // 具体的认证方案为 Bearer, 表示令牌类型
bearerFormat = "JWT" // 提示 Token 的格式为 JWT
)
public class SpringDocConfig {
}

2. 应用安全方案 (@SecurityRequirement)

定义好安全方案后,我们还需要将其应用到需要保护的 Controller 上。

文件路径: src/main/java/com/example/springbootdemo/controller/UserController.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.example.springbootdemo.controller;

import io.swagger.v3.oas.annotations.security.SecurityRequirement;
// ... other imports

@Tag(name = "用户管理", description = "提供用户相关的CRUD接口")
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Validated
@SecurityRequirement(name = "bearerAuth") // 在类上应用名为 bearerAuth 的安全方案
public class UserController {
// ... Controller 内容保持不变 ...
}

通过在 UserController 类上添加 @SecurityRequirement(name = "bearerAuth"),我们告诉 SpringDoc,这个 Controller 下的所有接口都需要使用我们刚刚定义的 bearerAuth 认证方案。当然,这个注解也可以用在单个方法上,以实现更细粒度的控制。