200 lines
5.6 KiB
TypeScript
200 lines
5.6 KiB
TypeScript
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;
|