fix: security, validation, and data integrity fixes across 53 files

- Auth: HS256 algorithm restriction on JWT verify, timing-safe bcrypt
  for inactive/locked users, locked_until check in loadAuthData, TOTP
  fixes (async bcrypt, BigInt conversion, future-code counter fix)
- Validation: Zod enums for leave_type/status, numeric transforms on
  foreign keys, VAT 0% coercion fix (Number(v)||21 → v!=null checks)
- Permissions: requirePermission on attendance PUT, attendance_users
  and project_logs access checks, trips users filtered by trips.record
- Prisma queries: fixed roles.is:{OR} pattern (doesn't work on to-one
  relations), attendance_users now filters by attendance.record only
- Transactions: wrapped deleteOrder, createOrder, updateUser, deleteUser,
  duplicateOffer, bulkCreateAttendance, createLeave, scope-templates,
  leave-requests, company-settings, profile updates
- Frontend: mountedRef reset in useListData, blob URL cleanup on unmount,
  null checks on date fields, AdminDatePicker min/max for HH:mm
- Security headers: COOP, CORP, CSP frame-ancestors/form-action/base-uri
- Other: exchange-rate cache TTL, invoice-alert midnight comparison fix,
  numbering.service releaseSequence no-op, nas-offers filename sanitize,
  Content-Disposition header injection fix, mojibake Czech strings

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-04-28 08:40:38 +02:00
parent 7f07032bf2
commit d7c7fbad88
52 changed files with 927 additions and 573 deletions

View File

@@ -6,11 +6,17 @@ import { requireAuth, requirePermission } from "../../middleware/auth";
import { success, error } from "../../utils/response";
import { encrypt } from "../../utils/encryption";
import { getSystemSettings } from "../../services/system-settings";
import { config } from "../../config/env";
import { OTPAuth } from "../../utils/totp";
import * as OTPAuthLib from "otpauth";
import { logAudit } from "../../services/audit";
import { parseBody } from "../../schemas/common";
import { TotpBackupSchema } from "../../schemas/auth.schema";
import {
TotpBackupSchema,
TotpEnableSchema,
TotpDisableSchema,
TotpRequiredSchema,
} from "../../schemas/auth.schema";
export default async function totpRoutes(
fastify: FastifyInstance,
@@ -47,12 +53,9 @@ export default async function totpRoutes(
"/enable",
{ preHandler: requireAuth, bodyLimit: 10240 },
async (request, reply) => {
const body = request.body as Record<string, unknown>;
const { secret, code } = body;
if (!secret || !code) {
return error(reply, "Secret a kód jsou povinné", 400);
}
const parsed = parseBody(TotpEnableSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const { secret, code, password, current_code } = parsed.data;
const user = await prisma.users.findUnique({
where: { id: request.authData!.userId },
@@ -60,41 +63,35 @@ export default async function totpRoutes(
if (!user) return error(reply, "Uživatel nenalezen", 404);
if (user.totp_enabled) {
if (!body.current_code) {
if (!current_code) {
return error(
reply,
"Aktuální TOTP kód je povinný pro změnu 2FA",
400,
);
}
const verifyResult = OTPAuth.verify(
user.totp_secret!,
String(body.current_code),
);
const verifyResult = OTPAuth.verify(user.totp_secret!, current_code);
if (!verifyResult.valid) {
return error(reply, "Neplatný aktuální TOTP kód", 400);
}
} else {
if (!body.password) {
if (!password) {
return error(reply, "Heslo je povinné pro aktivaci 2FA", 400);
}
const valid = await bcrypt.compare(
String(body.password),
user.password_hash,
);
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
return error(reply, "Nesprávné heslo", 400);
}
}
const totp = new OTPAuthLib.TOTP({
secret: OTPAuthLib.Secret.fromBase32(String(secret)),
secret: OTPAuthLib.Secret.fromBase32(secret),
algorithm: "SHA1",
digits: 6,
period: 30,
});
const delta = totp.validate({ token: String(code), window: 1 });
const delta = totp.validate({ token: code, window: 1 });
if (delta === null) {
return error(reply, "Neplatný TOTP kód", 400);
}
@@ -103,9 +100,11 @@ export default async function totpRoutes(
const backupCodesPlain: string[] = [];
const backupCodesHashed: string[] = [];
for (let i = 0; i < 8; i++) {
const code = crypto.randomBytes(4).toString("hex").toUpperCase();
backupCodesPlain.push(code);
backupCodesHashed.push(bcrypt.hashSync(code, 10));
const plainCode = crypto.randomBytes(4).toString("hex").toUpperCase();
backupCodesPlain.push(plainCode);
backupCodesHashed.push(
await bcrypt.hash(plainCode, config.security.bcryptCost),
);
}
const encryptedSecret = encrypt(String(secret));
@@ -140,11 +139,9 @@ export default async function totpRoutes(
"/disable",
{ preHandler: requireAuth },
async (request, reply) => {
const body = request.body as Record<string, unknown>;
if (!body.code) {
return error(reply, "TOTP kód je povinný pro deaktivaci", 400);
}
const parsed = parseBody(TotpDisableSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const { code } = parsed.data;
const user = await prisma.users.findUnique({
where: { id: request.authData!.userId },
@@ -153,7 +150,7 @@ export default async function totpRoutes(
return error(reply, "2FA není aktivní", 400);
}
const verifyResult = OTPAuth.verify(user.totp_secret, String(body.code));
const verifyResult = OTPAuth.verify(user.totp_secret, code);
if (!verifyResult.valid) {
return error(reply, "Neplatný TOTP kód", 400);
}
@@ -214,10 +211,9 @@ export default async function totpRoutes(
bodyLimit: 10240,
},
async (request, reply) => {
const body = request.body as Record<string, unknown>;
const required =
body.required === true || body.required === 1 || body.required === "1";
const parsed = parseBody(TotpRequiredSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const required = parsed.data.required;
const settings = await prisma.company_settings.findFirst({
select: { require_2fa: true },
@@ -339,7 +335,9 @@ export default async function totpRoutes(
return { error: "Neplatný záložní kód", status: 401 };
}
await tx.totp_login_tokens.delete({ where: { id: storedToken.id } });
await tx.totp_login_tokens.delete({
where: { id: Number(storedToken.id) },
});
backupCodes.splice(matchIndex, 1);
await tx.users.update({
@@ -408,6 +406,14 @@ export default async function totpRoutes(
maxAge: config.jwt.refreshTokenSessionExpiry,
});
await logAudit({
request,
action: "login_backup",
entityType: "user",
entityId: user.id,
description: `Backup code login for user ${user.username}`,
});
return success(reply, { access_token: accessToken, user: authData });
},
);