通过 Filter 实现 JWT 令牌认证,这套登录校验到底是怎么跑通的?
一、前言
在前后端分离项目里,登录认证几乎是绕不过去的一块内容。
如果我们继续使用传统 Session,服务端就要保存登录状态;而一旦项目开始拆前后端、接口越来越多、后续还可能接入移动端,使用无状态的 JWT 方案往往会更灵活一些。
这篇文章重点聊一聊:系统是如何通过 Filter 完成 JWT 令牌认证的。
这篇文章会围绕下面这条主线展开:
- 用户登录成功后,后端是怎么生成 JWT 的。
- 前端在后续请求里,应该把 token 放到哪里。
TokenFilter是怎么拦截请求、校验 token、放行请求的。- 为什么还要配合
ThreadLocal保存当前登录人信息。
二、先搞清楚 JWT 在这里解决了什么问题
JWT 的核心作用,其实就是把"登录成功后的身份信息"封装成一个字符串令牌,后续每次请求都带着它来访问接口。
这样做有两个明显好处:
- 服务端不用像 Session 那样单独保存每个用户的会话状态。
- 后续每次请求只要校验 token 是否合法、是否过期,就能知道当前请求是不是已登录用户发来的。
放到这个项目里,JWT 主要承担的职责就是:
- 登录成功时生成 token。
- 非登录请求进入系统时校验 token。
- 校验通过后,把当前登录员工的 id 交给后续业务使用。
三、这个项目里的 JWT 认证整体流程
先来看完整流程:

