Files
flyer-crawler.projectium.com/src/routes/auth.routes.ts
Torben Sorensen 6354189d5c
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 4m59s
fix tests ugh
2025-12-09 17:06:43 -08:00

354 lines
14 KiB
TypeScript

// src/routes/auth.routes.ts
import { Router, Request, Response, NextFunction } from 'express';
import * as bcrypt from 'bcrypt';
import zxcvbn from 'zxcvbn';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import rateLimit from 'express-rate-limit';
import passport from './passport.routes'; // Corrected import path
import { userRepo, adminRepo } from '../services/db/index.db';
import { UniqueConstraintError } from '../services/db/errors.db';
import { getPool } from '../services/db/connection.db';
import { logger } from '../services/logger.server';
import { sendPasswordResetEmail } from '../services/emailService.server';
import { UserProfile } from '../types';
const router = Router();
const JWT_SECRET = process.env.JWT_SECRET!;
/**
* Validates the strength of a password using zxcvbn.
* @param password The password to check.
* @returns An object with `isValid` and an optional `feedback` message.
*/
const validatePasswordStrength = (password: string): { isValid: boolean; feedback?: string } => {
const MIN_PASSWORD_SCORE = 3; // Require a 'Good' or 'Strong' password (score 3 or 4)
const strength = zxcvbn(password);
if (strength.score < MIN_PASSWORD_SCORE) {
const feedbackMessage = strength.feedback.warning || (strength.feedback.suggestions && strength.feedback.suggestions[0]);
return { isValid: false, feedback: `Password is too weak. ${feedbackMessage || 'Please choose a stronger password.'}`.trim() };
}
return { isValid: true };
};
// Conditionally disable rate limiting for the test environment
const isTestEnv = process.env.NODE_ENV === 'test';
// --- Rate Limiting Configuration ---
const forgotPasswordLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5,
message: 'Too many password reset requests from this IP, please try again after 15 minutes.',
standardHeaders: true,
legacyHeaders: false,
skip: () => isTestEnv, // Skip this middleware if in test environment
});
const resetPasswordLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
message: 'Too many password reset attempts from this IP, please try again after 15 minutes.',
standardHeaders: true,
legacyHeaders: false,
skip: () => isTestEnv, // Skip this middleware if in test environment
});
// --- Authentication Routes ---
// Registration Route
router.post('/register', async (req, res, next) => {
const { email, password, full_name, avatar_url } = req.body;
if (!email || !password) {
return res.status(400).json({ message: 'Email and password are required.' });
}
// --- Password Strength Check ---
const passwordValidation = validatePasswordStrength(password);
if (!passwordValidation.isValid) {
logger.warn(`Weak password rejected during registration for email: ${email}.`);
return res.status(400).json({ message: passwordValidation.feedback });
}
const client = await getPool().connect();
let newUser;
try {
await client.query('BEGIN');
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(password, saltRounds);
logger.info(`Hashing password for new user: ${email}`);
const repoWithTransaction = new (await import('../services/db/user.db')).UserRepository(client);
try {
newUser = await repoWithTransaction.createUser(email, hashedPassword, { full_name, avatar_url });
} catch (error) {
if (error instanceof UniqueConstraintError) {
return res.status(409).json({ message: error.message });
}
throw error;
}
await client.query('COMMIT');
} catch (error: unknown) {
if (error instanceof UniqueConstraintError) {
// If the email is a duplicate, return a 409 Conflict status.
return res.status(409).json({ message: error.message });
}
await client.query('ROLLBACK');
logger.error(`Transaction failed during user registration for email: ${email}.`, { error });
return next(error);
} finally {
client.release();
}
// Safe access to prevent crashing if the returned object structure is unexpected during tests
const userEmail = (newUser as UserProfile)?.user?.email || 'unknown';
const userId = newUser?.user_id || 'unknown';
logger.info(`Successfully created new user in DB: ${userEmail} (ID: ${userId})`);
// Use the new standardized logging function
await adminRepo.logActivity({
userId: newUser.user_id,
action: 'user_registered',
displayText: `${userEmail} has registered.`,
icon: 'user-plus',
});
const payload = { user_id: newUser.user_id, email: userEmail };
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' });
const refreshToken = crypto.randomBytes(64).toString('hex');
await userRepo.saveRefreshToken(newUser.user_id, refreshToken);
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
return res.status(201).json({ message: 'User registered successfully!', user: payload, token });
});
// Login Route
router.post('/login', (req: Request, res: Response, next: NextFunction) => {
passport.authenticate('local', { session: false }, async (err: Error, user: Express.User | false, info: { message: string }) => {
// --- LOGIN ROUTE DEBUG LOGGING ---
logger.debug(`[API /login] Received login request for email: ${req.body.email}`);
if (err) logger.error('[API /login] Passport reported an error.', { err });
if (!user) logger.warn('[API /login] Passport reported NO USER found.', { info });
if (user) logger.debug('[API /login] Passport user object:', { user }); // Log the user object passport returns
if (user) logger.info('[API /login] Passport reported USER FOUND.', { user });
try {
const allUsersInDb = await getPool().query('SELECT u.user_id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id');
logger.debug('[API /login] Current users in DB from SERVER perspective:');
console.table(allUsersInDb.rows);
} catch (dbError) {
logger.error('[API /login] Could not query users table for debugging.', { dbError });
}
// --- END DEBUG LOGGING ---
const { rememberMe } = req.body;
if (err) {
logger.error(`Login authentication error in /login route for email: ${req.body.email}`, { error: err });
return next(err);
}
if (!user) {
return res.status(401).json({ message: info.message || 'Login failed' });
}
const typedUser = user as { user_id: string; email: string };
const payload = { user_id: typedUser.user_id, email: typedUser.email };
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
try {
const refreshToken = crypto.randomBytes(64).toString('hex');
await userRepo.saveRefreshToken(typedUser.user_id, refreshToken);
logger.info(`JWT and refresh token issued for user: ${typedUser.email}`);
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: rememberMe ? 30 * 24 * 60 * 60 * 1000 : undefined
};
res.cookie('refreshToken', refreshToken, cookieOptions);
const userResponse = { user_id: typedUser.user_id, email: typedUser.email };
return res.json({ user: userResponse, token: accessToken });
} catch (tokenErr) {
logger.error(`Failed to save refresh token during login for user: ${typedUser.email}`, { error: tokenErr });
return next(tokenErr);
}
})(req, res, next);
});
// Route to request a password reset
router.post('/forgot-password', forgotPasswordLimiter, async (req, res, next) => {
const { email } = req.body;
if (!email) {
return res.status(400).json({ message: 'Email is required.' });
}
try {
logger.debug(`[API /forgot-password] Received request for email: ${email}`);
const user = await userRepo.findUserByEmail(email);
let token: string | undefined;
logger.debug(`[API /forgot-password] Database search result for ${email}:`, { user: user ? { user_id: user.user_id, email: user.email } : 'NOT FOUND' });
if (user) {
token = crypto.randomBytes(32).toString('hex');
const saltRounds = 10;
const tokenHash = await bcrypt.hash(token, saltRounds);
const expiresAt = new Date(Date.now() + 3600000); // 1 hour
await userRepo.createPasswordResetToken(user.user_id, tokenHash, expiresAt);
const resetLink = `${process.env.FRONTEND_URL}/reset-password/${token}`;
try {
await sendPasswordResetEmail(email, resetLink);
} catch (emailError) {
logger.error(`Email send failure during password reset for user: ${emailError}`);
}
} else {
logger.warn(`Password reset requested for non-existent email: ${email}`);
}
// For testability, return the token in the response only in the test environment.
const responsePayload: { message: string; token?: string } = {
message: 'If an account with that email exists, a password reset link has been sent.',
};
if (process.env.NODE_ENV === 'test' && user) responsePayload.token = token;
res.status(200).json(responsePayload);
} catch (error) {
logger.error(`An error occurred during /forgot-password for email: ${email}`, { error });
next(error);
}
});
// Route to reset the password using a token
router.post('/reset-password', resetPasswordLimiter, async (req, res, next) => {
const { token, newPassword } = req.body;
if (!token || !newPassword) {
return res.status(400).json({ message: 'Token and new password are required.' });
}
try {
const validTokens = await userRepo.getValidResetTokens();
let tokenRecord;
for (const record of validTokens) {
const isMatch = await bcrypt.compare(token, record.token_hash);
if (isMatch) {
tokenRecord = record;
break;
}
}
if (!tokenRecord) {
return res.status(400).json({ message: 'Invalid or expired password reset token.' });
}
const passwordValidation = validatePasswordStrength(newPassword);
if (!passwordValidation.isValid) {
logger.warn(`Weak password rejected during password reset for user ID: ${tokenRecord.user_id}.`);
return res.status(400).json({ message: passwordValidation.feedback });
}
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
await userRepo.updateUserPassword(tokenRecord.user_id, hashedPassword);
await userRepo.deleteResetToken(tokenRecord.token_hash);
// Log this security event after a successful password reset.
await adminRepo.logActivity({
userId: tokenRecord.user_id,
action: 'password_reset',
displayText: `User ID ${tokenRecord.user_id} has reset their password.`,
icon: 'key',
details: { source_ip: req.ip ?? null }
});
res.status(200).json({ message: 'Password has been reset successfully.' });
} catch (error) {
logger.error(`An error occurred during password reset.`, { error });
next(error);
}
});
// New Route to refresh the access token
router.post('/refresh-token', async (req: Request, res: Response) => {
const { refreshToken } = req.cookies;
if (!refreshToken) {
return res.status(401).json({ message: 'Refresh token not found.' });
}
try {
const user = await userRepo.findUserByRefreshToken(refreshToken);
if (!user) {
return res.status(403).json({ message: 'Invalid or expired refresh token.' });
}
const payload = { user_id: user.user_id, email: user.email };
const newAccessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
res.json({ token: newAccessToken });
} catch (error) {
logger.error('An error occurred during /refresh-token.', { error });
// Unlike other routes, we don't call next(error) here to avoid a server crash
// and instead send a generic 500 error to the client.
res.status(500).json({ message: 'An internal error occurred while refreshing the token.' });
}
});
/**
* POST /api/auth/logout - Logs the user out by invalidating their refresh token.
* It clears the refresh token from the database and instructs the client to
* expire the `refreshToken` cookie.
*/
router.post('/logout', async (req: Request, res: Response) => {
const { refreshToken } = req.cookies;
if (refreshToken) {
// Invalidate the token in the database so it cannot be used again.
// We don't need to wait for this to finish to respond to the user.
userRepo.deleteRefreshToken(refreshToken).catch((err: Error) => {
logger.error('Failed to delete refresh token from DB during logout.', { error: err });
});
}
// Instruct the browser to clear the cookie by setting its expiration to the past.
res.cookie('refreshToken', '', { httpOnly: true, expires: new Date(0), secure: process.env.NODE_ENV === 'production' });
res.status(200).json({ message: 'Logged out successfully.' });
});
// --- OAuth Routes ---
// const handleOAuthCallback = (req: Request, res: Response) => {
// const user = req.user as { user_id: string; email: string };
// const payload = { user_id: user.user_id, email: user.email };
// const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
// const refreshToken = crypto.randomBytes(64).toString('hex');
// db.saveRefreshToken(user.user_id, refreshToken).then(() => {
// res.cookie('refreshToken', refreshToken, {
// httpOnly: true,
// secure: process.env.NODE_ENV === 'production',
// maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
// });
// // Redirect to a frontend page that can handle the token
// res.redirect(`${process.env.FRONTEND_URL}/auth/callback?token=${accessToken}`);
// }).catch(err => {
// logger.error('Failed to save refresh token during OAuth callback:', { error: err });
// res.redirect(`${process.env.FRONTEND_URL}/login?error=auth_failed`);
// });
// };
// router.get('/google', passport.authenticate('google', { session: false }));
// router.get('/google/callback', passport.authenticate('google', { session: false, failureRedirect: '/login' }), handleOAuthCallback);
// router.get('/github', passport.authenticate('github', { session: false }));
// router.get('/github/callback', passport.authenticate('github', { session: false, failureRedirect: '/login' }), handleOAuthCallback);
export default router;