From 12a1e744f90fe7f39808caa3f97d6885a883f34a Mon Sep 17 00:00:00 2001 From: JustZvan Date: Wed, 8 Apr 2026 19:22:29 +0200 Subject: feat: use otp for kid link --- src/routes/parent.ts | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++- src/routes/signin.ts | 80 +++++++++++++++++++++++++++++++++++----------------- 2 files changed, 132 insertions(+), 27 deletions(-) (limited to 'src/routes') diff --git a/src/routes/parent.ts b/src/routes/parent.ts index d800dbf..41fb85c 100644 --- a/src/routes/parent.ts +++ b/src/routes/parent.ts @@ -1,11 +1,17 @@ import express from "express"; import { authParent } from "../middleware/auth"; import { db } from "../db/db"; -import { deviceConfig, linkedDevices, users, alerts, galleryScanningMode } from "../db/schema"; +import { deviceConfig, linkedDevices, users, alerts } from "../db/schema"; import { eq, and, desc } from "drizzle-orm"; import { isValidPushToken } from "../notifications/push"; import { logger } from "../lib/pino"; import { z } from "zod"; +import { redis } from "../db/redis/client"; +import { + generateKidLinkCode, + getKidLinkCodeRedisKey, + KID_LINK_CODE_TTL_SECONDS, +} from "../account/kid_link_code"; /** Validates email verification code from user input */ const VerifyEmailSchema = z.object({ @@ -172,6 +178,77 @@ function createParentRouter( } }); + router.post("/parent/kid-link-code", authParent, async (req, res) => { + const parentId = req.user!.id; + + try { + const parent = await db + .select({ emailVerified: users.emailVerified }) + .from(users) + .where(eq(users.id, parentId)) + .limit(1); + + if (parent.length === 0) { + logger.warn({ parentId }, "User not found for kid link code request"); + return res + .status(404) + .json({ success: false, reason: "User not found" }); + } + + if (!parent[0]!.emailVerified) { + logger.warn( + { parentId }, + "Kid link code request rejected: parent email not verified", + ); + return res.status(400).json({ + success: false, + reason: "Verify your email in the parent app before linking devices", + }); + } + + for (let attempt = 0; attempt < 5; attempt++) { + const code = generateKidLinkCode(); + const redisKey = getKidLinkCodeRedisKey(code); + + const stored = await redis.set( + redisKey, + parentId.toString(), + "EX", + KID_LINK_CODE_TTL_SECONDS, + "NX", + ); + + if (stored === "OK") { + logger.info( + { parentId }, + "Generated one-time kid link code successfully", + ); + + return res.json({ + success: true, + code, + expiresInSeconds: KID_LINK_CODE_TTL_SECONDS, + }); + } + } + + logger.warn( + { parentId }, + "Failed to allocate unique kid link code after retries", + ); + return res.status(503).json({ + success: false, + reason: "Failed to generate link code, please try again", + }); + } catch (e) { + logger.error({ error: e, parentId }, "Failed to generate kid link code"); + return res.status(500).json({ + success: false, + reason: "Failed to generate kid link code", + }); + } + }); + router.get("/parent/devices", authParent, async (req, res) => { const parentId = req.user!.id; diff --git a/src/routes/signin.ts b/src/routes/signin.ts index 50b03e3..38cfb51 100644 --- a/src/routes/signin.ts +++ b/src/routes/signin.ts @@ -10,6 +10,12 @@ import * as jose from "jose"; import { getTransporter } from "../email/email"; import { verifyGoogleIdToken } from "../account/google"; import { randomUUID } from "node:crypto"; +import { redis } from "../db/redis/client"; +import { + getKidLinkCodeRedisKey, + KID_LINK_CODE_REGEX, + normalizeKidLinkCode, +} from "../account/kid_link_code"; /** Validates signin request body with email and password */ const SigninBodySchema = z.object({ @@ -44,6 +50,15 @@ const GoogleSigninBodySchema = z.object({ idToken: z.string().min(1, { error: "Google ID token can't be empty" }), }); +const KidLinkBodySchema = z.object({ + code: z + .string() + .transform((value) => normalizeKidLinkCode(value)) + .refine((value) => KID_LINK_CODE_REGEX.test(value), { + message: "Code must match AAA-AAA format", + }), +}); + const router: express.Router = express.Router(); router.use(express.urlencoded({ extended: true })); @@ -534,58 +549,71 @@ router.post("/signin/google", async (req, res) => { }); router.post("/kid/link", async (req, res) => { - const body: Infer = req.body; - - const parsed = SigninBodySchema.safeParse(body); + const parsed = KidLinkBodySchema.safeParse(req.body); if (!parsed.success) { logger.warn({ error: parsed.error }, "Kid link validation failed"); res.send({ success: false, - reason: parsed.error, + reason: parsed.error.issues[0]?.message || "Invalid code format", token: "", }); return; } - logger.info({ email: parsed.data.email }, "Kid link request initiated"); + logger.info("Kid link request initiated with one-time code"); - const existingUser = ( - await db - .select() - .from(users) - .where(eq(users.email, parsed.data.email)) - .limit(1) - )[0]; + const redisKey = getKidLinkCodeRedisKey(parsed.data.code); + let parentIdRaw: string | null = null; - if (!existingUser) { - logger.warn( - { email: parsed.data.email }, - "Kid link failed: user not found", - ); + try { + parentIdRaw = await redis.getdel(redisKey); + } catch (error) { + logger.error({ error }, "Failed to consume kid link code from Redis"); res.send({ success: false, - reason: "Invalid email or password", + reason: "Failed to link device", }); return; } - logger.debug({ email: parsed.data.email }, "User found for kid link"); + if (!parentIdRaw) { + logger.warn("Kid link failed: code missing or expired"); + res.send({ + success: false, + reason: "Invalid or expired code", + }); + return; + } - const validPassword = await argon2.verify( - existingUser.password, - parsed.data.password, - ); + const parentId = Number(parentIdRaw); + if (!Number.isInteger(parentId) || parentId <= 0) { + logger.error({ parentIdRaw }, "Kid link code resolved to invalid parent ID"); + res.send({ + success: false, + reason: "Invalid or expired code", + }); + return; + } - if (!validPassword) { + const existingUser = ( + await db.select().from(users).where(eq(users.id, parentId)).limit(1) + )[0]; + + if (!existingUser) { + logger.warn({ parentId }, "Kid link failed: parent not found"); res.send({ success: false, - reason: "Invalid email or password", + reason: "Invalid or expired code", }); return; } if (!existingUser.emailVerified) { + logger.warn( + { parentId }, + "Kid link failed: parent email not verified", + ); res.send({ success: false, reason: "You must verify your email in the parent app before using Buddy", @@ -606,7 +634,7 @@ router.post("/kid/link", async (req, res) => { ); logger.info( - { deviceId: newDevice!.id, parentId: existingUser.id }, + { deviceId: newDevice!.id, parentId }, "New child device linked successfully", ); -- cgit v1.2.3