Compare commits
2 Commits
e633d693da
...
41fd933642
| Author | SHA1 | Date | |
|---|---|---|---|
| 41fd933642 | |||
|
|
e10b387be6 |
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,10 +38,16 @@ 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')) {
|
||||
// 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;
|
||||
next();
|
||||
|
||||
@@ -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() });
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user