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