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:
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user