JWT 登录鉴权与网关统一鉴权笔记
本项目前后端分离、多微服务。鉴权用 JWT 无状态方案:登录成功服务端签发 token → 前端存 Pinia(localStorage) → 以后每次请求自动带
Authorization头 → 网关AuthGlobalFilter统一验签,验过把用户 id/name 加进X-Login-*头透传给下游 → 各微服务用LoginInterceptor读头存入 ThreadLocal,业务直接取当前登录人。整条链路无 session、无 Redis 登录态。
一、为什么用 JWT(对比传统 session)
| 传统 Session | 本项目 JWT | |
|---|---|---|
| 登录态存哪 | 服务端内存/Redis | 不在服务端存,登录态全在 token 里 |
| 多服务共享 | 需要 session 共享/粘滞会话 | 各服务用同一把密钥即可独立校验 |
| 横向扩展 | 麻烦(要共享 session) | 天然支持,token 自包含 |
| 前后端分离 | 依赖 Cookie,跨域麻烦 | 放请求头里,彻底无 Cookie |
JWT 是 头.载荷.签名 三段 Base64URL 字符串,内容可读但不可篡改——改一个字符签名就对不上。
二、JWT 结构与工具类
mall-common(网关内还有一份相同的副本供网关独立校验)util/JwtUtil.java:
private static final String KEY = "dxk"; // HMAC256 共享密钥(教学写死,生产应走配置/环境变量)
// 登录成功签发
public static String createToken(Map<String, Object> map) {
return JWT.create()
.withClaim("claims", map) // 载荷:{id, name}
.withExpiresAt(new Date(System.currentTimeMillis() + 1000*60*60)) // 1 小时后过期
.sign(Algorithm.HMAC256(KEY)); // HMAC-SHA256 签名
}
// 校验:验签名 + 验过期,通过才取出 claims
public static Map<String, Object> parseToken(String token) {
return JWT.require(Algorithm.HMAC256(KEY)).build()
.verify(token).getClaim("claims").asMap();
}
- 载荷只放非敏感的 id/name(JWT 默认不加密,谁都能解开看,不能放密码)。
- 用 HMAC256 对称加密:所有可信服务持有同一把密钥
dxk都能验签。 - token 被篡改或过期,
verify()直接抛异常。
⚠️ 诚实说明:密钥
KEY="dxk"硬编码仅用于教学演示;生产应从application.yml/环境变量注入。当前也没有刷新 token(refresh token)机制,过期需重新登录。
三、登录签发 token(两个登录入口)
前台用户和后台管理员各有登录,成功后都用 JwtUtil.createToken 签发。
mall-user/.../controller/UserController.java:
@PostMapping("/login")
public Result<String> login(@RequestBody User user) {
User dbUser = userService.getOne(new QueryWrapper<User>().eq("name", user.getName()));
if (dbUser == null) return Result.error("用户名不存在");
if (!PasswordUtil.matches(user.getPassword(), dbUser.getPassword())) // BCrypt 比对
return Result.error("密码错误");
if (dbUser.getStatus() == 0) return Result.error("用户已禁用");
Map<String, Object> map = new HashMap<>();
map.put("id", dbUser.getId());
map.put("name", dbUser.getName());
return Result.ok("登录成功", JwtUtil.createToken(map)); // data 就是 token 字符串
}
密码用 BCrypt(PasswordUtil,带随机盐)存储与校验,数据库里不是明文。管理员登录 AdminController 同理(还带图形验证码校验)。
四、前端怎么存、怎么带
4.1 存在 Pinia,并用插件持久化到 localStorage
ui-app/src/store/token.js:Pinia 状态 + pinia-plugin-persistedstate,persist:true 把 token 写到 localStorage,刷新页面不丢。
4.2 axios 请求拦截器自动带头
ui-app/src/utils/request.js:
config.headers.Authorization = tokenStore.token // 每个请求自动带上
所以业务代码不用手动管 token,所有 axios 请求都会带 Authorization: <token>(注意是裸 token,没有 Bearer 前缀,网关直接 getFirst("Authorization") 拿去验)。
AI 客服的 SSE 用
fetch而非 axios,拦截器不生效,所以那里要手动写'Authorization': tokenStore.token。
4.3 前端路由守卫兜底
router/index.js:白名单只有 /login,其余路径没有 token 就跳登录页,并记下原路径,登录后跳回。这是前端体验层的拦截,真正的安全边界在后端网关。
五、网关统一验签 + 用户信息透传(核心)
如果每个微服务都写一遍验 token 很冗余。项目把鉴权集中到网关这一层。
mall-gateway/.../filter/AuthGlobalFilter.java:
@Component
@RequiredArgsConstructor
public class AuthGlobalFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
// 1. 白名单:登录、验证码、支付宝服务器回调,不带 token
if (path.startsWith("/service/captcha")
|| path.startsWith("/admin/login")
|| path.startsWith("/user/login")
|| path.startsWith("/order/notify")) {
return chain.filter(exchange);
}
// 2. 取 token 并验签
String token = exchange.getRequest().getHeaders().getFirst("Authorization");
try {
Map<String, Object> map = JwtUtil.parseToken(token);
// 3. 验签通过:把用户 id/name 放进新请求头,透传给下游
ServerHttpRequest request = exchange.getRequest().mutate()
.header("X-Login-Id", String.valueOf(map.get("id")))
.header("X-Login-Name", String.valueOf(map.get("name")))
.build();
return chain.filter(exchange.mutate().request(request).build());
} catch (Exception e) {
// 验签失败 → HTTP 401,请求根本到不了业务服务
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
}
@Override
public int getOrder() { return 0; } // 过滤器优先级
}
三个关键点:
- 白名单:登录接口本身还没 token,必须放行;图形验证码、支付宝异步通知(支付宝服务器 POST 回调,不可能带我们的 token)也放行。
- 失败直接 401 短路:非法/过期 token 在网关就被挡下,业务服务收不到请求。
- 验过之后改请求头:注意网关不把原始 token 往下传,而是解出 id/name 写进内部头
X-Login-Id、X-Login-Name。这是一种”网关注入身份”的设计,下游信任网关注入的内部头。
安全前提:生产环境各微服务的端口不能被外部直接访问,只能经网关进入——否则有人绕过网关,直接给商品服务发一个伪造的
X-Login-Id头就能冒充任何人。教学环境同机部署未做网络隔离,这点要清楚。
六、下游服务:请求头 → ThreadLocal
业务服务不再验 JWT,信任网关,只需把网关注入的用户信息存起来供业务取用。
6.1 拦截器读头存 ThreadLocal
mall-common/.../interceptor/LoginInterceptor.java:
public boolean preHandle(...) {
String id = request.getHeader("X-Login-Id");
String name = request.getHeader("X-Login-Name");
if (StringUtils.hasText(id) && StringUtils.hasText(name)) {
Map<String, Object> map = new HashMap<>();
map.put("id", Long.valueOf(id));
map.put("name", name);
LoginContext.setLoginInfo(map); // 存进 ThreadLocal
}
return true;
}
public void afterCompletion(...) {
LoginContext.removeLoginInfo(); // 必须清理,防线程复用污染 + 内存泄漏
}
WebConfig 里对所有路径注册该拦截器(当前未在这一层设白名单,白名单统一在网关)。
注意:这个拦截器只做”身份搬运”,不做拦截拒绝(没有 token 也放行,只是 ThreadLocal 里没用户)。真正”没登录就拒绝”是网关的 401 在管。
6.2 业务代码取当前登录人
LoginContext 就是个 ThreadLocal 包装:
Long userId = (Long) LoginContext.getLoginInfo().get("id");
下单、加购、查自己的订单/地址、操作日志切面,都这样拿当前用户,不用每个方法传 userId。
6.3 为什么用 ThreadLocal
Tomcat 用线程池处理请求,一个请求占一个工作线程,同一线程会被复用。LoginContext 用 ThreadLocal<Map> 给每个线程一份独立副本:这次请求 set 的用户只在本线程可见,请求结束 remove(),天然线程隔离。详见《多线程笔记.md》。
七、服务间 Feign 调用的身份透传
微服务 A 用 Feign 调 B 时,Feign 默认不会带上当前请求的 X-Login-* 头。项目在 mall-api 配了一个 Feign 拦截器补回去。
mall-api/.../config/DefaultFeignConfig.java:
@Bean
public RequestInterceptor usernameInterceptor() {
return template -> {
Map<String, Object> loginInfo = LoginContext.getLoginInfo();
if (loginInfo == null) return; // 定时任务/MQ 等非 HTTP 线程则跳过
template.header("X-Login-Id", String.valueOf(loginInfo.get("id")));
template.header("X-Login-Name", String.valueOf(loginInfo.get("name")));
};
}
这样 B 服务的 LoginInterceptor 也能拿到用户、建 ThreadLocal,Feign 链路里 LoginContext 不断流。
ThreadLocal 不跨线程:MQ 消费者、
@Scheduled定时任务、@Async线程池里没有请求上下文,LoginContext.getLoginInfo()是 null(拦截器里也注释了这一点)。所以秒杀消息体SeckillMessage要显式携带 userId,不能指望消费者从 ThreadLocal 取。
八、完整链路一张图
登录:
前端 POST /user/login ──网关白名单放行──► user-service
校验 BCrypt 密码 ──► JwtUtil.createToken({id,name}) ──► 返回 token
前端存入 Pinia → localStorage
之后每次请求(如 POST /order):
axios 拦截器加 Authorization: <token>
│
▼
网关 AuthGlobalFilter
├─ 白名单?放行
├─ parseToken 失败 → 401 短路
└─ 成功 → 注入 X-Login-Id / X-Login-Name,转发 lb://order-service
▼
order-service:LoginInterceptor 读 X-Login-* → 存 LoginContext(ThreadLocal)
│ (若内部再 Feign 调别的服务,DefaultFeignConfig 继续透传 X-Login-*)
▼
业务:LoginContext.getLoginInfo().get("id") 取当前用户
▼
afterCompletion:LoginContext.removeLoginInfo()
九、面试可主动说的不足与生产改进
- 密钥硬编码
dxk→ 生产用配置中心/环境变量;最好改非对称 RS256,网关用公钥验、认证服务持私钥签。 - 无法主动失效:JWT 签发后在过期前一直有效,改密/踢人下线需要配合 Redis 黑名单或白名单版本号。
- 有效期 1h、无 refresh token:生产一般 access token 短期 + refresh token 长期。
- 下游信任内部头的前提是网络隔离:业务端口不对外,只暴露网关。
- 两套登录(user/admin) 用同一结构 token,仅靠 name/id 区分;生产可用 claims 里加角色字段,网关做路径级角色鉴权(Spring Security / 粗粒度 RBAC)。