fix: auth middleware, JWT_SECRET env guard, and password reset token lookup
This commit is contained in:
@@ -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