feat(audit-logging): Implement Edge-compatible audit logging utility and safe logging module
- Added `auditLogEdge.js` for Edge Runtime compatible audit logging, including console logging and API fallback. - Introduced `auditLogSafe.js` for safe audit logging without direct database imports, ensuring compatibility across runtimes. - Enhanced `auth.js` to integrate safe audit logging for login actions, including success and failure cases. - Created middleware `auditLog.js` to facilitate audit logging for API routes with predefined configurations. - Updated `middleware.js` to allow API route access without authentication checks. - Added tests for audit logging functionality and Edge compatibility in `test-audit-logging.mjs` and `test-edge-compatibility.mjs`. - Implemented safe audit logging tests in `test-safe-audit-logging.mjs` to verify functionality across environments.
This commit is contained in:
424
src/lib/auditLog.js
Normal file
424
src/lib/auditLog.js
Normal file
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Audit log actions - standardized action types
|
||||
*/
|
||||
export const AUDIT_ACTIONS = {
|
||||
// Authentication
|
||||
LOGIN: "login",
|
||||
LOGOUT: "logout",
|
||||
LOGIN_FAILED: "login_failed",
|
||||
|
||||
// Projects
|
||||
PROJECT_CREATE: "project_create",
|
||||
PROJECT_UPDATE: "project_update",
|
||||
PROJECT_DELETE: "project_delete",
|
||||
PROJECT_VIEW: "project_view",
|
||||
|
||||
// Tasks
|
||||
TASK_CREATE: "task_create",
|
||||
TASK_UPDATE: "task_update",
|
||||
TASK_DELETE: "task_delete",
|
||||
TASK_STATUS_CHANGE: "task_status_change",
|
||||
|
||||
// Project Tasks
|
||||
PROJECT_TASK_CREATE: "project_task_create",
|
||||
PROJECT_TASK_UPDATE: "project_task_update",
|
||||
PROJECT_TASK_DELETE: "project_task_delete",
|
||||
PROJECT_TASK_STATUS_CHANGE: "project_task_status_change",
|
||||
|
||||
// Contracts
|
||||
CONTRACT_CREATE: "contract_create",
|
||||
CONTRACT_UPDATE: "contract_update",
|
||||
CONTRACT_DELETE: "contract_delete",
|
||||
|
||||
// Notes
|
||||
NOTE_CREATE: "note_create",
|
||||
NOTE_UPDATE: "note_update",
|
||||
NOTE_DELETE: "note_delete",
|
||||
|
||||
// Admin actions
|
||||
USER_CREATE: "user_create",
|
||||
USER_UPDATE: "user_update",
|
||||
USER_DELETE: "user_delete",
|
||||
USER_ROLE_CHANGE: "user_role_change",
|
||||
|
||||
// System actions
|
||||
DATA_EXPORT: "data_export",
|
||||
BULK_OPERATION: "bulk_operation",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resource types for audit logging
|
||||
*/
|
||||
export const RESOURCE_TYPES = {
|
||||
PROJECT: "project",
|
||||
TASK: "task",
|
||||
PROJECT_TASK: "project_task",
|
||||
CONTRACT: "contract",
|
||||
NOTE: "note",
|
||||
USER: "user",
|
||||
SESSION: "session",
|
||||
SYSTEM: "system",
|
||||
};
|
||||
|
||||
/**
|
||||
* Log an audit event
|
||||
* @param {Object} params - Audit log parameters
|
||||
* @param {string} params.action - Action performed (use AUDIT_ACTIONS constants)
|
||||
* @param {string} [params.userId] - ID of user performing the action
|
||||
* @param {string} [params.resourceType] - Type of resource affected (use RESOURCE_TYPES constants)
|
||||
* @param {string} [params.resourceId] - ID of the affected resource
|
||||
* @param {string} [params.ipAddress] - IP address of the user
|
||||
* @param {string} [params.userAgent] - User agent string
|
||||
* @param {Object} [params.details] - Additional details about the action
|
||||
* @param {string} [params.timestamp] - Custom timestamp (defaults to current time)
|
||||
*/
|
||||
export async function logAuditEvent({
|
||||
action,
|
||||
userId = null,
|
||||
resourceType = null,
|
||||
resourceId = null,
|
||||
ipAddress = null,
|
||||
userAgent = null,
|
||||
details = null,
|
||||
timestamp = null,
|
||||
}) {
|
||||
try {
|
||||
// Check if we're in Edge Runtime - if so, skip database operations
|
||||
if (
|
||||
typeof EdgeRuntime !== "undefined" ||
|
||||
process.env.NEXT_RUNTIME === "edge"
|
||||
) {
|
||||
console.log(
|
||||
`[Audit Log - Edge Runtime] ${action} by user ${
|
||||
userId || "anonymous"
|
||||
} on ${resourceType}:${resourceId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dynamic import to avoid Edge Runtime issues
|
||||
const { default: db } = await import("./db.js");
|
||||
|
||||
const auditTimestamp = timestamp || new Date().toISOString();
|
||||
const detailsJson = details ? JSON.stringify(details) : null;
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO audit_logs (
|
||||
user_id, action, resource_type, resource_id,
|
||||
ip_address, user_agent, timestamp, details
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
userId,
|
||||
action,
|
||||
resourceType,
|
||||
resourceId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
auditTimestamp,
|
||||
detailsJson
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Audit log: ${action} by user ${
|
||||
userId || "anonymous"
|
||||
} on ${resourceType}:${resourceId}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to log audit event:", error);
|
||||
// Don't throw error to avoid breaking the main application flow
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit logs with filtering and pagination
|
||||
* @param {Object} options - Query options
|
||||
* @param {string} [options.userId] - Filter by user ID
|
||||
* @param {string} [options.action] - Filter by action
|
||||
* @param {string} [options.resourceType] - Filter by resource type
|
||||
* @param {string} [options.resourceId] - Filter by resource ID
|
||||
* @param {string} [options.startDate] - Filter from this date (ISO string)
|
||||
* @param {string} [options.endDate] - Filter until this date (ISO string)
|
||||
* @param {number} [options.limit] - Maximum number of records to return
|
||||
* @param {number} [options.offset] - Number of records to skip
|
||||
* @param {string} [options.orderBy] - Order by field (default: timestamp)
|
||||
* @param {string} [options.orderDirection] - Order direction (ASC/DESC, default: DESC)
|
||||
* @returns {Array} Array of audit log entries
|
||||
*/
|
||||
export async function getAuditLogs({
|
||||
userId = null,
|
||||
action = null,
|
||||
resourceType = null,
|
||||
resourceId = null,
|
||||
startDate = null,
|
||||
endDate = null,
|
||||
limit = 100,
|
||||
offset = 0,
|
||||
orderBy = "timestamp",
|
||||
orderDirection = "DESC",
|
||||
} = {}) {
|
||||
try {
|
||||
// Check if we're in Edge Runtime - if so, return empty array
|
||||
if (
|
||||
typeof EdgeRuntime !== "undefined" ||
|
||||
process.env.NEXT_RUNTIME === "edge"
|
||||
) {
|
||||
console.log(
|
||||
"[Audit Log - Edge Runtime] Cannot query audit logs in Edge Runtime"
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Dynamic import to avoid Edge Runtime issues
|
||||
const { default: db } = await import("./db.js");
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
al.*,
|
||||
u.name as user_name,
|
||||
u.email as user_email
|
||||
FROM audit_logs al
|
||||
LEFT JOIN users u ON al.user_id = u.id
|
||||
WHERE 1=1
|
||||
`;
|
||||
|
||||
const params = [];
|
||||
|
||||
if (userId) {
|
||||
query += " AND al.user_id = ?";
|
||||
params.push(userId);
|
||||
}
|
||||
|
||||
if (action) {
|
||||
query += " AND al.action = ?";
|
||||
params.push(action);
|
||||
}
|
||||
|
||||
if (resourceType) {
|
||||
query += " AND al.resource_type = ?";
|
||||
params.push(resourceType);
|
||||
}
|
||||
|
||||
if (resourceId) {
|
||||
query += " AND al.resource_id = ?";
|
||||
params.push(resourceId);
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
query += " AND al.timestamp >= ?";
|
||||
params.push(startDate);
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
query += " AND al.timestamp <= ?";
|
||||
params.push(endDate);
|
||||
}
|
||||
|
||||
// Validate order direction
|
||||
const validOrderDirection = ["ASC", "DESC"].includes(
|
||||
orderDirection.toUpperCase()
|
||||
)
|
||||
? orderDirection.toUpperCase()
|
||||
: "DESC";
|
||||
|
||||
// Validate order by field
|
||||
const validOrderFields = [
|
||||
"timestamp",
|
||||
"action",
|
||||
"user_id",
|
||||
"resource_type",
|
||||
"resource_id",
|
||||
];
|
||||
const validOrderBy = validOrderFields.includes(orderBy)
|
||||
? orderBy
|
||||
: "timestamp";
|
||||
|
||||
query += ` ORDER BY al.${validOrderBy} ${validOrderDirection}`;
|
||||
|
||||
if (limit) {
|
||||
query += " LIMIT ?";
|
||||
params.push(limit);
|
||||
}
|
||||
|
||||
if (offset) {
|
||||
query += " OFFSET ?";
|
||||
params.push(offset);
|
||||
}
|
||||
|
||||
const stmt = db.prepare(query);
|
||||
const results = stmt.all(...params);
|
||||
|
||||
// Parse details JSON for each result
|
||||
return results.map((log) => ({
|
||||
...log,
|
||||
details: log.details ? JSON.parse(log.details) : null,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Failed to get audit logs:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit log statistics
|
||||
* @param {Object} options - Query options
|
||||
* @param {string} [options.startDate] - Filter from this date (ISO string)
|
||||
* @param {string} [options.endDate] - Filter until this date (ISO string)
|
||||
* @returns {Object} Statistics object
|
||||
*/
|
||||
export async function getAuditLogStats({
|
||||
startDate = null,
|
||||
endDate = null,
|
||||
} = {}) {
|
||||
try {
|
||||
// Check if we're in Edge Runtime - if so, return empty stats
|
||||
if (
|
||||
typeof EdgeRuntime !== "undefined" ||
|
||||
process.env.NEXT_RUNTIME === "edge"
|
||||
) {
|
||||
console.log(
|
||||
"[Audit Log - Edge Runtime] Cannot query audit log stats in Edge Runtime"
|
||||
);
|
||||
return {
|
||||
total: 0,
|
||||
actionBreakdown: [],
|
||||
userBreakdown: [],
|
||||
resourceBreakdown: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Dynamic import to avoid Edge Runtime issues
|
||||
const { default: db } = await import("./db.js");
|
||||
|
||||
let baseQuery = "FROM audit_logs WHERE 1=1";
|
||||
const params = [];
|
||||
|
||||
if (startDate) {
|
||||
baseQuery += " AND timestamp >= ?";
|
||||
params.push(startDate);
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
baseQuery += " AND timestamp <= ?";
|
||||
params.push(endDate);
|
||||
}
|
||||
|
||||
// Total count
|
||||
const totalStmt = db.prepare(`SELECT COUNT(*) as total ${baseQuery}`);
|
||||
const totalResult = totalStmt.get(...params);
|
||||
|
||||
// Actions breakdown
|
||||
const actionsStmt = db.prepare(`
|
||||
SELECT action, COUNT(*) as count
|
||||
${baseQuery}
|
||||
GROUP BY action
|
||||
ORDER BY count DESC
|
||||
`);
|
||||
const actionsResult = actionsStmt.all(...params);
|
||||
|
||||
// Users breakdown
|
||||
const usersStmt = db.prepare(`
|
||||
SELECT
|
||||
al.user_id,
|
||||
u.name as user_name,
|
||||
u.email as user_email,
|
||||
COUNT(*) as count
|
||||
${baseQuery}
|
||||
LEFT JOIN users u ON al.user_id = u.id
|
||||
GROUP BY al.user_id, u.name, u.email
|
||||
ORDER BY count DESC
|
||||
LIMIT 10
|
||||
`);
|
||||
const usersResult = usersStmt.all(...params);
|
||||
|
||||
// Resource types breakdown
|
||||
const resourcesStmt = db.prepare(`
|
||||
SELECT resource_type, COUNT(*) as count
|
||||
${baseQuery}
|
||||
WHERE resource_type IS NOT NULL
|
||||
GROUP BY resource_type
|
||||
ORDER BY count DESC
|
||||
`);
|
||||
const resourcesResult = resourcesStmt.all(...params);
|
||||
|
||||
return {
|
||||
total: totalResult.total,
|
||||
actionBreakdown: actionsResult,
|
||||
userBreakdown: usersResult,
|
||||
resourceBreakdown: resourcesResult,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to get audit log statistics:", error);
|
||||
return {
|
||||
total: 0,
|
||||
actionBreakdown: [],
|
||||
userBreakdown: [],
|
||||
resourceBreakdown: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to extract client information from request
|
||||
* @param {Request} req - The request object
|
||||
* @returns {Object} Object containing IP address and user agent
|
||||
*/
|
||||
export function getClientInfo(req) {
|
||||
const ipAddress =
|
||||
req.headers.get("x-forwarded-for") ||
|
||||
req.headers.get("x-real-ip") ||
|
||||
req.headers.get("cf-connecting-ip") ||
|
||||
req.ip ||
|
||||
"unknown";
|
||||
|
||||
const userAgent = req.headers.get("user-agent") || "unknown";
|
||||
|
||||
return { ipAddress, userAgent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware helper to log API actions
|
||||
* @param {Request} req - The request object
|
||||
* @param {string} action - The action being performed
|
||||
* @param {string} resourceType - The type of resource
|
||||
* @param {string} resourceId - The ID of the resource
|
||||
* @param {Object} session - The user session
|
||||
* @param {Object} additionalDetails - Additional details to log
|
||||
*/
|
||||
export async function logApiAction(
|
||||
req,
|
||||
action,
|
||||
resourceType,
|
||||
resourceId,
|
||||
session,
|
||||
additionalDetails = {}
|
||||
) {
|
||||
const { ipAddress, userAgent } = getClientInfo(req);
|
||||
|
||||
await logAuditEvent({
|
||||
action,
|
||||
userId: session?.user?.id || null,
|
||||
resourceType,
|
||||
resourceId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
details: {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
...additionalDetails,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const auditLog = {
|
||||
logAuditEvent,
|
||||
getAuditLogs,
|
||||
getAuditLogStats,
|
||||
getClientInfo,
|
||||
logApiAction,
|
||||
AUDIT_ACTIONS,
|
||||
RESOURCE_TYPES,
|
||||
};
|
||||
|
||||
export default auditLog;
|
||||
129
src/lib/auditLogEdge.js
Normal file
129
src/lib/auditLogEdge.js
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Edge-compatible audit logging utility
|
||||
* This version avoids direct database imports and can be used in Edge Runtime
|
||||
*/
|
||||
|
||||
import { AUDIT_ACTIONS, RESOURCE_TYPES } from "./auditLog.js";
|
||||
|
||||
/**
|
||||
* Log an audit event in Edge Runtime compatible way
|
||||
* @param {Object} params - Audit log parameters
|
||||
*/
|
||||
export async function logAuditEventAsync({
|
||||
action,
|
||||
userId = null,
|
||||
resourceType = null,
|
||||
resourceId = null,
|
||||
ipAddress = null,
|
||||
userAgent = null,
|
||||
details = null,
|
||||
timestamp = null,
|
||||
}) {
|
||||
try {
|
||||
// In Edge Runtime or when database is not available, log to console
|
||||
if (
|
||||
typeof EdgeRuntime !== "undefined" ||
|
||||
process.env.NEXT_RUNTIME === "edge"
|
||||
) {
|
||||
console.log(
|
||||
`[Audit Log - Edge] ${action} by user ${
|
||||
userId || "anonymous"
|
||||
} on ${resourceType}:${resourceId}`,
|
||||
{
|
||||
details,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
timestamp: timestamp || new Date().toISOString(),
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to make an API call to log the event
|
||||
try {
|
||||
const response = await fetch("/api/audit-logs/log", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action,
|
||||
userId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
details,
|
||||
timestamp,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
} catch (fetchError) {
|
||||
// Fallback to console logging if API call fails
|
||||
console.log(
|
||||
`[Audit Log - Fallback] ${action} by user ${
|
||||
userId || "anonymous"
|
||||
} on ${resourceType}:${resourceId}`,
|
||||
{
|
||||
details,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
timestamp: timestamp || new Date().toISOString(),
|
||||
error: fetchError.message,
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to log audit event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to extract client information from request (Edge compatible)
|
||||
* @param {Request} req - The request object
|
||||
* @returns {Object} Object containing IP address and user agent
|
||||
*/
|
||||
export function getClientInfoEdgeCompatible(req) {
|
||||
const ipAddress =
|
||||
req.headers.get("x-forwarded-for") ||
|
||||
req.headers.get("x-real-ip") ||
|
||||
req.headers.get("cf-connecting-ip") ||
|
||||
"unknown";
|
||||
|
||||
const userAgent = req.headers.get("user-agent") || "unknown";
|
||||
|
||||
return { ipAddress, userAgent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware helper to log API actions (Edge compatible)
|
||||
*/
|
||||
export async function logApiActionAsync(
|
||||
req,
|
||||
action,
|
||||
resourceType,
|
||||
resourceId,
|
||||
session,
|
||||
additionalDetails = {}
|
||||
) {
|
||||
const { ipAddress, userAgent } = getClientInfoEdgeCompatible(req);
|
||||
|
||||
await logAuditEventAsync({
|
||||
action,
|
||||
userId: session?.user?.id || null,
|
||||
resourceType,
|
||||
resourceId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
details: {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
...additionalDetails,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { AUDIT_ACTIONS, RESOURCE_TYPES };
|
||||
159
src/lib/auditLogSafe.js
Normal file
159
src/lib/auditLogSafe.js
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Safe audit logging that doesn't cause Edge Runtime issues
|
||||
* This module can be safely imported anywhere without causing database issues
|
||||
*/
|
||||
|
||||
// Constants that can be safely exported
|
||||
export const AUDIT_ACTIONS = {
|
||||
// Authentication
|
||||
LOGIN: "login",
|
||||
LOGOUT: "logout",
|
||||
LOGIN_FAILED: "login_failed",
|
||||
|
||||
// Projects
|
||||
PROJECT_CREATE: "project_create",
|
||||
PROJECT_UPDATE: "project_update",
|
||||
PROJECT_DELETE: "project_delete",
|
||||
PROJECT_VIEW: "project_view",
|
||||
|
||||
// Tasks
|
||||
TASK_CREATE: "task_create",
|
||||
TASK_UPDATE: "task_update",
|
||||
TASK_DELETE: "task_delete",
|
||||
TASK_STATUS_CHANGE: "task_status_change",
|
||||
|
||||
// Project Tasks
|
||||
PROJECT_TASK_CREATE: "project_task_create",
|
||||
PROJECT_TASK_UPDATE: "project_task_update",
|
||||
PROJECT_TASK_DELETE: "project_task_delete",
|
||||
PROJECT_TASK_STATUS_CHANGE: "project_task_status_change",
|
||||
|
||||
// Contracts
|
||||
CONTRACT_CREATE: "contract_create",
|
||||
CONTRACT_UPDATE: "contract_update",
|
||||
CONTRACT_DELETE: "contract_delete",
|
||||
|
||||
// Notes
|
||||
NOTE_CREATE: "note_create",
|
||||
NOTE_UPDATE: "note_update",
|
||||
NOTE_DELETE: "note_delete",
|
||||
|
||||
// Admin actions
|
||||
USER_CREATE: "user_create",
|
||||
USER_UPDATE: "user_update",
|
||||
USER_DELETE: "user_delete",
|
||||
USER_ROLE_CHANGE: "user_role_change",
|
||||
|
||||
// System actions
|
||||
DATA_EXPORT: "data_export",
|
||||
BULK_OPERATION: "bulk_operation",
|
||||
};
|
||||
|
||||
export const RESOURCE_TYPES = {
|
||||
PROJECT: "project",
|
||||
TASK: "task",
|
||||
PROJECT_TASK: "project_task",
|
||||
CONTRACT: "contract",
|
||||
NOTE: "note",
|
||||
USER: "user",
|
||||
SESSION: "session",
|
||||
SYSTEM: "system",
|
||||
};
|
||||
|
||||
/**
|
||||
* Safe audit logging function that works in any runtime
|
||||
*/
|
||||
export async function logAuditEventSafe({
|
||||
action,
|
||||
userId = null,
|
||||
resourceType = null,
|
||||
resourceId = null,
|
||||
ipAddress = null,
|
||||
userAgent = null,
|
||||
details = null,
|
||||
timestamp = null,
|
||||
}) {
|
||||
try {
|
||||
// Always log to console first
|
||||
console.log(
|
||||
`[Audit] ${action} by user ${
|
||||
userId || "anonymous"
|
||||
} on ${resourceType}:${resourceId}`
|
||||
);
|
||||
|
||||
// Check if we're in Edge Runtime
|
||||
if (
|
||||
typeof EdgeRuntime !== "undefined" ||
|
||||
process.env.NEXT_RUNTIME === "edge"
|
||||
) {
|
||||
console.log("[Audit] Edge Runtime detected - console logging only");
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to get the database-enabled audit function
|
||||
try {
|
||||
const auditModule = await import("./auditLog.js");
|
||||
await auditModule.logAuditEvent({
|
||||
action,
|
||||
userId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
details,
|
||||
timestamp,
|
||||
});
|
||||
} catch (dbError) {
|
||||
console.log(
|
||||
"[Audit] Database logging failed, using console fallback:",
|
||||
dbError.message
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Audit] Failed to log audit event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to extract client information from request
|
||||
*/
|
||||
export function getClientInfo(req) {
|
||||
const ipAddress =
|
||||
req.headers?.get?.("x-forwarded-for") ||
|
||||
req.headers?.get?.("x-real-ip") ||
|
||||
req.headers?.get?.("cf-connecting-ip") ||
|
||||
req.ip ||
|
||||
"unknown";
|
||||
|
||||
const userAgent = req.headers?.get?.("user-agent") || "unknown";
|
||||
|
||||
return { ipAddress, userAgent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe API action logging
|
||||
*/
|
||||
export async function logApiActionSafe(
|
||||
req,
|
||||
action,
|
||||
resourceType,
|
||||
resourceId,
|
||||
session,
|
||||
additionalDetails = {}
|
||||
) {
|
||||
const { ipAddress, userAgent } = getClientInfo(req);
|
||||
|
||||
await logAuditEventSafe({
|
||||
action,
|
||||
userId: session?.user?.id || null,
|
||||
resourceType,
|
||||
resourceId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
details: {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
...additionalDetails,
|
||||
},
|
||||
});
|
||||
}
|
||||
226
src/lib/auth.js
226
src/lib/auth.js
@@ -1,52 +1,60 @@
|
||||
import NextAuth from "next-auth"
|
||||
import Credentials from "next-auth/providers/credentials"
|
||||
import bcrypt from "bcryptjs"
|
||||
import { z } from "zod"
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email("Invalid email format"),
|
||||
password: z.string().min(6, "Password must be at least 6 characters")
|
||||
})
|
||||
email: z.string().email("Invalid email format"),
|
||||
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||
});
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
providers: [
|
||||
Credentials({
|
||||
name: "credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" }
|
||||
},
|
||||
async authorize(credentials) {
|
||||
try {
|
||||
// Import database here to avoid edge runtime issues
|
||||
const { default: db } = await import("./db.js")
|
||||
|
||||
// Validate input
|
||||
const validatedFields = loginSchema.parse(credentials)
|
||||
|
||||
// Check if user exists and is active
|
||||
const user = db.prepare(`
|
||||
providers: [
|
||||
Credentials({
|
||||
name: "credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
try {
|
||||
// Import database here to avoid edge runtime issues
|
||||
const { default: db } = await import("./db.js");
|
||||
|
||||
// Validate input
|
||||
const validatedFields = loginSchema.parse(credentials);
|
||||
|
||||
// Check if user exists and is active
|
||||
const user = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, email, name, password_hash, role, is_active,
|
||||
failed_login_attempts, locked_until
|
||||
FROM users
|
||||
WHERE email = ? AND is_active = 1
|
||||
`).get(validatedFields.email)
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Invalid credentials")
|
||||
}
|
||||
|
||||
// Check if account is locked
|
||||
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
||||
throw new Error("Account temporarily locked")
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValidPassword = await bcrypt.compare(validatedFields.password, user.password_hash)
|
||||
|
||||
if (!isValidPassword) {
|
||||
// Increment failed attempts
|
||||
db.prepare(`
|
||||
`
|
||||
)
|
||||
.get(validatedFields.email);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
// Check if account is locked
|
||||
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
||||
throw new Error("Account temporarily locked");
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValidPassword = await bcrypt.compare(
|
||||
validatedFields.password,
|
||||
user.password_hash
|
||||
);
|
||||
|
||||
if (!isValidPassword) {
|
||||
// Increment failed attempts
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE users
|
||||
SET failed_login_attempts = failed_login_attempts + 1,
|
||||
locked_until = CASE
|
||||
@@ -55,57 +63,95 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
ELSE locked_until
|
||||
END
|
||||
WHERE id = ?
|
||||
`).run(user.id)
|
||||
|
||||
throw new Error("Invalid credentials")
|
||||
}
|
||||
|
||||
// Reset failed attempts and update last login
|
||||
db.prepare(`
|
||||
`
|
||||
).run(user.id);
|
||||
|
||||
// Log failed login attempt (only in Node.js runtime)
|
||||
try {
|
||||
const { logAuditEventSafe, AUDIT_ACTIONS, RESOURCE_TYPES } =
|
||||
await import("./auditLogSafe.js");
|
||||
await logAuditEventSafe({
|
||||
action: AUDIT_ACTIONS.LOGIN_FAILED,
|
||||
userId: user.id,
|
||||
resourceType: RESOURCE_TYPES.SESSION,
|
||||
details: {
|
||||
email: validatedFields.email,
|
||||
reason: "invalid_password",
|
||||
failed_attempts: user.failed_login_attempts + 1,
|
||||
},
|
||||
});
|
||||
} catch (auditError) {
|
||||
console.error("Failed to log audit event:", auditError);
|
||||
}
|
||||
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
// Reset failed attempts and update last login
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE users
|
||||
SET failed_login_attempts = 0,
|
||||
locked_until = NULL,
|
||||
last_login = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(user.id)
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Login error:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
})
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.role = user.role
|
||||
token.userId = user.id
|
||||
}
|
||||
return token
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (token) {
|
||||
session.user.id = token.userId
|
||||
session.user.role = token.role
|
||||
}
|
||||
return session
|
||||
}
|
||||
},
|
||||
pages: {
|
||||
signIn: '/auth/signin',
|
||||
signOut: '/auth/signout',
|
||||
error: '/auth/error'
|
||||
},
|
||||
debug: process.env.NODE_ENV === 'development'
|
||||
})
|
||||
`
|
||||
).run(user.id);
|
||||
|
||||
// Log successful login (only in Node.js runtime)
|
||||
try {
|
||||
const { logAuditEventSafe, AUDIT_ACTIONS, RESOURCE_TYPES } =
|
||||
await import("./auditLogSafe.js");
|
||||
await logAuditEventSafe({
|
||||
action: AUDIT_ACTIONS.LOGIN,
|
||||
userId: user.id,
|
||||
resourceType: RESOURCE_TYPES.SESSION,
|
||||
details: {
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
},
|
||||
});
|
||||
} catch (auditError) {
|
||||
console.error("Failed to log audit event:", auditError);
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Login error:", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.role = user.role;
|
||||
token.userId = user.id;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (token) {
|
||||
session.user.id = token.userId;
|
||||
session.user.role = token.role;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: "/auth/signin",
|
||||
signOut: "/auth/signout",
|
||||
error: "/auth/error",
|
||||
},
|
||||
debug: process.env.NODE_ENV === "development",
|
||||
});
|
||||
|
||||
235
src/lib/middleware/auditLog.js
Normal file
235
src/lib/middleware/auditLog.js
Normal file
@@ -0,0 +1,235 @@
|
||||
import { logApiAction, AUDIT_ACTIONS, RESOURCE_TYPES } from "@/lib/auditLog.js";
|
||||
|
||||
/**
|
||||
* Higher-order function to add audit logging to API routes
|
||||
* @param {Function} handler - The original API route handler
|
||||
* @param {Object} auditConfig - Audit logging configuration
|
||||
* @param {string} auditConfig.action - The audit action to log
|
||||
* @param {string} auditConfig.resourceType - The resource type being accessed
|
||||
* @param {Function} [auditConfig.getResourceId] - Function to extract resource ID from request/params
|
||||
* @param {Function} [auditConfig.getAdditionalDetails] - Function to get additional details to log
|
||||
* @returns {Function} Wrapped handler with audit logging
|
||||
*/
|
||||
export function withAuditLog(handler, auditConfig) {
|
||||
return async (request, context) => {
|
||||
try {
|
||||
// Execute the original handler first
|
||||
const response = await handler(request, context);
|
||||
|
||||
// Extract resource ID if function provided
|
||||
let resourceId = null;
|
||||
if (auditConfig.getResourceId) {
|
||||
resourceId = auditConfig.getResourceId(request, context, response);
|
||||
} else if (context?.params?.id) {
|
||||
resourceId = context.params.id;
|
||||
}
|
||||
|
||||
// Get additional details if function provided
|
||||
let additionalDetails = {};
|
||||
if (auditConfig.getAdditionalDetails) {
|
||||
additionalDetails = auditConfig.getAdditionalDetails(
|
||||
request,
|
||||
context,
|
||||
response
|
||||
);
|
||||
}
|
||||
|
||||
// Log the action
|
||||
logApiAction(
|
||||
request,
|
||||
auditConfig.action,
|
||||
auditConfig.resourceType,
|
||||
resourceId,
|
||||
request.session,
|
||||
additionalDetails
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Log failed actions
|
||||
const resourceId = auditConfig.getResourceId
|
||||
? auditConfig.getResourceId(request, context, null)
|
||||
: context?.params?.id || null;
|
||||
|
||||
logApiAction(
|
||||
request,
|
||||
`${auditConfig.action}_failed`,
|
||||
auditConfig.resourceType,
|
||||
resourceId,
|
||||
request.session,
|
||||
{
|
||||
error: error.message,
|
||||
...(auditConfig.getAdditionalDetails
|
||||
? auditConfig.getAdditionalDetails(request, context, null)
|
||||
: {}),
|
||||
}
|
||||
);
|
||||
|
||||
// Re-throw the error
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Predefined audit configurations for common actions
|
||||
*/
|
||||
export const AUDIT_CONFIGS = {
|
||||
// Project actions
|
||||
PROJECT_VIEW: {
|
||||
action: AUDIT_ACTIONS.PROJECT_VIEW,
|
||||
resourceType: RESOURCE_TYPES.PROJECT,
|
||||
},
|
||||
PROJECT_CREATE: {
|
||||
action: AUDIT_ACTIONS.PROJECT_CREATE,
|
||||
resourceType: RESOURCE_TYPES.PROJECT,
|
||||
getResourceId: (req, ctx, res) => res?.json?.projectId?.toString(),
|
||||
getAdditionalDetails: async (req) => {
|
||||
const data = await req.json();
|
||||
return { projectData: data };
|
||||
},
|
||||
},
|
||||
PROJECT_UPDATE: {
|
||||
action: AUDIT_ACTIONS.PROJECT_UPDATE,
|
||||
resourceType: RESOURCE_TYPES.PROJECT,
|
||||
getAdditionalDetails: async (req) => {
|
||||
const data = await req.json();
|
||||
return { updatedData: data };
|
||||
},
|
||||
},
|
||||
PROJECT_DELETE: {
|
||||
action: AUDIT_ACTIONS.PROJECT_DELETE,
|
||||
resourceType: RESOURCE_TYPES.PROJECT,
|
||||
},
|
||||
|
||||
// Task actions
|
||||
TASK_VIEW: {
|
||||
action: AUDIT_ACTIONS.TASK_VIEW,
|
||||
resourceType: RESOURCE_TYPES.TASK,
|
||||
},
|
||||
TASK_CREATE: {
|
||||
action: AUDIT_ACTIONS.TASK_CREATE,
|
||||
resourceType: RESOURCE_TYPES.TASK,
|
||||
getAdditionalDetails: async (req) => {
|
||||
const data = await req.json();
|
||||
return { taskData: data };
|
||||
},
|
||||
},
|
||||
TASK_UPDATE: {
|
||||
action: AUDIT_ACTIONS.TASK_UPDATE,
|
||||
resourceType: RESOURCE_TYPES.TASK,
|
||||
getAdditionalDetails: async (req) => {
|
||||
const data = await req.json();
|
||||
return { updatedData: data };
|
||||
},
|
||||
},
|
||||
TASK_DELETE: {
|
||||
action: AUDIT_ACTIONS.TASK_DELETE,
|
||||
resourceType: RESOURCE_TYPES.TASK,
|
||||
},
|
||||
|
||||
// Project Task actions
|
||||
PROJECT_TASK_VIEW: {
|
||||
action: AUDIT_ACTIONS.PROJECT_TASK_VIEW,
|
||||
resourceType: RESOURCE_TYPES.PROJECT_TASK,
|
||||
},
|
||||
PROJECT_TASK_CREATE: {
|
||||
action: AUDIT_ACTIONS.PROJECT_TASK_CREATE,
|
||||
resourceType: RESOURCE_TYPES.PROJECT_TASK,
|
||||
getAdditionalDetails: async (req) => {
|
||||
const data = await req.json();
|
||||
return { taskData: data };
|
||||
},
|
||||
},
|
||||
PROJECT_TASK_UPDATE: {
|
||||
action: AUDIT_ACTIONS.PROJECT_TASK_UPDATE,
|
||||
resourceType: RESOURCE_TYPES.PROJECT_TASK,
|
||||
getAdditionalDetails: async (req) => {
|
||||
const data = await req.json();
|
||||
return { updatedData: data };
|
||||
},
|
||||
},
|
||||
PROJECT_TASK_DELETE: {
|
||||
action: AUDIT_ACTIONS.PROJECT_TASK_DELETE,
|
||||
resourceType: RESOURCE_TYPES.PROJECT_TASK,
|
||||
},
|
||||
|
||||
// Contract actions
|
||||
CONTRACT_VIEW: {
|
||||
action: AUDIT_ACTIONS.CONTRACT_VIEW,
|
||||
resourceType: RESOURCE_TYPES.CONTRACT,
|
||||
},
|
||||
CONTRACT_CREATE: {
|
||||
action: AUDIT_ACTIONS.CONTRACT_CREATE,
|
||||
resourceType: RESOURCE_TYPES.CONTRACT,
|
||||
getAdditionalDetails: async (req) => {
|
||||
const data = await req.json();
|
||||
return { contractData: data };
|
||||
},
|
||||
},
|
||||
CONTRACT_UPDATE: {
|
||||
action: AUDIT_ACTIONS.CONTRACT_UPDATE,
|
||||
resourceType: RESOURCE_TYPES.CONTRACT,
|
||||
getAdditionalDetails: async (req) => {
|
||||
const data = await req.json();
|
||||
return { updatedData: data };
|
||||
},
|
||||
},
|
||||
CONTRACT_DELETE: {
|
||||
action: AUDIT_ACTIONS.CONTRACT_DELETE,
|
||||
resourceType: RESOURCE_TYPES.CONTRACT,
|
||||
},
|
||||
|
||||
// Note actions
|
||||
NOTE_VIEW: {
|
||||
action: AUDIT_ACTIONS.NOTE_VIEW,
|
||||
resourceType: RESOURCE_TYPES.NOTE,
|
||||
},
|
||||
NOTE_CREATE: {
|
||||
action: AUDIT_ACTIONS.NOTE_CREATE,
|
||||
resourceType: RESOURCE_TYPES.NOTE,
|
||||
getAdditionalDetails: async (req) => {
|
||||
const data = await req.json();
|
||||
return { noteData: data };
|
||||
},
|
||||
},
|
||||
NOTE_UPDATE: {
|
||||
action: AUDIT_ACTIONS.NOTE_UPDATE,
|
||||
resourceType: RESOURCE_TYPES.NOTE,
|
||||
getAdditionalDetails: async (req) => {
|
||||
const data = await req.json();
|
||||
return { updatedData: data };
|
||||
},
|
||||
},
|
||||
NOTE_DELETE: {
|
||||
action: AUDIT_ACTIONS.NOTE_DELETE,
|
||||
resourceType: RESOURCE_TYPES.NOTE,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function to create audit-logged API handlers
|
||||
* @param {Object} handlers - Object with HTTP method handlers
|
||||
* @param {Object} auditConfig - Audit configuration for this route
|
||||
* @returns {Object} Object with audit-logged handlers
|
||||
*/
|
||||
export function createAuditedHandlers(handlers, auditConfig) {
|
||||
const auditedHandlers = {};
|
||||
|
||||
Object.entries(handlers).forEach(([method, handler]) => {
|
||||
// Get method-specific audit config or use default
|
||||
const config = auditConfig[method] || auditConfig.default || auditConfig;
|
||||
|
||||
auditedHandlers[method] = withAuditLog(handler, config);
|
||||
});
|
||||
|
||||
return auditedHandlers;
|
||||
}
|
||||
|
||||
const auditLogMiddleware = {
|
||||
withAuditLog,
|
||||
AUDIT_CONFIGS,
|
||||
createAuditedHandlers,
|
||||
};
|
||||
|
||||
export default auditLogMiddleware;
|
||||
Reference in New Issue
Block a user