【从零开始的 Web 后端学习】令牌技术一篇搞定(JWT 登录认证保姆级)

上一篇咱们把部门管理、员工管理都做完了,看着后台功能挺全。结果今天一测试就傻眼了:我压根没登录,直接浏览器开个新页面访问后台,居然也进去了! 这后台是随便谁都能进的吗?今天这篇就把令牌技术给你讲明白。全程无图(用文字画图)、代码可直接抄 ✅,建议收藏。


ok,咱们先想一个问题 😩

你做完了后台的几个接口:查部门、查员工、传文件。然后你在浏览器新开一个标签页,直接输入 http://localhost:90------

进去了。 看到员工数据了,还是没登录的那种。

你可能会想:我不是做了登录功能吗?怎么不拦人?

因为你做的那个登录,只是"徒有其表"。后端那几个接口压根没做"这个用户登录了没有"的校验,谁都能访问。

那怎么让它真正拦住人?靠的就是今天的正主------令牌技术 。但在讲令牌之前,得先搞清一个更底层的谜团:为什么我不登录,服务器就"不认识"我?


一、先搞懂:HTTP 是无状态的

一句话定义(加粗版):无状态,就是每一次请求都是独立的,下一次请求不会带上一次请求的数据。

你浏览网页,是靠 HTTP 协议 在和服务器聊天。这协议有个死性:每次请求各自独立,谁也不记得谁。

你登录成功了,服务器认识你了。可你下一次再发请求,服务器又把你当陌生人了------因为 HTTP 无状态,两次请求之间没有任何关联。

打个比方:你昨天去奶茶店办了张会员卡(登录成功),今天再去,店员完全不认识你------因为你没把卡带身上,店员也记不得这回事(服务器没存任何"你登录过"的记号)。

那问题就来了:怎么让服务器记住"我登录过了"?

这就得靠会话技术 。web 开发里,会话指的就是浏览器与服务器之间的一次连接:

复制代码
浏览器打开  ──►  第1次:访问登录接口(登录成功)
                    │
  第一次会话        ├─►  第2次:访问部门接口
                    │
(不关浏览器/不断连) └─►  第3次:访问员工接口

只要浏览器不关 、服务器不挂 ,这几次请求就属于同一次会话 。而会话跟踪 ,就是服务器用来识别"这几次请求是不是同一个浏览器发的"的方法,目的是在同一次会话的多次请求之间共享数据

为什么要在多个请求之间共享数据?因为 HTTP 无状态。登录成功产生的"数据",得想办法让后面的请求也拿到。

而实现会话跟踪的常用方案,就仨:Cookie、Session、令牌(Token)。咱一个个看它们有啥坑。


二、方案一/二:Cookie 和 Session 的坑

2.1 Cookie:存在客户端的"小纸条"

Cookie 是客户端会话跟踪技术 ------数据存在浏览器 里。流程是著名的"三个自动":

  1. 第一次请求登录,服务器在响应头 Set-Cookie 里塞个 Cookie(存个用户名、用户ID)

  2. 浏览器收到后自动存到本地

  3. 后续每次请求,浏览器都自动 通过请求头 Cookie 把小纸条带到服务端

    客户端(浏览器) 服务器
    │ │
    │── 请求 /login ───────────────────► │
    │◄── 响应 Set-Cookie: login_username=itheima│← 服务器塞纸条
    │ 存到本地浏览器 │
    │ │
    │── 请求 /dept(请求头带 Cookie)──► │← 浏览器自动带上
    │◄── 响应数据 ───────────────────── │

直接抄(完整、可运行 ✅):

java 复制代码
@Slf4j
@RestController
public class SessionController {

    // 设置 Cookie:通过响应头塞过去
    @GetMapping("/c1")
    public Result cookie1(HttpServletResponse response){
        response.addCookie(new Cookie("login_username","itheima"));
        return Result.success();
    }

    // 获取 Cookie:从请求头取出来
    @GetMapping("/c2")
    public Result cookie2(HttpServletRequest request){
        Cookie[] cookies = request.getCookies();
        for (Cookie cookie : cookies) {
            if(cookie.getName().equals("login_username")){
                System.out.println("login_username: "+cookie.getValue());
            }
        }
        return Result.success();
    }
}

优点:是 HTTP 协议里现成的技术,浏览器自动帮你处理,省心。

