diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c3dca009..13ade474 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -29,10 +29,44 @@ model User { referredBy Referral? @relation("Referree") verificationTokens VerificationToken[] emailDeliveries EmailDelivery[] + sessions Session[] + audits AuditLog[] @@map("users") } +model Session { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + token String @unique + refreshToken String? @unique + userAgent String? + ipAddress String? + expiresAt DateTime + isRevoked Boolean @default(false) + revokedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId, isRevoked]) + @@map("sessions") +} + +model AuditLog { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + action String // "PASSWORD_RESET", "EMAIL_VERIFIED", "LOGIN", etc. + metadata String? // JSON string for additional details (without secrets) + ipAddress String? + userAgent String? + createdAt DateTime @default(now()) + + @@index([userId, action, createdAt]) + @@map("audit_logs") +} + model VerificationToken { id String @id @default(uuid()) userId String diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 34bd3cb2..1e8dadbf 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -3,7 +3,7 @@ import { Request, Response } from 'express' import bcrypt from 'bcryptjs' import jwt from 'jsonwebtoken' import prisma from '../config/database' -import { loginSchema, registerSchema, verifyEmailSchema, resendVerificationSchema } from '../schemas/auth.schema' +import { loginSchema, registerSchema, verifyEmailSchema, resendVerificationSchema, forgotPasswordSchema, resetPasswordSchema } from '../schemas/auth.schema' import { UserRole } from '../types/user.types' import { emailService } from '../services/email.service' import logger from '../utils/logger' @@ -12,12 +12,18 @@ const JWT_SECRET = process.env.JWT_SECRET || 'your-default-secret' const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d' const VERIFICATION_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000 // 24 hours +const PASSWORD_RESET_TOKEN_EXPIRY_MS = 30 * 60 * 1000 // 30 minutes const RESEND_COOLDOWN_MS = 60 * 1000 // 1 minute const RESEND_ACCOUNT_LIMIT = 5 const RESEND_ACCOUNT_WINDOW_MS = 24 * 60 * 60 * 1000 // 24 hours +const RESET_PASSWORD_COOLDOWN_MS = 60 * 1000 // 1 minute +const RESET_PASSWORD_ACCOUNT_LIMIT = 3 +const RESET_PASSWORD_ACCOUNT_WINDOW_MS = 24 * 60 * 60 * 1000 // 24 hours export const resendCooldowns = new Map() export const resendAccountCounts = new Map() +export const resetPasswordCooldowns = new Map() +export const resetPasswordAccountCounts = new Map() function generateVerificationToken(): { rawToken: string; tokenHash: string } { const rawToken = crypto.randomBytes(32).toString('hex') @@ -48,6 +54,28 @@ function buildVerificationEmail(to: string, rawToken: string): { subject: string return { subject, body } } +function buildPasswordResetEmail(to: string, rawToken: string): { subject: string; body: string } { + const subject = 'Reset your password' + const body = `\ + + + + +

Reset your password

+

Use the link below to reset your password:

+

+ Reset password +

+

Or enter this token manually:

+
${rawToken}
+

This link expires in 30 minutes.

+

If you did not request this, please ignore this email.

+ +` + + return { subject, body } +} + export class AuthController { /** * @openapi @@ -448,6 +476,209 @@ export class AuthController { res.status(200).json({ message: 'Logged out successfully. Please clear your token client-side.' }) } + /** + * @openapi + * /auth/forgot-password: + * post: + * summary: Request a password reset email + * tags: [Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ForgotPasswordInput' + * responses: + * 200: + * description: If the account exists, a password reset email will be sent + * 429: + * description: Too many requests + * 500: + * description: Internal server error + */ + async forgotPassword(req: Request, res: Response): Promise { + try { + const validation = forgotPasswordSchema.safeParse(req.body) + if (!validation.success) { + res.status(200).json({ message: 'If the account exists, a password reset email has been sent.' }) + + return + } + + const { email } = validation.data + + const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() + || (req.headers['x-real-ip'] as string) + || req.socket.remoteAddress + || 'unknown' + + if (this.isRateLimited(`reset:ip:${ip}`, RESET_PASSWORD_COOLDOWN_MS)) { + res.status(429).json({ error: 'Too many requests. Please try again later.' }) + + return + } + + const user = await prisma.user.findUnique({ where: { email } }) + if (!user) { + res.status(200).json({ message: 'If the account exists, a password reset email has been sent.' }) + + return + } + + if (this.isResetPasswordAccountLimited(user.id)) { + res.status(429).json({ error: 'Too many requests. Please try again later.' }) + + return + } + + if (this.isRateLimited(`reset:user:${user.id}`, RESET_PASSWORD_COOLDOWN_MS)) { + res.status(429).json({ error: 'Too many requests. Please try again later.' }) + + return + } + + await prisma.verificationToken.updateMany({ + where: { userId: user.id, status: 'PENDING', type: 'PASSWORD_RESET' }, + data: { status: 'REVOKED' }, + }) + + const { rawToken, tokenHash } = generateVerificationToken() + const expiresAt = new Date(Date.now() + PASSWORD_RESET_TOKEN_EXPIRY_MS) + + await prisma.verificationToken.create({ + data: { + userId: user.id, + tokenHash, + type: 'PASSWORD_RESET', + expiresAt, + }, + }) + + const { subject, body } = buildPasswordResetEmail(email, rawToken) + emailService.queueEmail(user.id, email, subject, body, 'PASSWORD_RESET').catch(err => + logger.error('[Auth] Failed to queue password reset email:', err) + ) + + this.recordResetPasswordAccountRequest(user.id) + + res.status(200).json({ message: 'If the account exists, a password reset email has been sent.' }) + } catch (error) { + console.error('Forgot password error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } + + /** + * @openapi + * /auth/reset-password: + * post: + * summary: Reset password with a token + * tags: [Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ResetPasswordInput' + * responses: + * 200: + * description: Password reset successful + * 400: + * description: Invalid token or password + * 500: + * description: Internal server error + */ + async resetPassword(req: Request, res: Response): Promise { + try { + const validation = resetPasswordSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ error: 'Invalid token or password' }) + + return + } + + const { token, newPassword } = validation.data + + if (!/^[0-9a-f]{64}$/i.test(token)) { + res.status(400).json({ error: 'Invalid token' }) + + return + } + + const tokenHash = crypto.createHash('sha256').update(token).digest('hex') + + const resetToken = await prisma.verificationToken.findFirst({ + where: { tokenHash, type: 'PASSWORD_RESET' }, + include: { user: true }, + }) + + if (!resetToken) { + res.status(400).json({ error: 'Invalid token' }) + + return + } + + if (resetToken.status === 'USED' || resetToken.status === 'REVOKED') { + res.status(400).json({ error: 'Invalid token' }) + + return + } + + if (new Date() > resetToken.expiresAt) { + await prisma.verificationToken.update({ + where: { id: resetToken.id }, + data: { status: 'REVOKED' }, + }) + res.status(400).json({ error: 'Token expired' }) + + return + } + + const salt = await bcrypt.genSalt(10) + const hashedPassword = await bcrypt.hash(newPassword, salt) + + const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() + || (req.headers['x-real-ip'] as string) + || req.socket.remoteAddress + || 'unknown' + const userAgent = req.headers['user-agent'] || 'unknown' + + await prisma.$transaction([ + prisma.verificationToken.update({ + where: { id: resetToken.id }, + data: { status: 'USED' }, + }), + prisma.user.update({ + where: { id: resetToken.userId }, + data: { password: hashedPassword }, + }), + prisma.verificationToken.updateMany({ + where: { userId: resetToken.userId, status: 'PENDING' }, + data: { status: 'REVOKED' }, + }), + prisma.session.updateMany({ + where: { userId: resetToken.userId, isRevoked: false }, + data: { isRevoked: true, revokedAt: new Date() }, + }), + prisma.auditLog.create({ + data: { + userId: resetToken.userId, + action: 'PASSWORD_RESET', + ipAddress: ip, + userAgent: userAgent, + metadata: JSON.stringify({}), + }, + }), + ]) + + logger.info(`[Auth] Password reset for user ${resetToken.userId}`) + res.status(200).json({ message: 'Password reset successful' }) + } catch (error) { + console.error('Reset password error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } + private generateToken(userId: string, role: string): string { return jwt.sign( { id: userId, role }, @@ -488,4 +719,26 @@ export class AuthController { record.count++ } } + + private isResetPasswordAccountLimited(userId: string): boolean { + const now = Date.now() + const record = resetPasswordAccountCounts.get(userId) + + if (!record || record.resetAt < now) { + return false + } + + return record.count >= RESET_PASSWORD_ACCOUNT_LIMIT + } + + private recordResetPasswordAccountRequest(userId: string): void { + const now = Date.now() + const record = resetPasswordAccountCounts.get(userId) + + if (!record || record.resetAt < now) { + resetPasswordAccountCounts.set(userId, { count: 1, resetAt: now + RESET_PASSWORD_ACCOUNT_WINDOW_MS }) + } else { + record.count++ + } + } } diff --git a/src/docs/schemas.ts b/src/docs/schemas.ts index 35188741..eb1884f9 100644 --- a/src/docs/schemas.ts +++ b/src/docs/schemas.ts @@ -122,6 +122,27 @@ * message: * type: string * + * ForgotPasswordInput: + * type: object + * required: + * - email + * properties: + * email: + * type: string + * format: email + * + * ResetPasswordInput: + * type: object + * required: + * - token + * - newPassword + * properties: + * token: + * type: string + * newPassword: + * type: string + * format: password + * * Module: * type: object * properties: diff --git a/src/routes/v1/auth.routes.ts b/src/routes/v1/auth.routes.ts index 43d4fd72..021240d4 100644 --- a/src/routes/v1/auth.routes.ts +++ b/src/routes/v1/auth.routes.ts @@ -40,4 +40,18 @@ router.post('/verify-email', authController.verifyEmail.bind(authController)) */ router.post('/resend-verification', authLimiter, authController.resendVerification.bind(authController)) +/** + * @route POST /api/v1/auth/forgot-password + * @desc Request password reset email + * @access Public + */ +router.post('/forgot-password', authLimiter, authController.forgotPassword.bind(authController)) + +/** + * @route POST /api/v1/auth/reset-password + * @desc Reset password with token + * @access Public + */ +router.post('/reset-password', authController.resetPassword.bind(authController)) + export default router diff --git a/src/schemas/auth.schema.ts b/src/schemas/auth.schema.ts index 9a48cf79..dc1867dd 100644 --- a/src/schemas/auth.schema.ts +++ b/src/schemas/auth.schema.ts @@ -21,7 +21,18 @@ export const resendVerificationSchema = z.object({ email: z.string().email('Invalid email address'), }) +export const forgotPasswordSchema = z.object({ + email: z.string().email('Invalid email address'), +}) + +export const resetPasswordSchema = z.object({ + token: z.string().min(1, 'Token is required'), + newPassword: z.string().min(8, 'Password must be at least 8 characters long'), +}) + export type RegisterInput = z.infer; export type LoginInput = z.infer; export type VerifyEmailInput = z.infer; export type ResendVerificationInput = z.infer; +export type ForgotPasswordInput = z.infer; +export type ResetPasswordInput = z.infer;