JWT 登录鉴权与网关统一鉴权笔记

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; }   // 过滤器优先级
}

三个关键点:

  1. 白名单:登录接口本身还没 token,必须放行;图形验证码、支付宝异步通知(支付宝服务器 POST 回调,不可能带我们的 token)也放行。
  2. 失败直接 401 短路:非法/过期 token 在网关就被挡下,业务服务收不到请求。
  3. 验过之后改请求头:注意网关不把原始 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()

九、面试可主动说的不足与生产改进

  1. 密钥硬编码 dxk → 生产用配置中心/环境变量;最好改非对称 RS256,网关用公钥验、认证服务持私钥签。
  2. 无法主动失效:JWT 签发后在过期前一直有效,改密/踢人下线需要配合 Redis 黑名单或白名单版本号。
  3. 有效期 1h、无 refresh token:生产一般 access token 短期 + refresh token 长期。
  4. 下游信任内部头的前提是网络隔离:业务端口不对外,只暴露网关。
  5. 两套登录(user/admin) 用同一结构 token,仅靠 name/id 区分;生产可用 claims 里加角色字段,网关做路径级角色鉴权(Spring Security / 粗粒度 RBAC)。
上一篇
下一篇