缺点(也是它在企业里被淘汰的原因):

  • 移动端 APP(Android/iOS)用不了------手机 APP 不是浏览器,不带这套机制
  • 用户自己能禁用它,不安全
  • Cookie 不能跨域

跨域值得展开讲。判断跨域看三个维度:协议、IP(域名)、端口,任何一个不同就是跨域:

页面地址 接口地址 结果
http://a.com/login.html https://a.com/login 协议不同 → 跨域
http://a.com/login.html http://b.com/login IP 不同 → 跨域
http://a.com/login.html http://a.com:8080/login 端口不同 → 跨域
http://a.com/login.html http://a.com/login 都不变 → 不跨域

现在的项目基本都是前后端分离、分开部署(前端一个服务器、后端另一个服务器,端口还不一样),所以必然跨域。一跨域,服务器设的 Cookie 就废了------这一条基本宣判了 Cookie 方案的"死刑"。

Session 是服务端会话跟踪技术 ------数据存在服务器上。比存在用户手里的 Cookie 安全多了。

但它有个秘密:Session 的底层其实也是基于 Cookie 实现的。流程:

  1. 第一次请求,服务器创建一个 Session 对象,并给它一个 Session ID

  2. 服务器用 Cookie(名字固定叫 JSESSIONID)把这个 ID 发给浏览器

  3. 后续每次请求,浏览器带 JSESSIONID 过来,服务器按 ID 找到对应的 Session

    客户端(浏览器) 服务器
    │ │
    │── 请求 /s1 ───────────────────► │
    │◄── Set-Cookie: JSESSIONID=xxxx │← Session 的 ID 塞进 Cookie
    │ 存到本地 ◄── Session 里存了 loginUser
    │ │
    │── 请求 /s2(请求头带 JSESSIONID)──► │← 靠 ID 找回同一个 Session
    │◄── 响应 loginUser: tom ──────────── │

直接抄(完整、可运行 ✅):

java 复制代码
@Slf4j
@RestController
public class SessionController {

    // s1:往 Session 里存数据
    @GetMapping("/s1")
    public Result session1(HttpSession session){
        log.info("HttpSession-s1: {}", session.hashCode());
        session.setAttribute("loginUser", "tom");
        return Result.success();
    }

    // s2:从 Session 里取数据(两次请求拿到的是同一个 Session)
    @GetMapping("/s2")
    public Result session2(HttpServletRequest request){
        HttpSession session = request.getSession();
        log.info("HttpSession-s2: {}", session.hashCode());
        Object loginUser = session.getAttribute("loginUser");
        log.info("loginUser: {}", loginUser);
        return Result.success(loginUser);
    }
}

你会发现 s1、s2 两次请求拿到的 session.hashCode() 一样 ------说明是同一个 Session,存进去的名字第二次也能取出来 🎉 数据确实共享了。

优点 :存在服务端,安全

缺点,还是那几条,且多了一条要命的:

  • 服务器集群环境下无法直接用 Session
  • ❌ 移动端 APP 用不了(因为底层靠 Cookie)
  • ❌ 用户可禁用 Cookie
  • ❌ Cookie 不能跨域

📌 易忘点 :Session 底层是基于 Cookie 的。Cookie 用不了,Session 就跟着失效。

为什么集群下 Session 就废了? 这个特别重要,展开讲。

现在的项目一般不部署在单台服务器上------那样会单点故障 ,一台崩了全完蛋。所以都是集群部署:

复制代码
                    ┌──►  Tomcat 1
       负载均衡 ──►  ┌──►  Tomcat 2
   (把请求均匀分发)   └──►  Tomcat 3

你登录时,请求被负载均衡分到 Tomcat 1 ,Session 存在了 Tomcat 1 上,浏览器拿到 JSESSIONID=xxx

结果你查部门数据时,这次请求被分到了 Tomcat 2 。Tomcat 2 拿着 JSESSIONID=xxx 来找 Session------找得到吗?找不到! 因为 Session 在 Tomcat 1 上。

同一个浏览器发了俩请求,拿到的却不是一个会话对象,数据就"串"丢了。这就是集群下不能用 Session 的根本原因。


三、方案三:令牌技术(今天的正主)

前面俩方案都是"陪跑"。现在企业开发里用得最多的就是第三种------令牌技术

