本指南承接使用 Prisma,添加 session 功能。
access token 依然保持短期有效、无状态。session 功能为 refresh token 添加了服务端状态,这样当某个会话被撤销时,对应的 refresh token 就会被拒绝。
本指南使用 Redis 存储会话,因为它支持基于 TTL 的键,以及用于按用户建立会话索引的有序集合。
完成本指南后,你的应用将能够列出活跃会话、撤销某个特定会话、撤销其他会话,并拒绝来自已撤销会话的刷新请求。
- 已完成使用 Prisma的项目
- 一个正在运行的 Redis 实例
安装 Redis 客户端
Section titled “安装 Redis 客户端”安装本指南使用的 Redis 客户端:
pnpm add redisnpm install redisyarn add redis配置 Redis 连接
Section titled “配置 Redis 连接”把 Redis 连接配置添加到已有的 .env 文件中:
REDIS_HOST=localhostREDIS_PORT=6379创建 RedisModule
Section titled “创建 RedisModule”创建一个小型 Redis 模块,提供一个已连接的 Redis 客户端:
文件夹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 {}添加 session 功能
Section titled “添加 session 功能”添加功能
运行 CLI 命令:
brkpt auth add sessionCLI 会添加一个新的 features/session/ 文件夹。
实现 SessionAdapter
session 适配器的方法比之前的适配器多,但大多数都是简单的 Redis 操作。
它们分为两组:
- 会话存储:创建、读取、更新和删除一个会话。
- 用户索引:为每个用户维护一份会话列表,用于列出会话和批量撤销。
下面的实现足以让你在 Redis 上跑起来。
创建 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:
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:
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 { // ...}启动应用:
pnpm start:devnpm run start:devyarn start:devsession 功能新增以下端点:
| Method | Path | Description |
|---|---|---|
GET |
/auth/session |
列出当前用户的活跃会话 |
DELETE |
/auth/session/:sessionId |
撤销一个特定会话 |
DELETE |
/auth/session/others |
撤销除当前会话外的所有会话 |
使用开始使用中创建的账号,或者用 /auth/sign-up 创建一个新账号。
列出会话
用同一个账号登录多次,每次成功登录都会创建一个新会话。
然后列出活跃会话:
GET /auth/sessionAuthorization: Bearer <access-token>响应中应该包含当前用户的活跃会话。
撤销一个会话
按 id 撤销某一个会话:
DELETE /auth/session/<session-id>Authorization: Bearer <access-token>会话被撤销后,与该会话关联的 refresh token 就不能再用来调用 /auth/refresh。
撤销其他会话
撤销除当前会话外的所有会话:
DELETE /auth/session/othersAuthorization: Bearer <access-token>这对“在其他设备上登出”这类场景很有用。