fix: 收紧演示模式与刷新预限流
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user