summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/account/kid_link_code.ts23
-rw-r--r--src/routes/parent.ts79
-rw-r--r--src/routes/signin.ts80
3 files changed, 155 insertions, 27 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",
);