上一篇咱们把部门管理、员工管理都做完了,看着后台功能挺全。结果今天一测试就傻眼了:我压根没登录,直接浏览器开个新页面访问后台,居然也进去了! 这后台是随便谁都能进的吗?今天这篇就把令牌技术给你讲明白。全程无图(用文字画图)、代码可直接抄 ✅,建议收藏。
ok,咱们先想一个问题 😩
你做完了后台的几个接口:查部门、查员工、传文件。然后你在浏览器新开一个标签页,直接输入 http://localhost:90------
进去了。 看到员工数据了,还是没登录的那种。
你可能会想:我不是做了登录功能吗?怎么不拦人?
因为你做的那个登录,只是"徒有其表"。后端那几个接口压根没做"这个用户登录了没有"的校验,谁都能访问。
那怎么让它真正拦住人?靠的就是今天的正主------令牌技术 。但在讲令牌之前,得先搞清一个更底层的谜团:为什么我不登录,服务器就"不认识"我?
一、先搞懂:HTTP 是无状态的
一句话定义(加粗版):无状态,就是每一次请求都是独立的,下一次请求不会带上一次请求的数据。
你浏览网页,是靠 HTTP 协议 在和服务器聊天。这协议有个死性:每次请求各自独立,谁也不记得谁。
你登录成功了,服务器认识你了。可你下一次再发请求,服务器又把你当陌生人了------因为 HTTP 无状态,两次请求之间没有任何关联。
打个比方:你昨天去奶茶店办了张会员卡(登录成功),今天再去,店员完全不认识你------因为你没把卡带身上,店员也记不得这回事(服务器没存任何"你登录过"的记号)。
那问题就来了:怎么让服务器记住"我登录过了"?
这就得靠会话技术 。web 开发里,会话指的就是浏览器与服务器之间的一次连接:
浏览器打开 ──► 第1次:访问登录接口(登录成功)
│
第一次会话 ├─► 第2次:访问部门接口
│
(不关浏览器/不断连) └─► 第3次:访问员工接口
只要浏览器不关 、服务器不挂 ,这几次请求就属于同一次会话 。而会话跟踪 ,就是服务器用来识别"这几次请求是不是同一个浏览器发的"的方法,目的是在同一次会话的多次请求之间共享数据。
为什么要在多个请求之间共享数据?因为 HTTP 无状态。登录成功产生的"数据",得想办法让后面的请求也拿到。
而实现会话跟踪的常用方案,就仨:Cookie、Session、令牌(Token)。咱一个个看它们有啥坑。
二、方案一/二:Cookie 和 Session 的坑
2.1 Cookie:存在客户端的"小纸条"
Cookie 是客户端会话跟踪技术 ------数据存在浏览器 里。流程是著名的"三个自动":
-
第一次请求登录,服务器在响应头
Set-Cookie里塞个 Cookie(存个用户名、用户ID) -
浏览器收到后自动存到本地
-
后续每次请求,浏览器都自动 通过请求头
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 方案的"死刑"。
2.2 Session:存在服务端,但底层还是靠 Cookie
Session 是服务端会话跟踪技术 ------数据存在服务器上。比存在用户手里的 Cookie 安全多了。
但它有个秘密:Session 的底层其实也是基于 Cookie 实现的。流程:
-
第一次请求,服务器创建一个 Session 对象,并给它一个 Session ID
-
服务器用 Cookie(名字固定叫
JSESSIONID)把这个 ID 发给浏览器 -
后续每次请求,浏览器带
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) 解码,里面的 id、username 全都能看见。
所以 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 分钟,等 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 字段,原理一样,只是字段名和位置的小差别。)
八、易忘点 & 坑 总结(面试/开发必背)
📌 易忘点
- HTTP 是无状态协议:每次请求独立,不认识上一次。所以需要会话跟踪来"记住"登录状态。
- 会话跟踪三方案 :Cookie(客户端)、Session(服务端,底层靠 Cookie 的
JSESSIONID)、令牌(JWT)。 - 集群下不能用 Session:Session 存单台服务器,负载均衡把请求分到另一台就找不到了。
- JWT 三部分组成 :
Header.Payload.Signature,用点号分隔。 - Header/Payload 是 Base64 编码,不是加密 ,任何人都能解码,所以不能放密码等敏感信息。
- 校验密钥必须和生成密钥一致,否则解析报错。
- JWT 校验作用:Header/Payload 保证"能看",Signature 保证"没被改"。
- 传统方案(Cookie/Session)企业很少用了,现在主流是令牌技术。
❌ 坑(我踩过的报错)
Jwts报错找不到类 /javax.vs.jakarta对不上 → 根因:jjwt 版本和 Servlet 版本不匹配。解法:入门用io.jsonwebtoken:jjwt:0.9.1+ 对应 Servlet 依赖,或用新版jjwt-api/impl/jackson。- 解析 JWT 爆
SignatureException→ 根因:令牌里某个字符被改过(比如把 Header 里的9改成8),或密钥跟生成时不一致。解法:用同样的密钥,别动令牌内容。 - 令牌能解出来,但业务接口还是 401 → 根因:令牌过期了(
exp过了),或if(url.contains("login"))写太糙,把非登录请求也放行了。解法:用excludePathPatterns("/login")精确放行,过期时间给够。 - Filter 拦了所有请求,登录接口自己都进不去 → 根因:忘了给登录请求放行,形成死循环。解法:登录请求(
/login)必须放行。 - 以为 JWT 数据安全就随便存东西 → 根因:它只是编码不是加密。解法:敏感数据(密码、身份证)绝不能放进 JWT。
✅ 结论
读完这篇你掌握了:为什么 HTTP 无状态让登录变成难题 → 会话跟踪三方案优劣 → 令牌(JWT)为何成为主流 → JWT 的三部分构成和"编码不是加密"的真相 → 如何用代码生成、校验令牌 → 再用 Filter/Interceptor 做统一登录校验------前端登录 + 后端全接口认证,串成一条完整链路。 代码全都能直接抄 ✅。
结语 & 下篇预告
好了,令牌技术就讲到这。回顾今天的收获:
- HTTP 无状态 → 需要会话跟踪 → Cookie/Session 都有坑 → 主流选 JWT 令牌
- JWT =
Header.Payload.Signature,Base64 编码不是加密,签名防篡改 - 登录成功生成令牌 → 前端存好、每次带上 → Filter/Interceptor 统一校验放行
代码全都能直接抄 ✅,建议自己把整套跑一遍,尤其试试"把令牌某个字符改一下再请求,看后端是不是一刀切拒绝你"------跑通了你才算真懂。
下一篇,咱们继续啃 Web 后端剩下的硬骨头 💪 关注这个系列,一篇一篇不迷路~
如果本文对你有帮助,欢迎点赞 + 收藏 + 转发三连 🌟 评论区告诉我你还想看什么(Redis 实现登录?权限控制?前后端分离跨域?),咱们一篇一篇啃完!
参考资料
官方文档:
高频来源(juejin / 华为 / 腾讯云):