阅读视图

发现新文章,点击刷新页面。

把 Sa-Token 搬到 NestJS 生态:xlt-token 1.0 的几个设计取舍

最近发布了 xlt-token@1.0.0-rc.1,一个为 NestJS 设计的 Token 鉴权库,灵感来自 Java 生态的 Sa-Token

仓库:github.com/xiaoLangtou…

功能列表看起来不复杂——登录、登出、踢人、权限校验、会话存储——但动手实现时,每个"理应如此"的能力背后都有几个不那么显然的选择。这篇文章想聊聊其中几个,主要是为了自己复盘,也希望对做类似设计的人有参考价值。


为什么不直接用 Passport?

@nestjs/passport 几乎是 NestJS 鉴权的默认答案,但它本质上是个 strategy 调度器——你提供策略(local / jwt / oauth2),它负责调度。它不解决的问题包括:

同账号在第二台设备登录时,第一台应该被踢还是共存?用户被踢下线后,前端拿到 401,怎么区分"token 过期"和"管理员强制下线"?用户连续操作 24 小时不该被踢,但闲置 30 分钟应该自动登出——这两种过期机制怎么同时支持?除了 loginId,还想存最近 IP、设备 ID 等数据,且与 token 同生命周期。

这些是业务侧每次都要重新发明的轮子。Sa-Token 在 Java 生态把它们统一封装好了,我希望 Node 生态也有类似的东西。

但移植不是翻译。Java 的同步阻塞模型、Spring 的注解扫描、JVM 的反射,在 TypeScript 里都得重新设计。下面几个细节就是这种"重新设计"过程中最典型的取舍。


存储键的三层结构

最朴素的方案是 token -> userId 一对一映射:

auth:token:abc123 → "1001"

但这没法实现顶号。你拿到的是新登录的 userId=1001,不知道这个用户之前用的是哪个 token,要找到它只能遍历所有 key,性能上不可接受。

xlt-token 的方案是三层键空间:

authorization:login:token:<token>          → loginId
authorization:login:session:<loginId>      → token
authorization:login:lastActive:<token>timestamp

有了反向索引,登录时的顶号逻辑就是两次 O(1) 的 store 操作:

async login(loginId: string) {
  const oldToken = await store.get(sessionKey(loginId));
  if (oldToken && !isConcurrent) {
    await store.update(tokenKey(oldToken), 'BE_REPLACED');
  }
  const newToken = strategy.create();
  await store.set(tokenKey(newToken), loginId);
  await store.set(sessionKey(loginId), newToken);
  return newToken;
}

后续加上权限和会话后键空间又扩展了几条,但接口契约不变:所有键都是平铺的字符串 KV,可以无差别接到 Memory / Redis / 任何 KV 存储上。


为什么踢人不能删 Key

用户被踢下线时,直觉是直接删掉 tokenKey:

async kickout(loginId) {
  const token = await store.get(sessionKey(loginId));
  await store.delete(tokenKey(token));
  await store.delete(sessionKey(loginId));
}

问题在于,用户下次带着旧 token 来请求,store.get(tokenKey) 返回 null,你没法区分"被踢了"和"token 过期了"——前端只能收到一个通用的 401。

xlt-token 的做法是写哨兵值而不是删除:

async kickout(loginId) {
  const token = await store.get(sessionKey(loginId));
  await store.update(tokenKey(token), 'KICK_OUT');  // 保留 TTL,只改值
  await store.delete(sessionKey(loginId));
}

请求到来时,_resolveLoginId 按顺序判定:token 不存在、值为 null、值为 BE_REPLACED、值为 KICK_OUT、活跃过期、通过——最终前端拿到的 401 响应体可以精确区分六种未登录原因,客户端可以针对每种情况做不同处理("账号在其他设备登录"和"已被强制下线"是两种截然不同的用户体验)。

哨兵值的 TTL 跟着原 token 的剩余时间走,不会造成内存泄漏。代价是踢人时多写了一条数据,但读场景的诊断精度提升显著。


权限通配符匹配:两个 Bug

P1 加权限校验时要支持 user:* 匹配 user:add / user:edit。第一版写出了这样的代码:

function matchPermission(pattern: string, target: string): boolean {
  pattern.split('').forEach((char, i) => {
    if (char === '*') return true;
    if (char !== target[i]) return false;
  });
  return true;
}

forEach 回调里的 return 只结束当次回调,不结束外层函数,所以任何输入都返回 true,权限校验形同虚设。改用正则:

