From 0979d03a277ed732f60463b00e1342cd39fa5b78 Mon Sep 17 00:00:00 2001 From: Martin Dimitrov Date: Sat, 7 Jun 2025 13:43:16 -0700 Subject: [PATCH] add common validation path. Refactor auth route to use it --- bun.lockb | Bin 430728 -> 431136 bytes packages/doorman-api/package.json | 3 +- .../doorman-api/src/common/DoormanHandler.ts | 13 ++++ .../src/functions/api/door/auth.ts | 60 ++++++++++-------- .../doorman-api/src/utils/responseUtils.ts | 17 +++++ .../src/components/AuthComponent.tsx | 29 ++++++--- .../doorman-ui/src/helpers/FetchHelper.ts | 5 ++ packages/doorman-ui/src/pages/DoorPage.tsx | 2 +- 8 files changed, 90 insertions(+), 39 deletions(-) create mode 100644 packages/doorman-api/src/utils/responseUtils.ts create mode 100644 packages/doorman-ui/src/helpers/FetchHelper.ts diff --git a/bun.lockb b/bun.lockb index 9f73a9666de5215456e684ebf6b3f06ec3f3fb3e..031443ff4ec7c1930a4f2a6106e3aac399472428 100755 GIT binary patch delta 6673 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 diff --git a/packages/doorman-api/package.json b/packages/doorman-api/package.json index 377c643..1bcf997 100644 --- a/packages/doorman-api/package.json +++ b/packages/doorman-api/package.json @@ -22,7 +22,8 @@ "twilio": "^3.84.1", "winston": "^3.17.0", "winston-loki": "^6.1.3", - "zod": "^3.25.42" + "zod": "^3.25.42", + "zod-validation-error": "^3.4.1" }, "devDependencies": { "twilio-run": "^3.5.4", diff --git a/packages/doorman-api/src/common/DoormanHandler.ts b/packages/doorman-api/src/common/DoormanHandler.ts index 764ad68..f51a8e6 100644 --- a/packages/doorman-api/src/common/DoormanHandler.ts +++ b/packages/doorman-api/src/common/DoormanHandler.ts @@ -10,6 +10,8 @@ import VoiceResponse from 'twilio/lib/twiml/VoiceResponse'; import { createLogger, format, Logger, transports } from "winston"; import LokiTransport from "winston-loki"; import pTimeout, { TimeoutError } from "promise.timeout"; +import { ZodError } from "zod"; +import { fromError } from "zod-validation-error"; export type BaseEvent = { request: { cookies: {}; headers: {}; }; } @@ -243,6 +245,17 @@ export function withMetrics } callbackResult = failFastCallbackMethod(); + } else if (e instanceof ZodError) { + // global catch for validation errors + const response = new Twilio.Response(); + response.setStatusCode(400); + response.setHeaders({ + "Content-Type": "application/json" + }); + + // return nice to read error message from ZOD + response.setBody({ err: fromError(e).toString() }); + callbackResult = [null, response]; } } } diff --git a/packages/doorman-api/src/functions/api/door/auth.ts b/packages/doorman-api/src/functions/api/door/auth.ts index 902f807..c8298c6 100644 --- a/packages/doorman-api/src/functions/api/door/auth.ts +++ b/packages/doorman-api/src/functions/api/door/auth.ts @@ -13,26 +13,30 @@ import { AuthMetrics, registerMetrics } from "../../../metrics/AuthMetrics"; import { Counter } from "prom-client"; import { DoorConfig, getDoorConfigID } from "../../../schema/DoorConfig"; import { createLockStatusWithTimeout, getLockStatusID, isLockOpen } from "../../../schema/LockStatus"; +import { z } from "zod"; +import { setResponseJson } from "../../../utils/responseUtils"; -export interface AuthRequest extends ServerlessEventObject<{}, UserAgentHeader> { - door?: string; - key?: string; - ip: string; - timeout?: string; -} +export const AuthRequestSchema = z.object({ + door: z.string(), + key: z.string(), + ip: z.string().optional(), + timeout: z.number().gt(0, "Timeout cannot be 0").optional(), +}); -export const handler: ServerlessFunctionSignature = withMetrics('auth', async (context, event, callback, metricsRegistry) => { +export type AuthRequest = z.infer; + +export interface AuthRequestTwilio extends ServerlessEventObject { }; + +export const handler: ServerlessFunctionSignature = withMetrics('auth', async (context, event, callback, metricsRegistry) => { const response = new Twilio.Response(); registerMetrics(metricsRegistry); - let door = event.door; - let pin = event.key; + let req = AuthRequestSchema.parse(event); - if (!door || !pin) { - response.setStatusCode(400); - return callback(null, response); - } + // now we have validated obj + let door = req.door; + let pin = req.key; const db = createDynaBridgeClient(context); @@ -40,7 +44,10 @@ export const handler: ServerlessFunctionSignature = if (!config) { getMetricFromRegistry(metricsRegistry, AuthMetrics.DOOR_CONFIG_NOT_FOUND).inc({ door }, 1); - response.setStatusCode(404); + setResponseJson(response, 404, { + err: `Door ${door} not found`, + }); + return callback(null, response); } @@ -58,7 +65,9 @@ export const handler: ServerlessFunctionSignature = } if (!method) { - response.setStatusCode(401); + setResponseJson(response, 401, { + err: "Invalid PIN", + }); return callback(null, response); } @@ -72,7 +81,7 @@ export const handler: ServerlessFunctionSignature = }; // take timeout from the query string - const timeout = event.timeout ? parseInt(event.timeout) : config.timeout; + const timeout = event.timeout || config.timeout; // check lock status if locked, then unlock. If unlocked then lock const lock = await db.entities.lockStatus.findById(getLockStatusID(door)); @@ -81,21 +90,18 @@ export const handler: ServerlessFunctionSignature = if (isOpen && lock) { const fingerprint = JSON.parse(lock.fingerprint); - response - .setStatusCode(200) - .appendHeader('Content-Type', 'application/json') - .setBody({ - status: DoorStatus.CLOSED, - fingerprint, - }); + setResponseJson(response, 200, { + status: DoorStatus.CLOSED, + fingerprint, + }); await db.entities.lockStatus.deleteById(getLockStatusID(door)); } else { await db.entities.lockStatus.save(createLockStatusWithTimeout(door, timeout, fingerprint)); - response - .setStatusCode(200) - .appendHeader('Content-Type', 'application/json') - .setBody({ msg: `Opened the door "${door}" for ${timeout}s` }); + + setResponseJson(response, 200, { + msg: `Opened the door "${door}" for ${timeout}s` + }); } // destroy the internal client after diff --git a/packages/doorman-api/src/utils/responseUtils.ts b/packages/doorman-api/src/utils/responseUtils.ts new file mode 100644 index 0000000..385e28b --- /dev/null +++ b/packages/doorman-api/src/utils/responseUtils.ts @@ -0,0 +1,17 @@ +import { TwilioResponse } from "@twilio-labs/serverless-runtime-types/types" + +/** + * Fill a twilio response object with JSON and set the appropriate headers + * @param response + * @param statusCode + * @param json + * @returns twilio response + */ +export const setResponseJson = (response: TwilioResponse, statusCode: number, json: any): TwilioResponse => { + response + .setStatusCode(statusCode) + .appendHeader('Content-Type', 'application/json') + .setBody(json); + + return response; +} diff --git a/packages/doorman-ui/src/components/AuthComponent.tsx b/packages/doorman-ui/src/components/AuthComponent.tsx index fdfecb1..80f72af 100644 --- a/packages/doorman-ui/src/components/AuthComponent.tsx +++ b/packages/doorman-ui/src/components/AuthComponent.tsx @@ -1,6 +1,9 @@ import { Button, FormField, Icon, Input, SpaceBetween } from "@cloudscape-design/components"; import { useEffect, useState } from "react"; +import type { AuthRequest } from "../../../doorman-api/src/functions/api/door/auth"; +import { fetchUrlEncoded } from "../helpers/FetchHelper"; + // COPIED FROM SERVER export enum IAuthMode { FIXED_PIN = "Fixed Pin", @@ -18,7 +21,7 @@ export interface IAuthComponentProps { } export const AuthComponent = ({ door, secret, authMode, onError, onUnlock, runCheck }: IAuthComponentProps) => { - const [ key, setKey ] = useState(secret); + const [ key, setKey ] = useState(secret || ""); const [ error, setError ] = useState(""); useEffect(() => { @@ -38,15 +41,21 @@ export const AuthComponent = ({ door, secret, authMode, onError, onUnlock, runCh return "null"; }); - fetch(`/api/door/auth?key=${key}&rotatingKey=${key}&door=${door}&ip=${ip}`) - .then(async res => { - if (res.status !== 200) { - setError("Incorrect PIN"); - onError && onError(await res.json()); - return; - } - onUnlock(); - }) + const body: AuthRequest = { + door, + ip, + key, + }; + + fetchUrlEncoded('/api/door/auth', body) + .then(async res => { + if (res.status !== 200) { + setError("Incorrect PIN"); + onError && onError(await res.json()); + return; + } + onUnlock(); + }); } return ( diff --git a/packages/doorman-ui/src/helpers/FetchHelper.ts b/packages/doorman-ui/src/helpers/FetchHelper.ts new file mode 100644 index 0000000..6b996fe --- /dev/null +++ b/packages/doorman-ui/src/helpers/FetchHelper.ts @@ -0,0 +1,5 @@ +export const fetchUrlEncoded = (url: string, body: Record): Promise => { + const params = new URLSearchParams(body); + + return fetch(url + '?' + params.toString()); +} diff --git a/packages/doorman-ui/src/pages/DoorPage.tsx b/packages/doorman-ui/src/pages/DoorPage.tsx index 0fb6db4..8875761 100644 --- a/packages/doorman-ui/src/pages/DoorPage.tsx +++ b/packages/doorman-ui/src/pages/DoorPage.tsx @@ -183,7 +183,7 @@ export function DoorPage() { authMode={IAuthMode.FIXED_PIN} runCheck={submitPin} door={door} - secret={secret} + secret={secret} onUnlock={() => { setStep(2); dismissInProgressAlerts();