feat(audit): capture IP address, make TRUST_PROXY configurable

This commit is contained in:
SnapOtter
2026-06-13 16:25:58 +08:00
parent f6d5479334
commit 5d2f520d78
9 changed files with 38 additions and 32 deletions
+9 -1
View File
@@ -172,6 +172,14 @@ await startCancelListener();
ensureAiDirs();
recoverInterruptedInstalls();
function parseTrustProxy(value: string): boolean | number | string {
if (value === "true") return true;
if (value === "false") return false;
const asNum = Number(value);
if (!Number.isNaN(asNum)) return asNum;
return value; // CIDR list
}
const app = Fastify({
logger: {
level: env.LOG_LEVEL,
@@ -194,7 +202,7 @@ const app = Fastify({
redact: ["req.headers.authorization", "req.headers.cookie"],
},
bodyLimit: env.MAX_UPLOAD_SIZE_MB > 0 ? env.MAX_UPLOAD_SIZE_MB * 1024 * 1024 : 1073741824,
trustProxy: env.TRUST_PROXY,
trustProxy: parseTrustProxy(env.TRUST_PROXY),
routerOptions: { maxParamLength: 500 },
});
+3 -2
View File
@@ -39,8 +39,9 @@ export async function auditLog(
logger: FastifyBaseLogger,
event: AuditEvent,
details: Record<string, unknown> = {},
ip: string | null = null,
): Promise<void> {
logger.info({ audit: true, event, ...details }, `[AUDIT] ${event}`);
logger.info({ audit: true, event, ip, ...details }, `[AUDIT] ${event}`);
const actorId = (details.userId as string) ?? (details.adminId as string) ?? null;
const actorUsername = (details.username as string) ?? (details.newUsername as string) ?? "system";
@@ -56,7 +57,7 @@ export async function auditLog(
targetType,
targetId,
details,
ipAddress: null,
ipAddress: ip,
});
} catch {
logger.warn({ event }, "Failed to write audit log to DB");
+1 -4
View File
@@ -58,10 +58,7 @@ const envSchema = z
LIBREOFFICE_TIMEOUT_S: z.coerce.number().default(120),
SESSION_DURATION_HOURS: z.coerce.number().default(168),
LOGIN_ATTEMPT_LIMIT: z.coerce.number().default(30),
TRUST_PROXY: z
.enum(["true", "false"])
.default("false")
.transform((v) => v === "true"),
TRUST_PROXY: z.string().default("false"),
OIDC_ENABLED: z
.enum(["true", "false"])
.default("false")
+9 -9
View File
@@ -294,7 +294,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
await auditLog(request.log, "LOGIN_FAILED", {
username: sanitizeAuditInput(body.username),
reason: "unknown_user",
});
}, request.ip);
return reply.status(401).send({ error: "Invalid credentials" });
}
@@ -303,7 +303,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
await auditLog(request.log, "LOGIN_FAILED", {
username: sanitizeAuditInput(body.username),
reason: "bad_password",
});
}, request.ip);
return reply.status(401).send({ error: "Invalid credentials" });
}
@@ -317,7 +317,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
expiresAt,
});
await auditLog(request.log, "LOGIN_SUCCESS", { userId: user.id, username: user.username });
await auditLog(request.log, "LOGIN_SUCCESS", { userId: user.id, username: user.username }, request.ip);
const [teamRow] = await db.select().from(schema.teams).where(eq(schema.teams.id, user.team));
@@ -378,7 +378,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
cookieReply.clearCookie("snapotter-session", { path: "/" });
}
await auditLog(request.log, "LOGOUT", { userId: user?.id });
await auditLog(request.log, "LOGOUT", { userId: user?.id }, request.ip);
return reply.send({ ok: true, ...(logoutUrl && { logoutUrl }) });
});
@@ -503,7 +503,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
await auditLog(request.log, "PASSWORD_CHANGED", {
userId: authUser.id,
username: authUser.username,
});
}, request.ip);
return reply.send({ ok: true });
});
@@ -674,7 +674,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
newUserId: id,
newUsername: body.username,
role,
});
}, request.ip);
return reply.status(201).send({
id,
@@ -788,7 +788,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
adminId: admin.id,
targetUserId: id,
changes: { role: updates.role, team: updates.team },
});
}, request.ip);
return reply.send({ ok: true });
},
@@ -849,7 +849,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
adminId: admin.id,
targetUserId: id,
targetUsername: user.username,
});
}, request.ip);
return reply.send({ ok: true });
},
@@ -887,7 +887,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
adminId: admin.id,
deletedUserId: id,
deletedUsername: user.username,
});
}, request.ip);
return reply.send({ ok: true });
},
+7 -7
View File
@@ -229,7 +229,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
);
await auditLog(request.log, "OIDC_LOGIN_FAILED", {
reason: sanitizeAuditInput(String(query.error)),
});
}, request.ip);
return redirectToLogin(reply, "oidc_auth_failed");
}
@@ -257,7 +257,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
});
} catch (err) {
request.log.error({ err }, "OIDC token exchange failed");
await auditLog(request.log, "OIDC_LOGIN_FAILED", { reason: "token_exchange_failed" });
await auditLog(request.log, "OIDC_LOGIN_FAILED", { reason: "token_exchange_failed" }, request.ip);
return redirectToLogin(reply, "oidc_auth_failed");
}
@@ -265,7 +265,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
const claims = tokenResponse.claims();
if (!claims) {
request.log.error("OIDC callback: no ID token claims");
await auditLog(request.log, "OIDC_LOGIN_FAILED", { reason: "no_id_token" });
await auditLog(request.log, "OIDC_LOGIN_FAILED", { reason: "no_id_token" }, request.ip);
return redirectToLogin(reply, "oidc_auth_failed");
}
@@ -318,7 +318,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
userId: existingByEmail.id,
username: existingByEmail.username,
email,
});
}, request.ip);
}
}
@@ -361,7 +361,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
username: uniqueUsername,
email,
role: env.OIDC_DEFAULT_ROLE,
});
}, request.ip);
}
// 4d. No user found and no auto-create
@@ -370,7 +370,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
await auditLog(request.log, "OIDC_LOGIN_FAILED", {
reason: "user_not_authorized",
sub: sanitizeAuditInput(String(sub)),
});
}, request.ip);
return redirectToLogin(reply, "oidc_user_not_authorized");
}
@@ -391,7 +391,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
await auditLog(request.log, "OIDC_LOGIN_SUCCESS", {
userId,
username: user?.username ?? username,
});
}, request.ip);
// 6. Set session cookie
reply.setCookie("snapotter-session", token, {
+2 -2
View File
@@ -86,7 +86,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
return reply.status(409).send({ error: "Failed to create API key" });
}
await auditLog(request.log, "API_KEY_CREATED", { userId: user.id, keyId: id, keyName: name });
await auditLog(request.log, "API_KEY_CREATED", { userId: user.id, keyId: id, keyName: name }, request.ip);
// Return the raw key ONCE — it cannot be retrieved again
return reply.status(201).send({
@@ -155,7 +155,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
await db.delete(schema.apiKeys).where(eq(schema.apiKeys.id, id));
await auditLog(request.log, "API_KEY_DELETED", { userId: user.id, keyId: id });
await auditLog(request.log, "API_KEY_DELETED", { userId: user.id, keyId: id }, request.ip);
return reply.send({ ok: true });
},
+3 -3
View File
@@ -116,7 +116,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
createdBy: user.id,
});
await auditLog(request.log, "ROLE_CREATED", { adminId: user.id, roleId: id, roleName: name });
await auditLog(request.log, "ROLE_CREATED", { adminId: user.id, roleId: id, roleName: name }, request.ip);
return reply.status(201).send({
id,
@@ -185,7 +185,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
}
await tx.update(schema.roles).set(updates).where(eq(schema.roles.id, id));
});
await auditLog(request.log, "ROLE_UPDATED", { adminId: user.id, roleId: id });
await auditLog(request.log, "ROLE_UPDATED", { adminId: user.id, roleId: id }, request.ip);
return reply.send({ ok: true });
},
@@ -220,7 +220,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
adminId: user.id,
roleId: id,
roleName: role.name,
});
}, request.ip);
return reply.send({ ok: true });
},
+1 -1
View File
@@ -95,7 +95,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
adminId: admin.id,
username: admin.username,
keys: entries.map((e) => e.key),
});
}, request.ip);
}
return reply.send({ ok: true, updatedCount: entries.length });
+3 -3
View File
@@ -263,7 +263,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
userId,
count: created.length,
files: created.map((f) => f.originalName),
});
}, request.ip);
return reply.status(201).send({ files: created });
},
@@ -495,7 +495,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
.map((f) => f.id);
if (validIds.length === 0) {
await auditLog(request.log, "FILE_DELETED", { userId: user.id, count: 0, ids });
await auditLog(request.log, "FILE_DELETED", { userId: user.id, count: 0, ids }, request.ip);
return reply.send({ deleted: 0 });
}
@@ -546,7 +546,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
userId: user.id,
count: chainRows.length,
ids,
});
}, request.ip);
return reply.send({ deleted: chainRows.length });
});