All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 4m59s
354 lines
14 KiB
TypeScript
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; |