2 Commits

4 changed files with 45 additions and 20 deletions

View File

@@ -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',
};

View File

@@ -5,12 +5,14 @@ export async function up(knex: Knex): Promise<void> {
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<void> {

View File

@@ -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;

View File

@@ -21,6 +21,18 @@ function formatUser(user: Record<string, unknown>) {
};
}
/**
* 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() });
});
},