一句话定义(加粗版):令牌(Token)本质上就是一个字符串,它是用户的"电子身份证"。 看着神秘,说白了就是一个用户身份的标识字符串。

它凭啥把前面俩方案的毛病全治了?因为它既不塞进浏览器 (不依赖 Cookie),又不存服务器(不占服务器内存,集群谁也不依赖谁)。

网上有句总结到位:JWT 让服务器不需要存 Session 信息,认证所需的信息都在令牌里自己带着,能减轻服务端压力;而且用令牌认证不依赖 Cookie,还能避免 CSRF 攻击。

3.1 令牌的流转流程

用令牌跟踪会话,长这样:

复制代码
① 浏览器 ──► 请求 /login(用户名+密码)
                │
② 服务器验证密码 OK ──► 生成一个令牌(用户身份证)
                │
③ 服务器 ◄── 响应:把令牌发给前端
                │
④ 前端把令牌存起来(localStorage / Cookie 都行)
                │
⑤ 浏览器之后每次请求,都把令牌带到服务器(放请求头)
                │
⑥ 服务器校验令牌:有效→放行;无效→拒绝

3.2 三种方案怎么选?

方案 数据存哪 集群下 移动端 跨域 企业现状
Cookie 客户端浏览器 --- 很少用
Session 服务端 很少用
令牌(JWT) 自带在字符串里 目前主流

结论:现在企业最常用的是第三种------令牌技术。 它支持 PC/移动端、解决集群认证问题、减轻服务器存储压力。要说缺点,就一个:得自己实现(令牌的生成、传递、校验都要自己写)。

咱下面就把这个"自己实现"的活儿,用目前最主流的 JWT 令牌干了。


四、JWT:最主流的令牌

令牌的形式有很多种,咱用的这个叫 JWT

一句话定义(加粗版):JWT(JSON Web Token)是一种简洁的、自包含的令牌格式,用于在通信双方以 JSON 格式安全地传输信息。

  • 简洁:JWT 就是一个简单字符串,可直接放请求参数或请求头里传递。
  • 自包含:你可以在令牌里按需求存自定义数据,比如用户信息(id、username)。

4.1 JWT 长啥样?------三部分组成

JWT 是由三个部分用英文点 . 分隔的字符串:

复制代码
Header.Payload.Signature

拿个真实例子(后面代码生成的):

复制代码
eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MTAsInVzZXJuYW1lIjoiaXRoZWltYSJ9.ybXmRwZG9CnY...
部分 中文 里面装什么 举例
① Header 头部 记录令牌类型、签名算法 {"alg":"HS256","type":"JWT"}
② Payload 有效载荷 携自定义信息、默认信息 {"id":"1","username":"Tom"}
③ Signature 签名 防止令牌被篡改、确保安全 用密钥+算法算出来的
复制代码
┌─────────────────────┬─────────────────────┬─────────────────────┐
│      Header         │      Payload        │     Signature       │
│  记录类型 + 算法      │  存用户id/名字等数据   │  防篡改的"印章"      │
│ {"alg":"HS256"}     │  {"id":"1",...}      │  用密钥签名出来的     │
└─────────────────────┴─────────────────────┴─────────────────────┘

签名的目的就是防篡改。 正因为最后那个 Signature 存在,整个 JWT 才安全------一旦令牌里任何一个字符被改过,校验时整个令牌都会失败。

4.2 关键认知:Base64 是"编码",不是"加密"

你可能好奇:JWT 怎么把 JSON 变成那一坨字符串的?

答案是对 JSON 做了 Base64 编码。所谓 Base64,就是用 64 个可打印字符(A--Z、a--z、0--9、+、/)表示二进制数据的编码方式。

重点坑:Base64 是编码,不是加密! 能编码就能解码。

也就是说:JWT 的前两部分(Header 和 Payload)任何人用 Base64 一解码就能看到内容。 你把 Base64Url(Payload) 解码,里面的 idusername 全都能看见。

所以 JWT 里千万不能放密码、身份证号这些敏感信息! 因为它只是"编码"了,不是"加密"了,谁拿到都能看。真正保证安全的是第三部分 Signature 的签名(证明数据没被改),而不是让数据"看不见"。

(这一点全网几乎所有讲 JWT 的文章都会反复强调:Payload 只做 Base64 编码、不是加密,敏感数据不要往里放。官方文档和实战教程都是这个口径。)

