最近发布了 xlt-token@1.0.0-rc.1,一个为 NestJS 设计的 Token 鉴权库,灵感来自 Java 生态的 Sa-Token。
功能列表看起来不复杂——登录、登出、踢人、权限校验、会话存储——但动手实现时,每个"理应如此"的能力背后都有几个不那么显然的选择。这篇文章想聊聊其中几个,主要是为了自己复盘,也希望对做类似设计的人有参考价值。
为什么不直接用 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.user,xlt-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);
实现是个延迟单例,XltTokenModule 在 OnModuleInit 时把容器里的实例注入静态变量。两种风格的主要差异:DI 方式可测试性更好、天然支持多实例;静态门面使用更便捷,但是全局单例且必须在 Module init 之后才能调用。
两者并存是有意为之,让用户在不同上下文按习惯选择,底层实现是同一套,行为一致。
数据
1.0.0-rc.1 打包后 gzip 7.4 KB,单测覆盖率 98%+,E2E 覆盖率 95%+,195 个测试用例。依赖只有 es-toolkit、uuid 和 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。