export function matchPermission(pattern: string, target: string): boolean {
  if (pattern === target) return true;
  if (pattern === '*') return true;

  const regex = new RegExp(
    '^' + pattern.replace(/[.+?^${}()|[]\]/g, '\$&').replace(/*/g, '.*') + '$'
  );
  return regex.test(target);
}

第二个 bug 藏得更深。权限引擎里有这样的"短路优化":

async hasPermission(loginId: string, perm: string) {
  const list = await this.stpInterface.getPermissionList(loginId);
  if (list.includes(perm)) return true;
  return list.some(p => matchPermission(p, perm));
}

list.includes 是全等匹配。如果用户拥有 ['user:*'],校验 'user:add' 时,includes 返回 false,才会走到 some(...) 里的通配符匹配——这条路径是对的。但这段"short-circuit"代码本身掩盖了一个事实:includes 不是 matchPermission 的子集,两者语义不同。一旦业务逻辑稍微复杂一点(比如同时有精确权限和通配符权限),这条快路径就可能产生意料之外的行为,而且很难从测试中察觉,因为两条路径独立通过。

最终我把这个短路优化删掉了,性能损失不到 5%(权限列表通常 10~50 项),但逻辑变得线性可推理。


Guard 抽象类里的死代码

NestJS Guard 做鉴权后通常要把用户信息加载到 request.userxlt-token 为此提供了一个抽象基类:

@Injectable()
export abstract class XltAbstractLoginGuard implements CanActivate {
  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    if (!this.requiresLogin(ctx)) return true;

    const request = ctx.switchToHttp().getRequest();
    const result = await this.stpLogic.checkLogin(request);

    if (!result.ok) {
      await this.onAuthFail?.(result, request);
      throw new NotLoginException(result.reason);
    }

    request.stpLoginId = result.loginId;
    await this.onAuthSuccess?.(result, request);
    return true;
  }

  protected requiresLogin(ctx: ExecutionContext): boolean { /* 默认实现 */ }
  protected onAuthSuccess?(result, request): void | Promise<void>;
  protected onAuthFail?(result, request): void | Promise<void>;
}

业务子类只需实现 onAuthSuccess 加载用户信息。看起来很干净——单元测试全绿,提了 PR。

E2E 测试时发现 onAuthFail 永远没有被调用过。回看代码才发现:stpLogic.checkLogin 内部在校验失败时会直接抛出 NotLoginException,所以 if (!result.ok) 这条分支是死代码,onAuthFail 钩子永远到不了。

修复方式是把钩子塞进 catch:

async canActivate(ctx) {
  let result;
  try {
    result = await this.stpLogic.checkLogin(request);
  } catch (e) {
    if (e instanceof NotLoginException) {
      await this.onAuthFail?.({ ok: false, reason: e.message }, request);
    }
    throw e;
  }
}

这个 bug 用单元测试发现不了,因为单元测试通常会 mock checkLogin,让它返回一个 { ok: false } 对象而不是真的抛错。只有把整个 Guard 放进真实 Nest 容器里跑 E2E,才会暴露钩子从来没被触发过这件事。这之后我给项目补了完整的 E2E 测试基建。


StpUtil 静态门面 vs DI

NestJS 最佳实践是一切走 DI,但有些场景 DI 很不方便:全局异常过滤器、工具类 Helper、测试中需要快速 mock 全局认证状态。参考 Sa-Token,xlt-token 同时提供了静态门面:

import { StpUtil } from 'xlt-token';

const token = await StpUtil.login('1001');
const id = await StpUtil.getLoginId(req);

实现是个延迟单例,XltTokenModuleOnModuleInit 时把容器里的实例注入静态变量。两种风格的主要差异:DI 方式可测试性更好、天然支持多实例;静态门面使用更便捷,但是全局单例且必须在 Module init 之后才能调用。

两者并存是有意为之,让用户在不同上下文按习惯选择,底层实现是同一套,行为一致。


数据

1.0.0-rc.1 打包后 gzip 7.4 KB,单测覆盖率 98%+,E2E 覆盖率 95%+,195 个测试用例。依赖只有 es-toolkituuid 和 NestJS peer dep,没有任何 ORM / DB / Redis 强绑定。


未来

1.0 的范畴是完备的单点登录鉴权。1.1.0 计划补齐:二级认证 + 临时 token、多端登录管理(按设备类型互踢)、JWT Strategy(与当前 UUID 策略互切换)、在线用户列表等观测性 API。

详细 Roadmap:xiaolangtou.github.io/xlt-token/r…


pnpm add xlt-token@next

文档:xiaolangtou.github.io/xlt-token
GitHub:github.com/xiaoLangtou…

1.0.0-rc.1 是发稳定版前的最后窗口期,欢迎 API 命名、类型签名、文档方面的反馈,或者直接开 Issue。

❌