feature/registration-auth #37
@@ -38,10 +38,11 @@ SESSION_SECRET=replace_with_32plus_char_random_secret
|
|||||||
# SESSION_COOKIE_NAME=__Host-sid
|
# SESSION_COOKIE_NAME=__Host-sid
|
||||||
# SESSION_COOKIE_SECURE=true
|
# SESSION_COOKIE_SECURE=true
|
||||||
# SESSION_TTL_DAYS=30
|
# SESSION_TTL_DAYS=30
|
||||||
|
# AUTH_CLEANUP_INTERVAL_HOURS=24
|
||||||
|
|
||||||
# ─── Cloudflare Turnstile ────────────────────────────────────
|
# ─── Cloudflare Turnstile ────────────────────────────────────
|
||||||
TURNSTILE_SECRET_KEY=replace_with_turnstile_secret
|
TURNSTILE_SECRET_KEY=replace_with_turnstile_secret
|
||||||
# Local tests/dev only, never production:
|
# Local tests/dev only, rejected in production:
|
||||||
# TURNSTILE_BYPASS_TOKEN=mock-turnstile-token
|
# TURNSTILE_BYPASS_TOKEN=mock-turnstile-token
|
||||||
|
|
||||||
# ─── SMTP email ──────────────────────────────────────────────
|
# ─── SMTP email ──────────────────────────────────────────────
|
||||||
|
|||||||
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "calendar-run-backend",
|
"name": "calendar-run-backend",
|
||||||
"version": "1.4.0",
|
"version": "1.4.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "calendar-run-backend",
|
"name": "calendar-run-backend",
|
||||||
"version": "1.4.0",
|
"version": "1.4.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "calendar-run-backend",
|
"name": "calendar-run-backend",
|
||||||
"version": "1.4.0",
|
"version": "1.4.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
|||||||
@@ -8,32 +8,37 @@ import authRouter from "./routes/auth";
|
|||||||
import healthRouter from "./routes/health";
|
import healthRouter from "./routes/health";
|
||||||
import racesRouter from "./routes/races";
|
import racesRouter from "./routes/races";
|
||||||
|
|
||||||
|
const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com";
|
||||||
|
|
||||||
|
export function buildHelmetOptions(securityProfile: string) {
|
||||||
|
return {
|
||||||
|
contentSecurityPolicy:
|
||||||
|
securityProfile === "production"
|
||||||
|
? {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
scriptSrc: ["'self'", TURNSTILE_ORIGIN],
|
||||||
|
styleSrc: ["'self'"],
|
||||||
|
imgSrc: ["'self'", "data:", "https:"],
|
||||||
|
connectSrc: ["'self'", TURNSTILE_ORIGIN],
|
||||||
|
frameSrc: [TURNSTILE_ORIGIN],
|
||||||
|
objectSrc: ["'none'"],
|
||||||
|
frameAncestors: ["'none'"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: false,
|
||||||
|
hsts:
|
||||||
|
securityProfile === "production"
|
||||||
|
? { maxAge: 31_536_000, includeSubDomains: true }
|
||||||
|
: false,
|
||||||
|
referrerPolicy: { policy: "strict-origin-when-cross-origin" as const },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createApp(): express.Express {
|
export function createApp(): express.Express {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(
|
app.use(helmet(buildHelmetOptions(config.securityProfile)));
|
||||||
helmet({
|
|
||||||
contentSecurityPolicy:
|
|
||||||
config.securityProfile === "production"
|
|
||||||
? {
|
|
||||||
directives: {
|
|
||||||
defaultSrc: ["'self'"],
|
|
||||||
scriptSrc: ["'self'"],
|
|
||||||
styleSrc: ["'self'"],
|
|
||||||
imgSrc: ["'self'", "data:", "https:"],
|
|
||||||
connectSrc: ["'self'"],
|
|
||||||
objectSrc: ["'none'"],
|
|
||||||
frameAncestors: ["'none'"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: false,
|
|
||||||
hsts:
|
|
||||||
config.securityProfile === "production"
|
|
||||||
? { maxAge: 31_536_000, includeSubDomains: true }
|
|
||||||
: false,
|
|
||||||
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: config.corsOrigin,
|
origin: config.corsOrigin,
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ function appUrl(path: string, token: string): string {
|
|||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
|
return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "23505";
|
||||||
|
}
|
||||||
|
|
||||||
export async function findUserByEmail(email: string): Promise<UserRow | null> {
|
export async function findUserByEmail(email: string): Promise<UserRow | null> {
|
||||||
const normalized = normalizeEmail(email);
|
const normalized = normalizeEmail(email);
|
||||||
const { rows } = await pool.query<UserRow>(
|
const { rows } = await pool.query<UserRow>(
|
||||||
@@ -114,20 +118,25 @@ export async function sendResetEmail(email: string, token: string): Promise<void
|
|||||||
export async function registerUser(email: string, password: string): Promise<void> {
|
export async function registerUser(email: string, password: string): Promise<void> {
|
||||||
const normalized = normalizeEmail(email);
|
const normalized = normalizeEmail(email);
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
const existing = await findUserByEmail(normalized);
|
|
||||||
if (existing) {
|
|
||||||
securityLog("register.existing", { emailHash: anonymizeEmail(normalized) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
await client.query("BEGIN");
|
await client.query("BEGIN");
|
||||||
const { rows } = await client.query<UserRow>(
|
let rows: UserRow[];
|
||||||
`INSERT INTO users (email, password_hash)
|
try {
|
||||||
VALUES ($1, $2)
|
({ rows } = await client.query<UserRow>(
|
||||||
RETURNING id, email, password_hash, email_verified_at`,
|
`INSERT INTO users (email, password_hash)
|
||||||
[normalized, passwordHash],
|
VALUES ($1, $2)
|
||||||
);
|
RETURNING id, email, password_hash, email_verified_at`,
|
||||||
|
[normalized, passwordHash],
|
||||||
|
));
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
securityLog("register.existing", { emailHash: anonymizeEmail(normalized) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
const user = rows[0];
|
const user = rows[0];
|
||||||
if (user) {
|
if (user) {
|
||||||
const token = await createVerificationToken(client, user.id);
|
const token = await createVerificationToken(client, user.id);
|
||||||
@@ -239,14 +248,27 @@ export async function verifyEmailToken(token: string): Promise<boolean> {
|
|||||||
const { rows } = await client.query<{ id: string; user_id: string; token_hash: string }>(
|
const { rows } = await client.query<{ id: string; user_id: string; token_hash: string }>(
|
||||||
`SELECT id, user_id, token_hash
|
`SELECT id, user_id, token_hash
|
||||||
FROM email_verification_tokens
|
FROM email_verification_tokens
|
||||||
WHERE used_at IS NULL AND expires_at > NOW()`,
|
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > NOW()
|
||||||
|
FOR UPDATE`,
|
||||||
|
[tokenHash],
|
||||||
);
|
);
|
||||||
const row = rows.find((candidate) => timingSafeEqualHex(candidate.token_hash, tokenHash));
|
const row = rows.find((candidate) => timingSafeEqualHex(candidate.token_hash, tokenHash));
|
||||||
if (!row) {
|
if (!row) {
|
||||||
await client.query("ROLLBACK");
|
await client.query("ROLLBACK");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await client.query("UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL", [row.user_id]);
|
const consumed = await client.query(
|
||||||
|
"UPDATE email_verification_tokens SET used_at = NOW() WHERE id = $1 AND used_at IS NULL RETURNING id",
|
||||||
|
[row.id],
|
||||||
|
);
|
||||||
|
if (consumed.rowCount === 0) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await client.query(
|
||||||
|
"UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id = $1 AND id <> $2 AND used_at IS NULL",
|
||||||
|
[row.user_id, row.id],
|
||||||
|
);
|
||||||
await client.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()), updated_at = NOW() WHERE id = $1", [row.user_id]);
|
await client.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()), updated_at = NOW() WHERE id = $1", [row.user_id]);
|
||||||
await client.query("SELECT pg_advisory_xact_lock(706365)");
|
await client.query("SELECT pg_advisory_xact_lock(706365)");
|
||||||
const claimed = await client.query("SELECT value FROM app_settings WHERE key = 'orphan_races_claimed_by_user_id' FOR UPDATE");
|
const claimed = await client.query("SELECT value FROM app_settings WHERE key = 'orphan_races_claimed_by_user_id' FOR UPDATE");
|
||||||
@@ -300,14 +322,27 @@ export async function resetPassword(token: string, password: string): Promise<bo
|
|||||||
const { rows } = await client.query<{ id: string; user_id: string; token_hash: string }>(
|
const { rows } = await client.query<{ id: string; user_id: string; token_hash: string }>(
|
||||||
`SELECT id, user_id, token_hash
|
`SELECT id, user_id, token_hash
|
||||||
FROM password_reset_tokens
|
FROM password_reset_tokens
|
||||||
WHERE used_at IS NULL AND expires_at > NOW()`,
|
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > NOW()
|
||||||
|
FOR UPDATE`,
|
||||||
|
[tokenHash],
|
||||||
);
|
);
|
||||||
const row = rows.find((candidate) => timingSafeEqualHex(candidate.token_hash, tokenHash));
|
const row = rows.find((candidate) => timingSafeEqualHex(candidate.token_hash, tokenHash));
|
||||||
if (!row) {
|
if (!row) {
|
||||||
await client.query("ROLLBACK");
|
await client.query("ROLLBACK");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await client.query("UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL", [row.user_id]);
|
const consumed = await client.query(
|
||||||
|
"UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1 AND used_at IS NULL RETURNING id",
|
||||||
|
[row.id],
|
||||||
|
);
|
||||||
|
if (consumed.rowCount === 0) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await client.query(
|
||||||
|
"UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id = $1 AND id <> $2 AND used_at IS NULL",
|
||||||
|
[row.user_id, row.id],
|
||||||
|
);
|
||||||
await client.query("UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1", [row.user_id, passwordHash]);
|
await client.query("UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1", [row.user_id, passwordHash]);
|
||||||
await client.query("UPDATE sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL", [row.user_id]);
|
await client.query("UPDATE sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL", [row.user_id]);
|
||||||
await client.query("COMMIT");
|
await client.query("COMMIT");
|
||||||
@@ -346,3 +381,16 @@ export async function cleanupExpiredAuthRows(): Promise<void> {
|
|||||||
await pool.query("DELETE FROM email_verification_tokens WHERE expires_at <= NOW() OR used_at IS NOT NULL");
|
await pool.query("DELETE FROM email_verification_tokens WHERE expires_at <= NOW() OR used_at IS NOT NULL");
|
||||||
await pool.query("DELETE FROM password_reset_tokens WHERE expires_at <= NOW() OR used_at IS NOT NULL");
|
await pool.query("DELETE FROM password_reset_tokens WHERE expires_at <= NOW() OR used_at IS NOT NULL");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function startAuthCleanupSchedule(intervalHours = config.authCleanupIntervalHours): NodeJS.Timeout {
|
||||||
|
const runCleanup = () => {
|
||||||
|
void cleanupExpiredAuthRows().catch((error) => {
|
||||||
|
console.error("[auth-cleanup] Failed:", error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
runCleanup();
|
||||||
|
const safeHours = Number.isFinite(intervalHours) && intervalHours > 0 ? intervalHours : 24;
|
||||||
|
const timer = setInterval(runCleanup, safeHours * 60 * 60 * 1000);
|
||||||
|
timer.unref?.();
|
||||||
|
return timer;
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,23 @@ const useMockDb =
|
|||||||
process.env.CALENDAR_RUN_MOCK_DB === "1" ||
|
process.env.CALENDAR_RUN_MOCK_DB === "1" ||
|
||||||
process.env.CALENDAR_RUN_MOCK_DB?.toLowerCase() === "true";
|
process.env.CALENDAR_RUN_MOCK_DB?.toLowerCase() === "true";
|
||||||
|
|
||||||
|
const securityProfile = process.env.SECURITY_PROFILE?.trim() || process.env.NODE_ENV || "development";
|
||||||
|
|
||||||
|
export function resolveTurnstileBypassToken(params: {
|
||||||
|
rawBypassToken?: string;
|
||||||
|
securityProfile: string;
|
||||||
|
useMockDb: boolean;
|
||||||
|
}): string {
|
||||||
|
const raw = params.rawBypassToken?.trim() ?? "";
|
||||||
|
if (raw && params.securityProfile === "production" && !params.useMockDb) {
|
||||||
|
throw new Error("TURNSTILE_BYPASS_TOKEN is not allowed in production");
|
||||||
|
}
|
||||||
|
if (raw) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
return params.useMockDb ? "mock-turnstile-token" : "";
|
||||||
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
useMockDb,
|
useMockDb,
|
||||||
db: useMockDb
|
db: useMockDb
|
||||||
@@ -73,9 +90,14 @@ export const config = {
|
|||||||
},
|
},
|
||||||
turnstile: {
|
turnstile: {
|
||||||
secretKey: process.env.TURNSTILE_SECRET_KEY?.trim() || (useMockDb ? "mock-turnstile-secret" : ""),
|
secretKey: process.env.TURNSTILE_SECRET_KEY?.trim() || (useMockDb ? "mock-turnstile-secret" : ""),
|
||||||
bypassToken: process.env.TURNSTILE_BYPASS_TOKEN?.trim() || (useMockDb ? "mock-turnstile-token" : ""),
|
bypassToken: resolveTurnstileBypassToken({
|
||||||
|
rawBypassToken: process.env.TURNSTILE_BYPASS_TOKEN,
|
||||||
|
securityProfile,
|
||||||
|
useMockDb,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
securityProfile: process.env.SECURITY_PROFILE?.trim() || process.env.NODE_ENV || "development",
|
authCleanupIntervalHours: parseInt(process.env.AUTH_CLEANUP_INTERVAL_HOURS || "24", 10),
|
||||||
|
securityProfile,
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseCorsOrigins(): string | string[] {
|
function parseCorsOrigins(): string | string[] {
|
||||||
|
|||||||
@@ -101,6 +101,24 @@ function createMockPool(): Pool {
|
|||||||
return result([{ count: String(users.size) } as unknown as T]);
|
return result([{ count: String(users.size) } as unknown as T]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sql.includes("SELECT COUNT(*)::text AS count FROM sessions")) {
|
||||||
|
const tokenHash = p[0] != null ? String(p[0]) : null;
|
||||||
|
const count = Array.from(sessions.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length;
|
||||||
|
return result([{ count: String(count) } as unknown as T]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sql.includes("SELECT COUNT(*)::text AS count FROM email_verification_tokens")) {
|
||||||
|
const tokenHash = p[0] != null ? String(p[0]) : null;
|
||||||
|
const count = Array.from(verificationTokens.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length;
|
||||||
|
return result([{ count: String(count) } as unknown as T]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sql.includes("SELECT COUNT(*)::text AS count FROM password_reset_tokens")) {
|
||||||
|
const tokenHash = p[0] != null ? String(p[0]) : null;
|
||||||
|
const count = Array.from(resetTokens.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length;
|
||||||
|
return result([{ count: String(count) } as unknown as T]);
|
||||||
|
}
|
||||||
|
|
||||||
if (sql.includes("SELECT id FROM users WHERE LOWER(BTRIM(email))")) {
|
if (sql.includes("SELECT id FROM users WHERE LOWER(BTRIM(email))")) {
|
||||||
const email = String(p[0] ?? "");
|
const email = String(p[0] ?? "");
|
||||||
const user = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email);
|
const user = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email);
|
||||||
@@ -114,6 +132,13 @@ function createMockPool(): Pool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sql.includes("INSERT INTO users")) {
|
if (sql.includes("INSERT INTO users")) {
|
||||||
|
const email = String(p[0] ?? "").trim().toLowerCase();
|
||||||
|
const existing = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email);
|
||||||
|
if (existing) {
|
||||||
|
const err = new Error("duplicate key") as Error & { code?: string };
|
||||||
|
err.code = "23505";
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const row = {
|
const row = {
|
||||||
id,
|
id,
|
||||||
@@ -141,16 +166,37 @@ function createMockPool(): Pool {
|
|||||||
return result([row as unknown as T], "INSERT");
|
return result([row as unknown as T], "INSERT");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sql.includes("UPDATE email_verification_tokens SET used_at = NOW() WHERE id")) {
|
||||||
|
const id = String(p[0] ?? "");
|
||||||
|
const row = verificationTokens.get(id);
|
||||||
|
if (!row || row.used_at != null) {
|
||||||
|
return result<T>([], "UPDATE");
|
||||||
|
}
|
||||||
|
row.used_at = new Date();
|
||||||
|
return result([{ id: row.id } as unknown as T], "UPDATE");
|
||||||
|
}
|
||||||
|
|
||||||
if (sql.includes("UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id")) {
|
if (sql.includes("UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id")) {
|
||||||
const userId = String(p[0] ?? "");
|
const userId = String(p[0] ?? "");
|
||||||
|
const exceptId = p[1] != null ? String(p[1]) : null;
|
||||||
for (const row of verificationTokens.values()) {
|
for (const row of verificationTokens.values()) {
|
||||||
if (row.user_id === userId && row.used_at == null) {
|
if (row.user_id === userId && row.id !== exceptId && row.used_at == null) {
|
||||||
row.used_at = new Date();
|
row.used_at = new Date();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result<T>([], "UPDATE");
|
return result<T>([], "UPDATE");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sql.includes("FROM email_verification_tokens") && sql.includes("token_hash =")) {
|
||||||
|
const tokenHash = String(p[0] ?? "");
|
||||||
|
const now = Date.now();
|
||||||
|
return result(
|
||||||
|
Array.from(verificationTokens.values()).filter(
|
||||||
|
(row) => row.token_hash === tokenHash && !row.used_at && new Date(row.expires_at).getTime() > now,
|
||||||
|
) as T[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (sql.includes("FROM email_verification_tokens") && sql.includes("WHERE used_at IS NULL")) {
|
if (sql.includes("FROM email_verification_tokens") && sql.includes("WHERE used_at IS NULL")) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return result(
|
return result(
|
||||||
@@ -172,16 +218,37 @@ function createMockPool(): Pool {
|
|||||||
return result([row as unknown as T], "INSERT");
|
return result([row as unknown as T], "INSERT");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sql.includes("UPDATE password_reset_tokens SET used_at = NOW() WHERE id")) {
|
||||||
|
const id = String(p[0] ?? "");
|
||||||
|
const row = resetTokens.get(id);
|
||||||
|
if (!row || row.used_at != null) {
|
||||||
|
return result<T>([], "UPDATE");
|
||||||
|
}
|
||||||
|
row.used_at = new Date();
|
||||||
|
return result([{ id: row.id } as unknown as T], "UPDATE");
|
||||||
|
}
|
||||||
|
|
||||||
if (sql.includes("UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id")) {
|
if (sql.includes("UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id")) {
|
||||||
const userId = String(p[0] ?? "");
|
const userId = String(p[0] ?? "");
|
||||||
|
const exceptId = p[1] != null ? String(p[1]) : null;
|
||||||
for (const row of resetTokens.values()) {
|
for (const row of resetTokens.values()) {
|
||||||
if (row.user_id === userId && row.used_at == null) {
|
if (row.user_id === userId && row.id !== exceptId && row.used_at == null) {
|
||||||
row.used_at = new Date();
|
row.used_at = new Date();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result<T>([], "UPDATE");
|
return result<T>([], "UPDATE");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sql.includes("FROM password_reset_tokens") && sql.includes("token_hash =")) {
|
||||||
|
const tokenHash = String(p[0] ?? "");
|
||||||
|
const now = Date.now();
|
||||||
|
return result(
|
||||||
|
Array.from(resetTokens.values()).filter(
|
||||||
|
(row) => row.token_hash === tokenHash && !row.used_at && new Date(row.expires_at).getTime() > now,
|
||||||
|
) as T[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (sql.includes("FROM password_reset_tokens") && sql.includes("WHERE used_at IS NULL")) {
|
if (sql.includes("FROM password_reset_tokens") && sql.includes("WHERE used_at IS NULL")) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return result(
|
return result(
|
||||||
@@ -273,6 +340,44 @@ function createMockPool(): Pool {
|
|||||||
return result<T>([], "UPDATE");
|
return result<T>([], "UPDATE");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sql.includes("DELETE FROM sessions WHERE expires_at <= NOW()")) {
|
||||||
|
const now = Date.now();
|
||||||
|
let deleted = 0;
|
||||||
|
for (const [id, row] of sessions.entries()) {
|
||||||
|
const revokedAt = row.revoked_at ? new Date(row.revoked_at).getTime() : null;
|
||||||
|
const staleRevoked = revokedAt != null && revokedAt < now - 30 * 24 * 60 * 60 * 1000;
|
||||||
|
if (new Date(row.expires_at).getTime() <= now || staleRevoked) {
|
||||||
|
sessions.delete(id);
|
||||||
|
deleted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result<T>(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sql.includes("DELETE FROM email_verification_tokens WHERE expires_at <= NOW()")) {
|
||||||
|
const now = Date.now();
|
||||||
|
let deleted = 0;
|
||||||
|
for (const [id, row] of verificationTokens.entries()) {
|
||||||
|
if (new Date(row.expires_at).getTime() <= now || row.used_at != null) {
|
||||||
|
verificationTokens.delete(id);
|
||||||
|
deleted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result<T>(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sql.includes("DELETE FROM password_reset_tokens WHERE expires_at <= NOW()")) {
|
||||||
|
const now = Date.now();
|
||||||
|
let deleted = 0;
|
||||||
|
for (const [id, row] of resetTokens.entries()) {
|
||||||
|
if (new Date(row.expires_at).getTime() <= now || row.used_at != null) {
|
||||||
|
resetTokens.delete(id);
|
||||||
|
deleted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result<T>(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE");
|
||||||
|
}
|
||||||
|
|
||||||
if (sql.includes("SELECT value FROM app_settings")) {
|
if (sql.includes("SELECT value FROM app_settings")) {
|
||||||
const value = appSettings.get("orphan_races_claimed_by_user_id");
|
const value = appSettings.get("orphan_races_claimed_by_user_id");
|
||||||
return value ? result([{ value } as unknown as T]) : emptyResult();
|
return value ? result([{ value } as unknown as T]) : emptyResult();
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { config } from "./config";
|
import { config } from "./config";
|
||||||
import { createApp } from "./app";
|
import { createApp } from "./app";
|
||||||
|
import { startAuthCleanupSchedule } from "./authService";
|
||||||
|
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
startAuthCleanupSchedule();
|
||||||
|
|
||||||
app.listen(config.apiPort, () => {
|
app.listen(config.apiPort, () => {
|
||||||
console.log(`[api] Listening on http://localhost:${config.apiPort}`);
|
console.log(`[api] Listening on http://localhost:${config.apiPort}`);
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { createApp } from "../src/app";
|
import { buildHelmetOptions, createApp } from "../src/app";
|
||||||
import { createResetToken, resetPassword } from "../src/authService";
|
import {
|
||||||
|
cleanupExpiredAuthRows,
|
||||||
|
createResetToken,
|
||||||
|
createVerificationToken,
|
||||||
|
resetPassword,
|
||||||
|
verifyEmailToken,
|
||||||
|
} from "../src/authService";
|
||||||
|
import { resolveTurnstileBypassToken } from "../src/config";
|
||||||
import { pool } from "../src/db";
|
import { pool } from "../src/db";
|
||||||
import { extractRaceCoverImageFromHtml } from "../src/raceCoverImage";
|
import { extractRaceCoverImageFromHtml } from "../src/raceCoverImage";
|
||||||
import { hashPassword, normalizeEmail } from "../src/security";
|
import { hashPassword, normalizeEmail } from "../src/security";
|
||||||
@@ -30,6 +37,14 @@ async function authAgent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createVerifiedUser(email: string, password: string) {
|
async function createVerifiedUser(email: string, password: string) {
|
||||||
|
return createUser(email, password, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUnverifiedUser(email: string, password: string) {
|
||||||
|
return createUser(email, password, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(email: string, password: string, verified: boolean) {
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
const inserted = await pool.query<{ id: string }>(
|
const inserted = await pool.query<{ id: string }>(
|
||||||
`INSERT INTO users (email, password_hash)
|
`INSERT INTO users (email, password_hash)
|
||||||
@@ -37,12 +52,22 @@ async function createVerifiedUser(email: string, password: string) {
|
|||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[normalizeEmail(email), passwordHash],
|
[normalizeEmail(email), passwordHash],
|
||||||
);
|
);
|
||||||
await pool.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()) WHERE id = $1", [
|
if (verified) {
|
||||||
inserted.rows[0].id,
|
await pool.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()) WHERE id = $1", [
|
||||||
]);
|
inserted.rows[0].id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
return inserted.rows[0].id;
|
return inserted.rows[0].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function countByTokenHash(table: "sessions" | "email_verification_tokens" | "password_reset_tokens", tokenHash: string) {
|
||||||
|
const { rows } = await pool.query<{ count: string }>(
|
||||||
|
`SELECT COUNT(*)::text AS count FROM ${table} WHERE token_hash = $1`,
|
||||||
|
[tokenHash],
|
||||||
|
);
|
||||||
|
return Number(rows[0]?.count ?? "0");
|
||||||
|
}
|
||||||
|
|
||||||
test("GET /api/health returns ok", async () => {
|
test("GET /api/health returns ok", async () => {
|
||||||
const res = await request(app).get("/api/health").expect(200);
|
const res = await request(app).get("/api/health").expect(200);
|
||||||
assert.equal(res.body.status, "ok");
|
assert.equal(res.body.status, "ok");
|
||||||
@@ -62,6 +87,61 @@ test("GET /api/ready succeeds with mock database", async () => {
|
|||||||
assert.equal(res.body.db, "connected");
|
assert.equal(res.body.db, "connected");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("production config rejects Turnstile bypass token", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
resolveTurnstileBypassToken({
|
||||||
|
rawBypassToken: "unsafe-bypass",
|
||||||
|
securityProfile: "production",
|
||||||
|
useMockDb: false,
|
||||||
|
}),
|
||||||
|
/TURNSTILE_BYPASS_TOKEN/,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveTurnstileBypassToken({
|
||||||
|
rawBypassToken: "dev-bypass",
|
||||||
|
securityProfile: "development",
|
||||||
|
useMockDb: false,
|
||||||
|
}),
|
||||||
|
"dev-bypass",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveTurnstileBypassToken({
|
||||||
|
rawBypassToken: "mock-bypass",
|
||||||
|
securityProfile: "production",
|
||||||
|
useMockDb: true,
|
||||||
|
}),
|
||||||
|
"mock-bypass",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("production CSP allows Turnstile without unsafe script directives", () => {
|
||||||
|
const options = buildHelmetOptions("production") as any;
|
||||||
|
const directives = options.contentSecurityPolicy.directives;
|
||||||
|
|
||||||
|
assert.ok(directives.scriptSrc.includes("https://challenges.cloudflare.com"));
|
||||||
|
assert.ok(directives.frameSrc.includes("https://challenges.cloudflare.com"));
|
||||||
|
assert.ok(directives.connectSrc.includes("https://challenges.cloudflare.com"));
|
||||||
|
assert.ok(!directives.scriptSrc.includes("'unsafe-inline'"));
|
||||||
|
assert.ok(!directives.scriptSrc.includes("'unsafe-eval'"));
|
||||||
|
assert.deepEqual(directives.frameAncestors, ["'none'"]);
|
||||||
|
assert.deepEqual(directives.objectSrc, ["'none'"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("duplicate registration keeps generic accepted response", async () => {
|
||||||
|
const payload = {
|
||||||
|
email: "duplicate-register@example.com",
|
||||||
|
password: "correct horse battery staple",
|
||||||
|
turnstileToken: "mock-turnstile-token",
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = await request(app).post("/api/auth/register").send(payload).expect(202);
|
||||||
|
const second = await request(app).post("/api/auth/register").send(payload).expect(202);
|
||||||
|
|
||||||
|
assert.deepEqual(first.body, { ok: true });
|
||||||
|
assert.deepEqual(second.body, { ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
test("GET /api/races rejects invalid year", async () => {
|
test("GET /api/races rejects invalid year", async () => {
|
||||||
const { agent } = await authAgent();
|
const { agent } = await authAgent();
|
||||||
const res = await agent.get("/api/races?year=bad").expect(400);
|
const res = await agent.get("/api/races?year=bad").expect(400);
|
||||||
@@ -136,6 +216,63 @@ test("new password reset token invalidates previous token", async () => {
|
|||||||
|
|
||||||
assert.equal(await resetPassword(first, "new correct horse battery staple"), false);
|
assert.equal(await resetPassword(first, "new correct horse battery staple"), false);
|
||||||
assert.equal(await resetPassword(second, "new correct horse battery staple"), true);
|
assert.equal(await resetPassword(second, "new correct horse battery staple"), true);
|
||||||
|
assert.equal(await resetPassword(second, "new correct horse battery staple"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("email verification token is single use", async () => {
|
||||||
|
const userId = await createUnverifiedUser("verify-once@example.com", "correct horse battery staple");
|
||||||
|
const client = await pool.connect();
|
||||||
|
const token = await createVerificationToken(client, userId);
|
||||||
|
client.release();
|
||||||
|
|
||||||
|
assert.equal(await verifyEmailToken(token), true);
|
||||||
|
assert.equal(await verifyEmailToken(token), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("auth cleanup removes expired rows and keeps active rows", async () => {
|
||||||
|
const userId = await createVerifiedUser("cleanup@example.com", "correct horse battery staple");
|
||||||
|
const past = new Date(Date.now() - 60_000);
|
||||||
|
const future = new Date(Date.now() + 60_000);
|
||||||
|
const expiredSession = "expired-session-token-hash";
|
||||||
|
const activeSession = "active-session-token-hash";
|
||||||
|
const expiredVerify = "expired-verify-token-hash";
|
||||||
|
const activeVerify = "active-verify-token-hash";
|
||||||
|
const expiredReset = "expired-reset-token-hash";
|
||||||
|
const activeReset = "active-reset-token-hash";
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
"INSERT INTO sessions (user_id, token_hash, csrf_token_hash, expires_at) VALUES ($1, $2, $3, $4)",
|
||||||
|
[userId, expiredSession, "expired-csrf", past],
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
"INSERT INTO sessions (user_id, token_hash, csrf_token_hash, expires_at) VALUES ($1, $2, $3, $4)",
|
||||||
|
[userId, activeSession, "active-csrf", future],
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
"INSERT INTO email_verification_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
|
||||||
|
[userId, expiredVerify, past],
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
"INSERT INTO email_verification_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
|
||||||
|
[userId, activeVerify, future],
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
"INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
|
||||||
|
[userId, expiredReset, past],
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
"INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
|
||||||
|
[userId, activeReset, future],
|
||||||
|
);
|
||||||
|
|
||||||
|
await cleanupExpiredAuthRows();
|
||||||
|
|
||||||
|
assert.equal(await countByTokenHash("sessions", expiredSession), 0);
|
||||||
|
assert.equal(await countByTokenHash("sessions", activeSession), 1);
|
||||||
|
assert.equal(await countByTokenHash("email_verification_tokens", expiredVerify), 0);
|
||||||
|
assert.equal(await countByTokenHash("email_verification_tokens", activeVerify), 1);
|
||||||
|
assert.equal(await countByTokenHash("password_reset_tokens", expiredReset), 0);
|
||||||
|
assert.equal(await countByTokenHash("password_reset_tokens", activeReset), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("extractRaceCoverImageFromHtml prefers runc.run intro image", () => {
|
test("extractRaceCoverImageFromHtml prefers runc.run intro image", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user