diff --git a/backend/package.json b/backend/package.json index bd82053..0c54b7f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -30,6 +30,7 @@ "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "bcryptjs": "^3.0.3", + "nodemailer": "^6.10.1", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "multer": "^2.1.1", diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index c1b7e65..3637ae6 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,9 +1,10 @@ -import { Controller, Post, Body, UseGuards, Get, Req, Patch } from '@nestjs/common'; +import { Controller, Post, Body, UseGuards, Get, Req, Patch, BadRequestException } from '@nestjs/common'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { IsString, IsOptional, MinLength } from 'class-validator'; import { AuthService } from './auth.service'; import { JwtAuthGuard } from './jwt-auth.guard'; import { RegisterDto, LoginDto } from './dto/auth.dto'; +import { EmailOtpService } from './email-otp.service'; class SendOtpDto { @IsString() @@ -42,7 +43,10 @@ class ChangePasswordDto { @ApiTags('Auth') @Controller('auth') export class AuthController { - constructor(private auth: AuthService) {} + constructor( + private auth: AuthService, + private emailOtp: EmailOtpService, + ) {} @Post('register') register(@Body() dto: RegisterDto) { @@ -79,6 +83,26 @@ export class AuthController { return this.auth.linkEmail(req.user.sub, dto.email, dto.password); } + @Post('send-email-otp') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + async sendEmailOtp(@Body() dto: { email: string }) { + await this.emailOtp.sendOtp(dto.email); + return { ok: true }; + } + + @Post('link-email-otp') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + async linkEmailWithOtp( + @Req() req, + @Body() dto: { email: string; password: string; code: string }, + ) { + const valid = this.emailOtp.verifyOtp(dto.email, dto.code); + if (!valid) throw new BadRequestException('Código incorrecto o expirado'); + return this.auth.linkEmail(req.user.sub, dto.email, dto.password); + } + @Patch('change-password') @UseGuards(JwtAuthGuard) @ApiBearerAuth() diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index d66c95b..4cc7124 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -6,6 +6,7 @@ import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; import { JwtStrategy } from './jwt.strategy'; import { SmsModule } from '../sms/sms.module'; +import { EmailOtpService } from './email-otp.service'; @Module({ imports: [ @@ -20,7 +21,7 @@ import { SmsModule } from '../sms/sms.module'; }), SmsModule, ], - providers: [AuthService, JwtStrategy], + providers: [AuthService, JwtStrategy, EmailOtpService], controllers: [AuthController], exports: [AuthService, JwtModule], }) diff --git a/backend/src/auth/email-otp.service.ts b/backend/src/auth/email-otp.service.ts new file mode 100644 index 0000000..b933121 --- /dev/null +++ b/backend/src/auth/email-otp.service.ts @@ -0,0 +1,70 @@ +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import * as nodemailer from 'nodemailer'; + +const OTP_TTL_MS = 10 * 60 * 1000; // 10 minutos + +@Injectable() +export class EmailOtpService { + private readonly logger = new Logger(EmailOtpService.name); + private readonly otps = new Map(); + + private createTransport() { + const host = process.env.SMTP_HOST; + const port = parseInt(process.env.SMTP_PORT || '587'); + const user = process.env.SMTP_USER; + const pass = process.env.SMTP_PASS; + + if (!host || !user || !pass) { + throw new BadRequestException( + 'Correo no configurado. Añade SMTP_HOST, SMTP_USER y SMTP_PASS en las variables de entorno.', + ); + } + + return nodemailer.createTransport({ + host, + port, + secure: port === 465, + auth: { user, pass }, + }); + } + + async sendOtp(email: string): Promise { + const code = Math.floor(100000 + Math.random() * 900000).toString(); + this.otps.set(email.toLowerCase(), { code, expiresAt: Date.now() + OTP_TTL_MS }); + + const from = process.env.EMAIL_FROM || process.env.SMTP_USER; + const transporter = this.createTransport(); + + await transporter.sendMail({ + from: `ProsApp <${from}>`, + to: email, + subject: 'Código de verificación ProsApp', + html: ` +
+ +

Verifica tu correo

+

Usa este código para vincular tu correo a ProsApp. Válido por 10 minutos.

+
+ ${code} +
+

Si no solicitaste esto, ignora este correo.

+
+ `, + }); + + this.logger.log(`Email OTP enviado a ${email}`); + } + + verifyOtp(email: string, code: string): boolean { + const key = email.toLowerCase(); + const entry = this.otps.get(key); + if (!entry) return false; + if (Date.now() > entry.expiresAt) { + this.otps.delete(key); + return false; + } + if (entry.code !== code) return false; + this.otps.delete(key); + return true; + } +}