diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..adb5558 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.14.0 \ No newline at end of file diff --git a/src/features/auth/application/dtos/auth.dto.ts b/src/features/auth/application/dtos/auth.dto.ts index ff6ee4b..73ff982 100644 --- a/src/features/auth/application/dtos/auth.dto.ts +++ b/src/features/auth/application/dtos/auth.dto.ts @@ -26,6 +26,24 @@ export const authResponseSchema = z.object({ token: z.string(), }); +export const forgotPasswordSchema = z.object({ + email: z.string().email("Invalid email format"), +}); + +export const resetPasswordSchema = z.object({ + token: z.string().min(1, "Token is required"), + password: z + .string() + .min(8, "Password must be at least 8 characters") + .max(100, "Password must not exceed 100 characters") + .regex(/[A-Z]/, "Password must contain at least one uppercase letter") + .regex(/[a-z]/, "Password must contain at least one lowercase letter") + .regex(/[0-9]/, "Password must contain at least one number") + .regex(/[@$!%*?&.]/, "Password must contain at least one special character"), +}); + export type LoginDTO = z.infer; export type RegisterDTO = z.infer; export type AuthResponse = z.infer; +export type ForgotPasswordDTO = z.infer; +export type ResetPasswordDTO = z.infer; diff --git a/src/features/auth/application/services/auth.service.ts b/src/features/auth/application/services/auth.service.ts index 53ccf70..8ef7bcf 100644 --- a/src/features/auth/application/services/auth.service.ts +++ b/src/features/auth/application/services/auth.service.ts @@ -3,15 +3,22 @@ import { createHandler } from "@/core/infrastructure/lib/handler.wrapper,"; import { LoginRoute, RegisterRoute, + ForgotPasswordRoute, + ResetPasswordRoute, } from "@/auth/infrastructure/controllers/auth.routes"; import { hash, verify } from "@/shared/utils/crypto.util"; import { generateToken } from "@/shared/utils/jwt.util"; +import { EmailService } from "@/email/application/services/email.service"; import * as HttpStatusCodes from "stoker/http-status-codes"; +import { randomBytes } from "crypto"; export class AuthService { private static instance: AuthService; + private emailService: EmailService; - constructor(private readonly userRepository: IUserRepository) {} + constructor(private readonly userRepository: IUserRepository) { + this.emailService = EmailService.getInstance(); + } public static getInstance(userRepository: IUserRepository): AuthService { if (!AuthService.instance) { @@ -131,4 +138,129 @@ export class AuthService { HttpStatusCodes.CREATED ); }); + + forgotPassword = createHandler(async (c) => { + const { email } = c.req.valid("json"); + + const user = await this.userRepository.findByEmail(email); + if (!user || !user.active) { + return c.json( + { + success: false, + data: null, + message: "If the email exists, a recovery link will be sent", + }, + HttpStatusCodes.OK + ); + } + + const token = randomBytes(32).toString("hex"); + const expires = new Date(Date.now() + 3600000); + + await this.userRepository.setRecoveryToken(user.id, token, expires); + + const resetLink = `${process.env.FRONTEND_URL || "http://localhost:3001"}/reset-password?token=${token}`; + + const emailContent = ` + + + + + + + +
+
+

🔐 FoppyAI

+

Recuperación de Contraseña

+
+
+

Hola ${user.name},

+

Hemos recibido una solicitud para restablecer la contraseña de tu cuenta en FoppyAI.

+

Para continuar con el proceso, haz clic en el siguiente botón:

+
+ Restablecer Contraseña +
+

O copia y pega este enlace en tu navegador:

+

${resetLink}

+

Este enlace expirará en 1 hora.

+

Si no solicitaste este cambio, puedes ignorar este correo. Tu contraseña permanecerá sin cambios.

+
+ +
+ + + `; + + try { + await this.emailService.sendSimpleEmail( + email, + "Recuperación de Contraseña - FoppyAI", + emailContent, + { isHtml: true } + ); + } catch (error) { + console.error("Error sending email:", error); + } + + return c.json( + { + success: true, + data: null, + message: "If the email exists, a recovery link will be sent", + }, + HttpStatusCodes.OK + ); + }); + + resetPassword = createHandler(async (c) => { + const { token, password } = c.req.valid("json"); + + const user = await this.userRepository.findByRecoveryToken(token); + if (!user) { + return c.json( + { + success: false, + data: null, + message: "Invalid or expired token", + }, + HttpStatusCodes.BAD_REQUEST + ); + } + + if (!user.recoveryTokenExpires || user.recoveryTokenExpires < new Date()) { + await this.userRepository.clearRecoveryToken(user.id); + return c.json( + { + success: false, + data: null, + message: "Token has expired. Please request a new password reset", + }, + HttpStatusCodes.BAD_REQUEST + ); + } + + const passwordHash = await hash(password); + await this.userRepository.update(user.id, { passwordHash }); + await this.userRepository.clearRecoveryToken(user.id); + + return c.json( + { + success: true, + data: null, + message: "Password has been reset successfully", + }, + HttpStatusCodes.OK + ); + }); } diff --git a/src/features/auth/infrastructure/controllers/auth.controller.ts b/src/features/auth/infrastructure/controllers/auth.controller.ts index aa9603b..d92fc5c 100644 --- a/src/features/auth/infrastructure/controllers/auth.controller.ts +++ b/src/features/auth/infrastructure/controllers/auth.controller.ts @@ -8,6 +8,8 @@ const authService = AuthService.getInstance(userRepository); const router = createRouter() .openapi(routes.login, authService.login) - .openapi(routes.register, authService.register); + .openapi(routes.register, authService.register) + .openapi(routes.forgotPassword, authService.forgotPassword) + .openapi(routes.resetPassword, authService.resetPassword); export default router; diff --git a/src/features/auth/infrastructure/controllers/auth.routes.ts b/src/features/auth/infrastructure/controllers/auth.routes.ts index e18c5bc..828e72d 100644 --- a/src/features/auth/infrastructure/controllers/auth.routes.ts +++ b/src/features/auth/infrastructure/controllers/auth.routes.ts @@ -5,6 +5,8 @@ import { authResponseSchema, loginSchema, registerSchema, + forgotPasswordSchema, + resetPasswordSchema, } from "../../application/dtos/auth.dto"; import { z } from "zod"; @@ -85,5 +87,69 @@ export const register = createRoute({ }, }); +export const forgotPassword = createRoute({ + path: "/auth/forgot-password", + method: "post", + tags, + request: { + body: jsonContentRequired(forgotPasswordSchema, "Forgot password request"), + }, + responses: { + [HttpStatusCodes.OK]: { + content: { + "application/json": { + schema: z.object({ + success: z.boolean(), + data: z.null(), + message: z.string(), + }), + }, + }, + description: "Password recovery email sent", + }, + [HttpStatusCodes.NOT_FOUND]: { + content: { + "application/json": { + schema: errorResponseSchema, + }, + }, + description: "User not found", + }, + }, +}); + +export const resetPassword = createRoute({ + path: "/auth/reset-password", + method: "post", + tags, + request: { + body: jsonContentRequired(resetPasswordSchema, "Reset password request"), + }, + responses: { + [HttpStatusCodes.OK]: { + content: { + "application/json": { + schema: z.object({ + success: z.boolean(), + data: z.null(), + message: z.string(), + }), + }, + }, + description: "Password reset successful", + }, + [HttpStatusCodes.BAD_REQUEST]: { + content: { + "application/json": { + schema: errorResponseSchema, + }, + }, + description: "Invalid or expired token", + }, + }, +}); + export type LoginRoute = typeof login; export type RegisterRoute = typeof register; +export type ForgotPasswordRoute = typeof forgotPassword; +export type ResetPasswordRoute = typeof resetPassword;