feat: email OTP para vincular correo al perfil
- EmailOtpService: genera/verifica codigo 6 digitos via SMTP (requiere env vars: SMTP_HOST, SMTP_USER, SMTP_PASS, EMAIL_FROM) - POST /auth/send-email-otp: envia codigo al correo indicado - POST /auth/link-email-otp: verifica codigo y vincula email+pass - nodemailer agregado como dependencia Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
d3ca99fa1b
commit
7e68fcc2bb
@@ -30,6 +30,7 @@
|
|||||||
"@prisma/adapter-pg": "^7.8.0",
|
"@prisma/adapter-pg": "^7.8.0",
|
||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"nodemailer": "^6.10.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
|
|||||||
@@ -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 { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { IsString, IsOptional, MinLength } from 'class-validator';
|
import { IsString, IsOptional, MinLength } from 'class-validator';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
import { RegisterDto, LoginDto } from './dto/auth.dto';
|
import { RegisterDto, LoginDto } from './dto/auth.dto';
|
||||||
|
import { EmailOtpService } from './email-otp.service';
|
||||||
|
|
||||||
class SendOtpDto {
|
class SendOtpDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -42,7 +43,10 @@ class ChangePasswordDto {
|
|||||||
@ApiTags('Auth')
|
@ApiTags('Auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private auth: AuthService) {}
|
constructor(
|
||||||
|
private auth: AuthService,
|
||||||
|
private emailOtp: EmailOtpService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post('register')
|
@Post('register')
|
||||||
register(@Body() dto: RegisterDto) {
|
register(@Body() dto: RegisterDto) {
|
||||||
@@ -79,6 +83,26 @@ export class AuthController {
|
|||||||
return this.auth.linkEmail(req.user.sub, dto.email, dto.password);
|
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')
|
@Patch('change-password')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { AuthService } from './auth.service';
|
|||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { JwtStrategy } from './jwt.strategy';
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
import { SmsModule } from '../sms/sms.module';
|
import { SmsModule } from '../sms/sms.module';
|
||||||
|
import { EmailOtpService } from './email-otp.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -20,7 +21,7 @@ import { SmsModule } from '../sms/sms.module';
|
|||||||
}),
|
}),
|
||||||
SmsModule,
|
SmsModule,
|
||||||
],
|
],
|
||||||
providers: [AuthService, JwtStrategy],
|
providers: [AuthService, JwtStrategy, EmailOtpService],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
exports: [AuthService, JwtModule],
|
exports: [AuthService, JwtModule],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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<string, { code: string; expiresAt: number }>();
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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: `
|
||||||
|
<div style="font-family:sans-serif;max-width:480px;margin:auto;padding:32px">
|
||||||
|
<img src="https://prosapp.co/img/logo_prosapp.png" height="40" style="margin-bottom:24px">
|
||||||
|
<h2 style="color:#1e293b;margin:0 0 8px">Verifica tu correo</h2>
|
||||||
|
<p style="color:#64748b;margin:0 0 24px">Usa este código para vincular tu correo a ProsApp. Válido por 10 minutos.</p>
|
||||||
|
<div style="background:#f0f8ff;border:1px solid #bae3ff;border-radius:12px;padding:24px;text-align:center">
|
||||||
|
<span style="font-size:36px;font-weight:700;letter-spacing:10px;color:#42A4EF">${code}</span>
|
||||||
|
</div>
|
||||||
|
<p style="color:#94a3b8;font-size:12px;margin-top:24px">Si no solicitaste esto, ignora este correo.</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user