summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/account/kid_link_code.ts23
-rw-r--r--src/routes/parent.ts79
-rw-r--r--src/routes/signin.ts80
-rw-r--r--test/parent-routes.test.ts36
-rw-r--r--test/signin-routes.test.ts50
-rw-r--r--vitest.setup.ts1
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(() => ({