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(); ensureAiDirs();
recoverInterruptedInstalls(); 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({ const app = Fastify({
logger: { logger: {
level: env.LOG_LEVEL, level: env.LOG_LEVEL,
@@ -194,7 +202,7 @@ const app = Fastify({
redact: ["req.headers.authorization", "req.headers.cookie"], redact: ["req.headers.authorization", "req.headers.cookie"],
}, },
bodyLimit: env.MAX_UPLOAD_SIZE_MB > 0 ? env.MAX_UPLOAD_SIZE_MB * 1024 * 1024 : 1073741824, 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 }, routerOptions: { maxParamLength: 500 },
}); });
+3 -2
View File
@@ -39,8 +39,9 @@ export async function auditLog(
logger: FastifyBaseLogger, logger: FastifyBaseLogger,
event: AuditEvent, event: AuditEvent,
details: Record<string, unknown> = {}, details: Record<string, unknown> = {},
ip: string | null = null,
): Promise<void> { ): 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 actorId = (details.userId as string) ?? (details.adminId as string) ?? null;
const actorUsername = (details.username as string) ?? (details.newUsername as string) ?? "system"; const actorUsername = (details.username as string) ?? (details.newUsername as string) ?? "system";
@@ -56,7 +57,7 @@ export async function auditLog(
targetType, targetType,
targetId, targetId,
details, details,
ipAddress: null, ipAddress: ip,
}); });
} catch { } catch {
logger.warn({ event }, "Failed to write audit log to DB"); 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), LIBREOFFICE_TIMEOUT_S: z.coerce.number().default(120),
SESSION_DURATION_HOURS: z.coerce.number().default(168), SESSION_DURATION_HOURS: z.coerce.number().default(168),
LOGIN_ATTEMPT_LIMIT: z.coerce.number().default(30), LOGIN_ATTEMPT_LIMIT: z.coerce.number().default(30),
TRUST_PROXY: z TRUST_PROXY: z.string().default("false"),
.enum(["true", "false"])
.default("false")
.transform((v) => v === "true"),
OIDC_ENABLED: z OIDC_ENABLED: z
.enum(["true", "false"]) .enum(["true", "false"])
.default("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", { await auditLog(request.log, "LOGIN_FAILED", {
username: sanitizeAuditInput(body.username), username: sanitizeAuditInput(body.username),
reason: "unknown_user", reason: "unknown_user",
}); }, request.ip);
return reply.status(401).send({ error: "Invalid credentials" }); 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", { await auditLog(request.log, "LOGIN_FAILED", {
username: sanitizeAuditInput(body.username), username: sanitizeAuditInput(body.username),
reason: "bad_password", reason: "bad_password",
}); }, request.ip);
return reply.status(401).send({ error: "Invalid credentials" }); return reply.status(401).send({ error: "Invalid credentials" });
} }
@@ -317,7 +317,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
expiresAt, 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)); 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: "/" }); 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 }) }); 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", { await auditLog(request.log, "PASSWORD_CHANGED", {
userId: authUser.id, userId: authUser.id,
username: authUser.username, username: authUser.username,
}); }, request.ip);
return reply.send({ ok: true }); return reply.send({ ok: true });
}); });
@@ -674,7 +674,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
newUserId: id, newUserId: id,
newUsername: body.username, newUsername: body.username,
role, role,
}); }, request.ip);
return reply.status(201).send({ return reply.status(201).send({
id, id,
@@ -788,7 +788,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
adminId: admin.id, adminId: admin.id,
targetUserId: id, targetUserId: id,
changes: { role: updates.role, team: updates.team }, changes: { role: updates.role, team: updates.team },
}); }, request.ip);
return reply.send({ ok: true }); return reply.send({ ok: true });
}, },
@@ -849,7 +849,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
adminId: admin.id, adminId: admin.id,
targetUserId: id, targetUserId: id,
targetUsername: user.username, targetUsername: user.username,
}); }, request.ip);
return reply.send({ ok: true }); return reply.send({ ok: true });
}, },
@@ -887,7 +887,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
adminId: admin.id, adminId: admin.id,
deletedUserId: id, deletedUserId: id,
deletedUsername: user.username, deletedUsername: user.username,
}); }, request.ip);
return reply.send({ ok: true }); 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", { await auditLog(request.log, "OIDC_LOGIN_FAILED", {
reason: sanitizeAuditInput(String(query.error)), reason: sanitizeAuditInput(String(query.error)),
}); }, request.ip);
return redirectToLogin(reply, "oidc_auth_failed"); return redirectToLogin(reply, "oidc_auth_failed");
} }
@@ -257,7 +257,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
}); });
} catch (err) { } catch (err) {
request.log.error({ err }, "OIDC token exchange failed"); 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"); return redirectToLogin(reply, "oidc_auth_failed");
} }
@@ -265,7 +265,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
const claims = tokenResponse.claims(); const claims = tokenResponse.claims();
if (!claims) { if (!claims) {
request.log.error("OIDC callback: no ID token 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"); return redirectToLogin(reply, "oidc_auth_failed");
} }
@@ -318,7 +318,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
userId: existingByEmail.id, userId: existingByEmail.id,
username: existingByEmail.username, username: existingByEmail.username,
email, email,
}); }, request.ip);
} }
} }
@@ -361,7 +361,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
username: uniqueUsername, username: uniqueUsername,
email, email,
role: env.OIDC_DEFAULT_ROLE, role: env.OIDC_DEFAULT_ROLE,
}); }, request.ip);
} }
// 4d. No user found and no auto-create // 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", { await auditLog(request.log, "OIDC_LOGIN_FAILED", {
reason: "user_not_authorized", reason: "user_not_authorized",
sub: sanitizeAuditInput(String(sub)), sub: sanitizeAuditInput(String(sub)),
}); }, request.ip);
return redirectToLogin(reply, "oidc_user_not_authorized"); 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", { await auditLog(request.log, "OIDC_LOGIN_SUCCESS", {
userId, userId,
username: user?.username ?? username, username: user?.username ?? username,
}); }, request.ip);
// 6. Set session cookie // 6. Set session cookie
reply.setCookie("snapotter-session", token, { 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" }); 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 the raw key ONCE — it cannot be retrieved again
return reply.status(201).send({ 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 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 }); return reply.send({ ok: true });
}, },
+3 -3
View File
@@ -116,7 +116,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
createdBy: user.id, 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({ return reply.status(201).send({
id, 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 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 }); return reply.send({ ok: true });
}, },
@@ -220,7 +220,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
adminId: user.id, adminId: user.id,
roleId: id, roleId: id,
roleName: role.name, roleName: role.name,
}); }, request.ip);
return reply.send({ ok: true }); return reply.send({ ok: true });
}, },
+1 -1
View File
@@ -95,7 +95,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
adminId: admin.id, adminId: admin.id,
username: admin.username, username: admin.username,
keys: entries.map((e) => e.key), keys: entries.map((e) => e.key),
}); }, request.ip);
} }
return reply.send({ ok: true, updatedCount: entries.length }); 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, userId,
count: created.length, count: created.length,
files: created.map((f) => f.originalName), files: created.map((f) => f.originalName),
}); }, request.ip);
return reply.status(201).send({ files: created }); return reply.status(201).send({ files: created });
}, },
@@ -495,7 +495,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
.map((f) => f.id); .map((f) => f.id);
if (validIds.length === 0) { 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 }); return reply.send({ deleted: 0 });
} }
@@ -546,7 +546,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
userId: user.id, userId: user.id,
count: chainRows.length, count: chainRows.length,
ids, ids,
}); }, request.ip);
return reply.send({ deleted: chainRows.length }); return reply.send({ deleted: chainRows.length });
}); });