mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(audit): capture IP address, make TRUST_PROXY configurable
This commit is contained in:
@@ -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 },
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user