4.3 JWT vs Session(面试常考)

对比项 Session JWT
服务器存储 要存 Session,占内存/数据库 不用存,令牌自带信息
跨域支持 Cookie 不允许跨域 走请求头,天然支持跨域
分布式集群 需要 Session 共享机制 天然适合,任意节点都能校验
CSRF 防护 依赖 Cookie,有风险 不依赖 Cookie,可避免 CSRF
失效控制 服务端能主动删 Session 有效期内难主动撤销

五、动手:JWT 生成和校验(代码可抄 ✅)

5.1 引入依赖

xml 复制代码
<!-- JWT 依赖 -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

入门用 io.jsonwebtoken:jjwt:0.9.1 最简单,配 JDK 8+ 即可。新项目若报 javax/jakarta 兼容错,改用新版 jjwt-api + jjwt-impl + jjwt-jackson 三个依赖。本文沿用例程的 0.9.1,便于复现。

5.2 生成 JWT

用工具类 Jwts

java 复制代码
@Test
public void testGenJwt() {
    Map<String, Object> claims = new HashMap<>();
    claims.put("id", 10);
    claims.put("username", "itheima");

    String jwt = Jwts.builder()
        .signWith(SignatureAlgorithm.HS256, "aXRjYXN0")           // 指定签名算法和密钥
        .addClaims(claims)                                        // 塞自定义数据
        .setExpiration(new Date(System.currentTimeMillis() + 12 * 3600 * 1000)) // 12小时过期
        .compact();                                               // 生成令牌

    System.out.println(jwt);
}

运行输出(一长串,三个点分隔):

复制代码
eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MTAsInVzZXJuYW1lIjoiaXRoZWltYSJ9.ybXmRwZG9CnY...

把这段粘到 https://jwt.io/ 的 Encoded 框里,它当场给你解码 出 Header 和 Payload------你能清楚看到算法和自定义数据,但也能看到它们是明文,这正是"编码不是加密"的铁证。

5.3 校验(解析)JWT

java 复制代码
@Test
public void testParseJwt() {
    Claims claims = Jwts.parser()
        .setSigningKey("aXRjYXN0")   // 密钥必须和生成时配套!
        .parseClaimsJws("eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MTAsInVzZXJuYW1lIjoiaXRoZWltYSIsImV4cCI6MTcwMTkwOTAxNX0.ybXmRwZG9CnY...")
        .getBody();

    System.out.println(claims); // {id=10, username=itheima, exp=1701909015}
}

结论:解析时报错,就说明这个令牌被篡改或过期了,是个非法令牌。

两个易错点务必记住:

  1. 校验用的密钥,必须和生成时一致(配套)。密钥对不上,解析直接报错。
  2. 令牌过期了也会报错。把过期时间设成 1 分钟,等 1 分钟后再解析,直接抛异常。

六、综合实战:登录 + 下发令牌 + 统一校验

前面全是"零件",现在把它装成完整的机器。目标流程:

复制代码
用户登录 ──► 校验用户名密码 ──► 成功就生成 JWT ──► 返回给前端
                                                  │
  浏览器存令牌,之后每次请求都带上 ──► 后端统一拦截 ──► 校验令牌有效就放行

6.1 写个 JWT 工具类

把生成、解析的重复代码收进工具类(直接抄 ✅):

java 复制代码
package com.itheima.util;

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

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

public class JwtUtils {

    private static String signKey = "SVRIRUlNQQ==";   // 签名密钥(生产要保密,别写死!)
    private static Long expire = 43200000L;            // 过期时间:12小时(毫秒)

    /** 生成 JWT 令牌 */
    public static String generateJwt(Map<String,Object> claims){
        String jwt = Jwts.builder()
                .addClaims(claims)
                .signWith(SignatureAlgorithm.HS256, signKey)
                .setExpiration(new Date(System.currentTimeMillis() + expire))
                .compact();
        return jwt;
    }

    /** 解析 JWT 令牌,返回 Payload 里的内容 */
    public static Claims parseJWT(String jwt){
        Claims claims = Jwts.parser()
                .setSigningKey(signKey)
                .parseClaimsJws(jwt)
                .getBody();
        return claims;
    }
}

📌 易忘点 :密钥 signKey 别写死在代码里拿到生产用(这里为了教学写死)。实际项目要放到配置文件,按环境分离。

