From 677b67902bbc63adc52e3e72a17cd4615fd96646 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Mon, 11 May 2026 04:06:49 +0200 Subject: [PATCH] feat(devices): protected device enrollment + mobileconfig generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - ProtectedDevice prisma model + migration add_protected_devices - DB helpers: list/count/get/create/confirm/revoke - mobileconfig.ts utility — XML-escape, unique UUIDs per request - 5 endpoints under /api/devices/* (avoid /api/devices conflict with existing Capacitor UserDevice route by using /api/devices/protected for list) Phase 1: backend ready. DoH-server token-routing comes in phase 2. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migration.sql | 23 +++ backend/prisma/schema.prisma | 29 ++++ .../devices/[id]/confirm-installed.post.ts | 26 ++++ .../devices/[id]/profile.mobileconfig.get.ts | 45 ++++++ .../server/api/devices/[id]/revoke.delete.ts | 36 +++++ backend/server/api/devices/enroll.post.ts | 76 ++++++++++ backend/server/api/devices/protected.get.ts | 27 ++++ backend/server/db/protectedDevices.ts | 139 ++++++++++++++++++ backend/server/utils/mobileconfig.ts | 103 +++++++++++++ 9 files changed, 504 insertions(+) create mode 100644 backend/prisma/migrations/20260511_add_protected_devices/migration.sql create mode 100644 backend/server/api/devices/[id]/confirm-installed.post.ts create mode 100644 backend/server/api/devices/[id]/profile.mobileconfig.get.ts create mode 100644 backend/server/api/devices/[id]/revoke.delete.ts create mode 100644 backend/server/api/devices/enroll.post.ts create mode 100644 backend/server/api/devices/protected.get.ts create mode 100644 backend/server/db/protectedDevices.ts create mode 100644 backend/server/utils/mobileconfig.ts diff --git a/backend/prisma/migrations/20260511_add_protected_devices/migration.sql b/backend/prisma/migrations/20260511_add_protected_devices/migration.sql new file mode 100644 index 0000000..0eae59a --- /dev/null +++ b/backend/prisma/migrations/20260511_add_protected_devices/migration.sql @@ -0,0 +1,23 @@ +-- Migration: add_protected_devices +-- Multi-Device DNS-Schutz für Legend-User. +-- Legend: bis zu 3 Geräte mit individuellem DoH-Token geschützt. + +CREATE TABLE "rebreak"."protected_devices" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "user_id" UUID NOT NULL, + "dns_token" TEXT NOT NULL, + "platform" TEXT NOT NULL, + "label" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "installed_at" TIMESTAMPTZ, + "last_dns_query_at" TIMESTAMPTZ, + "revoked_at" TIMESTAMPTZ, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT "protected_devices_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "protected_devices_dns_token_key" ON "rebreak"."protected_devices"("dns_token"); +CREATE INDEX "protected_devices_user_id_idx" ON "rebreak"."protected_devices"("user_id"); +CREATE INDEX "protected_devices_dns_token_idx" ON "rebreak"."protected_devices"("dns_token"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 9c1aa3e..62ce727 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -709,3 +709,32 @@ model UserDevice { @@map("user_devices") @@schema("rebreak") } + +// Multi-Device DNS-Schutz: Legend-User können bis zu 3 Geräte (Mac/iOS/Android/Windows) +// mit einem per-Device DoH-Token schützen. Token wird in die mobileconfig/DoH-URL +// eingebettet — ist die einzige Auth für den DoH-Server (Phase 2). +model ProtectedDevice { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + /// Per-Device DoH-Token, embedded in mobileconfig ServerURL. + /// Format: 32-char cryptographic-random hex. + dnsToken String @unique @map("dns_token") + /// "mac" | "windows" | "ios" | "android" + platform String + /// User-friendly label, z.B. "MacBook Pro" oder "Olfas iPhone" + label String + /// pending (enrolled, profile not installed yet) | active (user confirmed install) | revoked + status String @default("pending") + /// User confirmed install via App (not server-side verified yet — DoH-routing kommt in Phase 2) + installedAt DateTime? @map("installed_at") + /// Optional: DoH-server pingt das später (Phase 2, separater Sprint) + lastDnsQueryAt DateTime? @map("last_dns_query_at") + revokedAt DateTime? @map("revoked_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([userId]) + @@index([dnsToken]) + @@map("protected_devices") + @@schema("rebreak") +} diff --git a/backend/server/api/devices/[id]/confirm-installed.post.ts b/backend/server/api/devices/[id]/confirm-installed.post.ts new file mode 100644 index 0000000..ef56a0b --- /dev/null +++ b/backend/server/api/devices/[id]/confirm-installed.post.ts @@ -0,0 +1,26 @@ +import { confirmProtectedDeviceInstalled } from "../../../db/protectedDevices"; + +/** + * POST /api/devices/:id/confirm-installed + * + * User klickt "Installiert" in der App nach dem Profil-Download. + * Setzt installedAt = NOW() und status = "active". + * + * Auth: requireUser + ownership (confirmProtectedDeviceInstalled prüft userId). + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const id = getRouterParam(event, "id"); + if (!id) throw createError({ statusCode: 400, data: { error: "ID_REQUIRED" } }); + + const updated = await confirmProtectedDeviceInstalled(id, user.id); + + if (!updated) { + throw createError({ + statusCode: 404, + data: { error: "DEVICE_NOT_FOUND_OR_REVOKED" }, + }); + } + + return { success: true, data: { status: updated.status, installedAt: updated.installedAt } }; +}); diff --git a/backend/server/api/devices/[id]/profile.mobileconfig.get.ts b/backend/server/api/devices/[id]/profile.mobileconfig.get.ts new file mode 100644 index 0000000..ac5ffe5 --- /dev/null +++ b/backend/server/api/devices/[id]/profile.mobileconfig.get.ts @@ -0,0 +1,45 @@ +import { getProtectedDevice } from "../../../db/protectedDevices"; +import { + generateMacOSDnsProfile, + labelToSlug, +} from "../../../utils/mobileconfig"; + +/** + * GET /api/devices/:id/profile.mobileconfig + * + * PUBLIC — der Mac muss ohne Auth-Header zugreifen können. + * Der dnsToken im Profil IST die Device-Authentifizierung beim DoH-Server. + * + * Liefert ein macOS DNS-Over-HTTPS Konfigurationsprofil. + * Content-Type: application/x-apple-aspen-config + */ +export default defineEventHandler(async (event) => { + const id = getRouterParam(event, "id"); + if (!id) throw createError({ statusCode: 400, data: { error: "ID_REQUIRED" } }); + + const device = await getProtectedDevice(id); + + if (!device || device.status === "revoked") { + throw createError({ statusCode: 404, data: { error: "DEVICE_NOT_FOUND" } }); + } + + const plist = generateMacOSDnsProfile({ + deviceId: device.id, + dnsToken: device.dnsToken, + label: device.label, + }); + + const slug = labelToSlug(device.label); + const filename = `rebreak-${slug || "schutz"}.mobileconfig`; + + setHeader(event, "Content-Type", "application/x-apple-aspen-config"); + setHeader( + event, + "Content-Disposition", + `attachment; filename="${filename}"`, + ); + // Kein Caching — Token ist sensitiv + setHeader(event, "Cache-Control", "no-store"); + + return plist; +}); diff --git a/backend/server/api/devices/[id]/revoke.delete.ts b/backend/server/api/devices/[id]/revoke.delete.ts new file mode 100644 index 0000000..04123c1 --- /dev/null +++ b/backend/server/api/devices/[id]/revoke.delete.ts @@ -0,0 +1,36 @@ +import { revokeProtectedDevice } from "../../../db/protectedDevices"; + +/** + * DELETE /api/devices/:id/revoke + * + * User entfernt ein Protected-Device aus der App. + * Soft-delete: status=revoked, revokedAt=NOW(). Kein hard-delete (DSGVO-Audit-Trail). + * + * WICHTIG: Das Profil bleibt auf dem Mac installiert bis der User es manuell + * entfernt. Das Backend kann das nicht aus der Ferne erzwingen (Phase 2: MDM). + * + * Auth: requireUser + ownership (revokeProtectedDevice prüft userId). + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const id = getRouterParam(event, "id"); + if (!id) throw createError({ statusCode: 400, data: { error: "ID_REQUIRED" } }); + + const revoked = await revokeProtectedDevice(id, user.id); + + if (!revoked) { + throw createError({ + statusCode: 404, + data: { error: "DEVICE_NOT_FOUND_OR_ALREADY_REVOKED" }, + }); + } + + return { + success: true, + manualRemovalRequired: true, + data: { + error: "MANUAL_REMOVAL_REQUIRED", + // Frontend übersetzt diesen Code in die passende UI-Meldung + }, + }; +}); diff --git a/backend/server/api/devices/enroll.post.ts b/backend/server/api/devices/enroll.post.ts new file mode 100644 index 0000000..e51ff3b --- /dev/null +++ b/backend/server/api/devices/enroll.post.ts @@ -0,0 +1,76 @@ +import { randomBytes } from "crypto"; +import { getProfile } from "../../db/profile"; +import { + countActiveProtectedDevices, + createProtectedDevice, +} from "../../db/protectedDevices"; + +/** + * POST /api/devices/enroll + * + * Legend-only. User klickt "Mac hinzufügen" in der App. + * Legt ein ProtectedDevice (status=pending) an und gibt die Download-URL + * für das mobileconfig-Profil zurück. + * + * Body: { platform: "mac" | "windows" | "ios" | "android", label: string } + * Response: { deviceId, dnsToken, downloadUrl } + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const profile = await getProfile(user.id); + if (profile?.plan !== "legend") { + throw createError({ + statusCode: 403, + data: { error: "LEGEND_REQUIRED" }, + }); + } + + const body = await readBody(event); + const platform = body?.platform as string | undefined; + const label = body?.label as string | undefined; + + const VALID_PLATFORMS = ["mac", "windows", "ios", "android"]; + if (!platform || !VALID_PLATFORMS.includes(platform)) { + throw createError({ + statusCode: 400, + data: { error: "INVALID_PLATFORM", validValues: VALID_PLATFORMS }, + }); + } + if (!label || typeof label !== "string" || label.trim().length === 0) { + throw createError({ statusCode: 400, data: { error: "LABEL_REQUIRED" } }); + } + const trimmedLabel = label.trim().slice(0, 100); + + // Limit: max 3 active+pending Devices + const activeCount = await countActiveProtectedDevices(user.id); + if (activeCount >= 3) { + throw createError({ + statusCode: 409, + data: { error: "DEVICE_LIMIT_REACHED", max: 3, current: activeCount }, + }); + } + + // 32-char hex token — kryptografisch sicher + const dnsToken = randomBytes(16).toString("hex"); + + const device = await createProtectedDevice({ + userId: user.id, + dnsToken, + platform, + label: trimmedLabel, + }); + + const config = useRuntimeConfig(event); + const apiBase = + (config.public as any)?.apiBase ?? "https://api.rebreak.org"; + + return { + success: true, + data: { + deviceId: device.id, + dnsToken: device.dnsToken, + downloadUrl: `${apiBase}/api/devices/${device.id}/profile.mobileconfig`, + }, + }; +}); diff --git a/backend/server/api/devices/protected.get.ts b/backend/server/api/devices/protected.get.ts new file mode 100644 index 0000000..800f958 --- /dev/null +++ b/backend/server/api/devices/protected.get.ts @@ -0,0 +1,27 @@ +import { getProfile } from "../../db/profile"; +import { listProtectedDevices } from "../../db/protectedDevices"; + +/** + * GET /api/devices/protected + * + * Liste aller aktiven+pending ProtectedDevices des Users. + * Niemals dnsToken zurückgeben — Security. + * + * Auth: requireUser + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const profile = await getProfile(user.id); + + const devices = await listProtectedDevices(user.id); + + return { + success: true, + data: { + devices, + plan: profile?.plan ?? "free", + max: 3, + isLegend: profile?.plan === "legend", + }, + }; +}); diff --git a/backend/server/db/protectedDevices.ts b/backend/server/db/protectedDevices.ts new file mode 100644 index 0000000..0e2e90a --- /dev/null +++ b/backend/server/db/protectedDevices.ts @@ -0,0 +1,139 @@ +import { usePrisma } from "../utils/prisma"; + +export interface ProtectedDeviceRecord { + id: string; + platform: string; + label: string; + status: string; + installedAt: Date | null; + createdAt: Date; +} + +export interface ProtectedDeviceWithToken extends ProtectedDeviceRecord { + dnsToken: string; + userId: string; +} + +/** Alle nicht-revoked Devices eines Users, neueste zuerst. */ +export async function listProtectedDevices( + userId: string, +): Promise { + const db = usePrisma(); + return db.protectedDevice.findMany({ + where: { userId, status: { not: "revoked" } }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + platform: true, + label: true, + status: true, + installedAt: true, + createdAt: true, + }, + }); +} + +/** Anzahl der aktiven+pending Devices für Limit-Check. */ +export async function countActiveProtectedDevices( + userId: string, +): Promise { + const db = usePrisma(); + return db.protectedDevice.count({ + where: { userId, status: { in: ["active", "pending"] } }, + }); +} + +/** Lookup by id — inkl. dnsToken und userId (für mobileconfig-Generation + ownership-check). */ +export async function getProtectedDevice( + id: string, +): Promise { + const db = usePrisma(); + return db.protectedDevice.findUnique({ + where: { id }, + select: { + id: true, + userId: true, + dnsToken: true, + platform: true, + label: true, + status: true, + installedAt: true, + createdAt: true, + }, + }); +} + +/** Anlegen eines neuen ProtectedDevice (status=pending). */ +export async function createProtectedDevice(opts: { + userId: string; + dnsToken: string; + platform: string; + label: string; +}): Promise { + const db = usePrisma(); + return db.protectedDevice.create({ + data: { + userId: opts.userId, + dnsToken: opts.dnsToken, + platform: opts.platform, + label: opts.label, + status: "pending", + }, + select: { + id: true, + userId: true, + dnsToken: true, + platform: true, + label: true, + status: true, + installedAt: true, + createdAt: true, + }, + }); +} + +/** User bestätigt Installation — setzt installedAt + status=active. */ +export async function confirmProtectedDeviceInstalled( + id: string, + userId: string, +): Promise { + const db = usePrisma(); + const device = await db.protectedDevice.findFirst({ + where: { id, userId, status: { not: "revoked" } }, + }); + if (!device) return null; + + return db.protectedDevice.update({ + where: { id }, + data: { + status: "active", + installedAt: new Date(), + }, + select: { + id: true, + platform: true, + label: true, + status: true, + installedAt: true, + createdAt: true, + }, + }); +} + +/** Soft-delete: setzt status=revoked + revokedAt. Ownership-check via userId. */ +export async function revokeProtectedDevice( + id: string, + userId: string, +): Promise { + const db = usePrisma(); + const device = await db.protectedDevice.findFirst({ + where: { id, userId, status: { not: "revoked" } }, + }); + if (!device) return false; + + await db.protectedDevice.update({ + where: { id }, + data: { status: "revoked", revokedAt: new Date() }, + }); + return true; +} diff --git a/backend/server/utils/mobileconfig.ts b/backend/server/utils/mobileconfig.ts new file mode 100644 index 0000000..0d6622c --- /dev/null +++ b/backend/server/utils/mobileconfig.ts @@ -0,0 +1,103 @@ +import { randomUUID } from "crypto"; + +/** XML-Escape für User-Input in plist-Strings. */ +function xmlEscape(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** Slugify label für Content-Disposition filename. */ +export function labelToSlug(label: string): string { + return label + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40); +} + +/** + * Generiert ein macOS DNS-Over-HTTPS Konfigurationsprofil (plist XML). + * + * Jeder Aufruf erzeugt neue random PayloadUUIDs — damit der Mac mehrere Profile + * unterscheiden kann falls ein User das Profil neu lädt. + * + * Phase 2: DoH-Server routet per dnsToken auf user-spezifische Blocklist. + */ +export function generateMacOSDnsProfile(opts: { + deviceId: string; + dnsToken: string; + label: string; +}): string { + const { deviceId, dnsToken, label } = opts; + + // Unique UUIDs pro Generierung (nicht deviceId verwenden — Mac dedupliciert sonst) + const outerUUID = randomUUID().toUpperCase(); + const innerUUID = randomUUID().toUpperCase(); + + // PayloadIdentifier: stabil pro Device (kein UUID hier — bleibt gleich bei Re-Download) + const tokenPrefix = dnsToken.slice(0, 8); + const payloadId = `org.rebreak.protection.dns.${tokenPrefix}`; + + const displayName = xmlEscape(`ReBreak Schutz — ${label}`); + const innerDisplayName = xmlEscape("ReBreak DNS-Filter"); + const description = xmlEscape( + "Leitet DNS-Anfragen über dns.rebreak.org. Glücksspiel-Domains werden blockiert.", + ); + const outerDescription = xmlEscape( + "Aktiviert den ReBreak-DNS-Filter auf diesem Mac. Glücksspiel-Domains werden auf System-Ebene blockiert — gilt für alle Browser und alle Apps. Entfernen erfordert Admin-Passwort.", + ); + const serverURL = `https://dns.rebreak.org/api/dns/${dnsToken}/dns-query`; + + return ` + + + + PayloadContent + + + PayloadDisplayName + ${innerDisplayName} + PayloadDescription + ${description} + PayloadIdentifier + ${payloadId}.dns + PayloadType + com.apple.dnsSettings.managed + PayloadUUID + ${innerUUID} + PayloadVersion + 1 + DNSSettings + + DNSProtocol + HTTPS + ServerURL + ${serverURL} + + + + PayloadDisplayName + ${displayName} + PayloadDescription + ${outerDescription} + PayloadIdentifier + ${payloadId} + PayloadOrganization + ReBreak + PayloadType + Configuration + PayloadUUID + ${outerUUID} + PayloadVersion + 1 + PayloadScope + System + PayloadRemovalDisallowed + + +`; +}