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) 执行时机 核心作用 preHandle
Controller 方法执行前 请求拦截 。返回 false
可中断请求。postHandle
Controller 方法执行后 ,视图渲染前 修改响应 。可以修改 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()); 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; @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: 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())) { HashMap<String, Object> payload = new HashMap <>(); 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); } token = token.substring(7 ); 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 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;@Tag(name = "用户管理", description = "提供用户相关的CRUD接口") @RestController @RequestMapping("/users") @RequiredArgsConstructor @Validated @SecurityRequirement(name = "bearerAuth") public class UserController { }
通过在 UserController
类上添加 @SecurityRequirement(name = "bearerAuth")
,我们告诉 SpringDoc,这个 Controller 下的所有接口 都需要使用我们刚刚定义的 bearerAuth
认证方案。当然,这个注解也可以用在单个方法上,以实现更细粒度的控制。