From a4303b018863d2ec12029e820805eb95e70461c7 Mon Sep 17 00:00:00 2001 From: BOHA Date: Mon, 23 Mar 2026 08:49:54 +0100 Subject: [PATCH] feat: add Zod validation for auth endpoints Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 12 +++++++++++- package.json | 3 ++- src/routes/admin/auth.ts | 22 ++++++++++------------ src/routes/admin/totp.ts | 13 ++++++------- src/schemas/auth.schema.ts | 21 +++++++++++++++++++++ src/schemas/common.ts | 12 ++++++++++++ 6 files changed, 62 insertions(+), 21 deletions(-) create mode 100644 src/schemas/auth.schema.ts create mode 100644 src/schemas/common.ts diff --git a/package-lock.json b/package-lock.json index e008962..37b739b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" + } } } } diff --git a/package.json b/package.json index 53e2952..bfdf098 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/routes/admin/auth.ts b/src/routes/admin/auth.ts index 152f1db..499a631 100644 --- a/src/routes/admin/auth.ts +++ b/src/routes/admin/auth.ts @@ -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 { - 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('/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'); diff --git a/src/routes/admin/totp.ts b/src/routes/admin/totp.ts index 9143e47..88ed5bc 100644 --- a/src/routes/admin/totp.ts +++ b/src/routes/admin/totp.ts @@ -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 { // GET - generate new TOTP secret @@ -138,14 +140,11 @@ export default async function totpRoutes(fastify: FastifyInstance): Promise { - const body = request.body as Record; - 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 }, diff --git a/src/schemas/auth.schema.ts b/src/schemas/auth.schema.ts new file mode 100644 index 0000000..84b5544 --- /dev/null +++ b/src/schemas/auth.schema.ts @@ -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; +export type TotpVerifyInput = z.infer; +export type TotpBackupInput = z.infer; diff --git a/src/schemas/common.ts b/src/schemas/common.ts new file mode 100644 index 0000000..be40d13 --- /dev/null +++ b/src/schemas/common.ts @@ -0,0 +1,12 @@ +import { ZodSchema, ZodError } from 'zod'; + +export function parseBody(schema: ZodSchema, 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' }; + } +}