fix: 加固认证撤销与运行配置
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
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",
|
||||
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user