实现 JWT (JSON Web Token) 的配置和开发主要包含以下 5 个核心步骤。在 Spring Boot 项目中,通常配合拦截器(Interceptor)来实现登录验证。
后端实现流程
1. 引入依赖 (pom.xml)
首先需要在项目中引入 JWT 的工具包,最常用的是 jjwt。
XML
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
</dependency>
2. 编写工具类 (JwtUtils)
我们需要一个工具类来负责 生成 Token 和 解析 Token。
java
package com.qcby.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
public class JwtUtils {
// 1. 定义秘钥 (只有服务端知道,不能泄露)
private static final String SECRET_KEY = "MySecretKey123";
// 2. 过期时间 (例如 24 小时)
private static final long TTL = 24 * 60 * 60 * 1000L;
/**
* 生成 Token
* @param claims 需要存入 Token 的业务数据 (如用户ID, 用户名)
* @return Token 字符串
*/
public static String createToken(Map<String, Object> claims) {
JwtBuilder builder = Jwts.builder()
.setClaims(claims) // 设置载荷
.setIssuedAt(new Date()) // 签发时间
.setExpiration(new Date(System.currentTimeMillis() + TTL)) // 过期时间
.signWith(SignatureAlgorithm.HS256, SECRET_KEY); // 签名算法 + 秘钥
return builder.compact();
}
/**
* 解析 Token
* @param token 客户端传来的 Token
* @return 载荷数据 (Claims)
*/
public static Claims parseToken(String token) {
return Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token)
.getBody();
}
}
3. 编写拦截器 (JwtInterceptor)
拦截器用于拦截请求,检查请求头中是否携带了合法的 Token。
java
package com.qcby.interceptor;
import com.qcby.utils.JwtUtils;
import io.jsonwebtoken.Claims;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class JwtInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 如果是 OPTIONS 请求(预检请求),直接放行
if("OPTIONS".equals(request.getMethod().toUpperCase())) {
return true;
}
// 2. 从请求头获取 token (通常约定 key 为 "token" 或 "Authorization")
String token = request.getHeader("token");
// 3. 校验 token 是否存在
if (token == null || token.isEmpty()) {
response.setStatus(401); // 未授权
response.getWriter().write("Token is missing");
return false;
}
try {
// 4. 解析 token (如果过期或被篡改,这里会抛出异常)
Claims claims = JwtUtils.parseToken(token);
// 5. 将解析出的用户信息放入 request,方便 Controller 使用
request.setAttribute("currUser", claims);
return true; // 放行
} catch (Exception e) {
response.setStatus(401);
response.getWriter().write("Token is invalid or expired");
return false; // 拦截
}
}
}
4. 注册拦截器 (WebConfig)
配置拦截器,告诉 Spring Boot 哪些路径需要拦截(如 /user/**),哪些不需要(如 /login)。
java
package com.qcby.config;
import com.qcby.interceptor.JwtInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new JwtInterceptor())
.addPathPatterns("/**") // 拦截所有路径
.excludePathPatterns( // 排除以下路径
"/login", // 登录接口不能拦截
"/register", // 注册接口
"/static/**" // 静态资源
);
}
}
5. 业务实现 (LoginController)
在登录成功后,调用 JwtUtils 生成 Token 并返回给前端。
java
package com.qcby.controller;
import com.qcby.entity.User;
import com.qcby.utils.JwtUtils;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
public class LoginController {
@PostMapping("/login")
public Map<String, Object> login(@RequestBody User user) {
Map<String, Object> result = new HashMap<>();
// 1. 模拟数据库校验 (实际开发请调用 Service)
if ("admin".equals(user.getUsername()) && "123456".equals(user.getPassword())) {
// 2. 登录成功,准备存入 Token 的数据
Map<String, Object> claims = new HashMap<>();
claims.put("username", user.getUsername());
claims.put("role", "admin");
// 3. 生成 Token
String token = JwtUtils.createToken(claims);
// 4. 返回 Token 给前端
result.put("code", 200);
result.put("msg", "登录成功");
result.put("token", token); // 核心:把 token 给前端
} else {
result.put("code", 400);
result.put("msg", "账号或密码错误");
}
return result;
}
// 测试需要验证接口
@GetMapping("/api/test")
public String testApi(HttpServletRequest request) {
// 从拦截器存入的 request 中获取用户信息
Map<String, Object> userInfo = (Map<String, Object>) request.getAttribute("currUser");
return "当前访问用户是:" + userInfo.get("username");
}
}
总结
-
引包 :
jjwt。 -
工具 :
createToken(生成) 和parseToken(验证)。 -
拦截 :在
preHandle中检查 Header 里的 token,验证通过则放行。 -
配置 :
addInterceptors绑定拦截路径,务必排除/login。 -
使用:登录成功发 Token,后续请求带 Token。
前端实现流程
前端 JWT 的实现流程主要包含 存储 Token 、请求携带 Token 、响应处理 以及 路由守卫 四个核心环节。通常配合 Axios 请求库和前端路由(如 Vue Router)来实现。
以下是基于 Vue + Axios 的标准实现流程(React/原生 JS 逻辑完全一致):
1. 登录与存储 (Login & Storage)
用户填写账号密码后,前端发送请求。后端验证通过返回 Token,前端需要把它存起来(通常存在 localStorage 或 sessionStorage)。
代码示例 (Login.vue):
// 登录方法
async function login() {
try {
// 1. 发送登录请求
const res = await axios.post('/api/login', {
username: 'admin',
password: '123'
});
if (res.data.code === 200) {
// 2. 核心步骤:将后端返回的 Token 存入本地缓存
const token = res.data.token;
localStorage.setItem('token', token);
// 3. 跳转到首页
router.push('/home');
} else {
alert(res.data.msg);
}
} catch (err) {
console.error(err);
}
}
2. 请求拦截器 (Request Interceptor)
这是最关键的一步。为了避免每次请求都手动写 headers: { token: ... },我们配置 Axios 的请求拦截器 。它会在每个请求发出之前自动执行,把 Token 塞进请求头。
代码示例 (request.js / main.js):
import axios from 'axios';
// 创建 axios 实例
const service = axios.create({
baseURL: 'http://localhost:8080',
timeout: 5000
});
// 🟢 请求拦截器
service.interceptors.request.use(
config => {
// 1. 从缓存中获取 Token
const token = localStorage.getItem('token');
// 2. 如果 Token 存在,就把它添加到请求头中
if (token) {
// header 的 key 要和后端拦截器里取的一致 (比如 'token' 或 'Authorization')
config.headers['token'] = token;
}
return config;
},
error => {
return Promise.reject(error);
}
);
export default service;
3. 响应拦截器 (Response Interceptor)
用于全局处理 Token 失效的情况。如果后端返回 401 状态码(代表 Token 过期或无效),前端应该自动清空缓存并跳转回登录页。
代码示例 (request.js):
// 🔴 响应拦截器
service.interceptors.response.use(
response => {
// 请求成功,直接返回数据
return response;
},
error => {
// 处理错误响应
if (error.response && error.response.status === 401) {
// 1. 401 说明 Token 过期或无效
alert("登录已过期,请重新登录");
// 2. 清除本地过期的 Token
localStorage.removeItem('token');
// 3. 强制跳转回登录页
location.href = '/login';
}
return Promise.reject(error);
}
);
4. 路由守卫 (Router Guard)
防止用户在没有登录的情况下,直接在浏览器地址栏输入 /home 强行访问后台页面。
代码示例 (router/index.js):
import router from './router'; // 你的路由实例
// 全局前置守卫
router.beforeEach((to, from, next) => {
// 1. 判断该页面是否需要登录权限 (比如在路由配置里加了 meta: { requireAuth: true })
// 或者简单粗暴:只要不是去登录页,都检查
if (to.path === '/login') {
next(); // 如果是去登录页,直接放行
} else {
// 2. 检查是否有 Token
const token = localStorage.getItem('token');
if (token) {
next(); // 有 Token,放行
} else {
// 3. 没有 Token,强制跳转登录页
next('/login');
}
}
});
5. 注销 (Logout)
用户点击退出登录时,清理工作很简单。
function logout() {
// 1. 清除本地 Token
localStorage.removeItem('token');
// 2. 跳转回登录页
router.push('/login');
}
总结前端 3 步走:
-
存 :登录成功后,把 Token 扔进
localStorage。 -
带 :用 Axios 拦截器,每次发请求自动把 Token 从
localStorage拿出来放到 Header 里。 -
清:遇到 401 错误或用户点退出,把 Token 删掉并踢回登录页。
简单来说,Session 是"服务端记账" ,而 JWT 是"客户端自带证明"。
为了让你更直观地理解,我用一个生活中的例子来打比方,然后进行详细的技术对比。
session和jwt(token)形式的区别
1. 通俗比喻:健身房会员卡
-
Session (服务端存储):
-
你办了一张只有卡号的会员卡。
-
每次你去健身房,前台刷一下卡号,然后去电脑系统里查:"这个卡号是谁?有没有过期?是不是VIP?"
-
关键点:数据都在健身房的电脑里(服务端),卡上只有个ID。
-
-
JWT (Token 存储):
-
你办了一张印着所有信息的工牌(并且有防伪印章)。
-
卡面上直接写着:"姓名:张三,过期时间:2026年,级别:VIP"。
-
前台看一眼卡面,验证一下防伪印章(签名)是对的,就直接放行,完全不需要查电脑。
-
关键点:数据都在你的卡上(客户端),健身房只要确认卡是真的就行。
-
2. 核心区别对比表
| 维度 | Session 实现 | JWT (Token) 实现 |
|---|---|---|
| 存储位置 (信息本体) | 服务端 (内存、数据库或 Redis) | 客户端 (存储在 Token 字符串内部) |
| 客户端持有 | 仅持有一个 SessionId (通常在 Cookie 中) |
持有包含完整数据的 Token 字符串 |
| 有状态/无状态 | 有状态 (Stateful) 服务端必须记住你登录过 | 无状态 (Stateless) 服务端不存数据,只负责解密验证 |
| 扩展性 (集群) | 差 多台服务器需要做 Session 共享 (如存入 Redis) | 好 任意一台服务器只要有"秘钥"就能验证,互不依赖 |
| 安全性 (注销/封号) | 高 服务端删掉 Session,用户立马下线 | 低 Token 一旦签发,在过期前一直有效,无法强制失效 (除非加黑名单) |
| 数据载荷 | 大 想存多少存多少,不影响传输 | 小 存多了 Token 会变得很长,导致网络传输变慢 |
| 跨域支持 | 麻烦 (Cookie 有跨域限制) | 简单 (放在 HTTP Header 中,无视跨域) |
3. 详细深度解析
(1) 存储方式与服务器压力
-
Session : 用户登录后,服务端会在内存(或 Redis)里开辟一块空间存用户信息
User={name: "admin", role: "vip"},生成一个 ID 给前端。- 缺点:如果 100 万人在线,服务端内存压力巨大。
-
JWT : 服务端把用户信息 JSON 对象加密签名,生成一串字符扔给前端,服务端自己不留底。
- 优点:服务端省内存,来一亿个用户也不怕,反正数据都在用户手机/电脑里。
(2) 集群扩展性 (最痛的点)
假设你有两台服务器 A 和 B:
-
Session : 你在 A 登录了,Session 存在 A 的内存里。下次请求被负载均衡分发到了 B,B 发现内存里没你的 Session,会让你重新登录。
- 解决办法:必须引入 Redis 做 Session 共享,增加了架构复杂度。
-
JWT : 你在 A 登录拿到 Token。下次请求发给 B,B 只需要用同样的"秘钥"解密 Token,发现签名正确,直接放行。天生支持集群。
(3) 强制下线 (JWT 的死穴)
-
Session: 管理员想封号,直接在服务端把这个 SessionId 删掉,用户下一秒就无法访问了。
-
JWT: 最大的问题是**"覆水难收"**。一旦 Token 发出去,只要没过期,它就是有效的。哪怕你把用户在数据库里删了,在 Token 过期前的这几分钟/几小时内,他依然能凭借手里的 Token 访问接口。
- 解决办法:必须引入 Redis 做"黑名单"机制(但这又变回类似 Session 的逻辑了)。
4. 你的前端项目该选哪个?
结合你之前的 Spring Boot + Vue 代码:
推荐使用 JWT,原因如下:
-
前后端分离标准:Vue 和 Spring Boot 通常不在同一个域下(跨域),Session+Cookie 处理跨域很麻烦,而 JWT 放在 Header 里非常方便。
-
移动端兼容:如果以后要开发 App 或小程序,它们不支持 Cookie,但支持 Header 传 Token。
-
学习成本:Spring Boot 整合 JWT (jjwt) 非常成熟,正如我之前给你的代码那样,一个拦截器就搞定了。
总结
-
用 Session:如果是传统的单体项目(JSP/Thymeleaf 直接在服务端渲染),或者对安全性要求极高(如银行系统,需要秒级踢人下线)。
-
用 JWT :如果是前后端分离 (Vue/React)、需要开发移动端、或者需要扛高并发集群。这也是目前互联网公司的主流选择。
面试题:当token过期了 该怎么进行补发
当 JWT(Access Token)过期时,我们不能修改 它(因为它是不可变的),而是必须签发一个新的 Token 给前端。
为了不让用户频繁重新登录(比如 Token 30分钟过期,用户正在填表单突然跳回登录页体验很差),业界通用的标准做法是使用 双 Token 机制 (Access Token + Refresh Token)。
1. 核心原理:双 Token 机制
我们需要两个 Token:
-
Access Token (短命):
-
有效期:短 (例如 30 分钟)。
-
作用:用于请求业务接口(如获取用户列表)。
-
存放:前端请求头。
-
-
Refresh Token (长命):
-
有效期:长 (例如 7 天)。
-
作用 :仅用于 当 Access Token 过期时,向后端换取一个新的 Access Token。
-
存放:安全存储 (Local Storage 或 HttpOnly Cookie)。
-
2. 后端实现 (Spring Boot)
第一步:修改登录接口,返回两个 Token
在 JwtUtils 中,你需要支持生成不同过期时间的 Token。或者简单调用两次生成方法,参数不同。
修改 LoginController:
java
@PostMapping("/login")
public Map<String, Object> login(@RequestBody User user) {
// ... 账号密码校验通过 ...
Map<String, Object> claims = new HashMap<>();
claims.put("username", user.getUsername());
// 1. 生成 Access Token (30分钟过期)
String accessToken = JwtUtils.createToken(claims, 30 * 60 * 1000L);
// 2. 生成 Refresh Token (7天过期) - 这里载荷可以只存用户ID,越少越好
String refreshToken = JwtUtils.createToken(claims, 7 * 24 * 60 * 60 * 1000L);
// 3. (可选但推荐) 将 refreshToken 存入 Redis,key为 username,以便服务端能强制注销
// redisTemplate.opsForValue().set("refresh:" + user.getUsername(), refreshToken, 7, TimeUnit.DAYS);
Map<String, Object> result = new HashMap<>();
result.put("token", accessToken); // 短
result.put("refreshToken", refreshToken); // 长
return result;
}
第二步:编写"刷新 Token"的接口
前端发现 401 过期后,会带着 refreshToken 来访问这个接口。
java
@PostMapping("/refresh")
public Map<String, Object> refreshToken(@RequestBody Map<String, String> request) {
String refreshToken = request.get("refreshToken");
Map<String, Object> result = new HashMap<>();
try {
// 1. 校验 Refresh Token 是否合法/过期
Claims claims = JwtUtils.parseToken(refreshToken);
// 2. (如果有Redis) 检查 Redis 里该用户的 refresh token 是否还存在
// String storedToken = redisTemplate.opsForValue().get("refresh:" + claims.getSubject());
// if (storedToken == null) throw new RuntimeException("已注销");
// 3. 校验通过,生成一个新的 Access Token
String newAccessToken = JwtUtils.createToken(claims, 30 * 60 * 1000L);
result.put("code", 200);
result.put("token", newAccessToken); // 只返回新的 Access Token
return result;
} catch (Exception e) {
// Refresh Token 也过期了,没救了,强制重新登录
result.put("code", 401);
result.put("msg", "登录凭证已失效,请重新登录");
return result;
}
}
3. 前端实现 (Vue + Axios)
前端最关键的是利用 Axios 响应拦截器 实现"无感刷新"。
流程 : 请求业务接口 -> 报 401 -> 拦截器暂停请求 -> 用 RefreshToken 换新 Token -> 重试原请求。
修改 request.js (Axios 配置):
javascript
import axios from 'axios'
import router from '@/router'
const service = axios.create({ baseURL: 'http://localhost:8080' })
// 防止并发刷新(如果有多个请求同时 401,只刷新一次)
let isRefreshing = false;
// 存储因为等待刷新而挂起的请求
let requests = [];
service.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
// 如果后端返回 401 (未授权/过期) 且不是请求刷新接口本身报错
if (error.response?.status === 401 && !originalRequest._retry) {
// 1. 如果已经在刷新了,就把当前请求挂起,放入队列
if (isRefreshing) {
return new Promise((resolve) => {
requests.push((newToken) => {
originalRequest.headers['token'] = newToken;
resolve(service(originalRequest));
});
});
}
originalRequest._retry = true; // 标记:这个请求已经重试过一次了,防止死循环
isRefreshing = true;
try {
// 2. 发起刷新 Token 请求
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
throw new Error("没有 Refresh Token");
}
const res = await axios.post('http://localhost:8080/refresh', {
refreshToken: refreshToken
});
if (res.data.code === 200) {
// 3. 刷新成功:保存新 Token
const newToken = res.data.token;
localStorage.setItem('token', newToken);
// 4. 修改默认 Header,供后续使用
service.defaults.headers.common['token'] = newToken;
originalRequest.headers['token'] = newToken;
// 5. 执行队列中挂起的请求
requests.forEach(cb => cb(newToken));
requests = [];
// 6. 重试当前请求
return service(originalRequest);
} else {
// 后端说 Refresh Token 也失效了
throw new Error("刷新失败");
}
} catch (refreshErr) {
// 7. 最坏情况:彻底凉凉,清除数据,跳转登录页
localStorage.clear();
router.push('/login');
return Promise.reject(refreshErr);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
4. 总结
-
登录时 :后端给
Token(30分) 和RefreshToken(7天)。 -
平时请求 :前端 Header 带
Token。 -
Token 过期:
-
请求失败 (401)。
-
前端拦截器捕获 401。
-
前端自动调用
/refresh接口,发送RefreshToken。 -
后端验证通过,返回新 Token。
-
前端拿到新 Token,重发刚才失败的请求。
-
用户完全感觉不到发生了一次 401,这就叫无感刷新。
-
-
RefreshToken 过期 :如果是 7 天后,
/refresh接口也会报错,此时前端拦截器会跳转到/login让用户重新登录。