fix: 加固认证撤销与运行配置

This commit is contained in:
root
2026-09-14 20:05:40 +08:00
parent c708205084
commit 6b453af364
13 changed files with 397 additions and 26 deletions
+2
View File
@@ -1,4 +1,6 @@
# Copy this file to .env and replace test-only values for non-test environments.
NODE_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
WEB_ORIGIN=http://localhost:3000
+36
View File
@@ -1,9 +1,11 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
codeHmac,
demoSmsCodeEnabled,
phoneHmac,
resetAuthEnvironmentForTests,
validateAuthEnvironment,
webOrigin,
} from "./auth.config.js";
const valid = {
@@ -12,6 +14,7 @@ const valid = {
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",
WEB_ORIGIN: "http://localhost:3000",
};
describe("auth environment", () => {
@@ -54,4 +57,37 @@ describe("auth environment", () => {
expect(phoneHmac("+8613800138000")).toBe(phoneDigest);
expect(codeHmac(phoneDigest, "123456")).not.toBe(first);
});
it("fails closed when WEB_ORIGIN is missing or is not one absolute HTTP origin", () => {
for (const value of [
undefined,
"localhost:3000",
"https://a.test/path",
"https://u:p@a.test",
"ftp://a.test",
]) {
resetAuthEnvironmentForTests();
if (value === undefined) delete process.env.WEB_ORIGIN;
else process.env.WEB_ORIGIN = value;
expect(() => validateAuthEnvironment()).toThrow(/WEB_ORIGIN/);
}
});
it("caches and exposes the validated web origin", () => {
validateAuthEnvironment();
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);
process.env.NODE_ENV = "development";
expect(demoSmsCodeEnabled()).toBe(true);
process.env.NODE_ENV = "production";
expect(demoSmsCodeEnabled()).toBe(false);
});
});
+42
View File
@@ -26,6 +26,31 @@ const REQUIRED_KEYS = [
] as const;
type AuthSecrets = Record<(typeof REQUIRED_KEYS)[number], Buffer>;
let secrets: AuthSecrets | undefined;
let configuredWebOrigin: string | undefined;
function parseWebOrigin(): string {
const raw = process.env.WEB_ORIGIN;
if (!raw) throw new Error("WEB_ORIGIN is required");
let url: URL;
try {
url = new URL(raw);
} catch {
throw new Error("WEB_ORIGIN must be an absolute HTTP(S) origin");
}
if (
!["http:", "https:"].includes(url.protocol) ||
url.username ||
url.password ||
url.pathname !== "/" ||
url.search ||
url.hash ||
url.origin !== raw
)
throw new Error(
"WEB_ORIGIN must be one absolute HTTP(S) origin without credentials or path",
);
return url.origin;
}
export function validateAuthEnvironment(): void {
const loaded = Object.fromEntries(
@@ -38,18 +63,35 @@ export function validateAuthEnvironment(): void {
);
if (new Set(fingerprints).size !== fingerprints.length)
throw new Error("Auth secrets must be independent");
const origin = parseWebOrigin();
if (process.env.DEMO_SMS_CODE_ENABLED === "true" && !demoEnvironmentAllowed())
throw new Error(
"DEMO_SMS_CODE_ENABLED is forbidden outside test or local development",
);
secrets = loaded;
configuredWebOrigin = origin;
}
export function resetAuthEnvironmentForTests(): void {
if (process.env.NODE_ENV !== "test")
throw new Error("Auth environment reset is test-only");
secrets = undefined;
configuredWebOrigin = undefined;
}
function authSecrets(): AuthSecrets {
if (!secrets) validateAuthEnvironment();
return secrets!;
}
export const jwtSecret = (): Buffer => authSecrets().JWT_SECRET;
export const webOrigin = (): string => {
if (!configuredWebOrigin) validateAuthEnvironment();
return configuredWebOrigin!;
};
const demoEnvironmentAllowed = (): boolean =>
process.env.NODE_ENV === "test" ||
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 =>
createHmac("sha256", authSecrets().PHONE_HMAC_KEY)
.update(phone)
+4 -3
View File
@@ -20,6 +20,7 @@ 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";
import { envInt, webOrigin } from "./auth.config.js";
const COOKIE = "refresh_token";
@Controller()
export class AuthController {
@@ -49,7 +50,7 @@ export class AuthController {
@Res({ passthrough: true }) res: Response,
) {
this.origin(req);
const pair = await this.auth.refresh(this.readCookie(req));
const pair = await this.auth.refresh(this.readCookie(req), this.ip(req));
this.cookie(res, pair.refreshToken);
return this.publicPair(pair);
}
@@ -91,12 +92,12 @@ export class AuthController {
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
path: "/api/v1/auth",
maxAge: Number(process.env.REFRESH_TOKEN_TTL_SECONDS ?? 2592000) * 1000,
maxAge: envInt("REFRESH_TOKEN_TTL_SECONDS", 2592000) * 1000,
});
}
private origin(req: Request) {
const origin = req.headers.origin;
if (origin !== process.env.WEB_ORIGIN)
if (origin !== webOrigin())
throw new DomainException(
ErrorCode.AUTH_ORIGIN_FORBIDDEN,
"Forbidden origin",
+93
View File
@@ -33,6 +33,7 @@ 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:";
process.env.DEMO_SMS_CODE_ENABLED = "true";
const phone = "138 0013 8000",
deviceId = "device-test-001",
origin = "http://localhost:3000";
@@ -67,6 +68,8 @@ describe("auth real PostgreSQL/Redis", () => {
delete process.env.SMS_CODE_TTL_SECONDS;
delete process.env.ACCESS_TOKEN_TTL_SECONDS;
delete process.env.TRUST_PROXY;
delete process.env.REFRESH_RATE_LIMIT;
process.env.DEMO_SMS_CODE_ENABLED = "true";
});
afterAll(async () => {
await app?.close();
@@ -172,6 +175,18 @@ describe("auth real PostgreSQL/Redis", () => {
process.env.NODE_ENV = "test";
}
});
it("omits debugCode by default and never leaks it in staging", async () => {
delete process.env.DEMO_SMS_CODE_ENABLED;
expect((await send()).body.data).not.toHaveProperty("debugCode");
process.env.DEMO_SMS_CODE_ENABLED = "true";
process.env.NODE_ENV = "staging";
try {
expect((await send()).body.data).not.toHaveProperty("debugCode");
} finally {
process.env.NODE_ENV = "test";
process.env.DEMO_SMS_CODE_ENABLED = "true";
}
});
it("rotates refresh, detects old-token replay, and revokes the session", async () => {
const logged = await login();
const oldCookie = cookieValue(
@@ -285,6 +300,76 @@ describe("auth real PostgreSQL/Redis", () => {
.expect(401);
expect((await prisma.session.findFirstOrThrow()).revokedAt).not.toBeNull();
});
it("revokes the family when account tokenVersion changes before refresh", async () => {
const logged = await login();
const cookie = cookieValue(
logged.headers["set-cookie"] as unknown as string[],
);
const session = await prisma.session.findFirstOrThrow();
expect(session.tokenVersion).toBe(0);
await prisma.account.update({
where: { id: session.accountId },
data: { tokenVersion: { increment: 1 } },
});
await request(app.getHttpServer())
.post("/api/v1/auth/token/refresh")
.set("Origin", origin)
.set("Cookie", cookie)
.expect(401);
expect(
(await prisma.session.findUniqueOrThrow({ where: { id: session.id } }))
.revokedAt,
).not.toBeNull();
});
it("deletes expired refresh history but retains unexpired history", async () => {
const logged = await login();
const session = await prisma.session.findFirstOrThrow();
await prisma.refreshToken.create({
data: {
tokenHash: "a".repeat(64),
sessionId: session.id,
generation: 99,
expiresAt: new Date(Date.now() - 1000),
},
});
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);
expect(
await prisma.refreshToken.findUnique({
where: { tokenHash: "a".repeat(64) },
}),
).toBeNull();
expect(
await prisma.refreshToken.count({ where: { sessionId: session.id } }),
).toBeGreaterThan(0);
});
it("rate limits refresh by hashed token/session and IP", async () => {
process.env.REFRESH_RATE_LIMIT = "1";
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", origin)
.set("Cookie", cookie)
.expect(200);
const limited = await request(app.getHttpServer())
.post("/api/v1/auth/token/refresh")
.set("Origin", origin)
.set("Cookie", cookie)
.expect(429);
expect(limited.body.code).toBe("RATE_LIMITED");
});
it("rejects tampered access and revoked/token-version sessions", async () => {
const logged = await login();
const token = logged.body.data.accessToken as string;
@@ -307,6 +392,14 @@ describe("auth real PostgreSQL/Redis", () => {
const cookie = cookieValue(
logged.headers["set-cookie"] as unknown as string[],
);
await request(app.getHttpServer())
.post("/api/v1/auth/token/refresh")
.set("Cookie", cookie)
.expect(403);
await request(app.getHttpServer())
.post("/api/v1/auth/logout")
.set("Cookie", cookie)
.expect(403);
await request(app.getHttpServer())
.post("/api/v1/auth/token/refresh")
.set("Origin", "https://evil.example")
+1
View File
@@ -33,6 +33,7 @@ export class AuthGuard implements CanActivate {
session.revokedAt ||
session.expiresAt <= new Date() ||
session.account.status !== "ACTIVE" ||
session.tokenVersion !== claims.token_version ||
session.account.tokenVersion !== claims.token_version
)
throw this.denied();
+56 -12
View File
@@ -7,6 +7,7 @@ import { PrismaService } from "../database/prisma.service.js";
import { RedisService } from "../redis/redis.service.js";
import {
codeHmac,
demoSmsCodeEnabled,
encryptPhone,
envInt,
phoneHmac,
@@ -110,7 +111,7 @@ export class AuthService {
{ EX: envInt("SMS_CODE_TTL_SECONDS", 300) },
);
const result: { sent: true; debugCode?: string } = { sent: true };
if (process.env.NODE_ENV !== "production") result.debugCode = code;
if (demoSmsCodeEnabled()) result.debugCode = code;
return result;
}
async login(
@@ -183,6 +184,7 @@ export class AuthService {
data: {
id,
accountId: account.id,
tokenVersion: account.tokenVersion,
deviceId,
refreshTokenHash: refreshHmac(refresh),
tokenFamily: id,
@@ -213,7 +215,11 @@ export class AuthService {
expiresIn,
};
}
async refresh(token: string): Promise<TokenPair> {
async refresh(
token: string,
ip: string,
retryAttempt = 0,
): Promise<TokenPair> {
if (!this.validRefreshShape(token)) throw this.invalid();
const hash = refreshHmac(token);
const observed = await this.prisma.refreshToken.findUnique({
@@ -221,6 +227,29 @@ export class AuthService {
include: { session: true },
});
if (!observed) throw this.invalid();
if (retryAttempt === 0) {
await this.limitAll([
{
kind: "refresh-token",
parts: [hash],
max: envInt("REFRESH_RATE_LIMIT", 60),
ttl: 60,
},
{
kind: "refresh-session",
parts: [observed.sessionId],
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);
}
if (observed.status === "USED")
return this.revokeReusedFamily(observed.session.tokenFamily);
@@ -235,6 +264,8 @@ export class AuthService {
if (stored.status === "USED")
return { reusedFamily: stored.session.tokenFamily };
const session = stored.session;
if (session.tokenVersion !== session.account.tokenVersion)
return { reusedFamily: session.tokenFamily };
if (
session.revokedAt ||
session.expiresAt <= new Date() ||
@@ -285,23 +316,36 @@ export class AuthService {
error instanceof Prisma.PrismaClientKnownRequestError &&
(error.code === "P2034" || error.code === "P2002")
) {
// A serialization/uniqueness loser can observe the winner only after its
// transaction commits. Poll briefly, then revoke outside the failed tx.
for (let attempt = 0; attempt < 5; attempt += 1) {
const raced = await this.prisma.refreshToken.findUnique({
where: { tokenHash: hash },
include: { session: true },
});
if (raced?.status === "USED")
return this.revokeReusedFamily(raced.session.tokenFamily);
if (retryAttempt < 3) {
await new Promise((resolve) =>
setTimeout(resolve, 10 * (attempt + 1)),
setTimeout(resolve, 10 * 2 ** retryAttempt),
);
return this.refresh(token, ip, retryAttempt + 1);
}
const raced = await this.prisma.refreshToken.findUnique({
where: { tokenHash: hash },
include: { session: true },
});
if (raced?.status === "USED")
return this.revokeReusedFamily(raced.session.tokenFamily);
throw new DomainException(
ErrorCode.SERVICE_UNAVAILABLE,
"Authentication temporarily unavailable",
HttpStatus.SERVICE_UNAVAILABLE,
);
}
throw error;
}
}
async cleanupExpiredRefreshTokens(sessionId?: string): Promise<number> {
const deleted = await this.prisma.refreshToken.deleteMany({
where: {
expiresAt: { lt: new Date() },
...(sessionId ? { sessionId } : {}),
},
});
return deleted.count;
}
private async revokeReusedFamily(family: string): Promise<never> {
await this.prisma.session.updateMany({
where: { tokenFamily: family },
+94
View File
@@ -0,0 +1,94 @@
import { createHmac, randomUUID } from "node:crypto";
import { beforeAll, describe, expect, it } from "vitest";
import { TokenService } from "./token.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 = "token-phone-hmac-key-with-at-least-32-bytes";
process.env.VERIFICATION_CODE_HMAC_KEY =
"token-code-hmac-key-with-at-least-32-bytes!";
process.env.REFRESH_TOKEN_HMAC_KEY =
"token-refresh-hmac-key-with-at-least-32-bytes";
process.env.JWT_SECRET = "token-jwt-secret-with-at-least-thirty-two-bytes";
const secret = process.env.JWT_SECRET;
const service = new TokenService();
const base = () => ({
sub: randomUUID(),
session_id: randomUUID(),
device_id: "device-test-001",
scopes: ["user"],
token_version: 0,
iss: "drift-api",
aud: "drift-web",
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 60,
});
const sign = (header: unknown, claims: unknown) => {
const h = Buffer.from(JSON.stringify(header)).toString("base64url");
const p = Buffer.from(JSON.stringify(claims)).toString("base64url");
const data = `${h}.${p}`;
return `${data}.${createHmac("sha256", secret).update(data).digest("base64url")}`;
};
describe("strict access JWT verification", () => {
beforeAll(() =>
expect(
service.verifyAccess(sign({ alg: "HS256", typ: "JWT" }, base())),
).toMatchObject({ scopes: ["user"] }),
);
it.each([
["wrong typ", { typ: "JOSE", alg: "HS256" }, base()],
["array payload", { typ: "JWT", alg: "HS256" }, []],
["bad sub", { typ: "JWT", alg: "HS256" }, { ...base(), sub: "bad" }],
[
"bad session",
{ typ: "JWT", alg: "HS256" },
{ ...base(), session_id: "bad" },
],
[
"bad device",
{ typ: "JWT", alg: "HS256" },
{ ...base(), device_id: "bad device!" },
],
[
"unknown scope",
{ typ: "JWT", alg: "HS256" },
{ ...base(), scopes: ["admin"] },
],
[
"negative version",
{ typ: "JWT", alg: "HS256" },
{ ...base(), token_version: -1 },
],
[
"fractional exp",
{ typ: "JWT", alg: "HS256" },
{ ...base(), exp: Date.now() / 1000 + 60.5 },
],
[
"future iat",
{ typ: "JWT", alg: "HS256" },
{ ...base(), iat: Math.floor(Date.now() / 1000) + 301 },
],
])("rejects signed malformed claims: %s", (_name, header, claims) => {
expect(() => service.verifyAccess(sign(header, claims))).toThrowError(
/Unauthorized/,
);
});
it.each(["=", "+", "/", "!"])(
"rejects non-base64url or padded segments containing %s",
(bad) => {
const token = sign({ typ: "JWT", alg: "HS256" }, base());
const parts = token.split(".");
parts[0] += bad;
expect(() => service.verifyAccess(parts.join("."))).toThrowError(
/Unauthorized/,
);
},
);
});
+44 -9
View File
@@ -15,6 +15,21 @@ export interface AccessClaims {
iat: number;
}
const b64 = (v: Buffer | string) => Buffer.from(v).toString("base64url");
const BASE64URL = /^[A-Za-z0-9_-]+$/;
const UUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const DEVICE_ID = /^[A-Za-z0-9_-]{8,128}$/;
const decodeCanonical = (value: string): Buffer => {
if (!BASE64URL.test(value)) throw new Error();
const decoded = Buffer.from(value, "base64url");
if (decoded.toString("base64url") !== value) throw new Error();
return decoded;
};
const plainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) === Object.prototype;
@Injectable()
export class TokenService {
issueAccess(
@@ -39,28 +54,48 @@ export class TokenService {
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 header: unknown = JSON.parse(decodeCanonical(h).toString());
if (
!plainObject(header) ||
header.alg !== "HS256" ||
header.typ !== "JWT" ||
Object.keys(header).length !== 2
)
throw new Error();
const expected = createHmac("sha256", jwtSecret())
.update(`${h}.${p}`)
.digest();
const actual = Buffer.from(s, "base64url");
const actual = decodeCanonical(s);
if (
expected.length !== actual.length ||
!timingSafeEqual(expected, actual)
)
throw new Error();
const claims = JSON.parse(
Buffer.from(p, "base64url").toString(),
) as AccessClaims;
const decodedClaims: unknown = JSON.parse(decodeCanonical(p).toString());
if (!plainObject(decodedClaims)) throw new Error();
const claims = decodedClaims as unknown as AccessClaims;
const now = Math.floor(Date.now() / 1000);
if (
Object.keys(claims).length !== 9 ||
!UUID.test(claims.sub) ||
!UUID.test(claims.session_id) ||
!DEVICE_ID.test(claims.device_id) ||
!Array.isArray(claims.scopes) ||
claims.scopes.length !== 1 ||
claims.scopes[0] !== "user" ||
!Number.isInteger(claims.token_version) ||
claims.token_version < 0 ||
!Number.isFinite(claims.iat) ||
!Number.isInteger(claims.iat) ||
!Number.isFinite(claims.exp) ||
!Number.isInteger(claims.exp) ||
claims.iat > now + 300 ||
claims.exp <= claims.iat ||
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))
if (claims.exp <= now)
throw new DomainException(
ErrorCode.AUTH_TOKEN_EXPIRED,
"Access token expired",
+2 -2
View File
@@ -4,13 +4,13 @@ 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 { validateAuthEnvironment, webOrigin } 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 allowedOrigin = webOrigin();
const adapter = app.getHttpAdapter?.() as
{ getInstance(): Application } | undefined;
adapter
@@ -0,0 +1,9 @@
ALTER TABLE "sessions" ADD COLUMN "token_version" INTEGER NOT NULL DEFAULT 0;
UPDATE "sessions" AS s
SET "token_version" = a."token_version"
FROM "accounts" AS a
WHERE s."account_id" = a."id";
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_token_version_nonnegative"
CHECK ("token_version" >= 0);
+1
View File
@@ -155,6 +155,7 @@ model AuthIdentity {
model Session {
id String @id @default(uuid()) @db.Uuid
accountId String @map("account_id") @db.Uuid
tokenVersion Int @default(0) @map("token_version")
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)
+13
View File
@@ -93,6 +93,19 @@ describe("database authority constraints", () => {
).toBe(true);
});
it("backfills and requires the session token version snapshot", async () => {
const columns = await prisma.$queryRaw<
Array<{ is_nullable: string; column_default: string | null }>
>`
SELECT is_nullable, column_default FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'sessions' AND column_name = 'token_version'`;
expect(columns).toHaveLength(1);
expect(columns[0]).toMatchObject({
is_nullable: "NO",
column_default: "0",
});
});
it("expresses independent moderation and pool lifecycle state", async () => {
const author = await createAccount("states");
const bottle = await prisma.bottle.create({