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

@@ -109,32 +109,42 @@ export async function login(
}
if (!user.is_active) {
return { type: "error", message: "Účet je deaktivován", status: 403 };
request.log.warn(`Login failed for deactivated user: ${username}`);
return {
type: "error",
message: "Neplatné přihlašovací údaje",
status: 401,
};
}
if (user.locked_until && new Date(user.locked_until) > new Date()) {
request.log.warn(`Login failed for locked user: ${username}`);
return {
type: "error",
message: "Účet je dočasně uzamčen. Zkuste to později.",
status: 429,
message: "Neplatné přihlašovací údaje",
status: 401,
};
}
const passwordValid = await bcrypt.compare(password, user.password_hash);
if (!passwordValid) {
const settings = await getSystemSettings();
const attempts = (user.failed_login_attempts ?? 0) + 1;
const updateData: Record<string, unknown> = {
failed_login_attempts: attempts,
};
await prisma.users.update({
where: { id: user.id },
data: { failed_login_attempts: { increment: 1 } },
});
if (attempts >= settings.max_login_attempts) {
updateData.locked_until = new Date(
Date.now() + settings.lockout_minutes * 60_000,
);
if ((user.failed_login_attempts ?? 0) + 1 >= settings.max_login_attempts) {
await prisma.users.update({
where: { id: user.id },
data: {
locked_until: new Date(
Date.now() + settings.lockout_minutes * 60_000,
),
},
});
}
await prisma.users.update({ where: { id: user.id }, data: updateData });
return {
type: "error",
message: "Neplatné přihlašovací údaje",
@@ -151,6 +161,17 @@ export async function login(
},
});
const companySettings = await prisma.company_settings.findFirst({
select: { require_2fa: true },
});
if (companySettings?.require_2fa && !user.totp_enabled) {
return {
type: "error",
message: "Dvoufázové ověření je povinné",
status: 403,
};
}
if (user.totp_enabled) {
const loginToken = crypto.randomBytes(32).toString("hex");
const tokenHash = hashToken(loginToken);
@@ -222,60 +243,69 @@ export async function refreshAccessToken(
> {
const tokenHash = hashToken(refreshTokenRaw);
const storedToken = await prisma.refresh_tokens.findUnique({
where: { token_hash: tokenHash },
});
return prisma.$transaction(async (tx) => {
const tokens = await tx.$queryRaw<
Array<{
id: number;
user_id: number;
expires_at: Date;
replaced_at: Date | null;
remember_me: boolean | null;
}>
>`
SELECT id, user_id, expires_at, replaced_at, remember_me FROM refresh_tokens WHERE token_hash = ${tokenHash} FOR UPDATE
`;
const storedToken = tokens[0] ?? null;
if (
!storedToken ||
storedToken.replaced_at ||
new Date(storedToken.expires_at) < new Date()
) {
return { type: "error", message: "Neplatný refresh token", status: 401 };
}
if (
!storedToken ||
storedToken.replaced_at ||
new Date(storedToken.expires_at) < new Date()
) {
return { type: "error", message: "Neplatný refresh token", status: 401 };
}
const authData = await loadAuthData(storedToken.user_id);
if (!authData) {
return { type: "error", message: "Uživatel nenalezen", status: 401 };
}
const authData = await loadAuthData(storedToken.user_id);
if (!authData) {
return { type: "error", message: "Uživatel nenalezen", status: 401 };
}
const newRefreshTokenRaw = generateRefreshToken();
const newRefreshTokenHash = hashToken(newRefreshTokenRaw);
const newRefreshTokenRaw = generateRefreshToken();
const newRefreshTokenHash = hashToken(newRefreshTokenRaw);
const expiresIn = storedToken.remember_me
? config.jwt.refreshTokenRememberExpiry
: config.jwt.refreshTokenSessionExpiry;
const expiresIn = storedToken.remember_me
? config.jwt.refreshTokenRememberExpiry
: config.jwt.refreshTokenSessionExpiry;
await prisma.$transaction([
prisma.refresh_tokens.update({
await tx.refresh_tokens.update({
where: { id: storedToken.id },
data: { replaced_at: new Date(), replaced_by_hash: newRefreshTokenHash },
}),
prisma.refresh_tokens.create({
});
await tx.refresh_tokens.create({
data: {
user_id: storedToken.user_id,
token_hash: newRefreshTokenHash,
expires_at: new Date(Date.now() + expiresIn * 1000),
remember_me: storedToken.remember_me,
remember_me: storedToken.remember_me ?? false,
ip_address: request.ip,
user_agent: request.headers["user-agent"] ?? null,
},
}),
]);
});
const accessToken = generateAccessToken({
id: authData.userId,
username: authData.username,
roleName: authData.roleName,
const accessToken = generateAccessToken({
id: authData.userId,
username: authData.username,
roleName: authData.roleName,
});
return {
type: "success",
accessToken,
refreshToken: newRefreshTokenRaw,
user: authData,
rememberMe: storedToken.remember_me ?? false,
};
});
return {
type: "success",
accessToken,
refreshToken: newRefreshTokenRaw,
user: authData,
rememberMe: storedToken.remember_me ?? false,
};
}
export async function logout(refreshTokenRaw: string): Promise<void> {