6.2 登录成功 → 生成并返回令牌

login 业务方法里,认证成功后生成 JWT 并塞进返回结果:

java 复制代码
@Override
public LoginInfo login(Emp emp) {
    // 1. 根据用户名密码查库(查不到说明密码不对)
    Emp empLogin = empMapper.getUsernameAndPassword(emp);

    if(empLogin != null){
        // 2. 登录成功,生成 JWT 令牌
        Map<String,Object> dataMap = new HashMap<>();
        dataMap.put("id", empLogin.getId());
        dataMap.put("username", empLogin.getUsername());

        String jwt = JwtUtils.generateJwt(dataMap);    // ← 生成令牌

        // 3. 把令牌放进返回给前端的结果里
        LoginInfo loginInfo = new LoginInfo(empLogin.getId(),
                empLogin.getUsername(), empLogin.getName(), jwt);
        return loginInfo;
    }
    return null; // 密码不对,登录失败
}

对应的 Mapper(根据用户名密码查员工):

java 复制代码
@Select("select * from emp where username = #{username} and password = #{password}")
Emp getUsernameAndPassword(Emp emp);

Controller:

java 复制代码
@Slf4j
@RestController
public class LoginController {

    @Autowired
    private EmpService empService;

    @PostMapping("/login")
    public Result login(@RequestBody Emp emp){
        log.info("员工来登录啦 , {}", emp);
        LoginInfo loginInfo = empService.login(emp);
        if(loginInfo != null){
            return Result.success(loginInfo);
        }
        return Result.error("用户名或密码错误 ~");
    }
}

登录成功后,前端拿到的响应里就有 token,存进 localStorage后续每个请求都在请求头带上这个 token

复制代码
{
  "code": 1,
  "msg": "success",
  "data": {
    "id": 1,
    "username": "songjiang",
    "name": "宋江",
    "token": "eyJhbGciOiJIUzI1NiJ9...."   ← 这就是令牌
  }
}

6.3 用 Filter 做统一拦截校验

令牌有了,但得有人"查票"。最简单是写个 Filter 过滤器,拦截所有请求,登录的放行,其余先校验令牌。

过滤器是 JavaWeb 三大组件(Servlet/Filter/Listener)之一 ,能把请求拦下来处理。步骤:定义类实现 Filter 接口 + 加 @WebFilter 配置拦截路径 + 启动类加 @ServletComponentScan

java 复制代码
package com.itheima.filter;

import com.itheima.util.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 org.springframework.util.StringUtils;

import java.io.IOException;

/** 令牌校验过滤器 */
@Slf4j
@WebFilter(urlPatterns = "/*")     // 拦截所有请求
public class TokenFilter implements Filter {

    @Override
    public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) resp;

        // 1. 获取请求 url
        String url = request.getRequestURL().toString();

        // 2. 请求里包含 login,说明是登录操作,直接放行
        if(url.contains("login")){
            log.info("登录请求 , 直接放行 ");
            chain.doFilter(request, response);
            return;
        }

        // 3. 获取请求头里的令牌(token)
        String jwt = request.getHeader("token");

        // 4. 令牌不存在,返回 401(未登录)
        if(StringUtils.hasLength(jwt) == false){    // jwt 为空
            log.info("获取到 jwt 令牌为空 , 返回错误结果 ");
            response.setStatus(HttpStatus.SC_UNAUTHORIZED);
            return;
        }

        // 5. 解析令牌,解析失败说明被篡改或过期,返回 401
        try {
            JwtUtils.parseJWT(jwt);
        } catch (Exception e) {
            e.printStackTrace();
            log.info("解析令牌失败 , 返回错误结果 ");
            response.setStatus(HttpStatus.SC_UNAUTHORIZED);
            return;
        }

        // 6. 全都通过,放行
        log.info("令牌合法 , 放行 ");
        chain.doFilter(request , response);
    }
}

启动类加上:

java 复制代码
@ServletComponentScan   // 开启对 Servlet 组件的支持
@SpringBootApplication
public class TliasManagementApplication {
    public static void main(String[] args) {
        SpringApplication.run(TliasManagementApplication.class, args);
    }
}

测试一下:

  • 不登录直接访问 http://localhost:90 → 返回 401,前端自动跳登录页 ✅
  • 先登录再访问 → 正常拿到数据 ✅

6.4 用 Interceptor 也行(更 Spring 味)

