2025-08-22 17:02:49 +09:00
|
|
|
import { Injectable, Inject } from "@nestjs/common";
|
2025-08-27 10:54:05 +09:00
|
|
|
import { Prisma } from "@prisma/client";
|
2025-12-10 16:08:34 +09:00
|
|
|
import { PrismaService } from "../database/prisma.service.js";
|
|
|
|
|
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
2025-08-22 17:02:49 +09:00
|
|
|
import { Logger } from "nestjs-pino";
|
2025-08-20 18:02:50 +09:00
|
|
|
|
|
|
|
|
export enum AuditAction {
|
2025-08-21 15:24:40 +09:00
|
|
|
LOGIN_SUCCESS = "LOGIN_SUCCESS",
|
|
|
|
|
LOGIN_FAILED = "LOGIN_FAILED",
|
|
|
|
|
LOGOUT = "LOGOUT",
|
|
|
|
|
SIGNUP = "SIGNUP",
|
|
|
|
|
PASSWORD_RESET = "PASSWORD_RESET",
|
|
|
|
|
PASSWORD_CHANGE = "PASSWORD_CHANGE",
|
|
|
|
|
ACCOUNT_LOCKED = "ACCOUNT_LOCKED",
|
|
|
|
|
ACCOUNT_UNLOCKED = "ACCOUNT_UNLOCKED",
|
|
|
|
|
PROFILE_UPDATE = "PROFILE_UPDATE",
|
|
|
|
|
MFA_ENABLED = "MFA_ENABLED",
|
|
|
|
|
MFA_DISABLED = "MFA_DISABLED",
|
|
|
|
|
API_ACCESS = "API_ACCESS",
|
2025-09-26 18:28:47 +09:00
|
|
|
SYSTEM_MAINTENANCE = "SYSTEM_MAINTENANCE",
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AuditLogData {
|
|
|
|
|
userId?: string;
|
|
|
|
|
action: AuditAction;
|
|
|
|
|
resource?: string;
|
2025-08-27 10:54:05 +09:00
|
|
|
details?: Record<string, unknown> | string | number | boolean | null;
|
2025-08-20 18:02:50 +09:00
|
|
|
ipAddress?: string;
|
|
|
|
|
userAgent?: string;
|
|
|
|
|
success?: boolean;
|
|
|
|
|
error?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class AuditService {
|
2025-08-22 17:02:49 +09:00
|
|
|
constructor(
|
|
|
|
|
private readonly prisma: PrismaService,
|
|
|
|
|
@Inject(Logger) private readonly logger: Logger
|
|
|
|
|
) {}
|
2025-08-20 18:02:50 +09:00
|
|
|
|
|
|
|
|
async log(data: AuditLogData): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
await this.prisma.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: data.userId,
|
|
|
|
|
action: data.action,
|
|
|
|
|
resource: data.resource,
|
2025-08-27 10:54:05 +09:00
|
|
|
details:
|
|
|
|
|
data.details === undefined
|
|
|
|
|
? undefined
|
|
|
|
|
: data.details === null
|
|
|
|
|
? Prisma.JsonNull
|
|
|
|
|
: (JSON.parse(JSON.stringify(data.details)) as Prisma.InputJsonValue),
|
2025-08-20 18:02:50 +09:00
|
|
|
ipAddress: data.ipAddress,
|
|
|
|
|
userAgent: data.userAgent,
|
|
|
|
|
success: data.success ?? true,
|
|
|
|
|
error: data.error,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
2025-08-22 17:02:49 +09:00
|
|
|
this.logger.error("Audit logging failed", {
|
|
|
|
|
errorType: error instanceof Error ? error.constructor.name : "Unknown",
|
|
|
|
|
message: getErrorMessage(error),
|
|
|
|
|
});
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async logAuthEvent(
|
|
|
|
|
action: AuditAction,
|
|
|
|
|
userId?: string,
|
2025-08-27 10:54:05 +09:00
|
|
|
details?: Record<string, unknown> | string | number | boolean | null,
|
2025-10-03 11:29:59 +09:00
|
|
|
request?: {
|
|
|
|
|
headers?: Record<string, string | string[] | undefined>;
|
|
|
|
|
ip?: string;
|
|
|
|
|
connection?: { remoteAddress?: string };
|
|
|
|
|
socket?: { remoteAddress?: string };
|
|
|
|
|
},
|
2025-08-20 18:02:50 +09:00
|
|
|
success: boolean = true,
|
2025-08-22 17:02:49 +09:00
|
|
|
error?: string
|
2025-08-20 18:02:50 +09:00
|
|
|
): Promise<void> {
|
|
|
|
|
const ipAddress = this.extractIpAddress(request);
|
2025-08-27 10:54:05 +09:00
|
|
|
const uaHeader = request?.headers?.["user-agent"];
|
|
|
|
|
const userAgent = Array.isArray(uaHeader) ? uaHeader[0] : uaHeader;
|
2025-08-20 18:02:50 +09:00
|
|
|
|
|
|
|
|
await this.log({
|
|
|
|
|
userId,
|
|
|
|
|
action,
|
2025-08-21 15:24:40 +09:00
|
|
|
resource: "auth",
|
2025-08-20 18:02:50 +09:00
|
|
|
details,
|
|
|
|
|
ipAddress,
|
|
|
|
|
userAgent,
|
|
|
|
|
success,
|
|
|
|
|
error,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-27 10:54:05 +09:00
|
|
|
private extractIpAddress(request?: {
|
|
|
|
|
headers?: Record<string, string | string[] | undefined>;
|
|
|
|
|
connection?: { remoteAddress?: string };
|
|
|
|
|
socket?: { remoteAddress?: string };
|
|
|
|
|
ip?: string;
|
|
|
|
|
}): string | undefined {
|
2025-08-20 18:02:50 +09:00
|
|
|
if (!request) return undefined;
|
2025-08-21 15:24:40 +09:00
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
return (
|
2025-08-27 10:54:05 +09:00
|
|
|
(typeof request.headers?.["x-forwarded-for"] === "string"
|
|
|
|
|
? request.headers["x-forwarded-for"].split(",")[0]?.trim()
|
|
|
|
|
: Array.isArray(request.headers?.["x-forwarded-for"]) &&
|
|
|
|
|
request.headers?.["x-forwarded-for"][0]
|
|
|
|
|
? request.headers["x-forwarded-for"][0]
|
|
|
|
|
: undefined) ||
|
|
|
|
|
(typeof request.headers?.["x-real-ip"] === "string"
|
|
|
|
|
? request.headers["x-real-ip"]
|
|
|
|
|
: undefined) ||
|
2025-08-20 18:02:50 +09:00
|
|
|
request.connection?.remoteAddress ||
|
|
|
|
|
request.socket?.remoteAddress ||
|
|
|
|
|
request.ip
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-09-17 18:43:43 +09:00
|
|
|
|
2025-09-18 17:49:43 +09:00
|
|
|
async getAuditLogs({
|
|
|
|
|
page,
|
|
|
|
|
limit,
|
|
|
|
|
action,
|
|
|
|
|
userId,
|
|
|
|
|
}: {
|
|
|
|
|
page: number;
|
|
|
|
|
limit: number;
|
|
|
|
|
action?: AuditAction;
|
|
|
|
|
userId?: string;
|
|
|
|
|
}) {
|
|
|
|
|
const skip = (page - 1) * limit;
|
|
|
|
|
const where: Prisma.AuditLogWhereInput = {};
|
|
|
|
|
if (action) where.action = action;
|
|
|
|
|
if (userId) where.userId = userId;
|
|
|
|
|
|
|
|
|
|
const [logs, total] = await Promise.all([
|
|
|
|
|
this.prisma.auditLog.findMany({
|
|
|
|
|
where,
|
|
|
|
|
include: {
|
|
|
|
|
user: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
email: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: { createdAt: "desc" },
|
|
|
|
|
skip,
|
|
|
|
|
take: limit,
|
|
|
|
|
}),
|
|
|
|
|
this.prisma.auditLog.count({ where }),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return { logs, total };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getSecurityStats() {
|
|
|
|
|
const today = new Date(new Date().setHours(0, 0, 0, 0));
|
|
|
|
|
|
|
|
|
|
const [totalUsers, lockedAccounts, failedLoginsToday, successfulLoginsToday] =
|
|
|
|
|
await Promise.all([
|
|
|
|
|
this.prisma.user.count(),
|
|
|
|
|
this.prisma.user.count({
|
|
|
|
|
where: {
|
|
|
|
|
lockedUntil: {
|
|
|
|
|
gt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
this.prisma.auditLog.count({
|
|
|
|
|
where: {
|
|
|
|
|
action: AuditAction.LOGIN_FAILED,
|
|
|
|
|
createdAt: {
|
|
|
|
|
gte: today,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
this.prisma.auditLog.count({
|
|
|
|
|
where: {
|
|
|
|
|
action: AuditAction.LOGIN_SUCCESS,
|
|
|
|
|
createdAt: {
|
|
|
|
|
gte: today,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
totalUsers,
|
|
|
|
|
lockedAccounts,
|
|
|
|
|
failedLoginsToday,
|
|
|
|
|
successfulLoginsToday,
|
|
|
|
|
securityEventsToday: failedLoginsToday + successfulLoginsToday,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|