feat: add Zod validation for auth endpoints

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-03-23 08:49:54 +01:00
parent 7689b28d6d
commit a4303b0188
6 changed files with 62 additions and 21 deletions

12
package-lock.json generated
View File

@@ -29,7 +29,8 @@
"react-datepicker": "^9.1.0",
"react-dom": "^18.3.1",
"react-quill-new": "^3.8.3",
"react-router-dom": "^6.30.3"
"react-router-dom": "^6.30.3",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
@@ -3748,6 +3749,15 @@
"engines": {
"node": ">=12"
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -42,7 +42,8 @@
"react-datepicker": "^9.1.0",
"react-dom": "^18.3.1",
"react-quill-new": "^3.8.3",
"react-router-dom": "^6.30.3"
"react-router-dom": "^6.30.3",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",

View File

@@ -7,6 +7,8 @@ import { LoginRequest, TotpVerifyRequest } from '../../types';
import prisma from '../../config/database';
import crypto from 'crypto';
import { OTPAuth } from '../../utils/totp';
import { parseBody } from '../../schemas/common';
import { LoginSchema, TotpVerifySchema } from '../../schemas/auth.schema';
function setRefreshCookie(reply: import('fastify').FastifyReply, token: string, rememberMe: boolean) {
const maxAge = rememberMe
@@ -33,13 +35,11 @@ export default async function authRoutes(fastify: FastifyInstance): Promise<void
},
bodyLimit: 10240,
}, async (request, reply) => {
const { username, password, remember_me } = request.body;
const parsed = parseBody(LoginSchema, request.body);
if ('error' in parsed) return error(reply, parsed.error, 400);
const { username, password, remember_me } = parsed.data;
if (!username || !password) {
return error(reply, 'Uživatelské jméno a heslo jsou povinné', 400);
}
const result = await login(username, password, !!remember_me, request);
const result = await login(username, password, remember_me, request);
if (result.type === 'error') {
await logAudit({
@@ -64,7 +64,7 @@ export default async function authRoutes(fastify: FastifyInstance): Promise<void
description: `Přihlášení uživatele ${result.user.username}`,
});
setRefreshCookie(reply, result.refreshToken, !!remember_me);
setRefreshCookie(reply, result.refreshToken, remember_me);
return success(reply, {
access_token: result.accessToken,
user: result.user,
@@ -73,11 +73,9 @@ export default async function authRoutes(fastify: FastifyInstance): Promise<void
// POST /api/admin/login/totp
fastify.post<{ Body: TotpVerifyRequest }>('/login/totp', { bodyLimit: 10240 }, async (request, reply) => {
const { login_token, totp_code } = request.body;
if (!login_token || !totp_code) {
return error(reply, 'Login token a TOTP kód jsou povinné', 400);
}
const parsed = parseBody(TotpVerifySchema, request.body);
if ('error' in parsed) return error(reply, parsed.error, 400);
const { login_token, totp_code } = parsed.data;
const tokenHash = crypto.createHash('sha256').update(login_token).digest('hex');

View File

@@ -8,6 +8,8 @@ import { encrypt } from '../../utils/encryption';
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';
export default async function totpRoutes(fastify: FastifyInstance): Promise<void> {
// GET - generate new TOTP secret
@@ -138,14 +140,11 @@ export default async function totpRoutes(fastify: FastifyInstance): Promise<void
// POST - verify backup code (pre-auth, no requireAuth)
fastify.post('/backup-verify', { bodyLimit: 10240 }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const { login_token, code } = body;
const parsed = parseBody(TotpBackupSchema, request.body);
if ('error' in parsed) return error(reply, parsed.error, 400);
const { login_token, backup_code: code } = parsed.data;
if (!login_token || !code) {
return error(reply, 'Login token a záložní kód jsou povinné', 400);
}
const tokenHash = crypto.createHash('sha256').update(String(login_token)).digest('hex');
const tokenHash = crypto.createHash('sha256').update(login_token).digest('hex');
const storedToken = await prisma.totp_login_tokens.findFirst({
where: { token_hash: tokenHash },

View File

@@ -0,0 +1,21 @@
import { z } from 'zod';
export const LoginSchema = z.object({
username: z.string().min(1, 'Uživatelské jméno je povinné'),
password: z.string().min(1, 'Heslo je povinné'),
remember_me: z.boolean().optional().default(false),
});
export const TotpVerifySchema = z.object({
login_token: z.string().min(1, 'Token je povinný'),
totp_code: z.string().length(6, 'Kód musí mít 6 číslic'),
});
export const TotpBackupSchema = z.object({
login_token: z.string().min(1, 'Token je povinný'),
backup_code: z.string().min(1, 'Záložní kód je povinný'),
});
export type LoginInput = z.infer<typeof LoginSchema>;
export type TotpVerifyInput = z.infer<typeof TotpVerifySchema>;
export type TotpBackupInput = z.infer<typeof TotpBackupSchema>;

12
src/schemas/common.ts Normal file
View File

@@ -0,0 +1,12 @@
import { ZodSchema, ZodError } from 'zod';
export function parseBody<T>(schema: ZodSchema<T>, body: unknown): { data: T } | { error: string } {
try {
return { data: schema.parse(body) };
} catch (e) {
if (e instanceof ZodError) {
return { error: e.errors.map(err => err.message).join(', ') };
}
return { error: 'Neplatný požadavek' };
}
}