Filter 和 Interceptor(拦截器) 都能干这活,二选一即可。拦截器是 Spring 提供的、用来拦截 Controller 方法执行的机制。

自定义拦截器preHandle 返回 true 放行,返回 false 不放行):

java 复制代码
@Slf4j
@Component
public class TokenInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                             Object handler) throws Exception {
        // 1. 获取请求 url
        String url = request.getRequestURL().toString();

        // 2. 包含 login 的登录请求 → 放行
        if(url.contains("login")){
            log.info("登录请求 , 直接放行 ");
            return true;
        }

        // 3. 获取请求头令牌
        String jwt = request.getHeader("token");

        // 4. 令牌为空 → 401
        if(StringUtils.hasLength(jwt) == false){
            log.info("获取到 jwt 令牌为空 , 返回错误结果 ");
            response.setStatus(HttpStatus.SC_UNAUTHORIZED);
            return false;
        }

        // 5. 解析失败 → 401
        try {
            JwtUtils.parseJWT(jwt);
        } catch (Exception e) {
            e.printStackTrace();
            log.info("解析令牌失败 , 返回错误结果 ");
            response.setStatus(HttpStatus.SC_UNAUTHORIZED);
            return false;
        }

        // 6. 放行
        log.info("令牌合法 , 放行 ");
        return true;
    }
}

注册配置拦截器 (写个配置类实现 WebMvcConfigurer):

java 复制代码
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private TokenInterceptor tokenInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(tokenInterceptor)
                .addPathPatterns("/**")            // 拦截所有请求
                .excludePathPatterns("/login");    // 不拦截登录接口
    }
}

.excludePathPatterns("/login") 这招比 Filter 里的 if(url.contains("login")) 优雅多了,登录接口直接排除。

6.5 Filter 和 Interceptor 到底啥区别?

这俩长得像,面试也爱问。记住两条就够:

对比 Filter 过滤器 Interceptor 拦截器
接口规范 实现 Filter 接口 实现 HandlerInterceptor 接口
拦截范围 拦截所有资源 只拦截 Spring 环境里的资源

更细一点:Filter 是 Servlet 容器层面(Tomcat 都认识),拦截范围最大;Interceptor 是 Spring MVC 层面,发生在 DispatcherServlet 之后、执行 Controller 方法之前。所以 Filter 最早拦到请求,Interceptor 只拦 Spring 里的那些 Controller 请求。


七、进阶思考(面试/实战加分)

7.1 为什么说 JWT 是"无状态"的?

因为它把用户信息塞在令牌自己身上 ,服务器校验时不查库、不查 Session ,只靠那个 Signature 就能验证"这是我发的、没被改过"。所以集群下任意一台机器用同一密钥都能校验,不用做 Session 共享------这就是它天然适合分布式的原因。

7.2 缺点:有效期内难撤销

JWT 最大的毛病是:一旦签发,有效期内服务器很难主动让它失效。 用户登出,只是客户端删了令牌;但如果令牌被偷了,攻击者能在有效期内用它干坏事。

所以实际项目往往是"组合拳":

  • Access Token + Refresh Token:Access Token 短时效(比如 30 分钟),Refresh Token 长时效(比如 7 天/30 天)。Access Token 过期后用 Refresh Token 去换新的。这样即使 Access Token 泄露,攻击者能得手的窗口也很短。
  • 黑名单机制 :退出登录/改密码时,把令牌唯一标识 jti 存进 Redis,有效期与令牌对齐,校验时查一下是否在黑名单。
  • HTTPS:一定要上。JWT 里的信息只是 Base64 编码不是加密,走 HTTP 等于明文裸奔。

📌 记住一句话:JWT 只是认证系统里的一个工具,不是完整的安全方案。 真正决定安全的,是有效期策略、存储位置、撤销/刷新机制,以及------HTTPS

7.3 令牌存哪?

  • 浏览器:localStorage,然后请求头放 Authorization: Bearer <token>
  • 移动端:iOS 存 Keychain,Android 存 SharedPreferences

(本案例用 localStorage + 请求头 token 字段,原理一样,只是字段名和位置的小差别。)


八、易忘点 & 坑 总结(面试/开发必背)

