add common validation path. Refactor auth route to use it

This commit is contained in:
Martin Dimitrov 2025-06-07 13:43:16 -07:00
parent fd3878d590
commit 0979d03a27
8 changed files with 90 additions and 39 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -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",

View File

@ -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<T extends DoormanLambdaContext, U extends BaseEvent>
}
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];
}
}
}

View File

@ -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<TwilioContext, AuthRequest> = withMetrics('auth', async (context, event, callback, metricsRegistry) => {
export type AuthRequest = z.infer<typeof AuthRequestSchema>;
export interface AuthRequestTwilio extends ServerlessEventObject<AuthRequest, UserAgentHeader> { };
export const handler: ServerlessFunctionSignature<TwilioContext, AuthRequestTwilio> = 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<TwilioContext, AuthRequest> =
if (!config) {
getMetricFromRegistry<Counter>(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<TwilioContext, AuthRequest> =
}
if (!method) {
response.setStatusCode(401);
setResponseJson(response, 401, {
err: "Invalid PIN",
});
return callback(null, response);
}
@ -72,7 +81,7 @@ export const handler: ServerlessFunctionSignature<TwilioContext, AuthRequest> =
};
// 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<TwilioContext, AuthRequest> =
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

View File

@ -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;
}

View File

@ -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 (

View File

@ -0,0 +1,5 @@
export const fetchUrlEncoded = (url: string, body: Record<string, any>): Promise<Response> => {
const params = new URLSearchParams(body);
return fetch(url + '?' + params.toString());
}

View File

@ -183,7 +183,7 @@ export function DoorPage() {
authMode={IAuthMode.FIXED_PIN}
runCheck={submitPin}
door={door}
secret={secret}
secret={secret}
onUnlock={() => {
setStep(2);
dismissInProgressAlerts();