如果把它翻译成一句更通俗的话,其实就是:
登录时发证,访问时验票,验票通过再放行。
四、登录成功后,token 是怎么生成的
4.1 Controller 层接收登录请求
登录入口在 LoginController:
java
@RestController
@RequestMapping("/login")
public class LoginController {
@Autowired
private EmpService empService;
@PostMapping
public Result login(@RequestBody Emp emp) {
LoginInfo info = empService.login(emp);
if (info != null) {
return Result.success(info);
}
return Result.error("登录失败");
}
}
这里的逻辑很直接:
- 前端把用户名和密码提交到
/login。 Controller调用业务层处理登录。- 如果登录成功,就把
LoginInfo返回给前端。
注意这里返回的并不只是"登录成功"四个字,而是把登录后的信息一起返回了,其中就包含 token。
4.2 Service 层校验用户并生成 token
真正生成 JWT 的逻辑在 EmpSeviceImpl.login 方法里:
java
@Override
public LoginInfo login(Emp emp) {
Emp emp1 = empMapper.getbyusername(emp);
if (emp1 != null) {
Map<String, Object> claims = new HashMap<>();
claims.put("id", emp1.getId());
claims.put("username", emp1.getUsername());
String token = JwtUtils.generateToken(claims);
return new LoginInfo(emp1.getId(), emp1.getUsername(), emp1.getName(), token);
}
return null;
}
这一段代码非常关键,它做了三件事:
- 根据登录信息查询员工是否存在。
- 如果存在,就把员工
id和username放进claims。 - 调用
JwtUtils.generateToken(claims)生成 token,并封装到LoginInfo返回。
也就是说,这个项目里的 token 不是随便生成的一串字符串,而是带有业务身份信息的。
五、JWT 工具类到底做了什么
项目里真正负责"生成令牌"和"解析令牌"的,是 JwtUtils。
5.1 生成 token
java
public class JwtUtils {
private static final String SIGNING_KEY = "******";
private static final long EXPIRATION_TIME = 12 * 60 * 60 * 1000L;
public static String generateToken(Map<String, Object> claims) {
return Jwts.builder()
.signWith(SignatureAlgorithm.HS256, SIGNING_KEY)
.addClaims(claims)
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.compact();
}
}
从这段代码里,我们可以得到两个很重要的结论:
- 这个项目使用的是
HS256签名算法。 - token 的过期时间是
12 * 60 * 60 * 1000L,也就是 12 小时。
这意味着:
- token 一旦被篡改,签名校验就过不了。
- token 一旦过期,解析时也会失败。
5.2 解析 token
java
public static Claims parseToken(String token) {
return Jwts.parser()
.setSigningKey(SIGNING_KEY)
.parseClaimsJws(token)
.getBody();
}
这个方法的作用就是:校验 token 合不合法,并把里面保存的数据重新解析出来。
只要 token 有下面这些问题之一,解析就会失败:
- token 缺失
- token 被篡改
- token 已过期
- token 根本不是系统签发的
六、重点来了:Filter 是怎么完成 JWT 认证的
这篇文章的重点,就是 TokenFilter。
java
@WebFilter(urlPatterns = "/*")
public class TokenFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String requestUrl = httpRequest.getRequestURI();
if (requestUrl.contains("/login")) {
chain.doFilter(request, response);
return;
}
String token = httpRequest.getHeader("token");
if (token == null || token.isEmpty()) {
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
try {
Claims claims = JwtUtils.parseToken(token);
Integer empId = Integer.valueOf(claims.get("id").toString());
CurrentHolder.setCurrentId(empId);
} catch (Exception e) {
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
chain.doFilter(request, response);
CurrentHolder.remove();
}
}
这段代码可以拆成 5 个步骤来理解。
6.1 第一步:拦截所有请求
@WebFilter(urlPatterns = "/*") 表示这个过滤器会拦截所有请求。
也就是说,客户端发来的请求,在进入 Controller 之前,会先过一遍这个过滤器。
6.2 第二步:放行登录接口
java
if (requestUrl.contains("/login")) {
chain.doFilter(request, response);
return;
}
因为登录接口本来就还没有 token,所以 /login 不能拦。
这里的逻辑是:
- 如果当前请求本身就是登录请求
- 那么直接放行
- 让它继续执行登录流程
6.3 第三步:从请求头中获取 token
java
String token = httpRequest.getHeader("token");
这一行特别值得注意。
很多同学写 JWT 时,习惯把 token 放到 Authorization 请求头里,再配合 Bearer xxx 使用。
但我这个项目不是这么写的。
这个项目读取的是请求头里的:
text
token: xxxxx
也就是说,前端后续请求必须带的是 token 这个请求头,而不是 Authorization。
6.4 第四步:校验 token 是否合法
java
Claims claims = JwtUtils.parseToken(token);
这里会调用 JwtUtils.parseToken(token) 对 token 做解析。
如果解析失败,说明 token 有问题,系统就会直接返回 401,不再继续往下执行:
java
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
所以 Filter 在这里扮演的角色,其实就是一层"门卫":
- 没带 token,不让进
- token 不合法,不让进
- token 过期,不让进
- token 合法,才允许继续访问业务接口
6.5 第五步:把当前登录人信息保存起来
java
Integer empId = Integer.valueOf(claims.get("id").toString());
CurrentHolder.setCurrentId(empId);
token 校验通过之后,过滤器并没有停在"验证成功"这一步,而是继续把当前登录员工的 id 存进了 CurrentHolder。
CurrentHolder 的实现非常简单:
java
public class CurrentHolder {
private static final ThreadLocal<Integer> CURRENT_LOCAL = new ThreadLocal<>();
public static void setCurrentId(Integer employeeId) {
CURRENT_LOCAL.set(employeeId);
}
public static Integer getCurrentId() {
return CURRENT_LOCAL.get();
}
public static void remove() {
CURRENT_LOCAL.remove();
}
}
这里本质上就是用 ThreadLocal 保存"当前这次请求对应的登录员工 id"。
这样后面的业务代码如果需要知道"是谁在操作",就可以直接从 CurrentHolder 里取,而不需要每个接口都重复传员工 id。
6.6 第六步:放行请求,请求结束后清理数据
java
chain.doFilter(request, response);
CurrentHolder.remove();
这两行也非常重要:
chain.doFilter(...)表示继续放行,让请求进入后面的 Controller、Service、Mapper。CurrentHolder.remove()表示请求处理完后,把当前线程里的用户信息清掉。
如果不清理,线程复用时就可能造成数据串用问题。
七、把整个执行过程串起来看一遍
为了把这套流程看得更清楚,我们再用时序图梳理一遍:
业务接口 CurrentHolder TokenFilter JwtUtils EmpSeviceImpl LoginController 前端 业务接口 CurrentHolder TokenFilter JwtUtils EmpSeviceImpl LoginController 前端 POST /login + 用户名密码 调用 login(emp) generateToken(claims) 返回 token 返回 LoginInfo 返回 token 请求业务接口 + 请求头 token parseToken(token) 返回 claims setCurrentId(empId) 放行请求 业务执行完成 remove()
到这里,JWT 认证是怎么通过的,其实就很清楚了:
- 登录接口负责签发 token。
- 过滤器负责检查 token。
- 检查通过之后,请求才能真正进入业务层。
八、当前项目里几个必须注意的细节
这一部分我觉得很有必要单独拎出来,因为这些地方非常容易在面试或者实战里踩坑。
8.1 当前项目读取的是 token 请求头,不是 Authorization
这一点前面已经提到过,但我还是想再强调一次。
如果前端写成下面这种:
text
Authorization: Bearer xxxxx
那按照当前 TokenFilter 的代码,后端是读不到 token 的,请求会直接被判定为未登录。
8.2 当前 token 的有效期是 12 小时
这不是泛泛而谈,而是项目代码里明确写死的:
java
private static final long EXPIRATION_TIME = 12 * 60 * 60 * 1000L;
所以如果登录后过了 12 小时再访问接口,parseToken 就会失败,最终返回 401。
8.3 CurrentHolder.remove() 更稳妥的写法应该放到 finally
当前代码是这样写的:
java
chain.doFilter(request, response);
CurrentHolder.remove();
如果后续链路在执行过程中抛出异常,那么 remove() 可能就来不及执行。
更稳妥的写法通常是:
java
try {
chain.doFilter(request, response);
} finally {
CurrentHolder.remove();
}
这样无论业务执行成功还是失败,都能确保线程变量被清理掉。
8.4 /login 的放行判断写得有点宽
当前代码使用的是:
java
if (requestUrl.contains("/login")) {
chain.doFilter(request, response);
return;
}
这意味着只要请求路径里"包含 /login"这段字符串,就会被放行。
严格一点的话,更常见的做法是判断精确路径,避免把不该放行的接口也一起放过去。
8.5 这份代码里 Filter 想生效,还要先完成注册
这是我在看项目代码时发现的一个很关键的点。
当前 TokenFilter 使用了 @WebFilter(urlPatterns = "/*"),但是项目启动类里虽然 import 了 ServletComponentScan,却没有真正加上 @ServletComponentScan 注解。
这意味着按当前代码来看,@WebFilter 标注的过滤器未必会被 Spring Boot 自动扫描注册。
也就是说,从设计意图上看,这套 JWT 认证链路是完整的;但从当前工程代码状态看,过滤器要先注册成功,认证流程才会真正跑起来。
这一点在实际开发里非常重要,因为很多时候不是 JWT 写错了,而是过滤器压根没有生效。
九、为什么这里用 Filter,而不是直接写在 Controller 里
把 JWT 校验写在 Filter 里,有一个很大的优势:统一拦截。
如果把校验逻辑分散写到每个 Controller 里,会有两个明显问题:
- 代码重复
- 很容易漏校验
而放到 Filter 里之后:
- 所有请求先统一经过一遍认证
- 合法请求放行
- 非法请求直接拦截
这样整个认证逻辑就集中起来了,后面的业务接口也能更专注于业务本身。
十、总结
回到文章标题,Filter 是如何让 JWT 认证"通过"的?
答案其实并不复杂:
- 登录成功后,业务层调用
JwtUtils.generateToken(...)生成 JWT。 - 前端把拿到的 token 放到后续请求头里的
token字段中。 TokenFilter拦截请求,并调用JwtUtils.parseToken(...)校验令牌。- 校验通过后,解析出员工 id,保存到
CurrentHolder。 - 请求继续进入业务层执行,请求结束后清理线程变量。
所以这套方案的核心不是"登录成功返回一个字符串"这么简单,而是把签发令牌、携带令牌、校验令牌、传递当前用户身份这一整套链路串起来。
我是新手程序猿乐锅。本次分享到此结束,感谢大家的观看与支持!如果本期内容对您有帮助,欢迎点赞、收藏,您的支持将是我持续创作的最大动力,谢谢!