security: fix all Critical and High findings from FLAWS_REPORT audit

- Auth: pessimistic locking on login tokens and refresh token rotation,
  backup code attempt counter, rate limiting verification
- Schema: unique constraints on business numbers, FK relations,
  unsigned/signed alignment, attendance duplicate prevention
- Invoices/PDFs: DOMPurify sanitization, bounded queries in stats
  and alerts, VAT rounding, Puppeteer error handling
- Orders/Offers: transactional parent+child creation, Zod NaN
  refinement, status enums, uniqueness checks
- Projects/Files: path traversal protection, streamed uploads,
  permission guards, query param validation
- Attendance/HR: duplicate checks, ownership validation, GPS
  restrictions, trip distance validation
- Frontend: modal lock reference counting, XSS escaping in print
  HTML, ref mutation fixes, accessibility attributes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-04-24 00:58:35 +02:00
parent 122eee175e
commit 528e55991b
57 changed files with 2355 additions and 1010 deletions

View File

@@ -54,6 +54,39 @@ export default async function totpRoutes(
return error(reply, "Secret a kód jsou povinné", 400);
}
const user = await prisma.users.findUnique({
where: { id: request.authData!.userId },
});
if (!user) return error(reply, "Uživatel nenalezen", 404);
if (user.totp_enabled) {
if (!body.current_code) {
return error(
reply,
"Aktuální TOTP kód je povinný pro změnu 2FA",
400,
);
}
const isValid = OTPAuth.verify(
user.totp_secret!,
String(body.current_code),
);
if (!isValid) {
return error(reply, "Neplatný aktuální TOTP kód", 400);
}
} else {
if (!body.password) {
return error(reply, "Heslo je povinné pro aktivaci 2FA", 400);
}
const valid = await bcrypt.compare(
String(body.password),
user.password_hash,
);
if (!valid) {
return error(reply, "Nesprávné heslo", 400);
}
}
const totp = new OTPAuthLib.TOTP({
secret: OTPAuthLib.Secret.fromBase32(String(secret)),
algorithm: "SHA1",
@@ -185,10 +218,26 @@ export default async function totpRoutes(
const required =
body.required === true || body.required === 1 || body.required === "1";
const settings = await prisma.company_settings.findFirst({
select: { require_2fa: true },
});
const oldValue = settings?.require_2fa ?? false;
await prisma.company_settings.updateMany({
data: { require_2fa: required },
});
await logAudit({
request,
authData: request.authData,
action: "update",
entityType: "company_settings",
description: `Povinné 2FA změněno z ${oldValue ? "zapnuto" : "vypnuto"} na ${required ? "zapnuto" : "vypnuto"}`,
oldValues: { require_2fa: oldValue },
newValues: { require_2fa: required },
});
const message = required
? "2FA je nyní povinné pro všechny uživatele"
: "2FA již není povinné";
@@ -200,7 +249,15 @@ export default async function totpRoutes(
// POST - verify backup code (pre-auth, no requireAuth)
fastify.post(
"/backup-verify",
{ bodyLimit: 10240 },
{
config: {
rateLimit: {
max: 5,
timeWindow: "1 minute",
},
},
bodyLimit: 10240,
},
async (request, reply) => {
const parsed = parseBody(TotpBackupSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
@@ -211,52 +268,95 @@ export default async function totpRoutes(
.update(login_token)
.digest("hex");
const storedToken = await prisma.totp_login_tokens.findFirst({
where: { token_hash: tokenHash },
});
const settings = await getSystemSettings();
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
return error(reply, "Neplatný nebo expirovaný login token", 401);
}
const txResult = await prisma.$transaction(async (tx) => {
const tokens = await tx.$queryRaw<
Array<{ id: number; user_id: number; expires_at: Date }>
>`
SELECT id, user_id, expires_at FROM totp_login_tokens WHERE token_hash = ${tokenHash} FOR UPDATE
`;
const storedToken = tokens[0] ?? null;
const user = await prisma.users.findUnique({
where: { id: storedToken.user_id },
include: { roles: true },
});
if (!user || !user.totp_backup_codes) {
return error(reply, "Uživatel nenalezen", 401);
}
const backupCodes: string[] = JSON.parse(
user.totp_backup_codes as string,
);
let matchIndex = -1;
for (let i = 0; i < backupCodes.length; i++) {
const isMatch = await bcrypt.compare(String(code), backupCodes[i]);
if (isMatch) {
matchIndex = i;
break;
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
return { error: "Neplatný nebo expirovaný login token", status: 401 };
}
}
if (matchIndex === -1) {
return error(reply, "Neplatný záložní kód", 401);
}
const user = await tx.users.findUnique({
where: { id: storedToken.user_id },
include: { roles: true },
});
backupCodes.splice(matchIndex, 1);
await prisma.users.update({
where: { id: user.id },
data: {
totp_backup_codes: JSON.stringify(backupCodes),
failed_login_attempts: 0,
locked_until: null,
last_login: new Date(),
},
if (!user || !user.totp_backup_codes) {
return { error: "Uživatel nenalezen", status: 401 };
}
if (!user.is_active) {
return { error: "Účet je deaktivován", status: 401 };
}
if (user.locked_until && new Date(user.locked_until) > new Date()) {
return { error: "Účet je dočasně uzamčen", status: 429 };
}
const backupCodes: string[] = JSON.parse(
user.totp_backup_codes as string,
);
let matchIndex = -1;
for (let i = 0; i < backupCodes.length; i++) {
const isMatch = await bcrypt.compare(String(code), backupCodes[i]);
if (isMatch) {
matchIndex = i;
break;
}
}
if (matchIndex === -1) {
const newFailedAttempts = (user.failed_login_attempts ?? 0) + 1;
if (newFailedAttempts >= settings.max_login_attempts) {
await tx.totp_login_tokens.delete({
where: { id: storedToken.id },
});
await tx.users.update({
where: { id: user.id },
data: {
failed_login_attempts: newFailedAttempts,
locked_until: new Date(
Date.now() + settings.lockout_minutes * 60_000,
),
},
});
return { error: "Účet je dočasně uzamčen", status: 429 };
}
await tx.users.update({
where: { id: user.id },
data: { failed_login_attempts: newFailedAttempts },
});
return { error: "Neplatný záložní kód", status: 401 };
}
await tx.totp_login_tokens.delete({ where: { id: storedToken.id } });
backupCodes.splice(matchIndex, 1);
await tx.users.update({
where: { id: user.id },
data: {
totp_backup_codes: JSON.stringify(backupCodes),
failed_login_attempts: 0,
locked_until: null,
last_login: new Date(),
},
});
return { user };
});
await prisma.totp_login_tokens.delete({ where: { id: storedToken.id } });
if ("error" in txResult) {
return error(reply, txResult.error!, txResult.status!);
}
const user = txResult.user;
// Create tokens (same as /login/totp flow)
const { loadAuthData } = await import("../../services/auth");