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();
|
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 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user