Commit d8221337 authored by ThinhNC's avatar ThinhNC

feat(auth): implement user self-deactivation with email verification

parent 1bf2ac6c
...@@ -20,6 +20,8 @@ export const AUDIT_ACTIONS = { ...@@ -20,6 +20,8 @@ export const AUDIT_ACTIONS = {
CREATE_WEBHOOK_CONFIG: "CREATE_WEBHOOK_CONFIG", CREATE_WEBHOOK_CONFIG: "CREATE_WEBHOOK_CONFIG",
DELETE_WEBHOOK_CONFIG: "DELETE_WEBHOOK_CONFIG", DELETE_WEBHOOK_CONFIG: "DELETE_WEBHOOK_CONFIG",
REDELIVER_WEBHOOK: "REDELIVER_WEBHOOK", REDELIVER_WEBHOOK: "REDELIVER_WEBHOOK",
REQUEST_DEACTIVATE_ACCOUNT: "REQUEST_DEACTIVATE_ACCOUNT",
CONFIRM_DEACTIVATE_ACCOUNT: "CONFIRM_DEACTIVATE_ACCOUNT",
} as const; } as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS]; export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS];
...@@ -472,6 +472,88 @@ export const swaggerPaths: Record<string, any> = { ...@@ -472,6 +472,88 @@ export const swaggerPaths: Record<string, any> = {
}, },
}, },
}, },
"/auth/deactivate/request": {
post: {
tags: ["Auth"],
summary: "Yêu cầu vô hiệu hóa tài khoản",
description:
"Gửi email chứa liên kết/mã xác nhận vô hiệu hóa tài khoản. Yêu cầu người dùng đang đăng nhập và phải nhập đúng mật khẩu hiện tại.",
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/RequestDeactivationRequest" },
},
},
},
responses: {
200: {
description:
"Yêu cầu vô hiệu hóa đã được tiếp nhận và email đã được gửi",
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: { type: "boolean", example: true },
message: {
type: "string",
example:
"Email xác nhận vô hiệu hóa tài khoản đã được gửi. Vui lòng kiểm tra hộp thư của bạn.",
},
},
},
},
},
},
400: {
description:
"Dữ liệu không hợp lệ hoặc tài khoản là Quản trị viên duy nhất",
},
401: { description: "Chưa xác thực hoặc mật khẩu không chính xác" },
},
},
},
"/auth/deactivate/confirm": {
post: {
tags: ["Auth"],
summary: "Xác nhận vô hiệu hóa tài khoản",
description:
"Sử dụng token được gửi qua email để hoàn tất vô hiệu hóa tài khoản. Khi hoàn tất, tài khoản bị vô hiệu hóa, toàn bộ refresh tokens, API keys và lịch crawl bị thu hồi.",
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ConfirmDeactivationRequest" },
},
},
},
responses: {
200: {
description: "Vô hiệu hóa tài khoản thành công",
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: { type: "boolean", example: true },
message: {
type: "string",
example:
"Tài khoản của bạn đã được vô hiệu hóa thành công.",
},
},
},
},
},
},
400: {
description:
"Mã xác nhận không hợp lệ, đã hết hạn hoặc tài khoản đã bị vô hiệu hóa",
},
},
},
},
"/users": { "/users": {
get: { get: {
tags: ["Users"], tags: ["Users"],
......
...@@ -645,6 +645,97 @@ ...@@ -645,6 +645,97 @@
} }
} }
}, },
"/auth/deactivate/request": {
"post": {
"description": "Gửi email chứa liên kết/mã xác nhận vô hiệu hóa tài khoản. Yêu cầu người dùng đang đăng nhập và phải nhập đúng mật khẩu hiện tại.",
"responses": {
"200": {
"description": "Yêu cầu vô hiệu hóa đã được tiếp nhận và email đã được gửi",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string",
"example": "Email xác nhận vô hiệu hóa tài khoản đã được gửi. Vui lòng kiểm tra hộp thư của bạn."
}
}
}
}
}
},
"400": {
"description": "Dữ liệu không hợp lệ hoặc tài khoản là Quản trị viên duy nhất"
},
"401": {
"description": "Chưa xác thực hoặc mật khẩu không chính xác"
}
},
"tags": [
"Auth"
],
"summary": "Yêu cầu vô hiệu hóa tài khoản",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RequestDeactivationRequest"
}
}
}
}
}
},
"/auth/deactivate/confirm": {
"post": {
"description": "Sử dụng token được gửi qua email để hoàn tất vô hiệu hóa tài khoản. Khi hoàn tất, tài khoản bị vô hiệu hóa, toàn bộ refresh tokens, API keys và lịch crawl bị thu hồi.",
"responses": {
"200": {
"description": "Vô hiệu hóa tài khoản thành công",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string",
"example": "Tài khoản của bạn đã được vô hiệu hóa thành công."
}
}
}
}
}
},
"400": {
"description": "Mã xác nhận không hợp lệ, đã hết hạn hoặc tài khoản đã bị vô hiệu hóa"
}
},
"tags": [
"Auth"
],
"summary": "Xác nhận vô hiệu hóa tài khoản",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConfirmDeactivationRequest"
}
}
}
}
}
},
"/users": { "/users": {
"get": { "get": {
"description": "Lấy danh sách phân trang người dùng trong hệ thống. Chỉ có ADMIN mới có quyền truy cập.", "description": "Lấy danh sách phân trang người dùng trong hệ thống. Chỉ có ADMIN mới có quyền truy cập.",
...@@ -3829,6 +3920,32 @@ ...@@ -3829,6 +3920,32 @@
} }
} }
}, },
"RequestDeactivationRequest": {
"type": "object",
"required": [
"password"
],
"properties": {
"password": {
"type": "string",
"example": "Password123!",
"description": "Mật khẩu hiện tại của người dùng để xác nhận danh tính"
}
}
},
"ConfirmDeactivationRequest": {
"type": "object",
"required": [
"token"
],
"properties": {
"token": {
"type": "string",
"example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"description": "Mã xác nhận vô hiệu hóa được gửi qua email"
}
}
},
"RefreshRequest": { "RefreshRequest": {
"type": "object", "type": "object",
"required": [ "required": [
......
...@@ -257,6 +257,28 @@ const rawSchemas = { ...@@ -257,6 +257,28 @@ const rawSchemas = {
email: { type: "string", format: "email", example: "user@example.com" }, email: { type: "string", format: "email", example: "user@example.com" },
}, },
}, },
RequestDeactivationRequest: {
type: "object",
required: ["password"],
properties: {
password: {
type: "string",
example: "Password123!",
description: "Mật khẩu hiện tại của người dùng để xác nhận danh tính",
},
},
},
ConfirmDeactivationRequest: {
type: "object",
required: ["token"],
properties: {
token: {
type: "string",
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
description: "Mã xác nhận vô hiệu hóa được gửi qua email",
},
},
},
RefreshRequest: { RefreshRequest: {
type: "object", type: "object",
required: ["refreshToken"], required: ["refreshToken"],
......
This diff is collapsed.
...@@ -10,6 +10,8 @@ import { ...@@ -10,6 +10,8 @@ import {
VerifyEmailDto, VerifyEmailDto,
ChangePasswordDto, ChangePasswordDto,
ResendVerificationDto, ResendVerificationDto,
RequestDeactivationDto,
ConfirmDeactivationDto,
} from "./auth.dto"; } from "./auth.dto";
import { AuditLogService } from "../audit-logs/audit-log.service"; import { AuditLogService } from "../audit-logs/audit-log.service";
import { AUDIT_ACTIONS } from "../../common/constants/audit-action.constant"; import { AUDIT_ACTIONS } from "../../common/constants/audit-action.constant";
...@@ -374,4 +376,60 @@ export class AuthController { ...@@ -374,4 +376,60 @@ export class AuthController {
next(error); next(error);
} }
}; };
requestDeactivation = async (
req: Request,
res: Response,
next: NextFunction,
) => {
try {
const requestDto: RequestDeactivationDto = req.body;
await this.service.requestDeactivation(req.user.id, requestDto);
await this.auditLogService.log({
userId: req.user.id,
action: AUDIT_ACTIONS.REQUEST_DEACTIVATE_ACCOUNT,
ipAddress: req.ip,
userAgent: req.headers["user-agent"] as string,
details: { email: req.user.email },
});
res.json({
success: true,
message:
"Email xác nhận vô hiệu hóa tài khoản đã được gửi. Vui lòng kiểm tra hộp thư của bạn.",
});
} catch (error) {
next(error);
}
};
confirmDeactivation = async (
req: Request,
res: Response,
next: NextFunction,
) => {
try {
const confirmDto: ConfirmDeactivationDto = req.body;
const result = await this.service.confirmDeactivation(confirmDto);
await this.auditLogService.log({
userId: result.userId,
action: AUDIT_ACTIONS.CONFIRM_DEACTIVATE_ACCOUNT,
ipAddress: req.ip,
userAgent: req.headers["user-agent"] as string,
details: { email: result.email },
});
res.clearCookie("accessToken");
res.clearCookie("refreshToken");
res.json({
success: true,
message: "Tài khoản của bạn đã được vô hiệu hóa thành công.",
});
} catch (error) {
next(error);
}
};
} }
...@@ -79,3 +79,11 @@ export interface VerifyEmailDto { ...@@ -79,3 +79,11 @@ export interface VerifyEmailDto {
export interface ResendVerificationDto { export interface ResendVerificationDto {
email: string; email: string;
} }
export interface RequestDeactivationDto {
password: string;
}
export interface ConfirmDeactivationDto {
token: string;
}
import { prisma } from "../../database/prisma.client"; import { prisma } from "../../database/prisma.client";
import { ROLES } from "../../common/constants/role.constant";
export class AuthRepository { export class AuthRepository {
findByEmail(email: string) { findByEmail(email: string) {
...@@ -24,7 +25,7 @@ export class AuthRepository { ...@@ -24,7 +25,7 @@ export class AuthRepository {
email: data.email, email: data.email,
passwordHash: data.passwordHash, passwordHash: data.passwordHash,
fullName: data.fullName, fullName: data.fullName,
role: "CRAWLER_USER", role: ROLES.CRAWLER_USER,
isActive: data.isActive ?? true, isActive: data.isActive ?? true,
}, },
}); });
...@@ -86,4 +87,41 @@ export class AuthRepository { ...@@ -86,4 +87,41 @@ export class AuthRepository {
where: { userId }, where: { userId },
}); });
} }
async countActiveAdmins(): Promise<number> {
return prisma.user.count({
where: {
role: ROLES.ADMIN,
isActive: true,
deletedAt: null,
},
});
}
async deactivateUser(userId: string): Promise<void> {
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: userId },
data: {
isActive: false,
deletedAt: new Date(),
deletedBy: userId,
},
});
await tx.refreshToken.deleteMany({
where: { userId },
});
await tx.apiKey.updateMany({
where: { userId, isActive: true },
data: { isActive: false },
});
await tx.crawlSchedule.updateMany({
where: { userId, isActive: true },
data: { isActive: false },
});
});
}
} }
...@@ -17,6 +17,8 @@ import { ...@@ -17,6 +17,8 @@ import {
verifyEmailSchema, verifyEmailSchema,
resendVerificationSchema, resendVerificationSchema,
changePasswordSchema, changePasswordSchema,
requestDeactivationSchema,
confirmDeactivationSchema,
} from "./auth.validation"; } from "./auth.validation";
const router = Router(); const router = Router();
...@@ -100,4 +102,23 @@ router.post("/verify-email", validate(verifyEmailSchema), (req, res, next) => { ...@@ -100,4 +102,23 @@ router.post("/verify-email", validate(verifyEmailSchema), (req, res, next) => {
controller.verifyEmail(req, res, next); controller.verifyEmail(req, res, next);
}); });
router.post(
"/deactivate/request",
authMiddleware,
authRateLimiter,
validate(requestDeactivationSchema),
(req, res, next) => {
controller.requestDeactivation(req, res, next);
},
);
router.post(
"/deactivate/confirm",
authRateLimiter,
validate(confirmDeactivationSchema),
(req, res, next) => {
controller.confirmDeactivation(req, res, next);
},
);
export default router; export default router;
...@@ -15,10 +15,13 @@ import { ...@@ -15,10 +15,13 @@ import {
ForgotPasswordDto, ForgotPasswordDto,
ResetPasswordDto, ResetPasswordDto,
ChangePasswordDto, ChangePasswordDto,
RequestDeactivationDto,
ConfirmDeactivationDto,
} from "./auth.dto"; } from "./auth.dto";
import { MailService } from "../mail/mail.service"; import { MailService } from "../mail/mail.service";
import { CrawlJobRepository } from "../crawl-jobs/crawl-job.repository"; import { CrawlJobRepository } from "../crawl-jobs/crawl-job.repository";
import { JOB_STATUS } from "../../common/constants/job-status.constant"; import { JOB_STATUS } from "../../common/constants/job-status.constant";
import { ROLES } from "../../common/constants/role.constant";
import { DEFAULT_TIMEZONE } from "../../common/constants/timezone.constant"; import { DEFAULT_TIMEZONE } from "../../common/constants/timezone.constant";
import { import {
getZonedDateParts, getZonedDateParts,
...@@ -643,4 +646,155 @@ export class AuthService { ...@@ -643,4 +646,155 @@ export class AuthService {
return { success: true, userId: user.id }; return { success: true, userId: user.id };
} }
async requestDeactivation(
userId: string,
data: RequestDeactivationDto,
): Promise<{ success: boolean }> {
const user = await this.repository.findById(userId);
if (!user || !user.isActive) {
throw new AppError(
"Người dùng không tồn tại hoặc tài khoản đã bị vô hiệu hóa.",
404,
ERROR_CODE.NOT_FOUND,
);
}
if (user.role === ROLES.ADMIN) {
const activeAdmins = await this.repository.countActiveAdmins();
if (activeAdmins <= 1) {
throw new AppError(
"Không thể vô hiệu hóa tài khoản Quản trị viên duy nhất trong hệ thống.",
400,
ERROR_CODE.VALIDATION_ERROR,
);
}
}
const isPasswordValid = await bcrypt.compare(
data.password,
user.passwordHash,
);
if (!isPasswordValid) {
throw new AppError(
"Mật khẩu xác nhận không chính xác.",
401,
ERROR_CODE.INVALID_CREDENTIALS,
);
}
const deactivationToken = jwt.sign(
{
id: user.id,
email: user.email,
purpose: "deactivate-account",
},
`${jwtConfig.accessSecret}:deactivate:${user.passwordHash}`,
{ expiresIn: "15m" },
);
try {
await this.mailService.sendDeactivationEmail(
user.email,
deactivationToken,
);
} catch (error: unknown) {
const mailError = error as { code?: string; responseCode?: number };
console.error(
`[Mail] Deactivation delivery failed: ${mailError.code ?? "UNKNOWN"}${mailError.responseCode ? ` (SMTP ${mailError.responseCode})` : ""}`,
);
throw new AppError(
"Không thể gửi email xác nhận vô hiệu hóa. Vui lòng thử lại sau.",
503,
ERROR_CODE.MAIL_DELIVERY_FAILED,
);
}
if (process.env.NODE_ENV !== "production") {
const { mailConfig } = await import("../../config/mail.config");
console.log(
`[DEV ONLY] Deactivation Link: ${mailConfig.frontendUrl}/deactivate-account?token=${deactivationToken}`,
);
}
return { success: true };
}
async confirmDeactivation(
data: ConfirmDeactivationDto,
): Promise<{ success: boolean; userId: string; email: string }> {
const { token } = data;
let untrustedPayload: AuthJwtPayload | null = null;
try {
untrustedPayload = jwt.decode(token) as AuthJwtPayload | null;
} catch {
throw new AppError(
"Mã xác nhận không hợp lệ.",
400,
ERROR_CODE.TOKEN_INVALID,
);
}
if (
!untrustedPayload ||
!untrustedPayload.id ||
untrustedPayload.purpose !== "deactivate-account"
) {
throw new AppError(
"Mã xác nhận không hợp lệ.",
400,
ERROR_CODE.TOKEN_INVALID,
);
}
const user = await this.repository.findById(untrustedPayload.id);
if (!user || !user.isActive) {
throw new AppError(
"Người dùng không tồn tại hoặc tài khoản đã bị vô hiệu hóa.",
400,
ERROR_CODE.USER_INACTIVE,
);
}
try {
jwt.verify(
token,
`${jwtConfig.accessSecret}:deactivate:${user.passwordHash}`,
);
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new AppError(
"Mã xác nhận vô hiệu hóa đã hết hạn.",
400,
ERROR_CODE.TOKEN_EXPIRED,
);
}
throw new AppError(
"Mã xác nhận không hợp lệ.",
400,
ERROR_CODE.TOKEN_INVALID,
);
}
if (user.role === ROLES.ADMIN) {
const activeAdmins = await this.repository.countActiveAdmins();
if (activeAdmins <= 1) {
throw new AppError(
"Không thể vô hiệu hóa tài khoản Quản trị viên duy nhất trong hệ thống.",
400,
ERROR_CODE.VALIDATION_ERROR,
);
}
}
await this.repository.deactivateUser(user.id);
return {
success: true,
userId: user.id,
email: user.email,
};
}
} }
...@@ -95,3 +95,11 @@ export const resendVerificationSchema = z.object({ ...@@ -95,3 +95,11 @@ export const resendVerificationSchema = z.object({
export const verifyEmailSchema = z.object({ export const verifyEmailSchema = z.object({
token: z.string().min(1, "Thiếu mã xác thực email."), token: z.string().min(1, "Thiếu mã xác thực email."),
}); });
export const requestDeactivationSchema = z.object({
password: z.string().min(1, "Vui lòng nhập mật khẩu xác nhận."),
});
export const confirmDeactivationSchema = z.object({
token: z.string().min(1, "Thiếu mã xác nhận vô hiệu hóa."),
});
...@@ -83,4 +83,41 @@ export class MailService { ...@@ -83,4 +83,41 @@ export class MailService {
await this.transporter.sendMail(mailOptions); await this.transporter.sendMail(mailOptions);
} }
async sendDeactivationEmail(email: string, token: string): Promise<void> {
const deactivateUrl = `${mailConfig.frontendUrl}/deactivate-account?token=${token}`;
const mailOptions = {
from: mailConfig.from,
to: email,
subject: "Confirm Account Deactivation - Data Crawler",
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e0e0e0; border-radius: 8px;">
<h2 style="color: #d9534f; text-align: center;">Confirm Account Deactivation</h2>
<p style="color: #555555; font-size: 16px; line-height: 1.5;">
We received a request to deactivate your Data Crawler account. Deactivating your account will immediately stop all active crawl schedules and revoke your API keys and active sessions.
</p>
<p style="color: #555555; font-size: 16px; line-height: 1.5;">
If you wish to proceed with deactivation, please click the confirmation button below:
</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${deactivateUrl}" style="background-color: #d9534f; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold; display: inline-block;">
Confirm Deactivation
</a>
</div>
<p style="color: #777777; font-size: 14px; line-height: 1.5;">
This link is valid for 15 minutes. If you did not request to deactivate your account, please ignore this email and change your password immediately.
</p>
<hr style="border: 0; border-top: 1px solid #eeeeee; margin: 20px 0;">
<p style="color: #999999; font-size: 12px; text-align: center;">
If you're having trouble clicking the button, copy and paste the URL below into your web browser:
<br>
<a href="${deactivateUrl}" style="color: #d9534f; word-break: break-all;">${deactivateUrl}</a>
</p>
</div>
`,
};
await this.transporter.sendMail(mailOptions);
}
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment