diff --git a/bun.lockb b/bun.lockb index 9f73a96..031443f 100755 Binary files a/bun.lockb and b/bun.lockb differ 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();