From 3e04197b026edde9be8bab5c75737d458995eb4b Mon Sep 17 00:00:00 2001 From: root Date: Mon, 14 Sep 2026 18:43:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=BC=94=E7=A4=BA?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E7=A0=81=E5=92=8C=E4=BB=A4=E7=89=8C=E8=BD=AE?= =?UTF-8?q?=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 13 + .prettierignore | 1 + apps/api/package.json | 3 +- apps/api/src/app.module.ts | 3 +- apps/api/src/auth/auth.config.spec.ts | 57 ++ apps/api/src/auth/auth.config.ts | 91 ++++ apps/api/src/auth/auth.controller.ts | 141 +++++ apps/api/src/auth/auth.e2e-spec.ts | 499 ++++++++++++++++++ apps/api/src/auth/auth.guard.ts | 49 ++ apps/api/src/auth/auth.module.ts | 14 + apps/api/src/auth/auth.service.ts | 313 +++++++++++ apps/api/src/auth/current-user.decorator.ts | 9 + apps/api/src/auth/dto.ts | 14 + apps/api/src/auth/token.service.ts | 82 +++ apps/api/src/database/database.module.ts | 5 + apps/api/src/main.ts | 9 + apps/api/src/redis/redis.module.ts | 4 + apps/api/src/redis/redis.service.ts | 137 +++++ apps/api/tsconfig.json | 3 +- packages/contracts/src/index.test.ts | 4 + packages/contracts/src/index.ts | 4 + pnpm-lock.yaml | 12 + .../0002_auth_sessions/migration.sql | 8 + prisma/schema.prisma | 23 +- 24 files changed, 1486 insertions(+), 12 deletions(-) create mode 100644 apps/api/src/auth/auth.config.spec.ts create mode 100644 apps/api/src/auth/auth.config.ts create mode 100644 apps/api/src/auth/auth.controller.ts create mode 100644 apps/api/src/auth/auth.e2e-spec.ts create mode 100644 apps/api/src/auth/auth.guard.ts create mode 100644 apps/api/src/auth/auth.module.ts create mode 100644 apps/api/src/auth/auth.service.ts create mode 100644 apps/api/src/auth/current-user.decorator.ts create mode 100644 apps/api/src/auth/dto.ts create mode 100644 apps/api/src/auth/token.service.ts create mode 100644 apps/api/src/database/database.module.ts create mode 100644 apps/api/src/redis/redis.module.ts create mode 100644 apps/api/src/redis/redis.service.ts create mode 100644 prisma/migrations/0002_auth_sessions/migration.sql diff --git a/.env.example b/.env.example index 4b3d45c..498d621 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,16 @@ # 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 REDIS_URL=redis://127.0.0.1:56379 +WEB_ORIGIN=http://localhost:3000 +PHONE_ENCRYPTION_KEY= +PHONE_HMAC_KEY= +VERIFICATION_CODE_HMAC_KEY= +REFRESH_TOKEN_HMAC_KEY= +JWT_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 diff --git a/.prettierignore b/.prettierignore index eb1120d..b31b17a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,3 +3,4 @@ dist coverage docs/ +pnpm-lock.yaml diff --git a/apps/api/package.json b/apps/api/package.json index 539ea40..dc396a9 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,7 +7,7 @@ "build": "tsc -p tsconfig.build.json", "start": "node dist/main.js", "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" }, "dependencies": { @@ -18,6 +18,7 @@ "@prisma/client": "6.19.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "cookie": "1.1.1", "helmet": "^8.1.0", "redis": "^5.8.2", "reflect-metadata": "^0.2.2", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 6535cdc..43f1937 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,8 +1,9 @@ import { MiddlewareConsumer, Module, type NestModule } from "@nestjs/common"; import { RequestIdMiddleware } from "./common/request-id.middleware.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 { configure(consumer: MiddlewareConsumer): void { consumer.apply(RequestIdMiddleware).forRoutes("{*path}"); diff --git a/apps/api/src/auth/auth.config.spec.ts b/apps/api/src/auth/auth.config.spec.ts new file mode 100644 index 0000000..6141c81 --- /dev/null +++ b/apps/api/src/auth/auth.config.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/auth/auth.config.ts b/apps/api/src/auth/auth.config.ts new file mode 100644 index 0000000..ec38d3e --- /dev/null +++ b/apps/api/src/auth/auth.config.ts @@ -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; +}; diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts new file mode 100644 index 0000000..a0d1458 --- /dev/null +++ b/apps/api/src/auth/auth.controller.ts @@ -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", +); diff --git a/apps/api/src/auth/auth.e2e-spec.ts b/apps/api/src/auth/auth.e2e-spec.ts new file mode 100644 index 0000000..08aaef7 --- /dev/null +++ b/apps/api/src/auth/auth.e2e-spec.ts @@ -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(); + const server = createServer((socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + await new Promise((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((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + process.env.REDIS_URL = previousUrl; + process.env.REDIS_OPERATION_TIMEOUT_MS = previousTimeout; + } + }); +}); diff --git a/apps/api/src/auth/auth.guard.ts b/apps/api/src/auth/auth.guard.ts new file mode 100644 index 0000000..98b1809 --- /dev/null +++ b/apps/api/src/auth/auth.guard.ts @@ -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 { + const req = ctx.switchToHttp().getRequest(); + 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, + ); + } +} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts new file mode 100644 index 0000000..8abd57d --- /dev/null +++ b/apps/api/src/auth/auth.module.ts @@ -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 {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts new file mode 100644 index 0000000..5c9d329 --- /dev/null +++ b/apps/api/src/auth/auth.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 => { + 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 { + 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 { + 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, + ); + } +} diff --git a/apps/api/src/auth/current-user.decorator.ts b/apps/api/src/auth/current-user.decorator.ts new file mode 100644 index 0000000..03ff95a --- /dev/null +++ b/apps/api/src/auth/current-user.decorator.ts @@ -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().user, +); diff --git a/apps/api/src/auth/dto.ts b/apps/api/src/auth/dto.ts new file mode 100644 index 0000000..0eee704 --- /dev/null +++ b/apps/api/src/auth/dto.ts @@ -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; +} diff --git a/apps/api/src/auth/token.service.ts b/apps/api/src/auth/token.service.ts new file mode 100644 index 0000000..5554617 --- /dev/null +++ b/apps/api/src/auth/token.service.ts @@ -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, + ): 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")}`; + } +} diff --git a/apps/api/src/database/database.module.ts b/apps/api/src/database/database.module.ts new file mode 100644 index 0000000..1b85f68 --- /dev/null +++ b/apps/api/src/database/database.module.ts @@ -0,0 +1,5 @@ +import { Module } from "@nestjs/common"; +import { PrismaService } from "./prisma.service.js"; + +@Module({ providers: [PrismaService], exports: [PrismaService] }) +export class DatabaseModule {} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index f71c81f..7d6b341 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -2,12 +2,20 @@ import "reflect-metadata"; import { ValidationPipe, type INestApplication } from "@nestjs/common"; import { NestFactory } from "@nestjs/core"; import helmet from "helmet"; +import type { Application } from "express"; import { AppModule } from "./app.module.js"; +import { validateAuthEnvironment } from "./auth/auth.config.js"; import { HttpResponseInterceptor } from "./common/http-response.interceptor.js"; import { DomainExceptionFilter } from "./common/domain-exception.filter.js"; export function configureApp(app: INestApplication): void { + validateAuthEnvironment(); 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.use(helmet()); app.useGlobalPipes( @@ -35,6 +43,7 @@ type AppFactory = () => Promise; export async function bootstrap( appFactory: AppFactory = () => NestFactory.create(AppModule), ): Promise { + validateAuthEnvironment(); const app = await appFactory(); configureApp(app); app.enableShutdownHooks(); diff --git a/apps/api/src/redis/redis.module.ts b/apps/api/src/redis/redis.module.ts new file mode 100644 index 0000000..6a827a6 --- /dev/null +++ b/apps/api/src/redis/redis.module.ts @@ -0,0 +1,4 @@ +import { Module } from "@nestjs/common"; +import { RedisService } from "./redis.service.js"; +@Module({ providers: [RedisService], exports: [RedisService] }) +export class RedisModule {} diff --git a/apps/api/src/redis/redis.service.ts b/apps/api/src/redis/redis.service.ts new file mode 100644 index 0000000..c582e3a --- /dev/null +++ b/apps/api/src/redis/redis.service.ts @@ -0,0 +1,137 @@ +import { + Inject, + Injectable, + Logger, + Optional, + type OnModuleDestroy, + type OnModuleInit, +} from "@nestjs/common"; +import { createClient } from "redis"; + +type RedisClient = ReturnType; +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>(); + private readonly destroyed = new WeakSet(); + private readonly loggedErrors = new WeakSet(); + 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 { + 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 { + /* Auth Redis connects lazily so unrelated modules remain isolated. */ + } + + async onModuleDestroy(): Promise { + 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( + client: RedisClient, + generation: number, + operation: Promise, + ): Promise { + return new Promise((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; + } +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 98a9e85..f10f82a 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -8,5 +8,6 @@ "noEmit": true, "types": ["node", "vitest/globals"] }, - "include": ["src/**/*.ts", "vitest.config.ts"] + "include": ["src/**/*.ts", "vitest.config.ts"], + "exclude": ["src/**/*spec.ts"] } diff --git a/packages/contracts/src/index.test.ts b/packages/contracts/src/index.test.ts index 36cbddc..2d8a1c9 100644 --- a/packages/contracts/src/index.test.ts +++ b/packages/contracts/src/index.test.ts @@ -28,6 +28,10 @@ describe("contracts", () => { "INTERNAL_ERROR", "AUTH_TOKEN_EXPIRED", "AUTH_REFRESH_REUSED", + "AUTH_INVALID_CREDENTIALS", + "AUTH_UNAUTHORIZED", + "RATE_LIMITED", + "AUTH_ORIGIN_FORBIDDEN", "BOTTLE_DAILY_LIMIT", "BOTTLE_POOL_EMPTY", "BOTTLE_LEASE_EXPIRED", diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 3b30903..31ab4da 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -6,6 +6,10 @@ export enum ErrorCode { INTERNAL_ERROR = "INTERNAL_ERROR", AUTH_TOKEN_EXPIRED = "AUTH_TOKEN_EXPIRED", 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_POOL_EMPTY = "BOTTLE_POOL_EMPTY", BOTTLE_LEASE_EXPIRED = "BOTTLE_LEASE_EXPIRED", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcb9212..1413d6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,6 +70,9 @@ importers: class-validator: specifier: ^0.14.2 version: 0.14.4 + cookie: + specifier: 1.1.1 + version: 1.1.1 helmet: specifier: ^8.1.0 version: 8.3.0 @@ -1076,6 +1079,13 @@ packages: integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==, } + cookie@1.1.1: + resolution: + { + integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==, + } + engines: { node: ">=18" } + "@types/chai@5.2.3": resolution: { @@ -4002,6 +4012,8 @@ snapshots: cookie@0.7.2: {} + cookie@1.1.1: {} + cookiejar@2.1.4: {} cors@2.8.6: diff --git a/prisma/migrations/0002_auth_sessions/migration.sql b/prisma/migrations/0002_auth_sessions/migration.sql new file mode 100644 index 0000000..93b922d --- /dev/null +++ b/prisma/migrations/0002_auth_sessions/migration.sql @@ -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"); \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8f0707b..324e009 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -148,17 +148,22 @@ model AuthIdentity { } model Session { - id String @id @default(uuid()) @db.Uuid - accountId String @map("account_id") @db.Uuid - refreshTokenHash String @unique @map("refresh_token_hash") @db.VarChar(255) - deviceId String? @map("device_id") @db.VarChar(255) - expiresAt DateTime @map("expires_at") @db.Timestamptz(3) - revokedAt DateTime? @map("revoked_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) + id String @id @default(uuid()) @db.Uuid + accountId String @map("account_id") @db.Uuid + refreshTokenHash String @unique @map("refresh_token_hash") @db.VarChar(255) + deviceId String? @map("device_id") @db.VarChar(255) + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3) + tokenFamily String @map("token_family") @db.Uuid + generation Int @default(0) + 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([tokenFamily]) @@map("sessions") }