From b480d2c91f944d99f9583c981a5d4ab30db1852e Mon Sep 17 00:00:00 2001 From: root Date: Tue, 15 Sep 2026 00:00:06 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7=E6=BC=94=E7=A4=BA?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E4=B8=8E=E5=88=B7=E6=96=B0=E9=A2=84=E9=99=90?= =?UTF-8?q?=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 ++ apps/api/src/auth/auth.config.spec.ts | 49 ++++++++++++++++++---- apps/api/src/auth/auth.config.ts | 3 +- apps/api/src/auth/auth.e2e-spec.ts | 59 ++++++++++++++++++++++++++- apps/api/src/auth/auth.service.ts | 15 ++++--- 5 files changed, 112 insertions(+), 18 deletions(-) diff --git a/.env.example b/.env.example index b973b5a..9df3a90 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ # Copy this file to .env and replace test-only values for non-test environments. NODE_ENV=production +APP_ENV=production DEMO_SMS_CODE_ENABLED=false 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 @@ -13,6 +14,9 @@ JWT_ISSUER=drift-api JWT_AUDIENCE=drift-web ACCESS_TOKEN_TTL_SECONDS=900 REFRESH_TOKEN_TTL_SECONDS=2592000 +REFRESH_ATTEMPT_IP_LIMIT=30 +REFRESH_ATTEMPT_IP_WINDOW_SECONDS=60 +REFRESH_RATE_LIMIT=60 SMS_CODE_TTL_SECONDS=300 REDIS_KEY_PREFIX=drift:auth: TRUST_PROXY=false diff --git a/apps/api/src/auth/auth.config.spec.ts b/apps/api/src/auth/auth.config.spec.ts index 2068fde..84663f3 100644 --- a/apps/api/src/auth/auth.config.spec.ts +++ b/apps/api/src/auth/auth.config.spec.ts @@ -78,16 +78,47 @@ describe("auth environment", () => { expect(webOrigin()).toBe("http://localhost:3000"); }); - it("returns demo codes only in test or explicitly enabled local development", () => { - delete process.env.DEMO_SMS_CODE_ENABLED; - expect(demoSmsCodeEnabled()).toBe(false); - process.env.DEMO_SMS_CODE_ENABLED = "true"; - expect(demoSmsCodeEnabled()).toBe(true); - process.env.NODE_ENV = "staging"; - expect(demoSmsCodeEnabled()).toBe(false); + it.each([ + { nodeEnv: "test", appEnv: undefined, enabled: true }, + { nodeEnv: "test", appEnv: "remote", enabled: true }, + { nodeEnv: "development", appEnv: "local", enabled: true }, + ])( + "allows explicitly enabled demo codes for NODE_ENV=$nodeEnv APP_ENV=$appEnv", + ({ nodeEnv, appEnv, enabled }) => { + process.env.NODE_ENV = nodeEnv; + if (appEnv === undefined) delete process.env.APP_ENV; + else process.env.APP_ENV = appEnv; + process.env.DEMO_SMS_CODE_ENABLED = String(enabled); + expect(() => validateAuthEnvironment()).not.toThrow(); + expect(demoSmsCodeEnabled()).toBe(true); + }, + ); + + it.each([ + { nodeEnv: "production", appEnv: "local" }, + { nodeEnv: "staging", appEnv: "local" }, + { nodeEnv: undefined, appEnv: "local" }, + { nodeEnv: "development", appEnv: undefined }, + { nodeEnv: "development", appEnv: "staging" }, + ])( + "fails closed for demo codes with NODE_ENV=$nodeEnv APP_ENV=$appEnv", + ({ nodeEnv, appEnv }) => { + if (nodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = nodeEnv; + if (appEnv === undefined) delete process.env.APP_ENV; + else process.env.APP_ENV = appEnv; + process.env.DEMO_SMS_CODE_ENABLED = "true"; + expect(demoSmsCodeEnabled()).toBe(false); + expect(() => validateAuthEnvironment()).toThrow( + "DEMO_SMS_CODE_ENABLED is forbidden outside test or local development", + ); + }, + ); + + it("keeps demo codes disabled without the explicit enable flag", () => { process.env.NODE_ENV = "development"; - expect(demoSmsCodeEnabled()).toBe(true); - process.env.NODE_ENV = "production"; + process.env.APP_ENV = "local"; + delete process.env.DEMO_SMS_CODE_ENABLED; expect(demoSmsCodeEnabled()).toBe(false); }); }); diff --git a/apps/api/src/auth/auth.config.ts b/apps/api/src/auth/auth.config.ts index 437fc8e..a44bd3b 100644 --- a/apps/api/src/auth/auth.config.ts +++ b/apps/api/src/auth/auth.config.ts @@ -88,8 +88,7 @@ export const webOrigin = (): string => { }; const demoEnvironmentAllowed = (): boolean => process.env.NODE_ENV === "test" || - process.env.NODE_ENV === "development" || - process.env.APP_ENV === "local"; + (process.env.NODE_ENV === "development" && process.env.APP_ENV === "local"); export const demoSmsCodeEnabled = (): boolean => process.env.DEMO_SMS_CODE_ENABLED === "true" && demoEnvironmentAllowed(); export const phoneHmac = (phone: string): string => diff --git a/apps/api/src/auth/auth.e2e-spec.ts b/apps/api/src/auth/auth.e2e-spec.ts index 73b5784..e50bdb0 100644 --- a/apps/api/src/auth/auth.e2e-spec.ts +++ b/apps/api/src/auth/auth.e2e-spec.ts @@ -3,7 +3,7 @@ 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 { randomBytes, randomUUID } from "node:crypto"; import { createServer, type Socket } from "node:net"; import { createClient } from "redis"; import request from "supertest"; @@ -19,6 +19,7 @@ import { import { assertSafeTestDatabaseUrl } from "../../../../prisma/database-safety.js"; import { AppModule } from "../app.module.js"; import { configureApp } from "../main.js"; +import { PrismaService } from "../database/prisma.service.js"; import { RedisService } from "../redis/redis.service.js"; import { TokenService } from "./token.service.js"; @@ -69,6 +70,8 @@ describe("auth real PostgreSQL/Redis", () => { delete process.env.ACCESS_TOKEN_TTL_SECONDS; delete process.env.TRUST_PROXY; delete process.env.REFRESH_RATE_LIMIT; + delete process.env.REFRESH_ATTEMPT_IP_LIMIT; + delete process.env.REFRESH_ATTEMPT_IP_WINDOW_SECONDS; process.env.DEMO_SMS_CODE_ENABLED = "true"; }); afterAll(async () => { @@ -370,6 +373,60 @@ describe("auth real PostgreSQL/Redis", () => { expect(limited.body.code).toBe("RATE_LIMITED"); }); + it("pre-limits random valid-shaped refresh attempts by hashed IP before querying the database", async () => { + process.env.REFRESH_ATTEMPT_IP_LIMIT = "2"; + process.env.REFRESH_ATTEMPT_IP_WINDOW_SECONDS = "60"; + const appPrisma = app.get(PrismaService); + const lookup = vi.spyOn(appPrisma.refreshToken, "findUnique"); + const tokens = Array.from({ length: 3 }, () => + randomBytes(32).toString("base64url"), + ); + try { + for (const token of tokens.slice(0, 2)) + await request(app.getHttpServer()) + .post("/api/v1/auth/token/refresh") + .set("Origin", origin) + .set("Cookie", `refresh_token=${token}`) + .expect(401); + expect(lookup).toHaveBeenCalledTimes(2); + + const limited = await request(app.getHttpServer()) + .post("/api/v1/auth/token/refresh") + .set("Origin", origin) + .set("Cookie", `refresh_token=${tokens[2]}`) + .expect(429); + expect(lookup).toHaveBeenCalledTimes(2); + expect(limited.body.code).toBe("RATE_LIMITED"); + expect(JSON.stringify(limited.body)).not.toMatch( + /refresh-attempt-ip|dimension|ip/i, + ); + + const keys = await redis.keys( + "drift:auth:e2e:limit:refresh-attempt-ip:*", + ); + expect(keys).toHaveLength(1); + expect(await redis.ttl(keys[0]!)).toBeGreaterThan(0); + for (const token of tokens) expect(keys[0]).not.toContain(token); + } finally { + lookup.mockRestore(); + } + }); + + it("checks refresh Origin before consuming the pre-auth IP limit", async () => { + process.env.REFRESH_ATTEMPT_IP_LIMIT = "1"; + const token = randomBytes(32).toString("base64url"); + await request(app.getHttpServer()) + .post("/api/v1/auth/token/refresh") + .set("Origin", "https://evil.example") + .set("Cookie", `refresh_token=${token}`) + .expect(403); + await request(app.getHttpServer()) + .post("/api/v1/auth/token/refresh") + .set("Origin", origin) + .set("Cookie", `refresh_token=${token}`) + .expect(401); + }); + it("rejects tampered access and revoked/token-version sessions", async () => { const logged = await login(); const token = logged.body.data.accessToken as string; diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 3f57197..51f4603 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -221,6 +221,15 @@ export class AuthService { retryAttempt = 0, ): Promise { if (!this.validRefreshShape(token)) throw this.invalid(); + if (retryAttempt === 0) + await this.limitAll([ + { + kind: "refresh-attempt-ip", + parts: [phoneHmac(ip)], + max: envInt("REFRESH_ATTEMPT_IP_LIMIT", 30), + ttl: envInt("REFRESH_ATTEMPT_IP_WINDOW_SECONDS", 60), + }, + ]); const hash = refreshHmac(token); const observed = await this.prisma.refreshToken.findUnique({ where: { tokenHash: hash }, @@ -241,12 +250,6 @@ export class AuthService { max: envInt("REFRESH_RATE_LIMIT", 60), ttl: 60, }, - { - kind: "refresh-ip", - parts: [phoneHmac(ip)], - max: envInt("REFRESH_RATE_LIMIT", 60), - ttl: 60, - }, ]); await this.cleanupExpiredRefreshTokens(observed.sessionId); }