跳转到内容

添加会话管理

用 Redis 为 refresh token 添加会话管理。

本指南承接使用 Prisma,添加 session 功能。

access token 依然保持短期有效、无状态。session 功能为 refresh token 添加了服务端状态,这样当某个会话被撤销时,对应的 refresh token 就会被拒绝。

本指南使用 Redis 存储会话,因为它支持基于 TTL 的键,以及用于按用户建立会话索引的有序集合。

完成本指南后,你的应用将能够列出活跃会话、撤销某个特定会话、撤销其他会话,并拒绝来自已撤销会话的刷新请求。

  • 已完成使用 Prisma的项目
  • 一个正在运行的 Redis 实例

安装本指南使用的 Redis 客户端:

Terminal window
pnpm add redis

把 Redis 连接配置添加到已有的 .env 文件中:

.env
REDIS_HOST=localhost
REDIS_PORT=6379

创建一个小型 Redis 模块,提供一个已连接的 Redis 客户端:

  • 文件夹src/
    • 文件夹redis/
      • redis.module.ts
src/redis/redis.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { createClient } from 'redis';
@Module({
imports: [ConfigModule],
providers: [
{
provide: 'REDIS_CLIENT',
inject: [ConfigService],
async useFactory(config: ConfigService) {
const client = createClient({
socket: {
host: config.getOrThrow('REDIS_HOST'),
port: Number(config.getOrThrow('REDIS_PORT')),
},
});
await client.connect();
return client;
},
},
],
exports: ['REDIS_CLIENT'],
})
export class RedisModule {}

添加功能

运行 CLI 命令:

Terminal window
brkpt auth add session

CLI 会添加一个新的 features/session/ 文件夹。

实现 SessionAdapter

session 适配器的方法比之前的适配器多,但大多数都是简单的 Redis 操作。

它们分为两组:

  • 会话存储:创建、读取、更新和删除一个会话。
  • 用户索引:为每个用户维护一份会话列表,用于列出会话和批量撤销。

下面的实现足以让你在 Redis 上跑起来。

创建 session.adapter.ts:

  • 文件夹src/
    • 文件夹brkpt-auth/
      • 文件夹adapters/
        • session.adapter.ts
src/brkpt-auth/adapters/session.adapter.ts
import { Inject, Injectable } from '@nestjs/common';
import { type RedisClientType } from 'redis';
import { SessionData } from '../common/interfaces';
import { SessionPort } from '../features/session/session.port';
import { AuthJwtPayload } from './types';
@Injectable()
export class SessionAdapter implements SessionPort {
constructor(
@Inject('REDIS_CLIENT') private readonly redis: RedisClientType,
) {}
private key(sessionId: string) {
return `session:${sessionId}`;
}
private userIndexKey(userId: unknown) {
return `session:user:${String(userId)}`;
}
async create(
sessionId: string,
data: SessionData,
ttlMs: number,
): Promise<void> {
await this.redis.set(this.key(sessionId), JSON.stringify(data), {
expiration: { type: 'PX', value: ttlMs },
});
}
async exists(sessionId: string): Promise<boolean> {
return !!(await this.redis.exists(this.key(sessionId)));
}
async findById(sessionId: string): Promise<SessionData | null> {
const data = await this.redis.get(this.key(sessionId));
return data ? (JSON.parse(data) as SessionData) : null;
}
async update(sessionId: string, data: SessionData): Promise<void> {
const key = this.key(sessionId);
const ttlMs = await this.redis.pTTL(key);
if (ttlMs > 0) {
await this.redis.set(key, JSON.stringify(data), {
expiration: { type: 'PX', value: ttlMs },
});
}
}
async delete(sessionId: string): Promise<void> {
await this.redis.del(this.key(sessionId));
}
async addToUserIndex(
userId: unknown,
sessionId: string,
expiresAt: number,
): Promise<void> {
await this.redis.zAdd(this.userIndexKey(userId), {
score: expiresAt,
value: sessionId,
});
}
async removeFromUserIndex(userId: unknown, sessionId: string): Promise<void> {
await this.redis.zRem(this.userIndexKey(userId), sessionId);
}
async pruneUserIndex(userId: unknown, before: number): Promise<void> {
await this.redis.zRemRangeByScore(
this.userIndexKey(userId),
'-inf',
before,
);
}
getUserIndexSessionIds(userId: unknown): Promise<string[]> {
return this.redis.zRange(this.userIndexKey(userId), 0, -1);
}
extractUserIdFromJwtPayload(payload: AuthJwtPayload): number {
return payload.sub;
}
}

注册功能

更新 features.ts,把 SessionAdapter 传给 sessionFeature:

src/brkpt-auth/features.ts
import { CoreAdapter } from './adapters/core.adapter';
import { CredentialsAdapter } from './adapters/credentials.adapter';
import { SessionAdapter } from './adapters/session.adapter';
import { FeatureConfig } from './common/interfaces';
import { coreFeature } from './features/core/core.feature';
import { credentialsFeature } from './features/credentials/credentials.feature';
import { sessionFeature } from './features/session/session.feature';
export const features: FeatureConfig[] = [
coreFeature(CoreAdapter),
credentialsFeature(CredentialsAdapter),
sessionFeature(SessionAdapter),
];

导入 RedisModule

SessionAdapter 依赖 REDIS_CLIENT,而它是由 RedisModule 导出的。

把 RedisModule 添加到 brkpt-auth.module.ts:

src/brkpt-auth/brkpt-auth.module.ts
import { PrismaModule } from '../prisma/prisma.module';
import { RedisModule } from '../redis/redis.module';
@Module({
imports: [PrismaModule],
imports: [PrismaModule, RedisModule],
controllers: [...features.flatMap((f) => f.controllers)],
providers: [...features.flatMap((f) => f.providers)],
})
export class BrkptAuthModule {
// ...
}

启动应用:

Terminal window
pnpm start:dev

session 功能新增以下端点:

Method Path Description
GET /auth/session 列出当前用户的活跃会话
DELETE /auth/session/:sessionId 撤销一个特定会话
DELETE /auth/session/others 撤销除当前会话外的所有会话

使用开始使用中创建的账号,或者用 /auth/sign-up 创建一个新账号。

列出会话

用同一个账号登录多次,每次成功登录都会创建一个新会话。

然后列出活跃会话:

GET /auth/session
Authorization: Bearer <access-token>

响应中应该包含当前用户的活跃会话。

撤销一个会话

按 id 撤销某一个会话:

DELETE /auth/session/<session-id>
Authorization: Bearer <access-token>

会话被撤销后,与该会话关联的 refresh token 就不能再用来调用 /auth/refresh。

撤销其他会话

撤销除当前会话外的所有会话:

DELETE /auth/session/others
Authorization: Bearer <access-token>

这对“在其他设备上登出”这类场景很有用。