import { Router, Request, Response } from 'express'; import { asyncHandler } from '../utils/asyncHandler'; import { db } from '../db'; import { users } from '../db/schema'; import { eq } from 'drizzle-orm'; import { generateToken, generateId, getCurrentTimestamp } from '../utils/auth'; import { AppError } from '../middleware/errorHandler'; import { z } from 'zod'; import bcrypt from 'bcryptjs'; import rateLimit from 'express-rate-limit'; import crypto from 'crypto'; const router = Router(); // Rate limiting for auth endpoints const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 10, // limit each IP to 10 requests per windowMs message: { error: { code: 'RATE_LIMITED', message: 'Too many attempts. Please try again later.', }, }, standardHeaders: true, legacyHeaders: false, }); const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, // stricter limit for login message: { error: { code: 'RATE_LIMITED', message: 'Too many login attempts. Please try again later.', }, }, standardHeaders: true, legacyHeaders: false, }); const passwordResetLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1 hour max: 3, // very strict for password reset message: { error: { code: 'RATE_LIMITED', message: 'Too many password reset requests. Please try again later.', }, }, standardHeaders: true, legacyHeaders: false, }); const usernameSchema = z .string() .min(3) .max(30) .regex(/^[a-zA-Z0-9_.-]+$/, 'Username can only contain letters, numbers, dots, dashes and underscores'); const registerSchema = z.object({ username: usernameSchema, password: z.string().min(8), }); const loginSchema = z.object({ username: z.string(), password: z.string(), }); const forgotPasswordSchema = z.object({ username: z.string().min(1), }); const resetPasswordSchema = z.object({ token: z.string().min(1), password: z.string().min(8), }); const BCRYPT_ROUNDS = 12; async function hashPassword(password: string): Promise { return bcrypt.hash(password, BCRYPT_ROUNDS); } async function verifyPassword(password: string, hash: string): Promise { return bcrypt.compare(password, hash); } function generateResetToken(): string { return crypto.randomBytes(32).toString('hex'); } router.post('/register', authLimiter, asyncHandler(async (req: Request, res: Response) => { const data = registerSchema.parse(req.body); const existing = await db.select().from(users).where(eq(users.username, data.username)).limit(1); if (existing.length > 0) { throw new AppError('USERNAME_EXISTS', 'Username already registered', 409); } const userId = generateId('user'); const now = getCurrentTimestamp(); await db.insert(users).values({ id: userId, username: data.username, passwordHash: await hashPassword(data.password), createdAt: now, }); const token = generateToken({ userId, username: data.username }); res.status(201).json({ user: { id: userId, username: data.username }, token, }); })); router.post('/login', loginLimiter, asyncHandler(async (req: Request, res: Response) => { const data = loginSchema.parse(req.body); const user = await db.select().from(users).where(eq(users.username, data.username)).limit(1); if (user.length === 0 || !verifyPassword(data.password, user[0].passwordHash)) { throw new AppError('INVALID_CREDENTIALS', 'Invalid username or password', 401); } const token = generateToken({ userId: user[0].id, username: user[0].username }); res.json({ user: { id: user[0].id, username: user[0].username }, token, }); })); router.get('/me', asyncHandler(async (req: Request, res: Response) => { if (!req.user) { throw new AppError('UNAUTHORIZED', 'Authentication required', 401); } const user = await db.select().from(users).where(eq(users.id, req.user.userId)).limit(1); if (user.length === 0) { throw new AppError('NOT_FOUND', 'User not found', 404); } res.json({ id: user[0].id, username: user[0].username }); })); router.post('/forgot-password', passwordResetLimiter, asyncHandler(async (req: Request, res: Response) => { const data = forgotPasswordSchema.parse(req.body); const user = await db.select().from(users).where(eq(users.username, data.username)).limit(1); // Always return success to prevent username enumeration if (user.length === 0) { res.json({ success: true }); return; } const resetToken = generateResetToken(); const resetTokenExpiry = Date.now() + 3600000; // 1 hour await db .update(users) .set({ resetToken, resetTokenExpiry }) .where(eq(users.id, user[0].id)); // In production, send email with reset link // For now, return token in response (dev only) if (process.env.NODE_ENV !== 'production') { res.json({ success: true, resetToken }); } else { res.json({ success: true }); } })); router.post('/reset-password', passwordResetLimiter, asyncHandler(async (req: Request, res: Response) => { const data = resetPasswordSchema.parse(req.body); const user = await db .select() .from(users) .where(eq(users.resetToken, data.token)) .limit(1); if (user.length === 0 || !user[0].resetTokenExpiry || user[0].resetTokenExpiry < Date.now()) { throw new AppError('INVALID_TOKEN', 'Invalid or expired reset token', 400); } const newPasswordHash = await hashPassword(data.password); await db .update(users) .set({ passwordHash: newPasswordHash, resetToken: null, resetTokenExpiry: null }) .where(eq(users.id, user[0].id)); res.json({ success: true }); })); export default router;