feat(devices): protected device enrollment + mobileconfig generator
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) <noreply@anthropic.com>
This commit is contained in:
parent
3088526fc1
commit
677b67902b
@ -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");
|
||||
@ -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")
|
||||
}
|
||||
|
||||
26
backend/server/api/devices/[id]/confirm-installed.post.ts
Normal file
26
backend/server/api/devices/[id]/confirm-installed.post.ts
Normal file
@ -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 } };
|
||||
});
|
||||
45
backend/server/api/devices/[id]/profile.mobileconfig.get.ts
Normal file
45
backend/server/api/devices/[id]/profile.mobileconfig.get.ts
Normal file
@ -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;
|
||||
});
|
||||
36
backend/server/api/devices/[id]/revoke.delete.ts
Normal file
36
backend/server/api/devices/[id]/revoke.delete.ts
Normal file
@ -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
|
||||
},
|
||||
};
|
||||
});
|
||||
76
backend/server/api/devices/enroll.post.ts
Normal file
76
backend/server/api/devices/enroll.post.ts
Normal file
@ -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`,
|
||||
},
|
||||
};
|
||||
});
|
||||
27
backend/server/api/devices/protected.get.ts
Normal file
27
backend/server/api/devices/protected.get.ts
Normal file
@ -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",
|
||||
},
|
||||
};
|
||||
});
|
||||
139
backend/server/db/protectedDevices.ts
Normal file
139
backend/server/db/protectedDevices.ts
Normal file
@ -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<ProtectedDeviceRecord[]> {
|
||||
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<number> {
|
||||
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<ProtectedDeviceWithToken | null> {
|
||||
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<ProtectedDeviceWithToken> {
|
||||
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<ProtectedDeviceRecord | null> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
103
backend/server/utils/mobileconfig.ts
Normal file
103
backend/server/utils/mobileconfig.ts
Normal file
@ -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, """)
|
||||
.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 `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>${innerDisplayName}</string>
|
||||
<key>PayloadDescription</key>
|
||||
<string>${description}</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>${payloadId}.dns</string>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.dnsSettings.managed</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>${innerUUID}</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>DNSSettings</key>
|
||||
<dict>
|
||||
<key>DNSProtocol</key>
|
||||
<string>HTTPS</string>
|
||||
<key>ServerURL</key>
|
||||
<string>${serverURL}</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>${displayName}</string>
|
||||
<key>PayloadDescription</key>
|
||||
<string>${outerDescription}</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>${payloadId}</string>
|
||||
<key>PayloadOrganization</key>
|
||||
<string>ReBreak</string>
|
||||
<key>PayloadType</key>
|
||||
<string>Configuration</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>${outerUUID}</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>PayloadScope</key>
|
||||
<string>System</string>
|
||||
<key>PayloadRemovalDisallowed</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>`;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user