fix: 加固认证撤销与运行配置
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
# Copy this file to .env and replace test-only values for non-test environments.
|
# 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
|
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
|
REDIS_URL=redis://127.0.0.1:56379
|
||||||
WEB_ORIGIN=http://localhost:3000
|
WEB_ORIGIN=http://localhost:3000
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
codeHmac,
|
codeHmac,
|
||||||
|
demoSmsCodeEnabled,
|
||||||
phoneHmac,
|
phoneHmac,
|
||||||
resetAuthEnvironmentForTests,
|
resetAuthEnvironmentForTests,
|
||||||
validateAuthEnvironment,
|
validateAuthEnvironment,
|
||||||
|
webOrigin,
|
||||||
} from "./auth.config.js";
|
} from "./auth.config.js";
|
||||||
|
|
||||||
const valid = {
|
const valid = {
|
||||||
@@ -12,6 +14,7 @@ const valid = {
|
|||||||
VERIFICATION_CODE_HMAC_KEY: "code-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",
|
REFRESH_TOKEN_HMAC_KEY: "refresh-key-that-is-at-least-thirty-two-bytes",
|
||||||
JWT_SECRET: "jwt-secret-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", () => {
|
describe("auth environment", () => {
|
||||||
@@ -54,4 +57,37 @@ describe("auth environment", () => {
|
|||||||
expect(phoneHmac("+8613800138000")).toBe(phoneDigest);
|
expect(phoneHmac("+8613800138000")).toBe(phoneDigest);
|
||||||
expect(codeHmac(phoneDigest, "123456")).not.toBe(first);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,31 @@ const REQUIRED_KEYS = [
|
|||||||
] as const;
|
] as const;
|
||||||
type AuthSecrets = Record<(typeof REQUIRED_KEYS)[number], Buffer>;
|
type AuthSecrets = Record<(typeof REQUIRED_KEYS)[number], Buffer>;
|
||||||
let secrets: AuthSecrets | undefined;
|
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 {
|
export function validateAuthEnvironment(): void {
|
||||||
const loaded = Object.fromEntries(
|
const loaded = Object.fromEntries(
|
||||||
@@ -38,18 +63,35 @@ export function validateAuthEnvironment(): void {
|
|||||||
);
|
);
|
||||||
if (new Set(fingerprints).size !== fingerprints.length)
|
if (new Set(fingerprints).size !== fingerprints.length)
|
||||||
throw new Error("Auth secrets must be independent");
|
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;
|
secrets = loaded;
|
||||||
|
configuredWebOrigin = origin;
|
||||||
}
|
}
|
||||||
export function resetAuthEnvironmentForTests(): void {
|
export function resetAuthEnvironmentForTests(): void {
|
||||||
if (process.env.NODE_ENV !== "test")
|
if (process.env.NODE_ENV !== "test")
|
||||||
throw new Error("Auth environment reset is test-only");
|
throw new Error("Auth environment reset is test-only");
|
||||||
secrets = undefined;
|
secrets = undefined;
|
||||||
|
configuredWebOrigin = undefined;
|
||||||
}
|
}
|
||||||
function authSecrets(): AuthSecrets {
|
function authSecrets(): AuthSecrets {
|
||||||
if (!secrets) validateAuthEnvironment();
|
if (!secrets) validateAuthEnvironment();
|
||||||
return secrets!;
|
return secrets!;
|
||||||
}
|
}
|
||||||
export const jwtSecret = (): Buffer => authSecrets().JWT_SECRET;
|
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 =>
|
export const phoneHmac = (phone: string): string =>
|
||||||
createHmac("sha256", authSecrets().PHONE_HMAC_KEY)
|
createHmac("sha256", authSecrets().PHONE_HMAC_KEY)
|
||||||
.update(phone)
|
.update(phone)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { AuthService, type TokenPair } from "./auth.service.js";
|
|||||||
import { CurrentUser } from "./current-user.decorator.js";
|
import { CurrentUser } from "./current-user.decorator.js";
|
||||||
import type { AccessClaims } from "./token.service.js";
|
import type { AccessClaims } from "./token.service.js";
|
||||||
import { LoginDto, SendSmsDto } from "./dto.js";
|
import { LoginDto, SendSmsDto } from "./dto.js";
|
||||||
|
import { envInt, webOrigin } from "./auth.config.js";
|
||||||
const COOKIE = "refresh_token";
|
const COOKIE = "refresh_token";
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
@@ -49,7 +50,7 @@ export class AuthController {
|
|||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
) {
|
) {
|
||||||
this.origin(req);
|
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);
|
this.cookie(res, pair.refreshToken);
|
||||||
return this.publicPair(pair);
|
return this.publicPair(pair);
|
||||||
}
|
}
|
||||||
@@ -91,12 +92,12 @@ export class AuthController {
|
|||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
path: "/api/v1/auth",
|
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) {
|
private origin(req: Request) {
|
||||||
const origin = req.headers.origin;
|
const origin = req.headers.origin;
|
||||||
if (origin !== process.env.WEB_ORIGIN)
|
if (origin !== webOrigin())
|
||||||
throw new DomainException(
|
throw new DomainException(
|
||||||
ErrorCode.AUTH_ORIGIN_FORBIDDEN,
|
ErrorCode.AUTH_ORIGIN_FORBIDDEN,
|
||||||
"Forbidden origin",
|
"Forbidden origin",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ process.env.JWT_SECRET = "test-jwt-secret-with-at-least-thirty-two-bytes";
|
|||||||
process.env.REFRESH_TOKEN_HMAC_KEY =
|
process.env.REFRESH_TOKEN_HMAC_KEY =
|
||||||
"test-refresh-hmac-key-with-at-least-32-bytes";
|
"test-refresh-hmac-key-with-at-least-32-bytes";
|
||||||
process.env.REDIS_KEY_PREFIX = "drift:auth:e2e:";
|
process.env.REDIS_KEY_PREFIX = "drift:auth:e2e:";
|
||||||
|
process.env.DEMO_SMS_CODE_ENABLED = "true";
|
||||||
const phone = "138 0013 8000",
|
const phone = "138 0013 8000",
|
||||||
deviceId = "device-test-001",
|
deviceId = "device-test-001",
|
||||||
origin = "http://localhost:3000";
|
origin = "http://localhost:3000";
|
||||||
@@ -67,6 +68,8 @@ describe("auth real PostgreSQL/Redis", () => {
|
|||||||
delete process.env.SMS_CODE_TTL_SECONDS;
|
delete process.env.SMS_CODE_TTL_SECONDS;
|
||||||
delete process.env.ACCESS_TOKEN_TTL_SECONDS;
|
delete process.env.ACCESS_TOKEN_TTL_SECONDS;
|
||||||
delete process.env.TRUST_PROXY;
|
delete process.env.TRUST_PROXY;
|
||||||
|
delete process.env.REFRESH_RATE_LIMIT;
|
||||||
|
process.env.DEMO_SMS_CODE_ENABLED = "true";
|
||||||
});
|
});
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await app?.close();
|
await app?.close();
|
||||||
@@ -172,6 +175,18 @@ describe("auth real PostgreSQL/Redis", () => {
|
|||||||
process.env.NODE_ENV = "test";
|
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 () => {
|
it("rotates refresh, detects old-token replay, and revokes the session", async () => {
|
||||||
const logged = await login();
|
const logged = await login();
|
||||||
const oldCookie = cookieValue(
|
const oldCookie = cookieValue(
|
||||||
@@ -285,6 +300,76 @@ describe("auth real PostgreSQL/Redis", () => {
|
|||||||
.expect(401);
|
.expect(401);
|
||||||
expect((await prisma.session.findFirstOrThrow()).revokedAt).not.toBeNull();
|
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 () => {
|
it("rejects tampered access and revoked/token-version sessions", async () => {
|
||||||
const logged = await login();
|
const logged = await login();
|
||||||
const token = logged.body.data.accessToken as string;
|
const token = logged.body.data.accessToken as string;
|
||||||
@@ -307,6 +392,14 @@ describe("auth real PostgreSQL/Redis", () => {
|
|||||||
const cookie = cookieValue(
|
const cookie = cookieValue(
|
||||||
logged.headers["set-cookie"] as unknown as string[],
|
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())
|
await request(app.getHttpServer())
|
||||||
.post("/api/v1/auth/token/refresh")
|
.post("/api/v1/auth/token/refresh")
|
||||||
.set("Origin", "https://evil.example")
|
.set("Origin", "https://evil.example")
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export class AuthGuard implements CanActivate {
|
|||||||
session.revokedAt ||
|
session.revokedAt ||
|
||||||
session.expiresAt <= new Date() ||
|
session.expiresAt <= new Date() ||
|
||||||
session.account.status !== "ACTIVE" ||
|
session.account.status !== "ACTIVE" ||
|
||||||
|
session.tokenVersion !== claims.token_version ||
|
||||||
session.account.tokenVersion !== claims.token_version
|
session.account.tokenVersion !== claims.token_version
|
||||||
)
|
)
|
||||||
throw this.denied();
|
throw this.denied();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { PrismaService } from "../database/prisma.service.js";
|
|||||||
import { RedisService } from "../redis/redis.service.js";
|
import { RedisService } from "../redis/redis.service.js";
|
||||||
import {
|
import {
|
||||||
codeHmac,
|
codeHmac,
|
||||||
|
demoSmsCodeEnabled,
|
||||||
encryptPhone,
|
encryptPhone,
|
||||||
envInt,
|
envInt,
|
||||||
phoneHmac,
|
phoneHmac,
|
||||||
@@ -110,7 +111,7 @@ export class AuthService {
|
|||||||
{ EX: envInt("SMS_CODE_TTL_SECONDS", 300) },
|
{ EX: envInt("SMS_CODE_TTL_SECONDS", 300) },
|
||||||
);
|
);
|
||||||
const result: { sent: true; debugCode?: string } = { sent: true };
|
const result: { sent: true; debugCode?: string } = { sent: true };
|
||||||
if (process.env.NODE_ENV !== "production") result.debugCode = code;
|
if (demoSmsCodeEnabled()) result.debugCode = code;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
async login(
|
async login(
|
||||||
@@ -183,6 +184,7 @@ export class AuthService {
|
|||||||
data: {
|
data: {
|
||||||
id,
|
id,
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
|
tokenVersion: account.tokenVersion,
|
||||||
deviceId,
|
deviceId,
|
||||||
refreshTokenHash: refreshHmac(refresh),
|
refreshTokenHash: refreshHmac(refresh),
|
||||||
tokenFamily: id,
|
tokenFamily: id,
|
||||||
@@ -213,7 +215,11 @@ export class AuthService {
|
|||||||
expiresIn,
|
expiresIn,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
async refresh(token: string): Promise<TokenPair> {
|
async refresh(
|
||||||
|
token: string,
|
||||||
|
ip: string,
|
||||||
|
retryAttempt = 0,
|
||||||
|
): Promise<TokenPair> {
|
||||||
if (!this.validRefreshShape(token)) throw this.invalid();
|
if (!this.validRefreshShape(token)) throw this.invalid();
|
||||||
const hash = refreshHmac(token);
|
const hash = refreshHmac(token);
|
||||||
const observed = await this.prisma.refreshToken.findUnique({
|
const observed = await this.prisma.refreshToken.findUnique({
|
||||||
@@ -221,6 +227,29 @@ export class AuthService {
|
|||||||
include: { session: true },
|
include: { session: true },
|
||||||
});
|
});
|
||||||
if (!observed) throw this.invalid();
|
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")
|
if (observed.status === "USED")
|
||||||
return this.revokeReusedFamily(observed.session.tokenFamily);
|
return this.revokeReusedFamily(observed.session.tokenFamily);
|
||||||
|
|
||||||
@@ -235,6 +264,8 @@ export class AuthService {
|
|||||||
if (stored.status === "USED")
|
if (stored.status === "USED")
|
||||||
return { reusedFamily: stored.session.tokenFamily };
|
return { reusedFamily: stored.session.tokenFamily };
|
||||||
const session = stored.session;
|
const session = stored.session;
|
||||||
|
if (session.tokenVersion !== session.account.tokenVersion)
|
||||||
|
return { reusedFamily: session.tokenFamily };
|
||||||
if (
|
if (
|
||||||
session.revokedAt ||
|
session.revokedAt ||
|
||||||
session.expiresAt <= new Date() ||
|
session.expiresAt <= new Date() ||
|
||||||
@@ -285,23 +316,36 @@ export class AuthService {
|
|||||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
(error.code === "P2034" || error.code === "P2002")
|
(error.code === "P2034" || error.code === "P2002")
|
||||||
) {
|
) {
|
||||||
// A serialization/uniqueness loser can observe the winner only after its
|
if (retryAttempt < 3) {
|
||||||
// transaction commits. Poll briefly, then revoke outside the failed tx.
|
await new Promise((resolve) =>
|
||||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
setTimeout(resolve, 10 * 2 ** retryAttempt),
|
||||||
|
);
|
||||||
|
return this.refresh(token, ip, retryAttempt + 1);
|
||||||
|
}
|
||||||
const raced = await this.prisma.refreshToken.findUnique({
|
const raced = await this.prisma.refreshToken.findUnique({
|
||||||
where: { tokenHash: hash },
|
where: { tokenHash: hash },
|
||||||
include: { session: true },
|
include: { session: true },
|
||||||
});
|
});
|
||||||
if (raced?.status === "USED")
|
if (raced?.status === "USED")
|
||||||
return this.revokeReusedFamily(raced.session.tokenFamily);
|
return this.revokeReusedFamily(raced.session.tokenFamily);
|
||||||
await new Promise((resolve) =>
|
throw new DomainException(
|
||||||
setTimeout(resolve, 10 * (attempt + 1)),
|
ErrorCode.SERVICE_UNAVAILABLE,
|
||||||
|
"Authentication temporarily unavailable",
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
throw error;
|
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> {
|
private async revokeReusedFamily(family: string): Promise<never> {
|
||||||
await this.prisma.session.updateMany({
|
await this.prisma.session.updateMany({
|
||||||
where: { tokenFamily: family },
|
where: { tokenFamily: family },
|
||||||
|
|||||||
@@ -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/,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -15,6 +15,21 @@ export interface AccessClaims {
|
|||||||
iat: number;
|
iat: number;
|
||||||
}
|
}
|
||||||
const b64 = (v: Buffer | string) => Buffer.from(v).toString("base64url");
|
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()
|
@Injectable()
|
||||||
export class TokenService {
|
export class TokenService {
|
||||||
issueAccess(
|
issueAccess(
|
||||||
@@ -39,28 +54,48 @@ export class TokenService {
|
|||||||
const parts = token.split(".");
|
const parts = token.split(".");
|
||||||
if (parts.length !== 3) throw new Error();
|
if (parts.length !== 3) throw new Error();
|
||||||
const [h, p, s] = parts as [string, string, string];
|
const [h, p, s] = parts as [string, string, string];
|
||||||
const header = JSON.parse(Buffer.from(h, "base64url").toString()) as {
|
const header: unknown = JSON.parse(decodeCanonical(h).toString());
|
||||||
alg?: string;
|
if (
|
||||||
};
|
!plainObject(header) ||
|
||||||
if (header.alg !== "HS256") throw new Error();
|
header.alg !== "HS256" ||
|
||||||
|
header.typ !== "JWT" ||
|
||||||
|
Object.keys(header).length !== 2
|
||||||
|
)
|
||||||
|
throw new Error();
|
||||||
const expected = createHmac("sha256", jwtSecret())
|
const expected = createHmac("sha256", jwtSecret())
|
||||||
.update(`${h}.${p}`)
|
.update(`${h}.${p}`)
|
||||||
.digest();
|
.digest();
|
||||||
const actual = Buffer.from(s, "base64url");
|
const actual = decodeCanonical(s);
|
||||||
if (
|
if (
|
||||||
expected.length !== actual.length ||
|
expected.length !== actual.length ||
|
||||||
!timingSafeEqual(expected, actual)
|
!timingSafeEqual(expected, actual)
|
||||||
)
|
)
|
||||||
throw new Error();
|
throw new Error();
|
||||||
const claims = JSON.parse(
|
const decodedClaims: unknown = JSON.parse(decodeCanonical(p).toString());
|
||||||
Buffer.from(p, "base64url").toString(),
|
if (!plainObject(decodedClaims)) throw new Error();
|
||||||
) as AccessClaims;
|
const claims = decodedClaims as unknown as AccessClaims;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
if (
|
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.iss !== (process.env.JWT_ISSUER ?? "drift-api") ||
|
||||||
claims.aud !== (process.env.JWT_AUDIENCE ?? "drift-web")
|
claims.aud !== (process.env.JWT_AUDIENCE ?? "drift-web")
|
||||||
)
|
)
|
||||||
throw new Error();
|
throw new Error();
|
||||||
if (claims.exp <= Math.floor(Date.now() / 1000))
|
if (claims.exp <= now)
|
||||||
throw new DomainException(
|
throw new DomainException(
|
||||||
ErrorCode.AUTH_TOKEN_EXPIRED,
|
ErrorCode.AUTH_TOKEN_EXPIRED,
|
||||||
"Access token expired",
|
"Access token expired",
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { NestFactory } from "@nestjs/core";
|
|||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
import type { Application } from "express";
|
import type { Application } from "express";
|
||||||
import { AppModule } from "./app.module.js";
|
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 { HttpResponseInterceptor } from "./common/http-response.interceptor.js";
|
||||||
import { DomainExceptionFilter } from "./common/domain-exception.filter.js";
|
import { DomainExceptionFilter } from "./common/domain-exception.filter.js";
|
||||||
|
|
||||||
export function configureApp(app: INestApplication): void {
|
export function configureApp(app: INestApplication): void {
|
||||||
validateAuthEnvironment();
|
validateAuthEnvironment();
|
||||||
const allowedOrigin = process.env.WEB_ORIGIN ?? "http://localhost:3000";
|
const allowedOrigin = webOrigin();
|
||||||
const adapter = app.getHttpAdapter?.() as
|
const adapter = app.getHttpAdapter?.() as
|
||||||
{ getInstance(): Application } | undefined;
|
{ getInstance(): Application } | undefined;
|
||||||
adapter
|
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);
|
||||||
@@ -155,6 +155,7 @@ model AuthIdentity {
|
|||||||
model Session {
|
model Session {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
accountId String @map("account_id") @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)
|
refreshTokenHash String @unique @map("refresh_token_hash") @db.VarChar(255)
|
||||||
deviceId String? @map("device_id") @db.VarChar(255)
|
deviceId String? @map("device_id") @db.VarChar(255)
|
||||||
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
|
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
|
||||||
|
|||||||
@@ -93,6 +93,19 @@ describe("database authority constraints", () => {
|
|||||||
).toBe(true);
|
).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 () => {
|
it("expresses independent moderation and pool lifecycle state", async () => {
|
||||||
const author = await createAccount("states");
|
const author = await createAccount("states");
|
||||||
const bottle = await prisma.bottle.create({
|
const bottle = await prisma.bottle.create({
|
||||||
|
|||||||
Reference in New Issue
Block a user