feat: cài đặt tính năng gửi mã OTP qua mail xác thực

This commit is contained in:
2026-06-18 23:00:03 +07:00
parent 659d2a0840
commit 88b47099b1
5 changed files with 486 additions and 26 deletions
+5
View File
@@ -15,6 +15,11 @@ export declare class TourRoleGuard implements CanActivate {
constructor(reflector: Reflector, prisma: PrismaService, cacheManager: Cache); constructor(reflector: Reflector, prisma: PrismaService, cacheManager: Cache);
canActivate(context: ExecutionContext): Promise<boolean>; canActivate(context: ExecutionContext): Promise<boolean>;
} }
export declare class EmailService {
private transporter;
constructor();
sendOTP(email: string, otp: string): Promise<any>;
}
export declare class CommentGateway implements OnGatewayConnection { export declare class CommentGateway implements OnGatewayConnection {
server: Server; server: Server;
handleConnection(client: Socket): void; handleConnection(client: Socket): void;
+233 -13
View File
@@ -48,13 +48,26 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod }; return (mod && mod.__esModule) ? mod : { "default": mod };
}; };
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.CommentGateway = exports.TourRoleGuard = exports.Roles = exports.ROLES_KEY = void 0; exports.CommentGateway = exports.EmailService = exports.TourRoleGuard = exports.Roles = exports.ROLES_KEY = void 0;
const dotenv = __importStar(require("dotenv")); const dotenv = __importStar(require("dotenv"));
const path = __importStar(require("path")); const path = __importStar(require("path"));
const envPath = path.resolve(process.cwd(), '..', '.env');
dotenv.config({ path: envPath });
require("reflect-metadata");
const fs = __importStar(require("fs")); const fs = __importStar(require("fs"));
const possibleEnvPaths = [
path.resolve(process.cwd(), '.env'),
path.resolve(process.cwd(), '..', '.env'),
path.resolve(__dirname, '..', '.env'),
path.resolve(__dirname, '..', '..', '.env'),
];
let loadedEnvPath = 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;
}
}
require("reflect-metadata");
const zlib = __importStar(require("zlib")); const zlib = __importStar(require("zlib"));
const util_1 = require("util"); const util_1 = require("util");
const sharp_1 = __importDefault(require("sharp")); const sharp_1 = __importDefault(require("sharp"));
@@ -67,6 +80,7 @@ const prisma_service_1 = require("../prisma/prisma.service");
const client_1 = require("@prisma/client"); const client_1 = require("@prisma/client");
const bcrypt = __importStar(require("bcrypt")); const bcrypt = __importStar(require("bcrypt"));
const admin_guard_1 = require("./auth/admin.guard"); const admin_guard_1 = require("./auth/admin.guard");
const nodemailer = __importStar(require("nodemailer"));
const jwt_1 = require("@nestjs/jwt"); const jwt_1 = require("@nestjs/jwt");
const jwt_auth_guard_1 = require("./auth/jwt-auth.guard"); const jwt_auth_guard_1 = require("./auth/jwt-auth.guard");
const jwt_strategy_1 = require("./auth/jwt.strategy"); const jwt_strategy_1 = require("./auth/jwt.strategy");
@@ -85,7 +99,7 @@ const CACHE_TTL = {
}; };
async function bootstrap() { async function bootstrap() {
if (!process.env.DATABASE_URL) { 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(', ')));
} }
console.log('===================================='); console.log('====================================');
console.log('DATABASE_URL:', process.env.DATABASE_URL); console.log('DATABASE_URL:', process.env.DATABASE_URL);
@@ -202,6 +216,68 @@ exports.TourRoleGuard = TourRoleGuard = __decorate([
__metadata("design:paramtypes", [core_2.Reflector, __metadata("design:paramtypes", [core_2.Reflector,
prisma_service_1.PrismaService, Object]) prisma_service_1.PrismaService, Object])
], TourRoleGuard); ], TourRoleGuard);
let EmailService = class EmailService {
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',
auth: {
user: user,
pass: pass,
},
});
this.transporter.verify((error) => {
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, otp) {
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;
}
}
};
exports.EmailService = EmailService;
exports.EmailService = EmailService = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [])
], EmailService);
let AppController = class AppController { let AppController = class AppController {
getHello() { getHello() {
return 'Travel Planning API is running!'; return 'Travel Planning API is running!';
@@ -217,9 +293,11 @@ AppController = __decorate([
(0, common_1.Controller)() (0, common_1.Controller)()
], AppController); ], AppController);
let AuthController = class AuthController { let AuthController = class AuthController {
constructor(prisma, jwtService) { constructor(prisma, jwtService, emailService, cacheManager) {
this.prisma = prisma; this.prisma = prisma;
this.jwtService = jwtService; this.jwtService = jwtService;
this.emailService = emailService;
this.cacheManager = cacheManager;
} }
async getStatus() { async getStatus() {
const userCount = await this.prisma.user.count(); const userCount = await this.prisma.user.count();
@@ -246,18 +324,46 @@ let AuthController = class AuthController {
}, },
}; };
} }
async signup(body) { async signupRequest(body) {
const { email, password, name, phone, address } = body; const { email, password, name, phone, address } = body;
const existingUser = await this.prisma.user.findUnique({ where: { email } }); const existingUser = await this.prisma.user.findUnique({ where: { email } });
if (existingUser) if (existingUser)
throw new common_1.BadRequestException('Email đã được sử dụng'); throw new common_1.BadRequestException('Email đã được sử dụng');
const otp = Math.floor(100000 + Math.random() * 900000).toString();
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 common_1.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.');
}
}
async signupVerify(body) {
const { email, otp } = body;
const storedOtp = await this.cacheManager.get(`signup_otp:${email}`);
if (!storedOtp || storedOtp !== otp) {
throw new common_1.BadRequestException('Mã OTP không chính xác hoặc đã hết hạn.');
}
const cachedDataStr = await this.cacheManager.get(`signup_data:${email}`);
if (!cachedDataStr) {
throw new common_1.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 userCount = await this.prisma.user.count();
const shouldBeAdmin = userCount === 0; const shouldBeAdmin = userCount === 0;
const passwordHash = await bcrypt.hash(password, 10); 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 }, data: { email, passwordHash, name, phone, address, isAdmin: shouldBeAdmin },
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true } select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
}); });
await Promise.all([
this.cacheManager.del(`signup_otp:${email}`),
this.cacheManager.del(`signup_data:${email}`)
]);
return user;
} }
}; };
__decorate([ __decorate([
@@ -274,15 +380,25 @@ __decorate([
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], AuthController.prototype, "login", null); ], AuthController.prototype, "login", null);
__decorate([ __decorate([
(0, common_1.Post)('signup'), (0, common_1.Post)('signup/request'),
__param(0, (0, common_1.Body)()), __param(0, (0, common_1.Body)()),
__metadata("design:type", Function), __metadata("design:type", Function),
__metadata("design:paramtypes", [Object]), __metadata("design:paramtypes", [Object]),
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], AuthController.prototype, "signup", null); ], AuthController.prototype, "signupRequest", null);
__decorate([
(0, common_1.Post)('signup/verify'),
__param(0, (0, common_1.Body)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [Object]),
__metadata("design:returntype", Promise)
], AuthController.prototype, "signupVerify", null);
AuthController = __decorate([ AuthController = __decorate([
(0, common_1.Controller)('auth'), (0, common_1.Controller)('auth'),
__metadata("design:paramtypes", [prisma_service_1.PrismaService, jwt_1.JwtService]) __param(3, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
jwt_1.JwtService,
EmailService, Object])
], AuthController); ], AuthController);
let PublicTourController = class PublicTourController { let PublicTourController = class PublicTourController {
constructor(prisma) { constructor(prisma) {
@@ -1593,6 +1709,110 @@ CommentController = __decorate([
__metadata("design:paramtypes", [prisma_service_1.PrismaService, __metadata("design:paramtypes", [prisma_service_1.PrismaService,
CommentGateway]) CommentGateway])
], CommentController); ], CommentController);
let AdminOtpController = class AdminOtpController {
constructor(prisma, emailService, cacheManager) {
this.prisma = prisma;
this.emailService = emailService;
this.cacheManager = cacheManager;
}
async sendOtpToUser(body, req) {
const { email } = body;
const clientIp = req.ip || req.headers['x-forwarded-for'] || req.connection.remoteAddress;
const emailLimitKey = `rl:otp:email:${email}`;
const ipLimitKey = `rl:otp:ip:${clientIp}`;
const [isEmailLimited, isIpLimited] = await Promise.all([
this.cacheManager.get(emailLimitKey),
this.cacheManager.get(ipLimitKey)
]);
if (isEmailLimited || isIpLimited) {
throw new common_1.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ã.', common_1.HttpStatus.TOO_MANY_REQUESTS);
}
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user) {
throw new common_1.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);
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 common_1.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');
}
}
async verifyOtp(body) {
const { email, otp } = body;
const otpCacheKey = `otp:${email}`;
const failCountKey = `otp_fails:${email}`;
const MAX_FAILED_ATTEMPTS = 5;
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user)
throw new common_1.NotFoundException('Người dùng không tồn tại');
if (user.isBlocked) {
throw new common_1.ForbiddenException('Tài khoản này hiện đang bị khóa. Vui lòng liên hệ quản trị viên.');
}
const storedOtp = await this.cacheManager.get(otpCacheKey);
if (!storedOtp) {
throw new common_1.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.');
}
if (storedOtp === otp) {
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 {
let fails = (await this.cacheManager.get(failCountKey)) || 0;
fails++;
if (fails >= MAX_FAILED_ATTEMPTS) {
await this.prisma.user.update({
where: { email },
data: { isBlocked: true }
});
await this.cacheManager.del(failCountKey);
await this.cacheManager.del(otpCacheKey);
throw new common_1.ForbiddenException(`Bạn đã nhập sai quá ${MAX_FAILED_ATTEMPTS} lần. Tài khoản đã bị khóa để bảo mật.`);
}
else {
await this.cacheManager.set(failCountKey, fails, 300000);
throw new common_1.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
});
}
}
}
};
__decorate([
(0, common_1.Post)('send'),
__param(0, (0, common_1.Body)()),
__param(1, (0, common_1.Req)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [Object, Object]),
__metadata("design:returntype", Promise)
], AdminOtpController.prototype, "sendOtpToUser", null);
__decorate([
(0, common_1.Post)('verify'),
__param(0, (0, common_1.Body)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [Object]),
__metadata("design:returntype", Promise)
], AdminOtpController.prototype, "verifyOtp", null);
AdminOtpController = __decorate([
(0, common_1.Controller)('admin/otp'),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard),
__param(2, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
EmailService, Object])
], AdminOtpController);
let AppModule = class AppModule { let AppModule = class AppModule {
}; };
AppModule = __decorate([ AppModule = __decorate([
@@ -1612,8 +1832,8 @@ AppModule = __decorate([
signOptions: { expiresIn: '1d' }, signOptions: { expiresIn: '1d' },
}), }),
], ],
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController], controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, AdminOtpController],
providers: [prisma_service_1.PrismaService, jwt_strategy_1.JwtStrategy, TourRoleGuard, jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard, CommentGateway, core_2.Reflector], providers: [prisma_service_1.PrismaService, jwt_strategy_1.JwtStrategy, TourRoleGuard, jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard, CommentGateway, core_2.Reflector, EmailService],
exports: [prisma_service_1.PrismaService] exports: [prisma_service_1.PrismaService]
}) })
], AppModule); ], AppModule);
+1 -1
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -33,6 +33,7 @@
"cache-manager": "^7.2.8", "cache-manager": "^7.2.8",
"cache-manager-redis-yet": "^5.1.5", "cache-manager-redis-yet": "^5.1.5",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"nodemailer": "^9.0.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.12.0", "pg": "^8.12.0",
+246 -12
View File
@@ -1,17 +1,31 @@
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
import * as path from 'path'; 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 // 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 envPath = path.resolve(process.cwd(), '..', '.env'); const possibleEnvPaths = [
dotenv.config({ path: envPath }); 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 'reflect-metadata';
import * as fs from 'fs';
import * as zlib from 'zlib'; import * as zlib from 'zlib';
import { promisify } from 'util'; import { promisify } from 'util';
import sharp from 'sharp'; import sharp from 'sharp';
import { NestFactory } from '@nestjs/core'; 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 { NestExpressApplication } from '@nestjs/platform-express';
import { FilesInterceptor } from '@nestjs/platform-express'; import { FilesInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer'; import { diskStorage } from 'multer';
@@ -21,6 +35,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { ParticipantRole } from '@prisma/client'; import { ParticipantRole } from '@prisma/client';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { AdminGuard } from './auth/admin.guard'; import { AdminGuard } from './auth/admin.guard';
import * as nodemailer from 'nodemailer';
import { JwtModule, JwtService } from '@nestjs/jwt'; import { JwtModule, JwtService } from '@nestjs/jwt';
import { JwtAuthGuard } from './auth/jwt-auth.guard'; import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { JwtStrategy } from './auth/jwt.strategy'; import { JwtStrategy } from './auth/jwt.strategy';
@@ -48,7 +63,7 @@ const CACHE_TTL = {
async function bootstrap() { async function bootstrap() {
if (!process.env.DATABASE_URL) { 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 // 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() @Controller()
class AppController { class AppController {
@Get() @Get()
@@ -197,7 +276,12 @@ class AppController {
@Controller('auth') @Controller('auth')
class AuthController { 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') @Get('status')
async getStatus() { async getStatus() {
@@ -233,22 +317,60 @@ class AuthController {
}; };
} }
@Post('signup') @Post('signup/request')
async signup(@Body() body: any) { async signupRequest(@Body() body: any) {
const { email, password, name, phone, address } = body; const { email, password, name, phone, address } = body;
const existingUser = await this.prisma.user.findUnique({ where: { email } }); const existingUser = await this.prisma.user.findUnique({ where: { email } });
if (existingUser) throw new BadRequestException('Email đã được sử dụng'); 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 userCount = await this.prisma.user.count();
const shouldBeAdmin = userCount === 0; const shouldBeAdmin = userCount === 0;
const passwordHash = await bcrypt.hash(password, 10); 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 }, data: { email, passwordHash, name, phone, address, isAdmin: shouldBeAdmin },
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true } 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({ @Module({
imports: [ imports: [
CacheModule.registerAsync({ CacheModule.registerAsync({
@@ -1496,8 +1730,8 @@ class CommentController {
signOptions: { expiresIn: '1d' }, signOptions: { expiresIn: '1d' },
}) as any, }) as any,
], ],
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController], controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, AdminOtpController],
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector], providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector, EmailService],
exports: [PrismaService] exports: [PrismaService]
}) })
class AppModule {} class AppModule {}