From e10b387be6ff5c90c07c05162d559ef2e3d69be1 Mon Sep 17 00:00:00 2001 From: Azriel Date: Sun, 10 May 2026 15:23:34 +0000 Subject: [PATCH] fix: auth middleware, JWT_SECRET env guard, and password reset token lookup --- src/config/env.ts | 8 +++- .../002_create_password_reset_tokens.ts | 2 + src/middleware/auth.ts | 15 ++++--- src/services/authService.ts | 40 +++++++++++++------ 4 files changed, 45 insertions(+), 20 deletions(-) diff --git a/src/config/env.ts b/src/config/env.ts index 2dd5ee3..63984fb 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -9,18 +9,22 @@ function requireEnv(key: string): string { return value; } +const isTest = process.env.NODE_ENV === 'test'; + export const config = { nodeEnv: process.env.NODE_ENV ?? 'development', port: parseInt(process.env.PORT ?? '3000', 10), databaseUrl: process.env.DATABASE_URL ?? 'postgresql://postgres:postgres@localhost:5432/pantree_test', - jwtSecret: process.env.JWT_SECRET ?? 'test_secret_do_not_use_in_production_ever', + jwtSecret: isTest + ? (process.env.JWT_SECRET ?? 'test_secret_do_not_use_in_production_ever') + : requireEnv('JWT_SECRET'), jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '24h', googleClientId: process.env.GOOGLE_CLIENT_ID ?? '', sendgridApiKey: process.env.SENDGRID_API_KEY ?? '', sendgridFromEmail: process.env.SENDGRID_FROM_EMAIL ?? 'noreply@pantree.app', frontendUrl: process.env.FRONTEND_URL ?? 'http://localhost:3000', passwordResetUrl: process.env.PASSWORD_RESET_URL ?? 'http://localhost:3000/reset-password', - isTest: process.env.NODE_ENV === 'test', + isTest, isDev: process.env.NODE_ENV === 'development', isProd: process.env.NODE_ENV === 'production', }; diff --git a/src/db/migrations/002_create_password_reset_tokens.ts b/src/db/migrations/002_create_password_reset_tokens.ts index 84f2861..8e532c6 100644 --- a/src/db/migrations/002_create_password_reset_tokens.ts +++ b/src/db/migrations/002_create_password_reset_tokens.ts @@ -5,12 +5,14 @@ export async function up(knex: Knex): Promise { table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); table.uuid('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE'); table.string('token_hash', 255).notNullable(); + table.string('lookup_hash', 64).notNullable(); table.timestamp('expires_at', { useTz: true }).notNullable(); table.timestamp('used_at', { useTz: true }).nullable(); table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now()); }); await knex.raw(`CREATE INDEX idx_prt_user_id ON password_reset_tokens (user_id)`); + await knex.raw(`CREATE UNIQUE INDEX idx_prt_lookup_hash ON password_reset_tokens (lookup_hash)`); } export async function down(knex: Knex): Promise { diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 329f3ad..4baa9af 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -28,10 +28,9 @@ export async function authMiddleware( return next(createError('Invalid or expired token.', 401, 'UNAUTHORIZED')); } - // Verify user still exists and is not hard-deleted + // Verify user still exists (including soft-deleted — they may be hitting /restore-account) const user = await db('users') .where({ id: payload.userId }) - .whereNull('deletion_scheduled_at') .select('id', 'deleted_at', 'deletion_scheduled_at') .first(); @@ -39,9 +38,15 @@ export async function authMiddleware( return next(createError('Invalid or expired token.', 401, 'UNAUTHORIZED')); } - // Block access for soft-deleted accounts (except restore endpoint) - if (user.deleted_at && !req.path.includes('/restore-account')) { - return next(createError('Account is pending deletion.', 403, 'FORBIDDEN')); + // Soft-deleted accounts may only access the restore-account route + if (user.deleted_at) { + const isRestorePath = + req.path === '/auth/restore-account' || + req.path.endsWith('/restore-account'); + + if (!isRestorePath) { + return next(createError('Account is pending deletion.', 403, 'FORBIDDEN')); + } } req.userId = payload.userId; diff --git a/src/services/authService.ts b/src/services/authService.ts index b6987d2..065800c 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -21,6 +21,18 @@ function formatUser(user: Record) { }; } +/** + * Derive a fast, constant-length lookup key from a raw reset token. + * HMAC-SHA256 with the JWT secret as the key — not a secret in itself, + * but prevents offline dictionary attacks against the stored hashes. + */ +function hmacToken(rawToken: string): string { + return crypto + .createHmac('sha256', config.jwtSecret) + .update(rawToken) + .digest('hex'); +} + export const authService = { async signup(email: string, password: string, name: string) { const existing = await db('users') @@ -166,6 +178,7 @@ export const authService = { const rawToken = crypto.randomBytes(32).toString('hex'); const token_hash = await bcrypt.hash(rawToken, BCRYPT_ROUNDS); + const lookup_hash = hmacToken(rawToken); const expires_at = new Date( Date.now() + PASSWORD_RESET_EXPIRES_HOURS * 60 * 60 * 1000 ).toISOString(); @@ -173,6 +186,7 @@ export const authService = { await db('password_reset_tokens').insert({ user_id: user.id, token_hash, + lookup_hash, expires_at, }); @@ -180,22 +194,22 @@ export const authService = { }, async confirmPasswordReset(rawToken: string, newPassword: string) { - // Find all unexpired, unused tokens and check each - const tokens = await db('password_reset_tokens') + // Derive the lookup key and fetch the single matching row — no full-table scan + const lookup_hash = hmacToken(rawToken); + + const candidate = await db('password_reset_tokens') + .where({ lookup_hash }) .whereNull('used_at') .where('expires_at', '>', db.fn.now()) - .orderBy('created_at', 'desc'); + .first(); - let matchedToken = null; - for (const t of tokens) { - const match = await bcrypt.compare(rawToken, t.token_hash); - if (match) { - matchedToken = t; - break; - } + if (!candidate) { + throw createError('Token is invalid or has expired.', 401, 'INVALID_TOKEN'); } - if (!matchedToken) { + // bcrypt-verify the raw token against the stored hash + const valid = await bcrypt.compare(rawToken, candidate.token_hash); + if (!valid) { throw createError('Token is invalid or has expired.', 401, 'INVALID_TOKEN'); } @@ -203,11 +217,11 @@ export const authService = { await db.transaction(async (trx) => { await trx('users') - .where({ id: matchedToken.user_id }) + .where({ id: candidate.user_id }) .update({ password_hash, updated_at: trx.fn.now() }); await trx('password_reset_tokens') - .where({ id: matchedToken.id }) + .where({ id: candidate.id }) .update({ used_at: trx.fn.now() }); }); },