This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
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<string> {
|
||||
return bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
}
|
||||
|
||||
async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
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;
|
||||
Reference in New Issue
Block a user