feat: 实现演示验证码和令牌轮换
This commit is contained in:
@@ -1,3 +1,16 @@
|
|||||||
# Copy this file to .env and replace test-only values for non-test environments.
|
# Copy this file to .env and replace test-only values for non-test environments.
|
||||||
DATABASE_URL=postgresql://drift:drift_test_only@127.0.0.1:55432/drift_bottle_test?schema=public
|
DATABASE_URL=postgresql://drift:drift_test_only@127.0.0.1:55432/drift_bottle_test?schema=public
|
||||||
REDIS_URL=redis://127.0.0.1:56379
|
REDIS_URL=redis://127.0.0.1:56379
|
||||||
|
WEB_ORIGIN=http://localhost:3000
|
||||||
|
PHONE_ENCRYPTION_KEY=<base64-encoded-exactly-32-byte-key>
|
||||||
|
PHONE_HMAC_KEY=<independent-at-least-32-byte-secret>
|
||||||
|
VERIFICATION_CODE_HMAC_KEY=<independent-at-least-32-byte-secret>
|
||||||
|
REFRESH_TOKEN_HMAC_KEY=<independent-at-least-32-byte-secret>
|
||||||
|
JWT_SECRET=<at-least-32-byte-secret>
|
||||||
|
JWT_ISSUER=drift-api
|
||||||
|
JWT_AUDIENCE=drift-web
|
||||||
|
ACCESS_TOKEN_TTL_SECONDS=900
|
||||||
|
REFRESH_TOKEN_TTL_SECONDS=2592000
|
||||||
|
SMS_CODE_TTL_SECONDS=300
|
||||||
|
REDIS_KEY_PREFIX=drift:auth:
|
||||||
|
TRUST_PROXY=false
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ dist
|
|||||||
coverage
|
coverage
|
||||||
|
|
||||||
docs/
|
docs/
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
"test": "vitest run --config vitest.config.ts --no-file-parallelism",
|
"test": "vitest run --config vitest.config.ts --no-file-parallelism",
|
||||||
"test:e2e": "vitest run --config vitest.config.ts src/health/health.e2e-spec.ts --no-file-parallelism",
|
"test:e2e": "vitest run --config vitest.config.ts src/health/health.e2e-spec.ts src/auth/auth.e2e-spec.ts --no-file-parallelism",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"@prisma/client": "6.19.0",
|
"@prisma/client": "6.19.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.2",
|
"class-validator": "^0.14.2",
|
||||||
|
"cookie": "1.1.1",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"redis": "^5.8.2",
|
"redis": "^5.8.2",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { MiddlewareConsumer, Module, type NestModule } from "@nestjs/common";
|
import { MiddlewareConsumer, Module, type NestModule } from "@nestjs/common";
|
||||||
import { RequestIdMiddleware } from "./common/request-id.middleware.js";
|
import { RequestIdMiddleware } from "./common/request-id.middleware.js";
|
||||||
import { HealthModule } from "./health/health.module.js";
|
import { HealthModule } from "./health/health.module.js";
|
||||||
|
import { AuthModule } from "./auth/auth.module.js";
|
||||||
|
|
||||||
@Module({ imports: [HealthModule] })
|
@Module({ imports: [HealthModule, AuthModule] })
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer): void {
|
configure(consumer: MiddlewareConsumer): void {
|
||||||
consumer.apply(RequestIdMiddleware).forRoutes("{*path}");
|
consumer.apply(RequestIdMiddleware).forRoutes("{*path}");
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
codeHmac,
|
||||||
|
phoneHmac,
|
||||||
|
resetAuthEnvironmentForTests,
|
||||||
|
validateAuthEnvironment,
|
||||||
|
} from "./auth.config.js";
|
||||||
|
|
||||||
|
const valid = {
|
||||||
|
PHONE_ENCRYPTION_KEY: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||||
|
PHONE_HMAC_KEY: "phone-hmac-key-that-is-at-least-32-bytes",
|
||||||
|
VERIFICATION_CODE_HMAC_KEY: "code-hmac-key-that-is-at-least-32-bytes!",
|
||||||
|
REFRESH_TOKEN_HMAC_KEY: "refresh-key-that-is-at-least-thirty-two-bytes",
|
||||||
|
JWT_SECRET: "jwt-secret-that-is-at-least-thirty-two-bytes",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("auth environment", () => {
|
||||||
|
const original = { ...process.env };
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.NODE_ENV = "test";
|
||||||
|
Object.assign(process.env, valid);
|
||||||
|
resetAuthEnvironmentForTests();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...original, NODE_ENV: "test" };
|
||||||
|
resetAuthEnvironmentForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(Object.keys(valid))("fails fast when %s is missing", (name) => {
|
||||||
|
delete process.env[name];
|
||||||
|
expect(() => validateAuthEnvironment()).toThrow(`${name} is required`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(Object.keys(valid))("fails fast when %s is too short", (name) => {
|
||||||
|
process.env[name] = "short";
|
||||||
|
expect(() => validateAuthEnvironment()).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects duplicate keys including the verification-code key", () => {
|
||||||
|
process.env.VERIFICATION_CODE_HMAC_KEY = process.env.PHONE_HMAC_KEY;
|
||||||
|
expect(() => validateAuthEnvironment()).toThrow(
|
||||||
|
"Auth secrets must be independent",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a verification-code key independent from the phone key", () => {
|
||||||
|
validateAuthEnvironment();
|
||||||
|
const phoneDigest = phoneHmac("+8613800138000");
|
||||||
|
const first = codeHmac(phoneDigest, "123456");
|
||||||
|
resetAuthEnvironmentForTests();
|
||||||
|
process.env.VERIFICATION_CODE_HMAC_KEY =
|
||||||
|
"different-code-key-that-is-at-least-32-bytes";
|
||||||
|
validateAuthEnvironment();
|
||||||
|
expect(phoneHmac("+8613800138000")).toBe(phoneDigest);
|
||||||
|
expect(codeHmac(phoneDigest, "123456")).not.toBe(first);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
createCipheriv,
|
||||||
|
createDecipheriv,
|
||||||
|
createHmac,
|
||||||
|
randomBytes,
|
||||||
|
timingSafeEqual,
|
||||||
|
} from "node:crypto";
|
||||||
|
|
||||||
|
function required(name: string, min = 32): Buffer {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) throw new Error(`${name} is required`);
|
||||||
|
const decoded =
|
||||||
|
name === "PHONE_ENCRYPTION_KEY"
|
||||||
|
? Buffer.from(value, "base64")
|
||||||
|
: Buffer.from(value);
|
||||||
|
if (decoded.length < min)
|
||||||
|
throw new Error(`${name} must be at least ${min} bytes`);
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
const REQUIRED_KEYS = [
|
||||||
|
"PHONE_ENCRYPTION_KEY",
|
||||||
|
"PHONE_HMAC_KEY",
|
||||||
|
"VERIFICATION_CODE_HMAC_KEY",
|
||||||
|
"REFRESH_TOKEN_HMAC_KEY",
|
||||||
|
"JWT_SECRET",
|
||||||
|
] as const;
|
||||||
|
type AuthSecrets = Record<(typeof REQUIRED_KEYS)[number], Buffer>;
|
||||||
|
let secrets: AuthSecrets | undefined;
|
||||||
|
|
||||||
|
export function validateAuthEnvironment(): void {
|
||||||
|
const loaded = Object.fromEntries(
|
||||||
|
REQUIRED_KEYS.map((name) => [name, required(name)]),
|
||||||
|
) as unknown as AuthSecrets;
|
||||||
|
if (loaded.PHONE_ENCRYPTION_KEY.length !== 32)
|
||||||
|
throw new Error("PHONE_ENCRYPTION_KEY must decode to exactly 32 bytes");
|
||||||
|
const fingerprints = REQUIRED_KEYS.map((name) =>
|
||||||
|
loaded[name].toString("hex"),
|
||||||
|
);
|
||||||
|
if (new Set(fingerprints).size !== fingerprints.length)
|
||||||
|
throw new Error("Auth secrets must be independent");
|
||||||
|
secrets = loaded;
|
||||||
|
}
|
||||||
|
export function resetAuthEnvironmentForTests(): void {
|
||||||
|
if (process.env.NODE_ENV !== "test")
|
||||||
|
throw new Error("Auth environment reset is test-only");
|
||||||
|
secrets = undefined;
|
||||||
|
}
|
||||||
|
function authSecrets(): AuthSecrets {
|
||||||
|
if (!secrets) validateAuthEnvironment();
|
||||||
|
return secrets!;
|
||||||
|
}
|
||||||
|
export const jwtSecret = (): Buffer => authSecrets().JWT_SECRET;
|
||||||
|
export const phoneHmac = (phone: string): string =>
|
||||||
|
createHmac("sha256", authSecrets().PHONE_HMAC_KEY)
|
||||||
|
.update(phone)
|
||||||
|
.digest("hex");
|
||||||
|
export const codeHmac = (phoneDigest: string, code: string): string =>
|
||||||
|
createHmac("sha256", authSecrets().VERIFICATION_CODE_HMAC_KEY)
|
||||||
|
.update(`${phoneDigest}:${code}`)
|
||||||
|
.digest("hex");
|
||||||
|
export const refreshHmac = (token: string): string =>
|
||||||
|
createHmac("sha256", authSecrets().REFRESH_TOKEN_HMAC_KEY)
|
||||||
|
.update(token)
|
||||||
|
.digest("hex");
|
||||||
|
export const safeEqual = (a: string, b: string): boolean => {
|
||||||
|
const x = Buffer.from(a);
|
||||||
|
const y = Buffer.from(b);
|
||||||
|
return x.length === y.length && timingSafeEqual(x, y);
|
||||||
|
};
|
||||||
|
export function encryptPhone(phone: string): Buffer {
|
||||||
|
const key = authSecrets().PHONE_ENCRYPTION_KEY;
|
||||||
|
if (key.length !== 32)
|
||||||
|
throw new Error("PHONE_ENCRYPTION_KEY must decode to exactly 32 bytes");
|
||||||
|
const nonce = randomBytes(12);
|
||||||
|
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
||||||
|
const body = Buffer.concat([cipher.update(phone, "utf8"), cipher.final()]);
|
||||||
|
return Buffer.concat([nonce, cipher.getAuthTag(), body]);
|
||||||
|
}
|
||||||
|
export function decryptPhone(value: Buffer): string {
|
||||||
|
const key = authSecrets().PHONE_ENCRYPTION_KEY;
|
||||||
|
const decipher = createDecipheriv("aes-256-gcm", key, value.subarray(0, 12));
|
||||||
|
decipher.setAuthTag(value.subarray(12, 28));
|
||||||
|
return Buffer.concat([
|
||||||
|
decipher.update(value.subarray(28)),
|
||||||
|
decipher.final(),
|
||||||
|
]).toString();
|
||||||
|
}
|
||||||
|
export const envInt = (name: string, fallback: number): number => {
|
||||||
|
const n = Number(process.env[name] ?? fallback);
|
||||||
|
return Number.isInteger(n) && n > 0 ? n : fallback;
|
||||||
|
};
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { parse as parseCookie } from "cookie";
|
||||||
|
import { ErrorCode } from "@drift/contracts";
|
||||||
|
import { DomainException } from "../common/domain.exception.js";
|
||||||
|
import { PrismaService } from "../database/prisma.service.js";
|
||||||
|
import { AuthGuard } from "./auth.guard.js";
|
||||||
|
import { AuthService, type TokenPair } from "./auth.service.js";
|
||||||
|
import { CurrentUser } from "./current-user.decorator.js";
|
||||||
|
import type { AccessClaims } from "./token.service.js";
|
||||||
|
import { LoginDto, SendSmsDto } from "./dto.js";
|
||||||
|
const COOKIE = "refresh_token";
|
||||||
|
@Controller()
|
||||||
|
export class AuthController {
|
||||||
|
constructor(
|
||||||
|
@Inject(AuthService) private readonly auth: AuthService,
|
||||||
|
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
@Post("auth/sms/send") send(@Body() dto: SendSmsDto, @Req() req: Request) {
|
||||||
|
return this.auth.sendSms(dto.phone, dto.deviceId, this.ip(req));
|
||||||
|
}
|
||||||
|
@Post("auth/sms/login") async login(
|
||||||
|
@Body() dto: LoginDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
const pair = await this.auth.login(
|
||||||
|
dto.phone,
|
||||||
|
dto.code,
|
||||||
|
dto.deviceId,
|
||||||
|
this.ip(req),
|
||||||
|
);
|
||||||
|
this.cookie(res, pair.refreshToken);
|
||||||
|
return this.publicPair(pair);
|
||||||
|
}
|
||||||
|
@Post("auth/token/refresh") @HttpCode(HttpStatus.OK) async refresh(
|
||||||
|
@Req() req: Request,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
this.origin(req);
|
||||||
|
const pair = await this.auth.refresh(this.readCookie(req));
|
||||||
|
this.cookie(res, pair.refreshToken);
|
||||||
|
return this.publicPair(pair);
|
||||||
|
}
|
||||||
|
@Post("auth/logout") @HttpCode(HttpStatus.OK) async logout(
|
||||||
|
@Req() req: Request,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
this.origin(req);
|
||||||
|
await this.auth.logout(this.readCookieOptional(req));
|
||||||
|
res.clearCookie(COOKIE, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "strict",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
path: "/api/v1/auth",
|
||||||
|
});
|
||||||
|
return { loggedOut: true };
|
||||||
|
}
|
||||||
|
@Get("me") @UseGuards(AuthGuard) async me(@CurrentUser() user: AccessClaims) {
|
||||||
|
const account = await this.prisma.account.findUniqueOrThrow({
|
||||||
|
where: { id: user.sub },
|
||||||
|
include: { anonymousProfile: true },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
accountId: account.id,
|
||||||
|
publicId: account.anonymousProfile?.publicId ?? null,
|
||||||
|
nickname: account.anonymousProfile?.nickname ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
private publicPair(pair: TokenPair) {
|
||||||
|
return {
|
||||||
|
accessToken: pair.accessToken,
|
||||||
|
expiresIn: pair.expiresIn,
|
||||||
|
tokenType: "Bearer",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
private cookie(res: Response, value: string) {
|
||||||
|
res.cookie(COOKIE, value, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "strict",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
path: "/api/v1/auth",
|
||||||
|
maxAge: Number(process.env.REFRESH_TOKEN_TTL_SECONDS ?? 2592000) * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private origin(req: Request) {
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
if (origin !== process.env.WEB_ORIGIN)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.AUTH_ORIGIN_FORBIDDEN,
|
||||||
|
"Forbidden origin",
|
||||||
|
HttpStatus.FORBIDDEN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
private readCookieOptional(req: Request) {
|
||||||
|
const raw = req.headers.cookie;
|
||||||
|
if (!raw) return undefined;
|
||||||
|
try {
|
||||||
|
return parseCookie(raw)[COOKIE];
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private readCookie(req: Request) {
|
||||||
|
const value = this.readCookieOptional(req);
|
||||||
|
if (!value)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.AUTH_INVALID_CREDENTIALS,
|
||||||
|
"Invalid credentials",
|
||||||
|
HttpStatus.UNAUTHORIZED,
|
||||||
|
);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
private ip(req: Request) {
|
||||||
|
return req.ip || req.socket.remoteAddress || "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
"design:paramtypes",
|
||||||
|
[SendSmsDto, Object],
|
||||||
|
AuthController.prototype,
|
||||||
|
"send",
|
||||||
|
);
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
"design:paramtypes",
|
||||||
|
[LoginDto, Object, Object],
|
||||||
|
AuthController.prototype,
|
||||||
|
"login",
|
||||||
|
);
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call */
|
||||||
|
import "reflect-metadata";
|
||||||
|
import { Logger, type INestApplication } from "@nestjs/common";
|
||||||
|
import { Test } from "@nestjs/testing";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { createServer, type Socket } from "node:net";
|
||||||
|
import { createClient } from "redis";
|
||||||
|
import request from "supertest";
|
||||||
|
import {
|
||||||
|
afterAll,
|
||||||
|
beforeAll,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from "vitest";
|
||||||
|
import { assertSafeTestDatabaseUrl } from "../../../../prisma/database-safety.js";
|
||||||
|
import { AppModule } from "../app.module.js";
|
||||||
|
import { configureApp } from "../main.js";
|
||||||
|
import { RedisService } from "../redis/redis.service.js";
|
||||||
|
|
||||||
|
process.env.NODE_ENV = "test";
|
||||||
|
process.env.WEB_ORIGIN = "http://localhost:3000";
|
||||||
|
process.env.PHONE_ENCRYPTION_KEY =
|
||||||
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||||
|
process.env.PHONE_HMAC_KEY = "test-phone-hmac-key-with-at-least-32-bytes";
|
||||||
|
process.env.VERIFICATION_CODE_HMAC_KEY =
|
||||||
|
"test-code-hmac-key-with-at-least-32-bytes";
|
||||||
|
process.env.JWT_SECRET = "test-jwt-secret-with-at-least-thirty-two-bytes";
|
||||||
|
process.env.REFRESH_TOKEN_HMAC_KEY =
|
||||||
|
"test-refresh-hmac-key-with-at-least-32-bytes";
|
||||||
|
process.env.REDIS_KEY_PREFIX = "drift:auth:e2e:";
|
||||||
|
const phone = "138 0013 8000",
|
||||||
|
deviceId = "device-test-001",
|
||||||
|
origin = "http://localhost:3000";
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const redis = createClient({
|
||||||
|
url: process.env.REDIS_URL ?? "redis://127.0.0.1:56379",
|
||||||
|
});
|
||||||
|
const cookieValue = (header: string[]) => header[0]?.split(";")[0] ?? "";
|
||||||
|
|
||||||
|
describe("auth real PostgreSQL/Redis", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
beforeAll(async () => {
|
||||||
|
assertSafeTestDatabaseUrl(process.env.DATABASE_URL ?? "");
|
||||||
|
await prisma.$connect();
|
||||||
|
await redis.connect();
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
app = module.createNestApplication();
|
||||||
|
configureApp(app);
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "accounts" CASCADE`);
|
||||||
|
for (const key of await redis.keys("drift:auth:e2e:*"))
|
||||||
|
await redis.del(key);
|
||||||
|
delete process.env.SMS_SEND_LIMIT;
|
||||||
|
delete process.env.SMS_SEND_IP_LIMIT;
|
||||||
|
delete process.env.SMS_VERIFY_LIMIT;
|
||||||
|
delete process.env.SMS_CODE_TTL_SECONDS;
|
||||||
|
delete process.env.ACCESS_TOKEN_TTL_SECONDS;
|
||||||
|
delete process.env.TRUST_PROXY;
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await app?.close();
|
||||||
|
await redis.quit();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
async function send() {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/sms/send")
|
||||||
|
.send({ phone, deviceId });
|
||||||
|
}
|
||||||
|
async function login() {
|
||||||
|
const sent = await send();
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/sms/login")
|
||||||
|
.send({ phone, deviceId, code: sent.body.data.debugCode });
|
||||||
|
}
|
||||||
|
it("sends, logs in with one-time code, stores encrypted identity, sets strict cookie, and guards me", async () => {
|
||||||
|
const sent = await send();
|
||||||
|
expect(sent.status).toBe(201);
|
||||||
|
expect(sent.body.data.debugCode).toMatch(/^\d{6}$/);
|
||||||
|
expect(JSON.stringify(sent.body)).not.toContain("13800138000");
|
||||||
|
const logged = await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/sms/login")
|
||||||
|
.send({ phone, deviceId, code: sent.body.data.debugCode })
|
||||||
|
.expect(201);
|
||||||
|
const cookies = logged.headers["set-cookie"] as unknown as string[];
|
||||||
|
expect(cookies[0]).toContain("HttpOnly");
|
||||||
|
expect(cookies[0]).toContain("SameSite=Strict");
|
||||||
|
expect(cookies[0]).toContain("Path=/api/v1/auth");
|
||||||
|
expect(cookies[0]).not.toContain("Secure");
|
||||||
|
expect(logged.body.data.accessToken).toEqual(expect.any(String));
|
||||||
|
const account = await prisma.account.findFirstOrThrow();
|
||||||
|
expect(Buffer.from(account.phoneCiphertext).toString()).not.toContain(
|
||||||
|
"13800138000",
|
||||||
|
);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/sms/login")
|
||||||
|
.send({ phone, deviceId, code: sent.body.data.debugCode })
|
||||||
|
.expect(401);
|
||||||
|
const me = await request(app.getHttpServer())
|
||||||
|
.get("/api/v1/me")
|
||||||
|
.set("Authorization", `Bearer ${logged.body.data.accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(me.body.data.accountId).toBe(account.id);
|
||||||
|
});
|
||||||
|
it("rejects wrong codes and enforces atomic rate limit with TTL", async () => {
|
||||||
|
await send();
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/sms/login")
|
||||||
|
.send({ phone, deviceId, code: "000000" })
|
||||||
|
.expect(401);
|
||||||
|
process.env.SMS_SEND_LIMIT = "1";
|
||||||
|
const oldKeys = await redis.keys("drift:auth:e2e:*");
|
||||||
|
for (const key of oldKeys) await redis.del(key);
|
||||||
|
await send();
|
||||||
|
const limited = await send();
|
||||||
|
expect(limited.status).toBe(429);
|
||||||
|
expect(limited.body.code).toBe("RATE_LIMITED");
|
||||||
|
const keys = await redis.keys("drift:auth:e2e:limit:send-phone:*");
|
||||||
|
expect(await redis.ttl(keys[0]!)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
it("omits debugCode in production", async () => {
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
|
try {
|
||||||
|
const response = await send();
|
||||||
|
expect(response.body.data).not.toHaveProperty("debugCode");
|
||||||
|
} finally {
|
||||||
|
process.env.NODE_ENV = "test";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("rotates refresh, detects old-token replay, and revokes the session", async () => {
|
||||||
|
const logged = await login();
|
||||||
|
const oldCookie = cookieValue(
|
||||||
|
logged.headers["set-cookie"] as unknown as string[],
|
||||||
|
);
|
||||||
|
const refreshed = await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/token/refresh")
|
||||||
|
.set("Origin", origin)
|
||||||
|
.set("Cookie", oldCookie)
|
||||||
|
.expect(200);
|
||||||
|
const nextCookie = cookieValue(
|
||||||
|
refreshed.headers["set-cookie"] as unknown as string[],
|
||||||
|
);
|
||||||
|
expect(nextCookie).not.toBe(oldCookie);
|
||||||
|
const replay = await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/token/refresh")
|
||||||
|
.set("Origin", origin)
|
||||||
|
.set("Cookie", oldCookie)
|
||||||
|
.expect(401);
|
||||||
|
expect(replay.body.code).toBe("AUTH_REFRESH_REUSED");
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/token/refresh")
|
||||||
|
.set("Origin", origin)
|
||||||
|
.set("Cookie", nextCookie)
|
||||||
|
.expect(401);
|
||||||
|
expect((await prisma.session.findFirstOrThrow()).revokedAt).not.toBeNull();
|
||||||
|
});
|
||||||
|
it("uses the first refresh cookie rather than allowing a duplicate to overwrite it", async () => {
|
||||||
|
const logged = await login();
|
||||||
|
const valid = cookieValue(
|
||||||
|
logged.headers["set-cookie"] as unknown as string[],
|
||||||
|
);
|
||||||
|
const malicious = `${randomUUID()}.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA`;
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/token/refresh")
|
||||||
|
.set("Origin", origin)
|
||||||
|
.set("Cookie", `${valid}; refresh_token=${malicious}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const second = await login();
|
||||||
|
const secondValid = cookieValue(
|
||||||
|
second.headers["set-cookie"] as unknown as string[],
|
||||||
|
);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/token/refresh")
|
||||||
|
.set("Origin", origin)
|
||||||
|
.set("Cookie", `refresh_token=${malicious}; ${secondValid}`)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
it("allows only one concurrent refresh and leaves the family revoked", async () => {
|
||||||
|
const logged = await login();
|
||||||
|
const cookie = cookieValue(
|
||||||
|
logged.headers["set-cookie"] as unknown as string[],
|
||||||
|
);
|
||||||
|
const results = await Promise.all(
|
||||||
|
[1, 2].map(() =>
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/token/refresh")
|
||||||
|
.set("Origin", origin)
|
||||||
|
.set("Cookie", cookie),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(results.map((x) => x.status).sort()).toEqual([200, 401]);
|
||||||
|
const winner = results.find((result) => result.status === 200);
|
||||||
|
const winnerCookie = cookieValue(
|
||||||
|
winner?.headers["set-cookie"] as unknown as string[],
|
||||||
|
);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/token/refresh")
|
||||||
|
.set("Origin", origin)
|
||||||
|
.set("Cookie", winnerCookie)
|
||||||
|
.expect(401);
|
||||||
|
expect((await prisma.session.findFirstOrThrow()).revokedAt).not.toBeNull();
|
||||||
|
});
|
||||||
|
it("rejects tampered access and revoked/token-version sessions", async () => {
|
||||||
|
const logged = await login();
|
||||||
|
const token = logged.body.data.accessToken as string;
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get("/api/v1/me")
|
||||||
|
.set("Authorization", `Bearer ${token}x`)
|
||||||
|
.expect(401);
|
||||||
|
const session = await prisma.session.findFirstOrThrow();
|
||||||
|
await prisma.account.update({
|
||||||
|
where: { id: session.accountId },
|
||||||
|
data: { tokenVersion: { increment: 1 } },
|
||||||
|
});
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get("/api/v1/me")
|
||||||
|
.set("Authorization", `Bearer ${token}`)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
it("requires exact Origin for refresh/logout and makes logout idempotent", async () => {
|
||||||
|
const logged = await login();
|
||||||
|
const cookie = cookieValue(
|
||||||
|
logged.headers["set-cookie"] as unknown as string[],
|
||||||
|
);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/token/refresh")
|
||||||
|
.set("Origin", "https://evil.example")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.expect(403);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/logout")
|
||||||
|
.set("Origin", "https://evil.example")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.expect(403);
|
||||||
|
const first = await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/logout")
|
||||||
|
.set("Origin", origin)
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.expect(200);
|
||||||
|
expect((first.headers["set-cookie"] as unknown as string[])[0]).toContain(
|
||||||
|
"refresh_token=",
|
||||||
|
);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/logout")
|
||||||
|
.set("Origin", origin)
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.expect(200);
|
||||||
|
expect((await prisma.session.findFirstOrThrow()).revokedAt).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks production cookies Secure and never stores access tokens in cookies", async () => {
|
||||||
|
const sent = await send();
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
|
try {
|
||||||
|
const logged = await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/sms/login")
|
||||||
|
.send({ phone, deviceId, code: sent.body.data.debugCode })
|
||||||
|
.expect(201);
|
||||||
|
const cookies = logged.headers["set-cookie"] as unknown as string[];
|
||||||
|
expect(cookies[0]).toContain("Secure");
|
||||||
|
expect(cookies.join(";")).not.toContain(logged.body.data.accessToken);
|
||||||
|
expect(cookies.join(";")).not.toContain("access_token=");
|
||||||
|
} finally {
|
||||||
|
process.env.NODE_ENV = "test";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expires SMS codes and blocks verification at SMS_VERIFY_LIMIT", async () => {
|
||||||
|
process.env.SMS_CODE_TTL_SECONDS = "1";
|
||||||
|
const expired = await send();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/sms/login")
|
||||||
|
.send({ phone, deviceId, code: expired.body.data.debugCode })
|
||||||
|
.expect(401);
|
||||||
|
process.env.SMS_VERIFY_LIMIT = "2";
|
||||||
|
for (const key of await redis.keys("drift:auth:e2e:limit:verify-*"))
|
||||||
|
await redis.del(key);
|
||||||
|
await send();
|
||||||
|
for (let attempt = 0; attempt < 2; attempt += 1)
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/sms/login")
|
||||||
|
.send({ phone, deviceId, code: "000000" })
|
||||||
|
.expect(401);
|
||||||
|
const limited = await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/sms/login")
|
||||||
|
.send({ phone, deviceId, code: "000000" })
|
||||||
|
.expect(429);
|
||||||
|
expect(limited.body.code).toBe("RATE_LIMITED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects actually expired access tokens", async () => {
|
||||||
|
process.env.ACCESS_TOKEN_TTL_SECONDS = "1";
|
||||||
|
const logged = await login();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||||
|
const denied = await request(app.getHttpServer())
|
||||||
|
.get("/api/v1/me")
|
||||||
|
.set("Authorization", `Bearer ${logged.body.data.accessToken}`)
|
||||||
|
.expect(401);
|
||||||
|
expect(denied.body.code).toBe("AUTH_TOKEN_EXPIRED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("issues only minimum HS256 claims for the configured issuer and audience", async () => {
|
||||||
|
const logged = await login();
|
||||||
|
const [encodedHeader, encodedClaims] = (
|
||||||
|
logged.body.data.accessToken as string
|
||||||
|
).split(".");
|
||||||
|
const header = JSON.parse(
|
||||||
|
Buffer.from(encodedHeader!, "base64url").toString(),
|
||||||
|
);
|
||||||
|
const claims = JSON.parse(
|
||||||
|
Buffer.from(encodedClaims!, "base64url").toString(),
|
||||||
|
);
|
||||||
|
expect(header).toEqual({ alg: "HS256", typ: "JWT" });
|
||||||
|
expect(Object.keys(claims).sort()).toEqual([
|
||||||
|
"aud",
|
||||||
|
"device_id",
|
||||||
|
"exp",
|
||||||
|
"iat",
|
||||||
|
"iss",
|
||||||
|
"scopes",
|
||||||
|
"session_id",
|
||||||
|
"sub",
|
||||||
|
"token_version",
|
||||||
|
]);
|
||||||
|
expect(claims).toMatchObject({
|
||||||
|
iss: "drift-api",
|
||||||
|
aud: "drift-web",
|
||||||
|
scopes: ["user"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("independently rejects a revoked session", async () => {
|
||||||
|
const logged = await login();
|
||||||
|
const session = await prisma.session.findFirstOrThrow();
|
||||||
|
await prisma.session.update({
|
||||||
|
where: { id: session.id },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get("/api/v1/me")
|
||||||
|
.set("Authorization", `Bearer ${logged.body.data.accessToken}`)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a suspended account", async () => {
|
||||||
|
const logged = await login();
|
||||||
|
const session = await prisma.session.findFirstOrThrow();
|
||||||
|
await prisma.account.update({
|
||||||
|
where: { id: session.accountId },
|
||||||
|
data: { status: "SUSPENDED" },
|
||||||
|
});
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get("/api/v1/me")
|
||||||
|
.set("Authorization", `Bearer ${logged.body.data.accessToken}`)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ phone: "not-a-phone", deviceId },
|
||||||
|
{ phone, deviceId: "short" },
|
||||||
|
{ phone, deviceId: "x".repeat(129) },
|
||||||
|
{ phone, deviceId: "bad device!" },
|
||||||
|
])("validates phone and deviceId DTO input %#", async (body) => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/sms/send")
|
||||||
|
.send(body)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses Express trust proxy parsing as the real IP rate-limit dimension", async () => {
|
||||||
|
process.env.SMS_SEND_IP_LIMIT = "1";
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/sms/send")
|
||||||
|
.set("X-Forwarded-For", "198.51.100.1")
|
||||||
|
.send({ phone: "13800138001", deviceId: "device-ip-001" })
|
||||||
|
.expect(201);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/sms/send")
|
||||||
|
.set("X-Forwarded-For", "198.51.100.2")
|
||||||
|
.send({ phone: "13800138002", deviceId: "device-ip-002" })
|
||||||
|
.expect(429);
|
||||||
|
for (const key of await redis.keys("drift:auth:e2e:*"))
|
||||||
|
await redis.del(key);
|
||||||
|
app.getHttpAdapter().getInstance().set("trust proxy", 1);
|
||||||
|
try {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/sms/send")
|
||||||
|
.set("X-Forwarded-For", "198.51.100.1")
|
||||||
|
.send({ phone: "13800138001", deviceId: "device-ip-001" })
|
||||||
|
.expect(201);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/sms/send")
|
||||||
|
.set("X-Forwarded-For", "198.51.100.2")
|
||||||
|
.send({ phone: "13800138002", deviceId: "device-ip-002" })
|
||||||
|
.expect(201);
|
||||||
|
} finally {
|
||||||
|
app.getHttpAdapter().getInstance().set("trust proxy", false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not log phone, code, JWT, refresh token, or Cookie", async () => {
|
||||||
|
const spies = (["log", "warn", "error", "debug", "verbose"] as const).map(
|
||||||
|
(method) =>
|
||||||
|
vi.spyOn(Logger.prototype, method).mockImplementation(() => undefined),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const sent = await send();
|
||||||
|
const logged = await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/sms/login")
|
||||||
|
.send({ phone, deviceId, code: sent.body.data.debugCode })
|
||||||
|
.expect(201);
|
||||||
|
const cookie = cookieValue(
|
||||||
|
logged.headers["set-cookie"] as unknown as string[],
|
||||||
|
);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/token/refresh")
|
||||||
|
.set("Origin", origin)
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.expect(200);
|
||||||
|
const output = JSON.stringify(spies.flatMap((spy) => spy.mock.calls));
|
||||||
|
for (const secret of [
|
||||||
|
"13800138000",
|
||||||
|
sent.body.data.debugCode,
|
||||||
|
logged.body.data.accessToken,
|
||||||
|
cookie,
|
||||||
|
])
|
||||||
|
expect(output).not.toContain(secret);
|
||||||
|
expect(output).not.toContain("refresh_token");
|
||||||
|
} finally {
|
||||||
|
for (const spy of spies) spy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not revoke a session for a random invalid refresh token", async () => {
|
||||||
|
await login();
|
||||||
|
const session = await prisma.session.findFirstOrThrow();
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/token/refresh")
|
||||||
|
.set("Origin", origin)
|
||||||
|
.set(
|
||||||
|
"Cookie",
|
||||||
|
`other=1; refresh_token=${session.id}.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA; tail=2`,
|
||||||
|
)
|
||||||
|
.expect(401);
|
||||||
|
expect(
|
||||||
|
(await prisma.session.findUniqueOrThrow({ where: { id: session.id } }))
|
||||||
|
.revokedAt,
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers Auth Redis after a connection deadline and closes its sockets", async () => {
|
||||||
|
const sockets = new Set<Socket>();
|
||||||
|
const server = createServer((socket) => {
|
||||||
|
sockets.add(socket);
|
||||||
|
socket.on("close", () => sockets.delete(socket));
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
server.listen(0, "127.0.0.1", resolve),
|
||||||
|
);
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string")
|
||||||
|
throw new Error("missing port");
|
||||||
|
const previousUrl = process.env.REDIS_URL;
|
||||||
|
const previousTimeout = process.env.REDIS_OPERATION_TIMEOUT_MS;
|
||||||
|
process.env.REDIS_URL = `redis://127.0.0.1:${address.port}`;
|
||||||
|
process.env.REDIS_OPERATION_TIMEOUT_MS = "500";
|
||||||
|
const service = new RedisService();
|
||||||
|
const failedClient = service.client;
|
||||||
|
let recoveredClient = failedClient;
|
||||||
|
try {
|
||||||
|
await expect(service.ensureConnected()).rejects.toThrow(/timed out/);
|
||||||
|
expect(failedClient.isOpen).toBe(false);
|
||||||
|
process.env.REDIS_URL = previousUrl ?? "redis://127.0.0.1:56379";
|
||||||
|
await expect(service.ensureConnected()).resolves.toBeUndefined();
|
||||||
|
recoveredClient = service.client;
|
||||||
|
expect(recoveredClient).not.toBe(failedClient);
|
||||||
|
expect(recoveredClient.isReady).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await service.onModuleDestroy();
|
||||||
|
expect(recoveredClient.isOpen).toBe(false);
|
||||||
|
for (const socket of sockets) socket.destroy();
|
||||||
|
await new Promise<void>((resolve, reject) =>
|
||||||
|
server.close((error) => (error ? reject(error) : resolve())),
|
||||||
|
);
|
||||||
|
process.env.REDIS_URL = previousUrl;
|
||||||
|
process.env.REDIS_OPERATION_TIMEOUT_MS = previousTimeout;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ErrorCode } from "@drift/contracts";
|
||||||
|
import type { Request } from "express";
|
||||||
|
import { DomainException } from "../common/domain.exception.js";
|
||||||
|
import { PrismaService } from "../database/prisma.service.js";
|
||||||
|
import { TokenService } from "./token.service.js";
|
||||||
|
import type { AuthenticatedRequest } from "./current-user.decorator.js";
|
||||||
|
@Injectable()
|
||||||
|
export class AuthGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||||
|
@Inject(TokenService) private readonly tokens: TokenService,
|
||||||
|
) {}
|
||||||
|
async canActivate(ctx: ExecutionContext): Promise<boolean> {
|
||||||
|
const req = ctx.switchToHttp().getRequest<Request & AuthenticatedRequest>();
|
||||||
|
const auth = req.headers.authorization;
|
||||||
|
if (!auth?.startsWith("Bearer ")) throw this.denied();
|
||||||
|
const claims = this.tokens.verifyAccess(auth.slice(7));
|
||||||
|
const session = await this.prisma.session.findUnique({
|
||||||
|
where: { id: claims.session_id },
|
||||||
|
include: { account: true },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!session ||
|
||||||
|
session.accountId !== claims.sub ||
|
||||||
|
session.deviceId !== claims.device_id ||
|
||||||
|
session.revokedAt ||
|
||||||
|
session.expiresAt <= new Date() ||
|
||||||
|
session.account.status !== "ACTIVE" ||
|
||||||
|
session.account.tokenVersion !== claims.token_version
|
||||||
|
)
|
||||||
|
throw this.denied();
|
||||||
|
req.user = claims;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
private denied() {
|
||||||
|
return new DomainException(
|
||||||
|
ErrorCode.AUTH_UNAUTHORIZED,
|
||||||
|
"Unauthorized",
|
||||||
|
HttpStatus.UNAUTHORIZED,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AuthController } from "./auth.controller.js";
|
||||||
|
import { AuthGuard } from "./auth.guard.js";
|
||||||
|
import { AuthService } from "./auth.service.js";
|
||||||
|
import { TokenService } from "./token.service.js";
|
||||||
|
import { DatabaseModule } from "../database/database.module.js";
|
||||||
|
import { RedisModule } from "../redis/redis.module.js";
|
||||||
|
@Module({
|
||||||
|
imports: [DatabaseModule, RedisModule],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, TokenService, AuthGuard],
|
||||||
|
exports: [AuthGuard, TokenService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import { HttpStatus, Inject, Injectable } from "@nestjs/common";
|
||||||
|
import { ErrorCode } from "@drift/contracts";
|
||||||
|
import { Prisma, type Account } from "@prisma/client";
|
||||||
|
import { randomInt, randomUUID } from "node:crypto";
|
||||||
|
import { DomainException } from "../common/domain.exception.js";
|
||||||
|
import { PrismaService } from "../database/prisma.service.js";
|
||||||
|
import { RedisService } from "../redis/redis.service.js";
|
||||||
|
import {
|
||||||
|
codeHmac,
|
||||||
|
encryptPhone,
|
||||||
|
envInt,
|
||||||
|
phoneHmac,
|
||||||
|
refreshHmac,
|
||||||
|
safeEqual,
|
||||||
|
} from "./auth.config.js";
|
||||||
|
import { TokenService } from "./token.service.js";
|
||||||
|
|
||||||
|
export interface TokenPair {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
expiresIn: number;
|
||||||
|
}
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||||
|
@Inject(RedisService) private readonly redis: RedisService,
|
||||||
|
@Inject(TokenService) private readonly tokens: TokenService,
|
||||||
|
) {}
|
||||||
|
normalizePhone(raw: string): string {
|
||||||
|
const compact = raw.replace(/[\s()-]/g, "");
|
||||||
|
const local = compact.startsWith("+86")
|
||||||
|
? compact.slice(3)
|
||||||
|
: compact.startsWith("86") && compact.length === 13
|
||||||
|
? compact.slice(2)
|
||||||
|
: compact;
|
||||||
|
if (!/^1[3-9]\d{9}$/.test(local))
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.AUTH_INVALID_CREDENTIALS,
|
||||||
|
"Invalid credentials",
|
||||||
|
HttpStatus.UNAUTHORIZED,
|
||||||
|
);
|
||||||
|
return `+86${local}`;
|
||||||
|
}
|
||||||
|
private prefix() {
|
||||||
|
return process.env.REDIS_KEY_PREFIX ?? "drift:auth:";
|
||||||
|
}
|
||||||
|
private async limit(
|
||||||
|
kind: string,
|
||||||
|
parts: string[],
|
||||||
|
max: number,
|
||||||
|
ttl: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.redis.ensureConnected();
|
||||||
|
const key = `${this.prefix()}limit:${kind}:${parts.join(":")}`;
|
||||||
|
const result = (await this.redis.client.eval(
|
||||||
|
`local n=redis.call('INCR',KEYS[1]); if n==1 then redis.call('EXPIRE',KEYS[1],ARGV[1]) end; return n`,
|
||||||
|
{ keys: [key], arguments: [String(ttl)] },
|
||||||
|
)) as number;
|
||||||
|
if (result > max)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.RATE_LIMITED,
|
||||||
|
"Too many requests",
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async sendSms(
|
||||||
|
rawPhone: string,
|
||||||
|
deviceId: string,
|
||||||
|
ip: string,
|
||||||
|
): Promise<{ sent: true; debugCode?: string }> {
|
||||||
|
const phone = this.normalizePhone(rawPhone);
|
||||||
|
const digest = phoneHmac(phone);
|
||||||
|
await Promise.all([
|
||||||
|
this.limit(
|
||||||
|
"send-phone",
|
||||||
|
[digest],
|
||||||
|
envInt("SMS_SEND_LIMIT", 3),
|
||||||
|
envInt("SMS_RATE_WINDOW_SECONDS", 300),
|
||||||
|
),
|
||||||
|
this.limit(
|
||||||
|
"send-device",
|
||||||
|
[deviceId],
|
||||||
|
envInt("SMS_SEND_DEVICE_LIMIT", 5),
|
||||||
|
envInt("SMS_RATE_WINDOW_SECONDS", 300),
|
||||||
|
),
|
||||||
|
this.limit(
|
||||||
|
"send-ip",
|
||||||
|
[phoneHmac(ip)],
|
||||||
|
envInt("SMS_SEND_IP_LIMIT", 10),
|
||||||
|
envInt("SMS_RATE_WINDOW_SECONDS", 300),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const code = randomInt(0, 1000000).toString().padStart(6, "0");
|
||||||
|
await this.redis.client.set(
|
||||||
|
`${this.prefix()}code:${digest}`,
|
||||||
|
codeHmac(digest, code),
|
||||||
|
{ EX: envInt("SMS_CODE_TTL_SECONDS", 300) },
|
||||||
|
);
|
||||||
|
const result: { sent: true; debugCode?: string } = { sent: true };
|
||||||
|
if (process.env.NODE_ENV !== "production") result.debugCode = code;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
async login(
|
||||||
|
rawPhone: string,
|
||||||
|
code: string,
|
||||||
|
deviceId: string,
|
||||||
|
ip: string,
|
||||||
|
): Promise<TokenPair> {
|
||||||
|
const phone = this.normalizePhone(rawPhone);
|
||||||
|
const digest = phoneHmac(phone);
|
||||||
|
await Promise.all([
|
||||||
|
this.limit(
|
||||||
|
"verify-phone",
|
||||||
|
[digest],
|
||||||
|
envInt("SMS_VERIFY_LIMIT", 5),
|
||||||
|
envInt("SMS_RATE_WINDOW_SECONDS", 300),
|
||||||
|
),
|
||||||
|
this.limit(
|
||||||
|
"verify-combined",
|
||||||
|
[digest, phoneHmac(ip), deviceId],
|
||||||
|
envInt("SMS_VERIFY_COMBINED_LIMIT", 5),
|
||||||
|
envInt("SMS_RATE_WINDOW_SECONDS", 300),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const key = `${this.prefix()}code:${digest}`;
|
||||||
|
const consumed = await this.redis.client.eval(
|
||||||
|
`local v=redis.call('GET',KEYS[1]); if v and v==ARGV[1] then redis.call('DEL',KEYS[1]); return 1 end; return 0`,
|
||||||
|
{ keys: [key], arguments: [codeHmac(digest, code)] },
|
||||||
|
);
|
||||||
|
if (Number(consumed) !== 1) throw this.invalid();
|
||||||
|
let account = await this.prisma.account.findUnique({
|
||||||
|
where: { phoneHmac: digest },
|
||||||
|
});
|
||||||
|
if (!account) account = await this.createAccount(phone, digest);
|
||||||
|
if (account.status !== "ACTIVE") throw this.invalid();
|
||||||
|
return this.createSession(account, deviceId);
|
||||||
|
}
|
||||||
|
private async createAccount(phone: string, digest: string): Promise<Account> {
|
||||||
|
try {
|
||||||
|
return await this.prisma.account.create({
|
||||||
|
data: {
|
||||||
|
phoneHmac: digest,
|
||||||
|
phoneCiphertext: new Uint8Array(encryptPhone(phone)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
error.code === "P2002"
|
||||||
|
) {
|
||||||
|
const found = await this.prisma.account.findUnique({
|
||||||
|
where: { phoneHmac: digest },
|
||||||
|
});
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private async createSession(
|
||||||
|
account: Account,
|
||||||
|
deviceId: string,
|
||||||
|
): Promise<TokenPair> {
|
||||||
|
const id = randomUUID();
|
||||||
|
const refresh = this.tokens.newRefresh(id);
|
||||||
|
const expiresAt = new Date(
|
||||||
|
Date.now() + envInt("REFRESH_TOKEN_TTL_SECONDS", 2592000) * 1000,
|
||||||
|
);
|
||||||
|
await this.prisma.session.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
accountId: account.id,
|
||||||
|
deviceId,
|
||||||
|
refreshTokenHash: refreshHmac(refresh),
|
||||||
|
tokenFamily: id,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.pair(account, id, deviceId, refresh);
|
||||||
|
}
|
||||||
|
private pair(
|
||||||
|
account: Account,
|
||||||
|
sessionId: string,
|
||||||
|
deviceId: string,
|
||||||
|
refreshToken: string,
|
||||||
|
): TokenPair {
|
||||||
|
const expiresIn = envInt("ACCESS_TOKEN_TTL_SECONDS", 900);
|
||||||
|
return {
|
||||||
|
accessToken: this.tokens.issueAccess({
|
||||||
|
sub: account.id,
|
||||||
|
session_id: sessionId,
|
||||||
|
device_id: deviceId,
|
||||||
|
scopes: ["user"],
|
||||||
|
token_version: account.tokenVersion,
|
||||||
|
}),
|
||||||
|
refreshToken,
|
||||||
|
expiresIn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async refresh(token: string): Promise<TokenPair> {
|
||||||
|
const parsed = this.parseRefresh(token);
|
||||||
|
const hash = refreshHmac(token);
|
||||||
|
const observed = await this.prisma.session.findUnique({
|
||||||
|
where: { id: parsed },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
observed?.previousTokenHash &&
|
||||||
|
safeEqual(observed.previousTokenHash, hash)
|
||||||
|
)
|
||||||
|
return this.revokeReusedFamily(observed.tokenFamily);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.prisma.$transaction(
|
||||||
|
async (tx): Promise<TokenPair | { reusedFamily: string }> => {
|
||||||
|
const session = await tx.session.findUnique({
|
||||||
|
where: { id: parsed },
|
||||||
|
include: { account: true },
|
||||||
|
});
|
||||||
|
if (!session) throw this.invalid();
|
||||||
|
if (
|
||||||
|
session.previousTokenHash &&
|
||||||
|
safeEqual(session.previousTokenHash, hash)
|
||||||
|
)
|
||||||
|
return { reusedFamily: session.tokenFamily };
|
||||||
|
if (
|
||||||
|
!safeEqual(session.refreshTokenHash, hash) ||
|
||||||
|
session.revokedAt ||
|
||||||
|
session.expiresAt <= new Date() ||
|
||||||
|
session.account.status !== "ACTIVE"
|
||||||
|
)
|
||||||
|
throw this.invalid();
|
||||||
|
const next = this.tokens.newRefresh(session.id);
|
||||||
|
const changed = await tx.session.updateMany({
|
||||||
|
where: { id: session.id, refreshTokenHash: hash, revokedAt: null },
|
||||||
|
data: {
|
||||||
|
previousTokenHash: hash,
|
||||||
|
refreshTokenHash: refreshHmac(next),
|
||||||
|
generation: { increment: 1 },
|
||||||
|
rotatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (changed.count !== 1) return { reusedFamily: session.tokenFamily };
|
||||||
|
return this.pair(
|
||||||
|
session.account,
|
||||||
|
session.id,
|
||||||
|
session.deviceId ?? "",
|
||||||
|
next,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||||
|
);
|
||||||
|
if ("reusedFamily" in result)
|
||||||
|
return this.revokeReusedFamily(result.reusedFamily);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DomainException) throw error;
|
||||||
|
if (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
error.code === "P2034"
|
||||||
|
) {
|
||||||
|
const session = await this.prisma.session.findUnique({
|
||||||
|
where: { id: parsed },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
session?.previousTokenHash &&
|
||||||
|
safeEqual(session.previousTokenHash, hash)
|
||||||
|
)
|
||||||
|
return this.revokeReusedFamily(session.tokenFamily);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private async revokeReusedFamily(family: string): Promise<never> {
|
||||||
|
await this.prisma.session.updateMany({
|
||||||
|
where: { tokenFamily: family },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.AUTH_REFRESH_REUSED,
|
||||||
|
"Refresh token reused",
|
||||||
|
HttpStatus.UNAUTHORIZED,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async logout(token: string | undefined): Promise<void> {
|
||||||
|
if (!token) return;
|
||||||
|
let id: string;
|
||||||
|
try {
|
||||||
|
id = this.parseRefresh(token);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hash = refreshHmac(token);
|
||||||
|
await this.prisma.session.updateMany({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
OR: [{ refreshTokenHash: hash }, { previousTokenHash: hash }],
|
||||||
|
},
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private parseRefresh(token: string): string {
|
||||||
|
const id = token.split(".")[0];
|
||||||
|
if (!id || !/^[0-9a-f-]{36}$/.test(id)) throw this.invalid();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
private invalid() {
|
||||||
|
return new DomainException(
|
||||||
|
ErrorCode.AUTH_INVALID_CREDENTIALS,
|
||||||
|
"Invalid credentials",
|
||||||
|
HttpStatus.UNAUTHORIZED,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { createParamDecorator, type ExecutionContext } from "@nestjs/common";
|
||||||
|
import type { AccessClaims } from "./token.service.js";
|
||||||
|
export interface AuthenticatedRequest {
|
||||||
|
user?: AccessClaims;
|
||||||
|
}
|
||||||
|
export const CurrentUser = createParamDecorator(
|
||||||
|
(_data: unknown, ctx: ExecutionContext) =>
|
||||||
|
ctx.switchToHttp().getRequest<AuthenticatedRequest>().user,
|
||||||
|
);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import {
|
||||||
|
IsPhoneNumber,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
} from "class-validator";
|
||||||
|
export class SendSmsDto {
|
||||||
|
@IsString() @MinLength(6) @MaxLength(32) @IsPhoneNumber("CN") phone!: string;
|
||||||
|
@IsString() @Matches(/^[A-Za-z0-9._:-]{8,128}$/) deviceId!: string;
|
||||||
|
}
|
||||||
|
export class LoginDto extends SendSmsDto {
|
||||||
|
@IsString() @Matches(/^\d{6}$/) code!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { HttpStatus, Injectable } from "@nestjs/common";
|
||||||
|
import { ErrorCode } from "@drift/contracts";
|
||||||
|
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
import { DomainException } from "../common/domain.exception.js";
|
||||||
|
import { envInt, jwtSecret } from "./auth.config.js";
|
||||||
|
export interface AccessClaims {
|
||||||
|
sub: string;
|
||||||
|
session_id: string;
|
||||||
|
device_id: string;
|
||||||
|
scopes: string[];
|
||||||
|
token_version: number;
|
||||||
|
iss: string;
|
||||||
|
aud: string;
|
||||||
|
exp: number;
|
||||||
|
iat: number;
|
||||||
|
}
|
||||||
|
const b64 = (v: Buffer | string) => Buffer.from(v).toString("base64url");
|
||||||
|
@Injectable()
|
||||||
|
export class TokenService {
|
||||||
|
issueAccess(
|
||||||
|
input: Omit<AccessClaims, "iss" | "aud" | "exp" | "iat">,
|
||||||
|
): string {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const header = b64(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||||
|
const body = b64(
|
||||||
|
JSON.stringify({
|
||||||
|
...input,
|
||||||
|
iss: process.env.JWT_ISSUER ?? "drift-api",
|
||||||
|
aud: process.env.JWT_AUDIENCE ?? "drift-web",
|
||||||
|
iat: now,
|
||||||
|
exp: now + envInt("ACCESS_TOKEN_TTL_SECONDS", 900),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const data = `${header}.${body}`;
|
||||||
|
return `${data}.${createHmac("sha256", jwtSecret()).update(data).digest("base64url")}`;
|
||||||
|
}
|
||||||
|
verifyAccess(token: string): AccessClaims {
|
||||||
|
try {
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length !== 3) throw new Error();
|
||||||
|
const [h, p, s] = parts as [string, string, string];
|
||||||
|
const header = JSON.parse(Buffer.from(h, "base64url").toString()) as {
|
||||||
|
alg?: string;
|
||||||
|
};
|
||||||
|
if (header.alg !== "HS256") throw new Error();
|
||||||
|
const expected = createHmac("sha256", jwtSecret())
|
||||||
|
.update(`${h}.${p}`)
|
||||||
|
.digest();
|
||||||
|
const actual = Buffer.from(s, "base64url");
|
||||||
|
if (
|
||||||
|
expected.length !== actual.length ||
|
||||||
|
!timingSafeEqual(expected, actual)
|
||||||
|
)
|
||||||
|
throw new Error();
|
||||||
|
const claims = JSON.parse(
|
||||||
|
Buffer.from(p, "base64url").toString(),
|
||||||
|
) as AccessClaims;
|
||||||
|
if (
|
||||||
|
claims.iss !== (process.env.JWT_ISSUER ?? "drift-api") ||
|
||||||
|
claims.aud !== (process.env.JWT_AUDIENCE ?? "drift-web")
|
||||||
|
)
|
||||||
|
throw new Error();
|
||||||
|
if (claims.exp <= Math.floor(Date.now() / 1000))
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.AUTH_TOKEN_EXPIRED,
|
||||||
|
"Access token expired",
|
||||||
|
HttpStatus.UNAUTHORIZED,
|
||||||
|
);
|
||||||
|
return claims;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DomainException) throw error;
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.AUTH_UNAUTHORIZED,
|
||||||
|
"Unauthorized",
|
||||||
|
HttpStatus.UNAUTHORIZED,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newRefresh(sessionId: string): string {
|
||||||
|
return `${sessionId}.${randomBytes(32).toString("base64url")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "./prisma.service.js";
|
||||||
|
|
||||||
|
@Module({ providers: [PrismaService], exports: [PrismaService] })
|
||||||
|
export class DatabaseModule {}
|
||||||
@@ -2,12 +2,20 @@ import "reflect-metadata";
|
|||||||
import { ValidationPipe, type INestApplication } from "@nestjs/common";
|
import { ValidationPipe, type INestApplication } from "@nestjs/common";
|
||||||
import { NestFactory } from "@nestjs/core";
|
import { NestFactory } from "@nestjs/core";
|
||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
|
import type { Application } from "express";
|
||||||
import { AppModule } from "./app.module.js";
|
import { AppModule } from "./app.module.js";
|
||||||
|
import { validateAuthEnvironment } from "./auth/auth.config.js";
|
||||||
import { HttpResponseInterceptor } from "./common/http-response.interceptor.js";
|
import { HttpResponseInterceptor } from "./common/http-response.interceptor.js";
|
||||||
import { DomainExceptionFilter } from "./common/domain-exception.filter.js";
|
import { DomainExceptionFilter } from "./common/domain-exception.filter.js";
|
||||||
|
|
||||||
export function configureApp(app: INestApplication): void {
|
export function configureApp(app: INestApplication): void {
|
||||||
|
validateAuthEnvironment();
|
||||||
const allowedOrigin = process.env.WEB_ORIGIN ?? "http://localhost:3000";
|
const allowedOrigin = process.env.WEB_ORIGIN ?? "http://localhost:3000";
|
||||||
|
const adapter = app.getHttpAdapter?.() as
|
||||||
|
{ getInstance(): Application } | undefined;
|
||||||
|
adapter
|
||||||
|
?.getInstance()
|
||||||
|
.set("trust proxy", process.env.TRUST_PROXY === "true" ? 1 : false);
|
||||||
app.setGlobalPrefix("api/v1");
|
app.setGlobalPrefix("api/v1");
|
||||||
app.use(helmet());
|
app.use(helmet());
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
@@ -35,6 +43,7 @@ type AppFactory = () => Promise<INestApplication>;
|
|||||||
export async function bootstrap(
|
export async function bootstrap(
|
||||||
appFactory: AppFactory = () => NestFactory.create(AppModule),
|
appFactory: AppFactory = () => NestFactory.create(AppModule),
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
validateAuthEnvironment();
|
||||||
const app = await appFactory();
|
const app = await appFactory();
|
||||||
configureApp(app);
|
configureApp(app);
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { RedisService } from "./redis.service.js";
|
||||||
|
@Module({ providers: [RedisService], exports: [RedisService] })
|
||||||
|
export class RedisModule {}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import {
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
Optional,
|
||||||
|
type OnModuleDestroy,
|
||||||
|
type OnModuleInit,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { createClient } from "redis";
|
||||||
|
|
||||||
|
type RedisClient = ReturnType<typeof createClient>;
|
||||||
|
type RedisClientFactory = () => RedisClient;
|
||||||
|
export const AUTH_REDIS_CLIENT_FACTORY = Symbol("AUTH_REDIS_CLIENT_FACTORY");
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RedisService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(RedisService.name);
|
||||||
|
private readonly timeoutMs: number;
|
||||||
|
private current: RedisClient | undefined;
|
||||||
|
private generation = 0;
|
||||||
|
private readonly connections = new WeakMap<RedisClient, Promise<void>>();
|
||||||
|
private readonly destroyed = new WeakSet<RedisClient>();
|
||||||
|
private readonly loggedErrors = new WeakSet<RedisClient>();
|
||||||
|
private shuttingDown = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Optional()
|
||||||
|
@Inject(AUTH_REDIS_CLIENT_FACTORY)
|
||||||
|
private readonly factory?: RedisClientFactory,
|
||||||
|
) {
|
||||||
|
const configured = Number(process.env.REDIS_OPERATION_TIMEOUT_MS ?? 1000);
|
||||||
|
this.timeoutMs =
|
||||||
|
Number.isFinite(configured) && configured > 0 ? configured : 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
get client(): RedisClient {
|
||||||
|
if (!this.current) {
|
||||||
|
if (this.shuttingDown) throw new Error("Auth Redis is shutting down");
|
||||||
|
const client = this.factory?.() ?? this.create();
|
||||||
|
client.on("error", () => {
|
||||||
|
if (this.loggedErrors.has(client)) return;
|
||||||
|
this.loggedErrors.add(client);
|
||||||
|
this.logger.warn({
|
||||||
|
event: "Auth Redis client error",
|
||||||
|
code: "REDIS_ERROR",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.current = client;
|
||||||
|
this.generation += 1;
|
||||||
|
}
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureConnected(): Promise<void> {
|
||||||
|
if (this.shuttingDown) throw new Error("Auth Redis is shutting down");
|
||||||
|
const client = this.client;
|
||||||
|
const generation = this.generation;
|
||||||
|
if (client.isReady) return;
|
||||||
|
let pending = this.connections.get(client);
|
||||||
|
if (!pending) {
|
||||||
|
pending = this.withDeadline(client, generation, client.connect()).then(
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
this.connections.set(client, pending);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await pending;
|
||||||
|
} catch (error) {
|
||||||
|
this.destroyClient(client, generation);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (this.connections.get(client) === pending)
|
||||||
|
this.connections.delete(client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
/* Auth Redis connects lazily so unrelated modules remain isolated. */
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
this.shuttingDown = true;
|
||||||
|
const client = this.current;
|
||||||
|
if (!client || !client.isOpen) return;
|
||||||
|
const generation = this.generation;
|
||||||
|
try {
|
||||||
|
await this.withDeadline(client, generation, client.quit());
|
||||||
|
} catch {
|
||||||
|
this.destroyClient(client, generation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private create(): RedisClient {
|
||||||
|
return createClient({
|
||||||
|
url: process.env.REDIS_URL ?? "redis://127.0.0.1:6379",
|
||||||
|
socket: { connectTimeout: this.timeoutMs, reconnectStrategy: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private withDeadline<T>(
|
||||||
|
client: RedisClient,
|
||||||
|
generation: number,
|
||||||
|
operation: Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.destroyClient(client, generation);
|
||||||
|
reject(new Error("Auth Redis operation timed out"));
|
||||||
|
}, this.timeoutMs);
|
||||||
|
void operation.then(
|
||||||
|
(value) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(
|
||||||
|
error instanceof Error ? error : new Error("Auth Redis failed"),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private destroyClient(client: RedisClient, generation: number): void {
|
||||||
|
if (!this.destroyed.has(client)) {
|
||||||
|
this.destroyed.add(client);
|
||||||
|
try {
|
||||||
|
client.destroy();
|
||||||
|
} catch {
|
||||||
|
/* best-effort cleanup */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.current === client && this.generation === generation)
|
||||||
|
this.current = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,5 +8,6 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": ["node", "vitest/globals"]
|
"types": ["node", "vitest/globals"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "vitest.config.ts"]
|
"include": ["src/**/*.ts", "vitest.config.ts"],
|
||||||
|
"exclude": ["src/**/*spec.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ describe("contracts", () => {
|
|||||||
"INTERNAL_ERROR",
|
"INTERNAL_ERROR",
|
||||||
"AUTH_TOKEN_EXPIRED",
|
"AUTH_TOKEN_EXPIRED",
|
||||||
"AUTH_REFRESH_REUSED",
|
"AUTH_REFRESH_REUSED",
|
||||||
|
"AUTH_INVALID_CREDENTIALS",
|
||||||
|
"AUTH_UNAUTHORIZED",
|
||||||
|
"RATE_LIMITED",
|
||||||
|
"AUTH_ORIGIN_FORBIDDEN",
|
||||||
"BOTTLE_DAILY_LIMIT",
|
"BOTTLE_DAILY_LIMIT",
|
||||||
"BOTTLE_POOL_EMPTY",
|
"BOTTLE_POOL_EMPTY",
|
||||||
"BOTTLE_LEASE_EXPIRED",
|
"BOTTLE_LEASE_EXPIRED",
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ export enum ErrorCode {
|
|||||||
INTERNAL_ERROR = "INTERNAL_ERROR",
|
INTERNAL_ERROR = "INTERNAL_ERROR",
|
||||||
AUTH_TOKEN_EXPIRED = "AUTH_TOKEN_EXPIRED",
|
AUTH_TOKEN_EXPIRED = "AUTH_TOKEN_EXPIRED",
|
||||||
AUTH_REFRESH_REUSED = "AUTH_REFRESH_REUSED",
|
AUTH_REFRESH_REUSED = "AUTH_REFRESH_REUSED",
|
||||||
|
AUTH_INVALID_CREDENTIALS = "AUTH_INVALID_CREDENTIALS",
|
||||||
|
AUTH_UNAUTHORIZED = "AUTH_UNAUTHORIZED",
|
||||||
|
RATE_LIMITED = "RATE_LIMITED",
|
||||||
|
AUTH_ORIGIN_FORBIDDEN = "AUTH_ORIGIN_FORBIDDEN",
|
||||||
BOTTLE_DAILY_LIMIT = "BOTTLE_DAILY_LIMIT",
|
BOTTLE_DAILY_LIMIT = "BOTTLE_DAILY_LIMIT",
|
||||||
BOTTLE_POOL_EMPTY = "BOTTLE_POOL_EMPTY",
|
BOTTLE_POOL_EMPTY = "BOTTLE_POOL_EMPTY",
|
||||||
BOTTLE_LEASE_EXPIRED = "BOTTLE_LEASE_EXPIRED",
|
BOTTLE_LEASE_EXPIRED = "BOTTLE_LEASE_EXPIRED",
|
||||||
|
|||||||
Generated
+12
@@ -70,6 +70,9 @@ importers:
|
|||||||
class-validator:
|
class-validator:
|
||||||
specifier: ^0.14.2
|
specifier: ^0.14.2
|
||||||
version: 0.14.4
|
version: 0.14.4
|
||||||
|
cookie:
|
||||||
|
specifier: 1.1.1
|
||||||
|
version: 1.1.1
|
||||||
helmet:
|
helmet:
|
||||||
specifier: ^8.1.0
|
specifier: ^8.1.0
|
||||||
version: 8.3.0
|
version: 8.3.0
|
||||||
@@ -1076,6 +1079,13 @@ packages:
|
|||||||
integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==,
|
integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cookie@1.1.1:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==,
|
||||||
|
}
|
||||||
|
engines: { node: ">=18" }
|
||||||
|
|
||||||
"@types/chai@5.2.3":
|
"@types/chai@5.2.3":
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@@ -4002,6 +4012,8 @@ snapshots:
|
|||||||
|
|
||||||
cookie@0.7.2: {}
|
cookie@0.7.2: {}
|
||||||
|
|
||||||
|
cookie@1.1.1: {}
|
||||||
|
|
||||||
cookiejar@2.1.4: {}
|
cookiejar@2.1.4: {}
|
||||||
|
|
||||||
cors@2.8.6:
|
cors@2.8.6:
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
ALTER TABLE "sessions" ADD COLUMN "token_family" UUID;
|
||||||
|
ALTER TABLE "sessions" ADD COLUMN "generation" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "sessions" ADD COLUMN "previous_token_hash" VARCHAR(255);
|
||||||
|
ALTER TABLE "sessions" ADD COLUMN "rotated_at" TIMESTAMPTZ(3);
|
||||||
|
UPDATE "sessions" SET "token_family" = "id" WHERE "token_family" IS NULL;
|
||||||
|
ALTER TABLE "sessions" ALTER COLUMN "token_family" SET NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX "sessions_previous_token_hash_key" ON "sessions"("previous_token_hash");
|
||||||
|
CREATE INDEX "sessions_token_family_idx" ON "sessions"("token_family");
|
||||||
+14
-9
@@ -148,17 +148,22 @@ model AuthIdentity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
accountId String @map("account_id") @db.Uuid
|
accountId String @map("account_id") @db.Uuid
|
||||||
refreshTokenHash String @unique @map("refresh_token_hash") @db.VarChar(255)
|
refreshTokenHash String @unique @map("refresh_token_hash") @db.VarChar(255)
|
||||||
deviceId String? @map("device_id") @db.VarChar(255)
|
deviceId String? @map("device_id") @db.VarChar(255)
|
||||||
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
|
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
|
||||||
revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3)
|
revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
tokenFamily String @map("token_family") @db.Uuid
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
generation Int @default(0)
|
||||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
previousTokenHash String? @unique @map("previous_token_hash") @db.VarChar(255)
|
||||||
|
rotatedAt DateTime? @map("rotated_at") @db.Timestamptz(3)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([accountId, expiresAt])
|
@@index([accountId, expiresAt])
|
||||||
|
@@index([tokenFamily])
|
||||||
@@map("sessions")
|
@@map("sessions")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user