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:
@@ -92,7 +92,15 @@ export default async function authRoutes(
|
||||
// POST /api/admin/login/totp
|
||||
fastify.post<{ Body: TotpVerifyRequest }>(
|
||||
"/login/totp",
|
||||
{ bodyLimit: 10240 },
|
||||
{
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 20,
|
||||
timeWindow: "1 minute",
|
||||
},
|
||||
},
|
||||
bodyLimit: 10240,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsed = parseBody(TotpVerifySchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
@@ -106,20 +114,42 @@ export default async function authRoutes(
|
||||
.update(login_token)
|
||||
.digest("hex");
|
||||
|
||||
const storedToken = await prisma.totp_login_tokens.findFirst({
|
||||
where: { token_hash: tokenHash },
|
||||
const totpResult = 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;
|
||||
|
||||
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
|
||||
return { error: "Neplatný nebo expirovaný login token", status: 401 };
|
||||
}
|
||||
|
||||
await tx.totp_login_tokens.delete({ where: { id: storedToken.id } });
|
||||
|
||||
const user = await tx.users.findUnique({
|
||||
where: { id: storedToken.user_id },
|
||||
include: { roles: true },
|
||||
});
|
||||
|
||||
if (!user || !user.totp_secret) {
|
||||
return { error: "Uživatel nenalezen", status: 401 };
|
||||
}
|
||||
|
||||
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
||||
return { error: "Účet je dočasně uzamčen", status: 429 };
|
||||
}
|
||||
|
||||
return { user };
|
||||
});
|
||||
|
||||
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
|
||||
return error(reply, "Neplatný nebo expirovaný login token", 401);
|
||||
if ("error" in totpResult) {
|
||||
return error(reply, totpResult.error!, totpResult.status!);
|
||||
}
|
||||
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: storedToken.user_id },
|
||||
include: { roles: true },
|
||||
});
|
||||
|
||||
if (!user || !user.totp_secret) {
|
||||
const user = totpResult.user;
|
||||
if (!user.totp_secret) {
|
||||
return error(reply, "Uživatel nenalezen", 401);
|
||||
}
|
||||
|
||||
@@ -128,8 +158,6 @@ export default async function authRoutes(
|
||||
return error(reply, "Neplatný TOTP kód", 401);
|
||||
}
|
||||
|
||||
await prisma.totp_login_tokens.delete({ where: { id: storedToken.id } });
|
||||
|
||||
// Reset failed attempts and update last login (TOTP verified = successful login)
|
||||
await prisma.users.update({
|
||||
where: { id: user.id },
|
||||
@@ -186,31 +214,43 @@ export default async function authRoutes(
|
||||
);
|
||||
|
||||
// POST /api/admin/refresh
|
||||
fastify.post("/refresh", { bodyLimit: 10240 }, async (request, reply) => {
|
||||
const refreshTokenRaw = request.cookies.refresh_token;
|
||||
if (!refreshTokenRaw) {
|
||||
return error(reply, "Refresh token chybí", 401);
|
||||
}
|
||||
fastify.post(
|
||||
"/refresh",
|
||||
{
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 10,
|
||||
timeWindow: "1 minute",
|
||||
},
|
||||
},
|
||||
bodyLimit: 10240,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const refreshTokenRaw = request.cookies.refresh_token;
|
||||
if (!refreshTokenRaw) {
|
||||
return error(reply, "Refresh token chybí", 401);
|
||||
}
|
||||
|
||||
const result = await refreshAccessToken(refreshTokenRaw, request);
|
||||
const result = await refreshAccessToken(refreshTokenRaw, request);
|
||||
|
||||
if (result.type === "error") {
|
||||
reply.clearCookie("refresh_token", {
|
||||
path: "/api/admin",
|
||||
httpOnly: true,
|
||||
secure: config.isProduction,
|
||||
sameSite: "strict",
|
||||
if (result.type === "error") {
|
||||
reply.clearCookie("refresh_token", {
|
||||
path: "/api/admin",
|
||||
httpOnly: true,
|
||||
secure: config.isProduction,
|
||||
sameSite: "strict",
|
||||
});
|
||||
return error(reply, result.message, result.status);
|
||||
}
|
||||
|
||||
// Preserve the original remember_me flag so long-lived sessions stay long-lived after rotation
|
||||
setRefreshCookie(reply, result.refreshToken, result.rememberMe);
|
||||
return success(reply, {
|
||||
access_token: result.accessToken,
|
||||
user: result.user,
|
||||
});
|
||||
return error(reply, result.message, result.status);
|
||||
}
|
||||
|
||||
// Preserve the original remember_me flag so long-lived sessions stay long-lived after rotation
|
||||
setRefreshCookie(reply, result.refreshToken, result.rememberMe);
|
||||
return success(reply, {
|
||||
access_token: result.accessToken,
|
||||
user: result.user,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/logout
|
||||
fastify.post("/logout", async (request, reply) => {
|
||||
|
||||
Reference in New Issue
Block a user