feat: cài đặt tính năng gửi mã OTP qua mail xác thực
This commit is contained in:
+246
-12
@@ -1,17 +1,31 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
// Sửa đường dẫn: lùi 2 cấp từ backend/src để ra root monorepo
|
||||
const envPath = path.resolve(process.cwd(), '..', '.env');
|
||||
dotenv.config({ path: envPath });
|
||||
// Tự động tìm kiếm file .env ở nhiều vị trí để đảm bảo tải đúng trong môi trường monorepo
|
||||
const possibleEnvPaths = [
|
||||
path.resolve(process.cwd(), '.env'),
|
||||
path.resolve(process.cwd(), '..', '.env'),
|
||||
path.resolve(__dirname, '..', '.env'),
|
||||
path.resolve(__dirname, '..', '..', '.env'),
|
||||
];
|
||||
|
||||
let loadedEnvPath: string | null = null;
|
||||
for (const p of possibleEnvPaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
dotenv.config({ path: p });
|
||||
console.log(`[Config] 📂 Biến môi trường được tải từ: ${p}`);
|
||||
loadedEnvPath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
import 'reflect-metadata';
|
||||
import * as fs from 'fs';
|
||||
import * as zlib from 'zlib';
|
||||
import { promisify } from 'util';
|
||||
import sharp from 'sharp';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query, ForbiddenException, Injectable, UseInterceptors, UploadedFiles, Inject } from '@nestjs/common';
|
||||
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query, ForbiddenException, Injectable, UseInterceptors, UploadedFiles, Inject, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
@@ -21,6 +35,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ParticipantRole } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { AdminGuard } from './auth/admin.guard';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { JwtModule, JwtService } from '@nestjs/jwt';
|
||||
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||
import { JwtStrategy } from './auth/jwt.strategy';
|
||||
@@ -48,7 +63,7 @@ const CACHE_TTL = {
|
||||
|
||||
async function bootstrap() {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error('❌ DATABASE_URL không tồn tại trong biến môi trường! Kiểm tra file .env tại: ' + envPath);
|
||||
throw new Error('❌ DATABASE_URL không tồn tại trong biến môi trường! Kiểm tra file .env tại: ' + (loadedEnvPath || 'một trong các đường dẫn đã thử: ' + possibleEnvPaths.join(', ')));
|
||||
}
|
||||
|
||||
// Kiểm tra log xem biến môi trường đã nhận đúng chưa
|
||||
@@ -187,6 +202,70 @@ export class TourRoleGuard implements CanActivate {
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
private transporter: any;
|
||||
|
||||
constructor() {
|
||||
const user = process.env.SMTP_USER;
|
||||
const pass = process.env.SMTP_PASS;
|
||||
|
||||
if (!user || !pass) {
|
||||
console.warn('[EmailService] ⚠️ SMTP_USER hoặc SMTP_PASS chưa được cấu hình trong file .env. Tính năng gửi mã OTP sẽ không khả dụng.');
|
||||
} else {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: process.env.SMTP_SECURE === 'true', // true cho cổng 465, false cho cổng 587/25
|
||||
auth: {
|
||||
user: user,
|
||||
pass: pass,
|
||||
},
|
||||
});
|
||||
|
||||
// Tự động kiểm tra kết nối khi khởi tạo để phát hiện lỗi cấu hình sớm
|
||||
this.transporter.verify((error: any) => {
|
||||
if (error) {
|
||||
console.error('[EmailService] ❌ Lỗi kết nối SMTP:', error.message);
|
||||
} else {
|
||||
console.log('[EmailService] ✅ Kết nối SMTP thành công. Sẵn sàng gửi mail.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async sendOTP(email: string, otp: string) {
|
||||
if (!process.env.SMTP_USER || !process.env.SMTP_PASS) {
|
||||
throw new Error('Thiếu cấu hình SMTP_USER hoặc SMTP_PASS trong file .env');
|
||||
}
|
||||
|
||||
const mailOptions = {
|
||||
from: `"Travel Planning Support" <${process.env.SMTP_USER}>`,
|
||||
to: email,
|
||||
subject: 'Mã OTP Xác Thực Hệ Thống',
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e0e0e0; border-radius: 10px;">
|
||||
<h2 style="color: #2563eb; text-align: center;">Mã Xác Thực OTP</h2>
|
||||
<p>Xin chào,</p>
|
||||
<p>Quản trị viên hệ thống đã yêu cầu cấp và gửi mã OTP xác thực tới tài khoản của bạn. Vui lòng sử dụng mã bảo mật dưới đây:</p>
|
||||
<div style="background-color: #f3f4f6; padding: 15px; text-align: center; font-size: 24px; font-weight: bold; letter-spacing: 5px; color: #1e3a8a; margin: 20px 0; border-radius: 5px;">
|
||||
${otp}
|
||||
</div>
|
||||
<p>Mã OTP này có hiệu lực trong vòng 5 phút. Vui lòng tuyệt đối không chia sẻ mã này cho bất kỳ ai khác.</p>
|
||||
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 20px 0;" />
|
||||
<p style="font-size: 12px; color: #6b7280; text-align: center;">Đây là email tự động từ ứng dụng hệ thống Travel Planning.</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
try {
|
||||
return await this.transporter.sendMail(mailOptions);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Controller()
|
||||
class AppController {
|
||||
@Get()
|
||||
@@ -197,7 +276,12 @@ class AppController {
|
||||
|
||||
@Controller('auth')
|
||||
class AuthController {
|
||||
constructor(private prisma: PrismaService, private jwtService: JwtService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
private emailService: EmailService,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache
|
||||
) {}
|
||||
|
||||
@Get('status')
|
||||
async getStatus() {
|
||||
@@ -233,22 +317,60 @@ class AuthController {
|
||||
};
|
||||
}
|
||||
|
||||
@Post('signup')
|
||||
async signup(@Body() body: any) {
|
||||
@Post('signup/request')
|
||||
async signupRequest(@Body() body: any) {
|
||||
const { email, password, name, phone, address } = body;
|
||||
|
||||
const existingUser = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (existingUser) throw new BadRequestException('Email đã được sử dụng');
|
||||
|
||||
const otp = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
|
||||
// Lưu OTP và dữ liệu form đăng ký vào Cache trong vòng 5 phút (300000 ms)
|
||||
await this.cacheManager.set(`signup_otp:${email}`, otp, 300000);
|
||||
await this.cacheManager.set(`signup_data:${email}`, JSON.stringify({ password, name, phone, address }), 300000);
|
||||
|
||||
try {
|
||||
await this.emailService.sendOTP(email, otp);
|
||||
return { success: true, message: 'Mã OTP đã được gửi đến email của bạn.' };
|
||||
} catch (error) {
|
||||
console.error('[Signup OTP] Error:', error);
|
||||
throw new BadRequestException('Không thể gửi mã xác thực tới email này. Vui lòng kiểm tra lại cấu hình SMTP.');
|
||||
}
|
||||
}
|
||||
|
||||
@Post('signup/verify')
|
||||
async signupVerify(@Body() body: { email: string; otp: string }) {
|
||||
const { email, otp } = body;
|
||||
|
||||
const storedOtp = await this.cacheManager.get<string>(`signup_otp:${email}`);
|
||||
if (!storedOtp || storedOtp !== otp) {
|
||||
throw new BadRequestException('Mã OTP không chính xác hoặc đã hết hạn.');
|
||||
}
|
||||
|
||||
const cachedDataStr = await this.cacheManager.get<string>(`signup_data:${email}`);
|
||||
if (!cachedDataStr) {
|
||||
throw new BadRequestException('Thông tin đăng ký đã hết hạn. Vui lòng thực hiện lại từ đầu.');
|
||||
}
|
||||
|
||||
const { password, name, phone, address } = JSON.parse(cachedDataStr);
|
||||
const userCount = await this.prisma.user.count();
|
||||
const shouldBeAdmin = userCount === 0;
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
return this.prisma.user.create({
|
||||
const user = await this.prisma.user.create({
|
||||
data: { email, passwordHash, name, phone, address, isAdmin: shouldBeAdmin },
|
||||
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
|
||||
});
|
||||
|
||||
// Xóa dữ liệu cache sau khi đăng ký thành công
|
||||
await Promise.all([
|
||||
this.cacheManager.del(`signup_otp:${email}`),
|
||||
this.cacheManager.del(`signup_data:${email}`)
|
||||
]);
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1480,6 +1602,118 @@ class CommentController {
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/otp')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
class AdminOtpController {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private emailService: EmailService,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache
|
||||
) {}
|
||||
|
||||
@Post('send')
|
||||
async sendOtpToUser(@Body() body: { email: string }, @Req() req: any) {
|
||||
const { email } = body;
|
||||
|
||||
// 1. Xác định định danh: IP và Email
|
||||
const clientIp = req.ip || req.headers['x-forwarded-for'] || req.connection.remoteAddress;
|
||||
const emailLimitKey = `rl:otp:email:${email}`;
|
||||
const ipLimitKey = `rl:otp:ip:${clientIp}`;
|
||||
|
||||
// 2. Kiểm tra xem IP hoặc Email này có đang bị giới hạn không (ví dụ: 1 phút 1 lần)
|
||||
const [isEmailLimited, isIpLimited] = await Promise.all([
|
||||
this.cacheManager.get(emailLimitKey),
|
||||
this.cacheManager.get(ipLimitKey)
|
||||
]);
|
||||
|
||||
if (isEmailLimited || isIpLimited) {
|
||||
throw new HttpException(
|
||||
'Thao tác quá nhanh. Vui lòng đợi 60 giây giữa mỗi lần yêu cầu gửi mã.',
|
||||
HttpStatus.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('Không tìm thấy người dùng với email này');
|
||||
}
|
||||
|
||||
const otp = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
const otpCacheKey = `otp:${email}`;
|
||||
|
||||
await this.cacheManager.set(otpCacheKey, otp, 300000);
|
||||
|
||||
try {
|
||||
await this.emailService.sendOTP(email, otp);
|
||||
|
||||
// 3. Thiết lập khóa chặn sau khi gửi thành công (hết hạn sau 60 giây)
|
||||
await Promise.all([
|
||||
this.cacheManager.set(emailLimitKey, true, 60000),
|
||||
this.cacheManager.set(ipLimitKey, true, 60000)
|
||||
]);
|
||||
|
||||
return { success: true, message: `Đã gửi mã OTP tới email ${email} thành công.` };
|
||||
} catch (error) {
|
||||
console.error('[Admin OTP] SMTP Error:', error);
|
||||
throw new BadRequestException('Lỗi cấu hình SMTP hoặc không thể kết nối tới máy chủ gửi mail');
|
||||
}
|
||||
}
|
||||
|
||||
@Post('verify')
|
||||
async verifyOtp(@Body() body: { email: string; otp: string }) {
|
||||
const { email, otp } = body;
|
||||
const otpCacheKey = `otp:${email}`;
|
||||
const failCountKey = `otp_fails:${email}`;
|
||||
const MAX_FAILED_ATTEMPTS = 5;
|
||||
|
||||
// 1. Kiểm tra tài khoản có đang bị khóa không
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user) throw new NotFoundException('Người dùng không tồn tại');
|
||||
if (user.isBlocked) {
|
||||
throw new ForbiddenException('Tài khoản này hiện đang bị khóa. Vui lòng liên hệ quản trị viên.');
|
||||
}
|
||||
|
||||
// 2. Lấy OTP từ Cache
|
||||
const storedOtp = await this.cacheManager.get<string>(otpCacheKey);
|
||||
if (!storedOtp) {
|
||||
throw new BadRequestException('Mã OTP đã hết hạn hoặc không tồn tại. Vui lòng yêu cầu mã mới.');
|
||||
}
|
||||
|
||||
// 3. So sánh mã OTP
|
||||
if (storedOtp === otp) {
|
||||
// Thành công: Xóa OTP và bộ đếm lỗi
|
||||
await Promise.all([
|
||||
this.cacheManager.del(otpCacheKey),
|
||||
this.cacheManager.del(failCountKey)
|
||||
]);
|
||||
return { success: true, message: 'Xác thực mã OTP thành công.' };
|
||||
} else {
|
||||
// Thất bại: Tăng bộ đếm lỗi
|
||||
let fails: number = (await this.cacheManager.get<number>(failCountKey)) || 0;
|
||||
fails++;
|
||||
|
||||
if (fails >= MAX_FAILED_ATTEMPTS) {
|
||||
// Khóa tài khoản trong DB
|
||||
await this.prisma.user.update({
|
||||
where: { email },
|
||||
data: { isBlocked: true }
|
||||
});
|
||||
await this.cacheManager.del(failCountKey);
|
||||
await this.cacheManager.del(otpCacheKey);
|
||||
|
||||
throw new ForbiddenException(`Bạn đã nhập sai quá ${MAX_FAILED_ATTEMPTS} lần. Tài khoản đã bị khóa để bảo mật.`);
|
||||
} else {
|
||||
// Cập nhật số lần sai vào cache (TTL 5 phút bằng với OTP)
|
||||
await this.cacheManager.set(failCountKey, fails, 300000);
|
||||
throw new BadRequestException({
|
||||
message: `Mã OTP không chính xác. Bạn còn ${MAX_FAILED_ATTEMPTS - fails} lần thử.`,
|
||||
remainingAttempts: MAX_FAILED_ATTEMPTS - fails
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
CacheModule.registerAsync({
|
||||
@@ -1496,8 +1730,8 @@ class CommentController {
|
||||
signOptions: { expiresIn: '1d' },
|
||||
}) as any,
|
||||
],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController],
|
||||
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, AdminOtpController],
|
||||
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector, EmailService],
|
||||
exports: [PrismaService]
|
||||
})
|
||||
class AppModule {}
|
||||
|
||||
Reference in New Issue
Block a user