feat(backend): device-account binding for pro/legend users

Closes the bypass loophole where a Pro/Legend user could log out in a
craving moment, sign in with a fresh Free account on the same iPhone,
and watch the NEFilter blocklist shrink from 208k Casino domains to
the curated 30-domain stub. The user is the patient — the addiction
itself is the attacker.

When a Pro/Legend account signs in via x-device-id, the device is
bound to that user_id (UserDevice.boundToPlan = 'pro'|'legend' …).
A subsequent login attempt from a different account on the same
device returns 409 DEVICE_LOCKED. The original user gets a Resend
email naming the nickname only (no firstName / email leaked per
the anonymity rule) with a link to either confirm the foreign attempt
or release the device.

Release flow:
  - POST /api/devices/:id/request-release schedules releaseAt = now + 24h
  - POST /api/devices/:id/cancel-release reverts it
  - a Nitro plugin cron sweeps both (24h-requested releases AND
    30-day-idle auto-releases) hourly

Free -> Free swaps stay unrestricted so onboarding on a second-hand
iPhone keeps working. Free -> Pro upgrade binds going forward; a
Pro -> Free downgrade keeps the existing lock so the bypass vector
stays closed.

Lock check runs BEFORE Supabase auth in /api/auth/login to avoid
giving a timing oracle for account enumeration. The dummy-UUID filter
in findActiveDeviceLock is the trick: it queries "someone else's
lock" with a userId that can never match.

DSGVO: ON DELETE CASCADE on UserDevice means an Art-17 deletion of
the original user releases all their locks automatically (Hans-Mueller
hand-off noted in the migration SQL).

24 vitest cases cover bind / lock / request-release-24h /
cancel-release / 30-day-idle-release / email rate-limit (1 per 6h) /
DSGVO cascade / multi-device Legend.

Migration to deploy after push:
  infisical run -- npx prisma migrate deploy --schema backend/prisma/schema.prisma

Frontend follow-up (separate task):
  - Sign-In: handle 409 DEVICE_LOCKED with a dedicated error UI
  - Settings/Devices page: "Release device" button + 24h countdown
  - GET /api/devices to include boundToPlan + releaseRequestedAt
This commit is contained in:
chahinebrini 2026-05-16 00:29:35 +02:00
parent 4c4792c153
commit 1bc38e0732
9 changed files with 875 additions and 6 deletions

View File

@ -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");

View File

@ -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")
}

View File

@ -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,
},

View File

@ -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 };
});

View File

@ -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(),
};
});

View File

@ -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<DeviceRecord | null> {
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<void> {
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<boolean> {
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<boolean> {
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<void> {
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<number> {
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<DeviceRecord[]> {
const db = usePrisma();

View File

@ -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);
}
}

View File

@ -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<void> {
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 = `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #1a1a1a; background: #f5f5f7; margin: 0; padding: 0; }
.container { max-width: 560px; margin: 32px auto; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.header { background: #1a1a1a; padding: 24px 32px; }
.header h1 { color: #fff; font-size: 18px; font-weight: 600; margin: 0; letter-spacing: -0.3px; }
.body { padding: 28px 32px; }
.body p { font-size: 15px; line-height: 1.6; color: #3a3a3a; margin: 0 0 16px; }
.highlight { background: #f5f5f7; border-radius: 8px; padding: 12px 16px; margin: 20px 0; font-size: 14px; color: #555; }
.actions { margin: 24px 0 0; display: flex; flex-direction: column; gap: 10px; }
.btn { display: block; text-align: center; padding: 12px 20px; border-radius: 8px; font-size: 15px; font-weight: 500; text-decoration: none; }
.btn-primary { background: #1a1a1a; color: #fff; }
.btn-secondary { background: #f5f5f7; color: #1a1a1a; }
.footer { padding: 16px 32px; font-size: 12px; color: #888; border-top: 1px solid #f0f0f0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>ReBreak Sicherheitshinweis</h1>
</div>
<div class="body">
<p>Hallo ${opts.recipientNickname},</p>
<p>
Auf <strong>${deviceLabel}</strong> hat sich jemand mit einem anderen Account angemeldet.
Dein Gerät ist mit deinem ReBreak-Account verknüpft der Anmeldeversuch wurde geblockt.
</p>
<div class="highlight">
<strong>Was das bedeutet:</strong><br>
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.
</div>
<p>
<strong>Wenn das nicht du warst:</strong> Alles ist in Ordnung kein Handlungsbedarf.
Dein Schutz funktioniert wie gewünscht.
</p>
<p>
<strong>Wenn das du warst:</strong> 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.
</p>
<div class="actions">
<a href="${devicesUrl}" class="btn btn-primary">Meine Geräte verwalten</a>
<a href="${reviewUrl}" class="btn btn-secondary">Account überprüfen</a>
</div>
</div>
<div class="footer">
Diese Mail wurde automatisch verschickt. Falls du Fragen hast, melde dich unter support@rebreak.org.
</div>
</div>
</body>
</html>
`.trim();
try {
await resend.emails.send({
from: "ReBreak <noreply@rebreak.org>",
to: opts.recipientEmail,
subject,
html,
});
} catch (err: any) {
console.error("[device-lock-email] Failed to send:", err?.message ?? err);
}
}

View File

@ -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<string, unknown> = {}) {
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();
});
});