diff options
| author | JustZvan <justzvan@justzvan.xyz> | 2026-04-08 19:22:29 +0200 |
|---|---|---|
| committer | JustZvan <justzvan@justzvan.xyz> | 2026-04-08 19:22:29 +0200 |
| commit | 12a1e744f90fe7f39808caa3f97d6885a883f34a (patch) | |
| tree | 67f5b9c0bd72858dccf2d06b1b4bc7ba6ad417e0 | |
| parent | 6a5a165a035459af601006286e4a6e46ded1487c (diff) | |
feat: use otp for kid linkmain
| -rw-r--r-- | src/account/kid_link_code.ts | 23 | ||||
| -rw-r--r-- | src/routes/parent.ts | 79 | ||||
| -rw-r--r-- | src/routes/signin.ts | 80 | ||||
| -rw-r--r-- | test/parent-routes.test.ts | 36 | ||||
| -rw-r--r-- | test/signin-routes.test.ts | 50 | ||||
| -rw-r--r-- | vitest.setup.ts | 1 |
6 files changed, 240 insertions, 29 deletions
diff --git a/src/account/kid_link_code.ts b/src/account/kid_link_code.ts new file mode 100644 index 0000000..a9d3c63 --- /dev/null +++ b/src/account/kid_link_code.ts @@ -0,0 +1,23 @@ +import { randomInt } from "node:crypto"; + +const KID_LINK_CODE_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + +export const KID_LINK_CODE_TTL_SECONDS = 5 * 60; +export const KID_LINK_CODE_REGEX = /^[A-Z0-9]{3}-[A-Z0-9]{3}$/; + +export function generateKidLinkCode(): string { + const rawCode = Array.from({ length: 6 }, () => { + const index = randomInt(0, KID_LINK_CODE_CHARACTERS.length); + return KID_LINK_CODE_CHARACTERS[index]!; + }).join(""); + + return `${rawCode.slice(0, 3)}-${rawCode.slice(3)}`; +} + +export function normalizeKidLinkCode(value: string): string { + return value.trim().toUpperCase(); +} + +export function getKidLinkCodeRedisKey(code: string): string { + return `kid-link-code:${code}`; +} 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<typeof SigninBodySchema> = 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", ); diff --git a/test/parent-routes.test.ts b/test/parent-routes.test.ts index b93e85d..83734bb 100644 --- a/test/parent-routes.test.ts +++ b/test/parent-routes.test.ts @@ -2,9 +2,10 @@ import { describe, test, expect, vi, beforeEach } from "vitest"; import request from "supertest"; import express, { Request, Response, NextFunction } from "express"; import { db } from "../src/db/db"; -import { linkedDevices, deviceConfig } from "../src/db/schema"; +import { linkedDevices, deviceConfig, users } from "../src/db/schema"; import { eq } from "drizzle-orm"; import createParentRouter from "../src/routes/parent"; +import { redis } from "../src/db/redis/client"; vi.mock("../src/middleware/auth", () => ({ authParent: (req: Request, res: Response, next: NextFunction) => { @@ -28,6 +29,8 @@ describe("Parent Routes", () => { app.use("/", createParentRouter(onlineDevices)); await db.delete(deviceConfig).execute(); + await db.update(users).set({ emailVerified: true }).where(eq(users.id, 1)); + vi.mocked(redis.set).mockReset(); }); test("should get devices for parent", async () => { @@ -126,4 +129,35 @@ describe("Parent Routes", () => { expect(response.body.success).toBe(false); }); + + test("should generate one-time kid link code", async () => { + vi.mocked(redis.set).mockResolvedValueOnce("OK"); + + const response = await request(app).post("/parent/kid-link-code").expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.code).toMatch(/^[A-Z0-9]{3}-[A-Z0-9]{3}$/); + expect(response.body.expiresInSeconds).toBe(300); + + const firstCall = vi.mocked(redis.set).mock.calls[0]; + expect(firstCall).toBeDefined(); + expect(firstCall?.[0]).toMatch(/^kid-link-code:[A-Z0-9]{3}-[A-Z0-9]{3}$/); + expect(firstCall?.[1]).toBe("1"); + expect(firstCall?.[2]).toBe("EX"); + expect(firstCall?.[3]).toBe(300); + expect(firstCall?.[4]).toBe("NX"); + }); + + test("should reject kid link code generation when email is not verified", async () => { + await db + .update(users) + .set({ emailVerified: false }) + .where(eq(users.id, 1)); + + const response = await request(app).post("/parent/kid-link-code").expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.reason).toContain("Verify your email"); + expect(redis.set).not.toHaveBeenCalled(); + }); }); diff --git a/test/signin-routes.test.ts b/test/signin-routes.test.ts index 74f205d..e044d72 100644 --- a/test/signin-routes.test.ts +++ b/test/signin-routes.test.ts @@ -2,12 +2,13 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import argon2 from "argon2"; import * as jose from "jose"; import { db } from "../src/db/db"; -import { users } from "../src/db/schema"; +import { users, linkedDevices } from "../src/db/schema"; import { eq } from "drizzle-orm"; import signinRouter from "../src/routes/signin"; import type { Request, Response } from "express"; import { verifyGoogleIdToken } from "../src/account/google"; import { signJwt } from "../src/account/jwt"; +import { redis } from "../src/db/redis/client"; const sendMail = vi.fn(); @@ -41,6 +42,7 @@ describe("Signin Routes", () => { vi.mocked(verifyGoogleIdToken).mockReset(); vi.mocked(signJwt).mockReset(); vi.mocked(signJwt).mockResolvedValue("signed-jwt"); + vi.mocked(redis.getdel).mockReset(); }); function getRouteHandler(path: string, method: "get" | "post") { @@ -274,4 +276,50 @@ describe("Signin Routes", () => { `action="/reset-password/${encodeURIComponent(token)}"`, ); }); + + test("should link a kid device with a valid one-time code", async () => { + const beforeDevices = await db + .select() + .from(linkedDevices) + .where(eq(linkedDevices.parentId, 1)); + + vi.mocked(redis.getdel).mockResolvedValueOnce("1"); + + const response = await invokeRoute("/kid/link", "post", { + body: { + code: "abc-123", + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ + success: true, + token: "signed-jwt", + reason: "", + }); + expect(redis.getdel).toHaveBeenCalledWith("kid-link-code:ABC-123"); + + const afterDevices = await db + .select() + .from(linkedDevices) + .where(eq(linkedDevices.parentId, 1)); + + expect(afterDevices.length).toBe(beforeDevices.length + 1); + }); + + test("should reject linking when code is expired or invalid", async () => { + vi.mocked(redis.getdel).mockResolvedValueOnce(null); + + const response = await invokeRoute("/kid/link", "post", { + body: { + code: "ABC-123", + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ + success: false, + reason: "Invalid or expired code", + }); + }); }); diff --git a/vitest.setup.ts b/vitest.setup.ts index 60f7ae3..deee36c 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -88,6 +88,7 @@ vi.mock("./src/db/db", async () => { vi.mock("./src/db/redis/client", () => ({ redis: { get: vi.fn(), + getdel: vi.fn(), set: vi.fn(), del: vi.fn(), pipeline: vi.fn(() => ({ |