diff --git a/backend/prisma/migrations/20260516_device_account_binding/migration.sql b/backend/prisma/migrations/20260516_device_account_binding/migration.sql new file mode 100644 index 0000000..240a655 --- /dev/null +++ b/backend/prisma/migrations/20260516_device_account_binding/migration.sql @@ -0,0 +1,32 @@ +-- Device-Account-Binding: Bypass-Schutz für Pro/Legend-User. +-- +-- Wenn ein Pro/Legend-User ein Gerät registriert, wird dieses Gerät an seinen +-- Account gebunden. Ein Ausloggen + Einloggen mit einem anderen Account auf +-- demselben Gerät wird mit 409 DEVICE_LOCKED blockiert. +-- +-- Neue Felder auf user_devices: +-- bound_to_plan — Plan des Users ZUM ZEITPUNKT der Bindung. Binding gilt +-- nur wenn bound_to_plan IN ('pro','legend','standard','premium'). +-- Free-Devices binden NICHT. Der Lock bleibt bestehen +-- auch wenn der Original-User danach auf Free downgradet +-- (Lock aufheben = Bypass-Vektor öffnen). +-- release_requested_at — wann der Original-User "Gerät freigeben" angeklickt +-- hat. release_requested_at + 24h = automatische Freigabe +-- (24h Cooldown schützt gegen impulsive Freigabe im Drang-Window). +-- lock_notified_at — Rate-Limit: max. 1 Mail pro Device pro 6h wenn jemand +-- auf einem gebundenen Gerät versucht sich einzuloggen. +-- +-- DSGVO-Hinweis (Hans-Müller): wenn der Original-User sein Konto löscht +-- (Art. 17 Recht auf Löschung), werden alle seine user_devices-Rows kaskadiert +-- gelöscht → alle Device-Locks automatisch released. Dies ist korrekt, da die +-- Verarbeitungsgrundlage (Nutzervertrag) erlischt. Keine gesonderte Cascade-Logik +-- nötig — DB-ON DELETE CASCADE reicht. + +ALTER TABLE "rebreak"."user_devices" + ADD COLUMN IF NOT EXISTS "bound_to_plan" TEXT NULL, + ADD COLUMN IF NOT EXISTS "release_requested_at" TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS "lock_notified_at" TIMESTAMPTZ NULL; + +-- Index: schnelle Suche "ist deviceId schon an einen anderen User gebunden?" +CREATE INDEX IF NOT EXISTS "user_devices_device_id_idx" + ON "rebreak"."user_devices" ("device_id"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 9f9a49b..9564580 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -839,6 +839,18 @@ model ConsentLog { // Device-Binding pro User: Free=1, Pro=1, Legend=3 (siehe plan-features.ts maxDevices). // Frontend liefert via Capacitor Device.getId() eine persistente UUID — diese wird // bei jedem authentifizierten Request via x-device-id Header geprüft. +// +// Device-Account-Lock (Bypass-Schutz): +// boundToPlan — Plan des Users zum Zeitpunkt der Bindung. NULL = noch nicht +// gebunden (Free-User oder Device vor Migration). Lock gilt +// nur wenn boundToPlan IN (pro, legend, standard, premium). +// releaseRequestedAt — wann Original-User "Gerät freigeben" angeklickt hat. +// releaseRequestedAt + 24h = Freigabe (Drang-Cooldown). +// lockNotifiedAt — Rate-Limit-Marker: letzte Mail-Notification bei Login-Versuch +// auf gebundenem Gerät. Max 1 Mail / 6h / Device. +// +// DSGVO Hans-Müller: Art-17-Konto-Löschung kaskadiert user_devices via ON DELETE CASCADE +// im DB-FK → alle Device-Locks automatisch released. Keine gesonderte Logik nötig. model UserDevice { id String @id @default(uuid()) @db.Uuid userId String @map("user_id") @db.Uuid @@ -850,8 +862,19 @@ model UserDevice { lastSeenAt DateTime @default(now()) @map("last_seen_at") createdAt DateTime @default(now()) @map("created_at") + // ─── Device-Account-Lock ──────────────────────────────────────────────── + /// Plan des Users zum Zeitpunkt der Bindung. NULL → kein Lock aktiv. + /// Lock gilt wenn: boundToPlan != null AND releaseRequestedAt + 24h > NOW() + boundToPlan String? @map("bound_to_plan") + /// Wann der Original-User "Gerät freigeben" beantragt hat. + /// NULL → noch kein Release-Request. Freigabe wird aktiv nach +24h. + releaseRequestedAt DateTime? @map("release_requested_at") + /// Letzte Mail-Notification bei fremdem Login-Versuch. Rate-Limit 6h. + lockNotifiedAt DateTime? @map("lock_notified_at") + @@unique([userId, deviceId]) @@index([userId]) + @@index([deviceId]) @@map("user_devices") @@schema("rebreak") } diff --git a/backend/server/api/auth/login.post.ts b/backend/server/api/auth/login.post.ts index c392162..2997305 100644 --- a/backend/server/api/auth/login.post.ts +++ b/backend/server/api/auth/login.post.ts @@ -1,5 +1,14 @@ -import { serverSupabaseClient } from "../../utils/useSupabase"; import { getProfile } from "../../db/profile"; +import { + findActiveDeviceLock, + bindDeviceToUser, + isLockingPlan, + markDeviceLockNotified, +} from "../../db/devices"; +import { + isLockNotifyRateLimited, + sendDeviceLockEmail, +} from "../../utils/device-lock-email"; export default defineEventHandler(async (event) => { const { username, password } = await readBody(event); @@ -11,6 +20,83 @@ export default defineEventHandler(async (event) => { }); } + // ─── Device-Lock-Check (vor Auth) ────────────────────────────────────────── + // Wenn x-device-id Header gesetzt und das Device an einen anderen Pro/Legend- + // User gebunden ist → 409 DEVICE_LOCKED. Login wird NICHT durchgeführt. + // + // Warum vor Auth: wir brauchen nicht zu wissen ob die Credentials korrekt sind. + // Das würde nur Timing-Side-Channel für Account-Enumeration öffnen. + const incomingDeviceId = getHeader(event, "x-device-id"); + + if (incomingDeviceId) { + // Wir brauchen die user-id des einlogenden Users für den Check — + // aber wir haben noch keinen eingeloggten User. Wir übergeben eine + // Dummy-UUID die nie matcht, da wir hier nur prüfen ob das Device an + // irgendwen (außer NULL) gebunden ist. findActiveDeviceLock matched + // "deviceId + NOT userId", also matcht die Dummy-UUID nie auf eine Row. + const DUMMY_REQUESTING_USER = "00000000-0000-0000-0000-000000000000"; + + const lock = await findActiveDeviceLock(incomingDeviceId, DUMMY_REQUESTING_USER); + + if (lock) { + // Async: Mail-Notification an Original-User (Rate-Limited auf 6h) + if (!isLockNotifyRateLimited(lock.lockNotifiedAt)) { + // Original-User-Profil laden für Mail-Details + // Alle Ressourcen hier im äußeren Scope cachen (kein async-after-response) + const config = useRuntimeConfig(event); + const supabaseCfg = (config as any).public?.supabase ?? (config as any).supabase; + const resendApiKey = (config as any).resendApiKey as string | undefined; + const supabaseServiceKey = (config as any).supabaseServiceKey as string | undefined; + + void (async () => { + try { + const { getProfile: gp } = await import("../../db/profile"); + const ownerProfile = await gp(lock.userId); + if (ownerProfile && resendApiKey && supabaseServiceKey) { + const { createClient } = await import("@supabase/supabase-js"); + const adminClient = createClient( + supabaseCfg.url as string, + supabaseServiceKey, + ); + const { data: adminUser } = await adminClient.auth.admin.getUserById(lock.userId); + const ownerEmail = adminUser?.user?.email; + + if (ownerEmail) { + await sendDeviceLockEmail({ + recipientNickname: ownerProfile.nickname ?? ownerProfile.username ?? "Nutzer", + recipientEmail: ownerEmail, + deviceRowId: lock.id, + deviceName: lock.name, + lockNotifiedAt: lock.lockNotifiedAt, + resendApiKey, + }); + await markDeviceLockNotified(lock.id); + } + } + } catch (mailErr: any) { + console.error("[login] device-lock mail failed:", mailErr?.message ?? mailErr); + } + })(); + } + + // lockedUntil: release_requested_at + 24h, oder "unbestimmt" wenn kein Request + const lockedUntil = lock.releaseRequestedAt + ? new Date(lock.releaseRequestedAt.getTime() + 24 * 60 * 60 * 1000).toISOString() + : null; + + throw createError({ + statusCode: 409, + statusMessage: "DEVICE_LOCKED", + data: { + error: "DEVICE_LOCKED", + lockedUntil, + releaseRequestable: true, + }, + }); + } + } + + // ─── Normaler Auth-Flow ───────────────────────────────────────────────────── const email = `${username.toLowerCase()}@rebreak.internal`; const supabase = serverSupabaseClient(event); @@ -21,6 +107,24 @@ export default defineEventHandler(async (event) => { if (error) throw createError({ statusCode: 401, message: error.message }); const dbProfile = await getProfile(data.user.id); + const normalizedPlan = ( + dbProfile?.plan === "premium" + ? "legend" + : dbProfile?.plan === "standard" + ? "pro" + : dbProfile?.plan ?? "free" + ) as "free" | "pro" | "legend"; + + // ─── Device-Binding nach erfolgreichem Login ──────────────────────────────── + // Wenn Pro/Legend-User einloggt und Device-ID bekannt → Device binden. + // Free-User binden nicht (isLockingPlan filtert). + if (incomingDeviceId && isLockingPlan(dbProfile?.plan ?? "free")) { + // Fire-and-forget — Login soll nicht wegen Binding-Fehler blockieren + void bindDeviceToUser(data.user.id, incomingDeviceId, dbProfile?.plan ?? "free") + .catch((err: any) => { + console.error("[login] device-bind failed:", err?.message ?? err); + }); + } return { session: { @@ -34,11 +138,7 @@ export default defineEventHandler(async (event) => { username: dbProfile?.username ?? "", nickname: dbProfile?.nickname ?? null, avatar: dbProfile?.avatar ?? null, - plan: (dbProfile?.plan === "premium" - ? "legend" - : dbProfile?.plan === "standard" - ? "pro" - : dbProfile?.plan ?? "free") as "free" | "pro" | "legend", + plan: normalizedPlan, streak: dbProfile?.streak ?? 0, created_at: dbProfile?.createdAt?.toISOString() ?? data.user.created_at, }, diff --git a/backend/server/api/devices/[id]/cancel-release.post.ts b/backend/server/api/devices/[id]/cancel-release.post.ts new file mode 100644 index 0000000..487d9cb --- /dev/null +++ b/backend/server/api/devices/[id]/cancel-release.post.ts @@ -0,0 +1,29 @@ +import { cancelDeviceRelease } from "../../../db/devices"; + +/** + * POST /api/devices/:id/cancel-release + * + * User zieht einen offenen Release-Request zurück. + * Setzt release_requested_at zurück auf NULL — Lock bleibt aktiv. + * + * Auth: eingeloggter User, ownership-check via userId im DB-Query. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event, { skipDeviceCheck: true }); + const id = getRouterParam(event, "id"); + + if (!id) { + throw createError({ statusCode: 400, data: { error: "MISSING_ID" } }); + } + + const updated = await cancelDeviceRelease(user.id, id); + + if (!updated) { + throw createError({ + statusCode: 404, + data: { error: "DEVICE_NOT_FOUND_OR_NO_PENDING_RELEASE" }, + }); + } + + return { success: true }; +}); diff --git a/backend/server/api/devices/[id]/request-release.post.ts b/backend/server/api/devices/[id]/request-release.post.ts new file mode 100644 index 0000000..b7fbce8 --- /dev/null +++ b/backend/server/api/devices/[id]/request-release.post.ts @@ -0,0 +1,35 @@ +import { requestDeviceRelease } from "../../../db/devices"; + +/** + * POST /api/devices/:id/request-release + * + * Original-User markiert sein eigenes Device für Freigabe. + * Freigabe wird aktiv nach 24h (Cooldown schützt gegen impulsiven Release im Drang-Window). + * + * Auth: eingeloggter User, ownership-check via userId im DB-Query. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event, { skipDeviceCheck: true }); + const id = getRouterParam(event, "id"); + + if (!id) { + throw createError({ statusCode: 400, data: { error: "MISSING_ID" } }); + } + + const updated = await requestDeviceRelease(user.id, id); + + if (!updated) { + // Device nicht gefunden, gehört nicht dem User, oder hat kein boundToPlan + throw createError({ + statusCode: 404, + data: { error: "DEVICE_NOT_FOUND_OR_NOT_BOUND" }, + }); + } + + const releaseAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + + return { + success: true, + releaseAt: releaseAt.toISOString(), + }; +}); diff --git a/backend/server/db/devices.ts b/backend/server/db/devices.ts index 960f4a0..3bdbc66 100644 --- a/backend/server/db/devices.ts +++ b/backend/server/db/devices.ts @@ -7,6 +7,7 @@ import { usePrisma } from "../utils/prisma"; export interface DeviceRecord { id: string; + userId: string; deviceId: string; platform: string; model: string | null; @@ -14,10 +15,173 @@ export interface DeviceRecord { osVersion: string | null; lastSeenAt: Date; createdAt: Date; + // Device-Account-Lock + boundToPlan: string | null; + releaseRequestedAt: Date | null; + lockNotifiedAt: Date | null; +} + +/** Pläne die einen Device-Account-Lock aktivieren. Free-User binden nie. */ +const LOCKING_PLANS = new Set(["pro", "legend", "standard", "premium"]); + +/** Ist ein Plan ein "locking" Plan (Pro/Legend inkl. Legacy-Namen)? */ +export function isLockingPlan(plan: string | null | undefined): boolean { + if (!plan) return false; + return LOCKING_PLANS.has(plan.toLowerCase()); +} + +/** + * Prüft ob ein gegebenes deviceId bereits an einen anderen User gebunden ist + * (Lock aktiv). Gibt die bound Row zurück wenn ja, null wenn frei. + * + * "Gebunden" = boundToPlan ist gesetzt (isLockingPlan) UND kein Release + * abgelaufen (releaseRequestedAt + 24h <= NOW() = released). + */ +export async function findActiveDeviceLock( + deviceId: string, + requestingUserId: string, +): Promise { + const db = usePrisma(); + + const row = await db.userDevice.findFirst({ + where: { + deviceId, + // Gebunden an einen anderen User + NOT: { userId: requestingUserId }, + // Binding existiert (Pro/Legend-Account) + boundToPlan: { not: null }, + }, + select: { + ...DEVICE_SELECT_WITH_LOCK, + }, + }); + + if (!row) return null; + + // Kein Lock wenn boundToPlan kein Locking-Plan (Sicherheitsnetz, eigentlich + // schon durch oben gefiltert aber explizit prüfen) + if (!isLockingPlan(row.boundToPlan)) return null; + + // Wenn Release-Request existiert und 24h abgelaufen → Lock ist released + if (row.releaseRequestedAt) { + const releaseAt = new Date(row.releaseRequestedAt.getTime() + 24 * 60 * 60 * 1000); + if (releaseAt <= new Date()) return null; + } + + // Lock ist aktiv + return row; +} + +/** + * Bindet ein Device an den User (setzt boundToPlan). + * Wird nach erfolgreichem Login aufgerufen wenn user.plan ein Locking-Plan ist. + * Idempotent: wenn bereits gebunden → kein Update. + */ +export async function bindDeviceToUser( + userId: string, + deviceId: string, + plan: string, +): Promise { + if (!isLockingPlan(plan)) return; // Free-User binden nicht + + const db = usePrisma(); + await db.userDevice.updateMany({ + where: { + userId, + deviceId, + boundToPlan: null, // Nur setzen wenn noch nicht gebunden + }, + data: { + boundToPlan: plan, + }, + }); +} + +/** + * Request-Release: Original-User setzt release_requested_at = NOW(). + * Nach 24h ist der Lock automatisch gelöst (Lazy-Check in findActiveDeviceLock). + * Gibt false zurück wenn Device nicht gefunden oder nicht dem User gehört. + */ +export async function requestDeviceRelease( + userId: string, + deviceId: string, // row-id (UUID), nicht deviceId-String +): Promise { + const db = usePrisma(); + const result = await db.userDevice.updateMany({ + where: { + id: deviceId, + userId, // Ownership-Check + boundToPlan: { not: null }, // Muss gebunden sein um freizugeben + }, + data: { + releaseRequestedAt: new Date(), + }, + }); + return result.count > 0; +} + +/** + * Cancel-Release: User zieht den Release-Request zurück. + * Setzt release_requested_at zurück auf NULL. + */ +export async function cancelDeviceRelease( + userId: string, + deviceId: string, // row-id +): Promise { + const db = usePrisma(); + const result = await db.userDevice.updateMany({ + where: { + id: deviceId, + userId, + releaseRequestedAt: { not: null }, // Muss offener Request sein + }, + data: { + releaseRequestedAt: null, + }, + }); + return result.count > 0; +} + +/** + * Setzt lockNotifiedAt für Rate-Limiting der E-Mail-Notifications. + */ +export async function markDeviceLockNotified(rowId: string): Promise { + const db = usePrisma(); + await db.userDevice.update({ + where: { id: rowId }, + data: { lockNotifiedAt: new Date() }, + }).catch(() => {}); +} + +/** + * Auto-Release: Alle Devices die 30 Tage nicht gesehen wurden UND noch + * boundToPlan gesetzt haben → boundToPlan zurücksetzen. + * Wird vom Cron-Plugin (device-lock-cron.ts) aufgerufen. + * + * Schützt vor verlorenen/verkauften/defekten Geräten ohne Customer-Support. + */ +export async function autoReleaseInactiveDevices(): Promise { + const db = usePrisma(); + const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + + const result = await db.userDevice.updateMany({ + where: { + boundToPlan: { not: null }, + lastSeenAt: { lt: cutoff }, + // Nur wenn kein Release-Request bereits pending (würde in 24h ohnehin ablaufen) + }, + data: { + boundToPlan: null, + releaseRequestedAt: null, + }, + }); + + return result.count; } const DEVICE_SELECT = { id: true, + userId: true, deviceId: true, platform: true, model: true, @@ -25,8 +189,14 @@ const DEVICE_SELECT = { osVersion: true, lastSeenAt: true, createdAt: true, + boundToPlan: true, + releaseRequestedAt: true, + lockNotifiedAt: true, } as const; +// Alias — identisch mit DEVICE_SELECT, explizit für Lock-Queries +const DEVICE_SELECT_WITH_LOCK = DEVICE_SELECT; + /** Liste aller Devices eines Users, aktuellstes zuerst. */ export async function listUserDevices(userId: string): Promise { const db = usePrisma(); diff --git a/backend/server/plugins/device-lock-cron.ts b/backend/server/plugins/device-lock-cron.ts new file mode 100644 index 0000000..bc96ce0 --- /dev/null +++ b/backend/server/plugins/device-lock-cron.ts @@ -0,0 +1,57 @@ +/** + * Device-Lock Auto-Release Cron + * + * Läuft alle 24h. Findet alle UserDevice-Rows die: + * - boundToPlan gesetzt haben (Lock aktiv) + * - lastSeenAt < NOW() - 30 Tage (Gerät inaktiv) + * + * → setzt boundToPlan + releaseRequestedAt zurück auf NULL. + * + * Begründung 30-Tage-Limit: schützt vor verlorenen/verkauften/defekten + * Geräten ohne dass User in Customer-Support muss. Nach 30 Tagen ohne + * authentifizierten API-Call ist das Gerät faktisch nicht mehr in Benutzung. + * + * Zusätzlich wird hier der "abgelaufene Release-Request" Lazy-Check + * nicht ersetzt — der läuft in findActiveDeviceLock() inline. Dieser + * Cron ist nur für den 30-Tage-Inaktivitäts-Case. + */ +import { consola } from "consola"; +import { autoReleaseInactiveDevices } from "../db/devices"; + +const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000; + +export default defineNitroPlugin((nitro) => { + if (import.meta.dev) { + consola.info("[device-lock-cron] Skipping cron in dev mode"); + return; + } + + consola.info("[device-lock-cron] Starting (24h interval)"); + + // Erster Lauf nach 2 Minuten (Server-Boot-Phase abwarten) + const initialTimer = setTimeout(() => { + runAutoRelease().catch(() => {}); + }, 2 * 60 * 1000); + + const interval = setInterval(() => { + runAutoRelease().catch(() => {}); + }, TWENTY_FOUR_HOURS); + + nitro.hooks.hook("close", () => { + clearTimeout(initialTimer); + clearInterval(interval); + }); +}); + +async function runAutoRelease() { + try { + const released = await autoReleaseInactiveDevices(); + if (released > 0) { + consola.success(`[device-lock-cron] Auto-released ${released} inactive device locks (30d)`); + } else { + consola.info("[device-lock-cron] No inactive device locks to release"); + } + } catch (err: any) { + consola.error("[device-lock-cron] run failed:", err?.message ?? err); + } +} diff --git a/backend/server/utils/device-lock-email.ts b/backend/server/utils/device-lock-email.ts new file mode 100644 index 0000000..f73c576 --- /dev/null +++ b/backend/server/utils/device-lock-email.ts @@ -0,0 +1,139 @@ +/** + * Device-Lock Email-Notification + * + * Wird versendet wenn ein fremder Account versucht sich auf einem gebundenen + * Gerät (Pro/Legend) einzuloggen. + * + * Rate-Limit: max. 1 Mail pro Device pro 6h (lockNotifiedAt in UserDevice). + * + * Anonymität: nur nickname wird im Mail-Body gezeigt — NIEMALS firstName/email. + * Siehe memory/feedback_anonymity_nickname.md + * + * Template-Inhalt ist bewusst sprachneutral (strukturierte Info) und NICHT + * Lyra-Voice-formatiert (kein SOS-Ton). Plain informational. + */ + +import { Resend } from "resend"; + +const LOCK_NOTIFY_COOLDOWN_MS = 6 * 60 * 60 * 1000; // 6h + +export interface DeviceLockEmailOpts { + /** Nickname des Original-Users (der das Gerät besitzt) */ + recipientNickname: string; + /** Email-Adresse des Original-Users (Supabase-Auth-Email — nur für Versand, erscheint NICHT im Body) */ + recipientEmail: string; + /** Row-ID des UserDevice (für Release-Link) */ + deviceRowId: string; + /** Lesbarer Name des Geräts (z.B. "Chahines iPhone") oder null */ + deviceName: string | null; + /** lockNotifiedAt des Devices — Rate-Limit-Check */ + lockNotifiedAt: Date | null; + /** Resend API Key — caller holt via useRuntimeConfig im Request-Context */ + resendApiKey: string; + /** App-Base-URL für Links (z.B. "https://app.rebreak.org") */ + appBaseUrl?: string; +} + +/** + * Gibt true zurück wenn innerhalb der letzten 6h bereits eine Mail für dieses + * Device verschickt wurde (Rate-Limit-Check ohne DB-Call nötig). + */ +export function isLockNotifyRateLimited(lockNotifiedAt: Date | null): boolean { + if (!lockNotifiedAt) return false; + return Date.now() - lockNotifiedAt.getTime() < LOCK_NOTIFY_COOLDOWN_MS; +} + +/** + * Sendet die Device-Lock-Notification per Resend. + * Fire-and-forget Pattern: Caller sollte nicht auf Fehler warten. + * Bei Fehler wird geloggt aber kein Throw (Mail-Fehler blockiert nie den Auth-Flow). + */ +export async function sendDeviceLockEmail(opts: DeviceLockEmailOpts): Promise { + if (!opts.resendApiKey) { + console.warn("[device-lock-email] resendApiKey not provided — skipping mail"); + return; + } + + const resend = new Resend(opts.resendApiKey); + + const baseUrl = opts.appBaseUrl ?? "https://app.rebreak.org"; + const deviceLabel = opts.deviceName ?? "dein Gerät"; + + // Deep-Link zur Devices-Settings-Page — Frontend rendert Release-Button + const devicesUrl = `${baseUrl}/settings/devices`; + const reviewUrl = `${baseUrl}/settings`; + + const subject = `Anmeldeversuch auf ${deviceLabel}`; + + const html = ` + + + + + + ${subject} + + + +
+
+

ReBreak — Sicherheitshinweis

+
+
+

Hallo ${opts.recipientNickname},

+

+ Auf ${deviceLabel} hat sich jemand mit einem anderen Account angemeldet. + Dein Gerät ist mit deinem ReBreak-Account verknüpft — der Anmeldeversuch wurde geblockt. +

+
+ Was das bedeutet:
+ Dein Schutz ist aktiv. Der andere Account hat auf diesem Gerät keinen Zugang bekommen. + Falls das du warst und du den Account wechseln möchtest, kannst du das Gerät unten freigeben. +
+

+ Wenn das nicht du warst: Alles ist in Ordnung — kein Handlungsbedarf. + Dein Schutz funktioniert wie gewünscht. +

+

+ Wenn das du warst: Du kannst das Gerät freigeben. + Bitte beachte: die Freigabe wird erst nach 24 Stunden wirksam. + Das ist ein bewusstes Sicherheits-Feature — es schützt dich davor, + im Drang-Moment impulsiv die Schutz-Bindung aufzuheben. +

+ +
+ +
+ + + `.trim(); + + try { + await resend.emails.send({ + from: "ReBreak ", + to: opts.recipientEmail, + subject, + html, + }); + } catch (err: any) { + console.error("[device-lock-email] Failed to send:", err?.message ?? err); + } +} diff --git a/backend/tests/devices/device-account-binding.test.ts b/backend/tests/devices/device-account-binding.test.ts new file mode 100644 index 0000000..e44bd56 --- /dev/null +++ b/backend/tests/devices/device-account-binding.test.ts @@ -0,0 +1,284 @@ +/** + * Tests für Device-Account-Binding (Bypass-Schutz). + * + * Getestet: + * 1. findActiveDeviceLock — Lock-Detection-Logic + * 2. isLockingPlan — Plan-Classification + * 3. isLockNotifyRateLimited — Mail-Rate-Limit + * 4. requestDeviceRelease / cancelDeviceRelease — Endpoint-Logic via DB-Layer + * + * Kein echtes Prisma. Alle DB-Calls werden über vi.mock("../../server/utils/prisma") gemockt. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { + isLockingPlan, + findActiveDeviceLock, + requestDeviceRelease, + cancelDeviceRelease, +} from "../../server/db/devices"; +import { isLockNotifyRateLimited } from "../../server/utils/device-lock-email"; + +// ─── Prisma-Mock ───────────────────────────────────────────────────────────── + +const mockPrisma = { + userDevice: { + findFirst: vi.fn(), + findUnique: vi.fn(), + findMany: vi.fn(), + updateMany: vi.fn(), + update: vi.fn(), + create: vi.fn(), + count: vi.fn(), + deleteMany: vi.fn(), + }, +}; + +vi.mock("../../server/utils/prisma", () => ({ + usePrisma: () => mockPrisma, +})); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeDevice(overrides: Record = {}) { + return { + id: "row-uuid-1", + userId: "owner-user-id", + deviceId: "device-capacitor-id", + platform: "ios", + model: "iPhone18,4", + name: "Chahines iPhone", + osVersion: "18.4", + lastSeenAt: new Date(), + createdAt: new Date(), + boundToPlan: "pro", + releaseRequestedAt: null, + lockNotifiedAt: null, + ...overrides, + }; +} + +// ─── isLockingPlan ──────────────────────────────────────────────────────────── + +describe("isLockingPlan", () => { + it("returns true for pro", () => { + expect(isLockingPlan("pro")).toBe(true); + }); + + it("returns true for legend", () => { + expect(isLockingPlan("legend")).toBe(true); + }); + + it("returns true for legacy standard (=pro)", () => { + expect(isLockingPlan("standard")).toBe(true); + }); + + it("returns true for legacy premium (=legend)", () => { + expect(isLockingPlan("premium")).toBe(true); + }); + + it("returns false for free", () => { + expect(isLockingPlan("free")).toBe(false); + }); + + it("returns false for null", () => { + expect(isLockingPlan(null)).toBe(false); + }); + + it("returns false for undefined", () => { + expect(isLockingPlan(undefined)).toBe(false); + }); + + it("returns false for empty string", () => { + expect(isLockingPlan("")).toBe(false); + }); +}); + +// ─── isLockNotifyRateLimited ───────────────────────────────────────────────── + +describe("isLockNotifyRateLimited", () => { + it("returns false when lockNotifiedAt is null (never notified)", () => { + expect(isLockNotifyRateLimited(null)).toBe(false); + }); + + it("returns true when last notification was 1h ago (within 6h limit)", () => { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + expect(isLockNotifyRateLimited(oneHourAgo)).toBe(true); + }); + + it("returns true when last notification was 5h 59m ago", () => { + const almostSixHours = new Date(Date.now() - (6 * 60 * 60 * 1000 - 60 * 1000)); + expect(isLockNotifyRateLimited(almostSixHours)).toBe(true); + }); + + it("returns false when last notification was 7h ago (beyond 6h limit)", () => { + const sevenHoursAgo = new Date(Date.now() - 7 * 60 * 60 * 1000); + expect(isLockNotifyRateLimited(sevenHoursAgo)).toBe(false); + }); +}); + +// ─── findActiveDeviceLock ───────────────────────────────────────────────────── + +describe("findActiveDeviceLock", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns null when no device found (no lock)", async () => { + mockPrisma.userDevice.findFirst.mockResolvedValue(null); + + const result = await findActiveDeviceLock("device-id", "requesting-user"); + expect(result).toBeNull(); + }); + + it("returns the device when locked (boundToPlan set, no release request)", async () => { + const device = makeDevice({ boundToPlan: "pro", releaseRequestedAt: null }); + mockPrisma.userDevice.findFirst.mockResolvedValue(device); + + const result = await findActiveDeviceLock("device-capacitor-id", "other-user"); + expect(result).not.toBeNull(); + expect(result!.id).toBe("row-uuid-1"); + }); + + it("returns null when release was requested and 24h have passed", async () => { + const twentyFiveHoursAgo = new Date(Date.now() - 25 * 60 * 60 * 1000); + const device = makeDevice({ + boundToPlan: "pro", + releaseRequestedAt: twentyFiveHoursAgo, + }); + mockPrisma.userDevice.findFirst.mockResolvedValue(device); + + const result = await findActiveDeviceLock("device-capacitor-id", "other-user"); + expect(result).toBeNull(); + }); + + it("returns device (lock still active) when release requested but 24h not yet passed", async () => { + const oneHourAgo = new Date(Date.now() - 1 * 60 * 60 * 1000); + const device = makeDevice({ + boundToPlan: "pro", + releaseRequestedAt: oneHourAgo, + }); + mockPrisma.userDevice.findFirst.mockResolvedValue(device); + + const result = await findActiveDeviceLock("device-capacitor-id", "other-user"); + expect(result).not.toBeNull(); + }); + + it("returns null when boundToPlan is a non-locking plan (e.g. free — edge case)", async () => { + // DB-Query filtered already on boundToPlan != null, but if somehow 'free' slips through + const device = makeDevice({ boundToPlan: "free" }); + mockPrisma.userDevice.findFirst.mockResolvedValue(device); + + const result = await findActiveDeviceLock("device-capacitor-id", "other-user"); + expect(result).toBeNull(); + }); +}); + +// ─── requestDeviceRelease ───────────────────────────────────────────────────── + +describe("requestDeviceRelease", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns true when device found and updated (ownership + bound check pass)", async () => { + mockPrisma.userDevice.updateMany.mockResolvedValue({ count: 1 }); + + const result = await requestDeviceRelease("owner-user-id", "row-uuid-1"); + expect(result).toBe(true); + expect(mockPrisma.userDevice.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: "row-uuid-1", + userId: "owner-user-id", + }), + data: expect.objectContaining({ + releaseRequestedAt: expect.any(Date), + }), + }), + ); + }); + + it("returns false when device not found or not owned by user", async () => { + mockPrisma.userDevice.updateMany.mockResolvedValue({ count: 0 }); + + const result = await requestDeviceRelease("wrong-user-id", "row-uuid-1"); + expect(result).toBe(false); + }); +}); + +// ─── cancelDeviceRelease ───────────────────────────────────────────────────── + +describe("cancelDeviceRelease", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns true when release request cancelled successfully", async () => { + mockPrisma.userDevice.updateMany.mockResolvedValue({ count: 1 }); + + const result = await cancelDeviceRelease("owner-user-id", "row-uuid-1"); + expect(result).toBe(true); + expect(mockPrisma.userDevice.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: "row-uuid-1", + userId: "owner-user-id", + releaseRequestedAt: { not: null }, + }), + data: { releaseRequestedAt: null }, + }), + ); + }); + + it("returns false when no pending release request exists", async () => { + mockPrisma.userDevice.updateMany.mockResolvedValue({ count: 0 }); + + const result = await cancelDeviceRelease("owner-user-id", "row-uuid-1"); + expect(result).toBe(false); + }); +}); + +// ─── 409 DEVICE_LOCKED path — Integration-light ────────────────────────────── +// Kein echtes HTTP — testet den Login-Endpoint-Handler indirekt über +// die DB-Layer-Funktionen die er aufruft. + +describe("409 DEVICE_LOCKED path (logic, no HTTP)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("lock is detected when device is bound to another pro user and no release pending", async () => { + const device = makeDevice({ boundToPlan: "pro", releaseRequestedAt: null }); + mockPrisma.userDevice.findFirst.mockResolvedValue(device); + + // Simulates what login.post.ts does before Auth: + const DUMMY = "00000000-0000-0000-0000-000000000000"; + const lock = await findActiveDeviceLock("device-capacitor-id", DUMMY); + + expect(lock).not.toBeNull(); + expect(lock!.boundToPlan).toBe("pro"); + }); + + it("lock is not detected when release was processed (24h passed)", async () => { + const expired = makeDevice({ + boundToPlan: "pro", + releaseRequestedAt: new Date(Date.now() - 25 * 60 * 60 * 1000), + }); + mockPrisma.userDevice.findFirst.mockResolvedValue(expired); + + const DUMMY = "00000000-0000-0000-0000-000000000000"; + const lock = await findActiveDeviceLock("device-capacitor-id", DUMMY); + + expect(lock).toBeNull(); + }); + + it("no lock when device has no binding (Free-User switched accounts)", async () => { + // findFirst returns null → DB already filtered boundToPlan != null + mockPrisma.userDevice.findFirst.mockResolvedValue(null); + + const DUMMY = "00000000-0000-0000-0000-000000000000"; + const lock = await findActiveDeviceLock("device-capacitor-id", DUMMY); + + expect(lock).toBeNull(); + }); +});