【Tilas|第九篇】登录认证功能实现

通过 Filter 实现 JWT 令牌认证,这套登录校验到底是怎么跑通的?

一、前言

在前后端分离项目里,登录认证几乎是绕不过去的一块内容。

如果我们继续使用传统 Session,服务端就要保存登录状态;而一旦项目开始拆前后端、接口越来越多、后续还可能接入移动端,使用无状态的 JWT 方案往往会更灵活一些。

这篇文章重点聊一聊:系统是如何通过 Filter 完成 JWT 令牌认证的

这篇文章会围绕下面这条主线展开:

  1. 用户登录成功后,后端是怎么生成 JWT 的。
  2. 前端在后续请求里,应该把 token 放到哪里。
  3. TokenFilter 是怎么拦截请求、校验 token、放行请求的。
  4. 为什么还要配合 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;
}

这一段代码非常关键,它做了三件事:

  1. 根据登录信息查询员工是否存在。
  2. 如果存在,就把员工 idusername 放进 claims
  3. 调用 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 = "/*"),但是项目启动类里虽然 importServletComponentScan,却没有真正加上 @ServletComponentScan 注解

这意味着按当前代码来看,@WebFilter 标注的过滤器未必会被 Spring Boot 自动扫描注册。

也就是说,从设计意图上看,这套 JWT 认证链路是完整的;但从当前工程代码状态看,过滤器要先注册成功,认证流程才会真正跑起来。

这一点在实际开发里非常重要,因为很多时候不是 JWT 写错了,而是过滤器压根没有生效。

九、为什么这里用 Filter,而不是直接写在 Controller 里

把 JWT 校验写在 Filter 里,有一个很大的优势:统一拦截

如果把校验逻辑分散写到每个 Controller 里,会有两个明显问题:

  • 代码重复
  • 很容易漏校验

而放到 Filter 里之后:

  • 所有请求先统一经过一遍认证
  • 合法请求放行
  • 非法请求直接拦截

这样整个认证逻辑就集中起来了,后面的业务接口也能更专注于业务本身。

十、总结

回到文章标题,Filter 是如何让 JWT 认证"通过"的?

答案其实并不复杂:

  1. 登录成功后,业务层调用 JwtUtils.generateToken(...) 生成 JWT。
  2. 前端把拿到的 token 放到后续请求头里的 token 字段中。
  3. TokenFilter 拦截请求,并调用 JwtUtils.parseToken(...) 校验令牌。
  4. 校验通过后,解析出员工 id,保存到 CurrentHolder
  5. 请求继续进入业务层执行,请求结束后清理线程变量。

所以这套方案的核心不是"登录成功返回一个字符串"这么简单,而是把签发令牌、携带令牌、校验令牌、传递当前用户身份这一整套链路串起来。

我是新手程序猿乐锅。本次分享到此结束,感谢大家的观看与支持!如果本期内容对您有帮助,欢迎点赞、收藏,您的支持将是我持续创作的最大动力,谢谢!

相关推荐
带刺的坐椅3 小时前
Solon Flow 实战:用 50 行 YAML 实现一个请假审批流(含中断恢复、并行网关、条件分支)
java·solon·工作流·审批流·流程编排
张二娃同学3 小时前
02_C语言数据类型_整型浮点型字符型一次讲清楚
android·java·c语言
optimistic_chen3 小时前
【AI Agent 全栈开发】RAG(检索增强生成)
java·linux·运维·人工智能·ai编程·rag
诸葛李3 小时前
集成构建xxxxx
java·junit·单元测试
Co_Hui3 小时前
Java: 集合
java·开发语言
ch.ju3 小时前
Java程序设计(第3版)第四章——动态部分
java·开发语言
_Evan_Yao3 小时前
从 select 到 epoll,再到 Agent 循环:如何用 I/O 多路复用撑起千军万马?
java·数据库·人工智能·后端
代码不停3 小时前
记忆化搜索题目练习
java·算法
05候补工程师3 小时前
【线性代数】硬核复习笔记:核心定理推导、矩阵变换本质与自创高频题解
经验分享·笔记·线性代数·考研·矩阵