Compare commits
1 Commits
feature/an
...
feature/ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e10b387be6 |
@@ -9,18 +9,22 @@ function requireEnv(key: string): string {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isTest = process.env.NODE_ENV === 'test';
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
nodeEnv: process.env.NODE_ENV ?? 'development',
|
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||||
port: parseInt(process.env.PORT ?? '3000', 10),
|
port: parseInt(process.env.PORT ?? '3000', 10),
|
||||||
databaseUrl: process.env.DATABASE_URL ?? 'postgresql://postgres:postgres@localhost:5432/pantree_test',
|
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',
|
jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '24h',
|
||||||
googleClientId: process.env.GOOGLE_CLIENT_ID ?? '',
|
googleClientId: process.env.GOOGLE_CLIENT_ID ?? '',
|
||||||
sendgridApiKey: process.env.SENDGRID_API_KEY ?? '',
|
sendgridApiKey: process.env.SENDGRID_API_KEY ?? '',
|
||||||
sendgridFromEmail: process.env.SENDGRID_FROM_EMAIL ?? 'noreply@pantree.app',
|
sendgridFromEmail: process.env.SENDGRID_FROM_EMAIL ?? 'noreply@pantree.app',
|
||||||
frontendUrl: process.env.FRONTEND_URL ?? 'http://localhost:3000',
|
frontendUrl: process.env.FRONTEND_URL ?? 'http://localhost:3000',
|
||||||
passwordResetUrl: process.env.PASSWORD_RESET_URL ?? 'http://localhost:3000/reset-password',
|
passwordResetUrl: process.env.PASSWORD_RESET_URL ?? 'http://localhost:3000/reset-password',
|
||||||
isTest: process.env.NODE_ENV === 'test',
|
isTest,
|
||||||
isDev: process.env.NODE_ENV === 'development',
|
isDev: process.env.NODE_ENV === 'development',
|
||||||
isProd: process.env.NODE_ENV === 'production',
|
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('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
|
||||||
table.uuid('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE');
|
table.uuid('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE');
|
||||||
table.string('token_hash', 255).notNullable();
|
table.string('token_hash', 255).notNullable();
|
||||||
|
table.string('lookup_hash', 64).notNullable();
|
||||||
table.timestamp('expires_at', { useTz: true }).notNullable();
|
table.timestamp('expires_at', { useTz: true }).notNullable();
|
||||||
table.timestamp('used_at', { useTz: true }).nullable();
|
table.timestamp('used_at', { useTz: true }).nullable();
|
||||||
table.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now());
|
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 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> {
|
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'));
|
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')
|
const user = await db('users')
|
||||||
.where({ id: payload.userId })
|
.where({ id: payload.userId })
|
||||||
.whereNull('deletion_scheduled_at')
|
|
||||||
.select('id', 'deleted_at', 'deletion_scheduled_at')
|
.select('id', 'deleted_at', 'deletion_scheduled_at')
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
@@ -39,9 +38,15 @@ export async function authMiddleware(
|
|||||||
return next(createError('Invalid or expired token.', 401, 'UNAUTHORIZED'));
|
return next(createError('Invalid or expired token.', 401, 'UNAUTHORIZED'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block access for soft-deleted accounts (except restore endpoint)
|
// Soft-deleted accounts may only access the restore-account route
|
||||||
if (user.deleted_at && !req.path.includes('/restore-account')) {
|
if (user.deleted_at) {
|
||||||
return next(createError('Account is pending deletion.', 403, 'FORBIDDEN'));
|
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;
|
req.userId = payload.userId;
|
||||||
|
|||||||
@@ -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 = {
|
export const authService = {
|
||||||
async signup(email: string, password: string, name: string) {
|
async signup(email: string, password: string, name: string) {
|
||||||
const existing = await db('users')
|
const existing = await db('users')
|
||||||
@@ -166,6 +178,7 @@ export const authService = {
|
|||||||
|
|
||||||
const rawToken = crypto.randomBytes(32).toString('hex');
|
const rawToken = crypto.randomBytes(32).toString('hex');
|
||||||
const token_hash = await bcrypt.hash(rawToken, BCRYPT_ROUNDS);
|
const token_hash = await bcrypt.hash(rawToken, BCRYPT_ROUNDS);
|
||||||
|
const lookup_hash = hmacToken(rawToken);
|
||||||
const expires_at = new Date(
|
const expires_at = new Date(
|
||||||
Date.now() + PASSWORD_RESET_EXPIRES_HOURS * 60 * 60 * 1000
|
Date.now() + PASSWORD_RESET_EXPIRES_HOURS * 60 * 60 * 1000
|
||||||
).toISOString();
|
).toISOString();
|
||||||
@@ -173,6 +186,7 @@ export const authService = {
|
|||||||
await db('password_reset_tokens').insert({
|
await db('password_reset_tokens').insert({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
token_hash,
|
token_hash,
|
||||||
|
lookup_hash,
|
||||||
expires_at,
|
expires_at,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -180,22 +194,22 @@ export const authService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async confirmPasswordReset(rawToken: string, newPassword: string) {
|
async confirmPasswordReset(rawToken: string, newPassword: string) {
|
||||||
// Find all unexpired, unused tokens and check each
|
// Derive the lookup key and fetch the single matching row — no full-table scan
|
||||||
const tokens = await db('password_reset_tokens')
|
const lookup_hash = hmacToken(rawToken);
|
||||||
|
|
||||||
|
const candidate = await db('password_reset_tokens')
|
||||||
|
.where({ lookup_hash })
|
||||||
.whereNull('used_at')
|
.whereNull('used_at')
|
||||||
.where('expires_at', '>', db.fn.now())
|
.where('expires_at', '>', db.fn.now())
|
||||||
.orderBy('created_at', 'desc');
|
.first();
|
||||||
|
|
||||||
let matchedToken = null;
|
if (!candidate) {
|
||||||
for (const t of tokens) {
|
throw createError('Token is invalid or has expired.', 401, 'INVALID_TOKEN');
|
||||||
const match = await bcrypt.compare(rawToken, t.token_hash);
|
|
||||||
if (match) {
|
|
||||||
matchedToken = t;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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');
|
throw createError('Token is invalid or has expired.', 401, 'INVALID_TOKEN');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,11 +217,11 @@ export const authService = {
|
|||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx('users')
|
await trx('users')
|
||||||
.where({ id: matchedToken.user_id })
|
.where({ id: candidate.user_id })
|
||||||
.update({ password_hash, updated_at: trx.fn.now() });
|
.update({ password_hash, updated_at: trx.fn.now() });
|
||||||
|
|
||||||
await trx('password_reset_tokens')
|
await trx('password_reset_tokens')
|
||||||
.where({ id: matchedToken.id })
|
.where({ id: candidate.id })
|
||||||
.update({ used_at: trx.fn.now() });
|
.update({ used_at: trx.fn.now() });
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user