Files
carry-your-live/backend/src/routes/auth.ts
T
2026-08-06 11:16:47 +02:00

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;