fix: 收紧演示模式与刷新预限流

This commit is contained in:
root
2026-09-15 00:00:06 +08:00
parent 6b453af364
commit b480d2c91f
5 changed files with 112 additions and 18 deletions
+4
View File
@@ -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
+40 -9
View File
@@ -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);
});
});
+1 -2
View File
@@ -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 =>
+58 -1
View File
@@ -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;
+9 -6
View File
@@ -221,6 +221,15 @@ export class AuthService {
retryAttempt = 0,
): Promise<TokenPair> {
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);
}