fix: auth middleware, JWT_SECRET env guard, and password reset token lookup

This commit is contained in:
Azriel
2026-05-10 15:23:34 +00:00
parent e633d693da
commit e10b387be6
4 changed files with 45 additions and 20 deletions

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() });
});
},