📌 易忘点

  1. HTTP 是无状态协议:每次请求独立,不认识上一次。所以需要会话跟踪来"记住"登录状态。
  2. 会话跟踪三方案 :Cookie(客户端)、Session(服务端,底层靠 Cookie 的 JSESSIONID)、令牌(JWT)。
  3. 集群下不能用 Session:Session 存单台服务器,负载均衡把请求分到另一台就找不到了。
  4. JWT 三部分组成Header.Payload.Signature,用点号分隔。
  5. Header/Payload 是 Base64 编码,不是加密 ,任何人都能解码,所以不能放密码等敏感信息
  6. 校验密钥必须和生成密钥一致,否则解析报错。
  7. JWT 校验作用:Header/Payload 保证"能看",Signature 保证"没被改"。
  8. 传统方案(Cookie/Session)企业很少用了,现在主流是令牌技术

❌ 坑(我踩过的报错)

  1. Jwts 报错找不到类 / javax.vs.jakarta 对不上 → 根因:jjwt 版本和 Servlet 版本不匹配。解法:入门用 io.jsonwebtoken:jjwt:0.9.1 + 对应 Servlet 依赖,或用新版 jjwt-api/impl/jackson
  2. 解析 JWT 爆 SignatureException → 根因:令牌里某个字符被改过(比如把 Header 里的 9 改成 8),或密钥跟生成时不一致。解法:用同样的密钥,别动令牌内容。
  3. 令牌能解出来,但业务接口还是 401 → 根因:令牌过期了(exp 过了),或 if(url.contains("login")) 写太糙,把非登录请求也放行了。解法:用 excludePathPatterns("/login") 精确放行,过期时间给够。
  4. Filter 拦了所有请求,登录接口自己都进不去 → 根因:忘了给登录请求放行,形成死循环。解法:登录请求(/login)必须放行。
  5. 以为 JWT 数据安全就随便存东西 → 根因:它只是编码不是加密。解法:敏感数据(密码、身份证)绝不能放进 JWT。

✅ 结论

读完这篇你掌握了:为什么 HTTP 无状态让登录变成难题 → 会话跟踪三方案优劣 → 令牌(JWT)为何成为主流 → JWT 的三部分构成和"编码不是加密"的真相 → 如何用代码生成、校验令牌 → 再用 Filter/Interceptor 做统一登录校验------前端登录 + 后端全接口认证,串成一条完整链路。 代码全都能直接抄 ✅。


结语 & 下篇预告

好了,令牌技术就讲到这。回顾今天的收获:

  • HTTP 无状态 → 需要会话跟踪 → Cookie/Session 都有坑 → 主流选 JWT 令牌
  • JWT = Header.Payload.Signature,Base64 编码不是加密,签名防篡改
  • 登录成功生成令牌 → 前端存好、每次带上 → Filter/Interceptor 统一校验放行

代码全都能直接抄 ✅,建议自己把整套跑一遍,尤其试试"把令牌某个字符改一下再请求,看后端是不是一刀切拒绝你"------跑通了你才算真懂。

下一篇,咱们继续啃 Web 后端剩下的硬骨头 💪 关注这个系列,一篇一篇不迷路~

如果本文对你有帮助,欢迎点赞 + 收藏 + 转发三连 🌟 评论区告诉我你还想看什么(Redis 实现登录?权限控制?前后端分离跨域?),咱们一篇一篇啃完!


参考资料

官方文档:

高频来源(juejin / 华为 / 腾讯云):

相关推荐
wxwx_bscxy3221 小时前
springboot巡更系统10192
java·spring boot·后端·巡更系统
lmy_loveF1 小时前
go 切换go version 版本
开发语言·后端·golang
行者全栈架构师1 小时前
Spring Boot + FFmpeg 视频批量处理实战:压缩、HLS切片与异步任务引擎
后端
用户345138101341 小时前
搭建一个springboot项目并整合其他中间件(更新中)
后端
Gopher_HBo1 小时前
Cobra使用指南
后端
用户7713970207061 小时前
ASP.NET Core Identity 从入门到实战:常见问题与解决方案
后端
用户298698530141 小时前
Python 实现文本文件与 Word 文档互转的两种方法
后端·python·api
Zane19942 小时前
明明在赋值前读取,为什么还会报 UnboundLocalError?global 与 nonlocal 深挖
后端·python
留白_2 小时前
【tableau入门学习】6、盒须图、靶心图、四象限图、甘特图、直方图
学习·甘特图