chahinebrini 1bc38e0732 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
2026-05-16 00:29:35 +02:00

364 lines
10 KiB
TypeScript

import { usePrisma } from "../utils/prisma";
/**
* Device-Binding pro User. Free=1, Pro=1, Legend=3 (siehe plan-features.maxDevices).
* deviceId kommt vom Frontend via Capacitor Device.getId() (persistent UUID).
*/
export interface DeviceRecord {
id: string;
userId: string;
deviceId: string;
platform: string;
model: string | null;
name: string | null;
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,
name: true,
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();
return db.userDevice.findMany({
where: { userId },
orderBy: { lastSeenAt: "desc" },
select: DEVICE_SELECT,
});
}
/** Gibt das Device zurück wenn registriert; sonst null. */
export async function findUserDevice(
userId: string,
deviceId: string,
): Promise<DeviceRecord | null> {
const db = usePrisma();
return db.userDevice.findUnique({
where: { userId_deviceId: { userId, deviceId } },
select: DEVICE_SELECT,
});
}
/**
* Sucht nach einem "Merge-Kandidaten": existierendes Device des Users mit
* identischem name + model das zuletzt innerhalb der letzten 30 Tage gesehen
* wurde. Tritt auf wenn iOS IDFV sich nach Recovery-Restore ändert → neuer
* deviceId, aber gleicher name ("iPhone von Chahine") + gleiches model
* ("iPhone18,4").
*
* Bekannter Trade-off: zwei physisch identische iPhones mit gleichem Namen
* würden gemerged. Edge-case, bewusst akzeptiert.
*/
async function findMergeCandidate(
userId: string,
name: string | null | undefined,
model: string | null | undefined,
): Promise<DeviceRecord | null> {
if (!name || !model) return null;
const db = usePrisma();
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const candidate = await db.userDevice.findFirst({
where: {
userId,
name,
model,
lastSeenAt: { gte: cutoff },
},
orderBy: { lastSeenAt: "desc" },
select: DEVICE_SELECT,
});
return candidate;
}
/**
* Idempotente Registrierung. Wenn Device bereits existiert: Touch lastSeenAt.
* Wenn nicht existiert UND gleicher name+model innerhalb 30d: Merge (IDFV-Reset-Heuristik).
* Wenn nicht existiert UND Limit erreicht: throw mit Liste der existierenden Devices.
*/
export async function registerDevice(opts: {
userId: string;
deviceId: string;
platform: string;
model?: string | null;
name?: string | null;
osVersion?: string | null;
maxDevices: number;
}): Promise<{
device: DeviceRecord;
created: boolean;
merged?: boolean;
}> {
const db = usePrisma();
// Idempotent: existiert das Device schon?
const existing = await findUserDevice(opts.userId, opts.deviceId);
if (existing) {
// model/name/osVersion beim Re-Register aktualisieren — User-Agent oder OS-Version
// kann sich geändert haben (App-Update, OS-Upgrade, iPad-Detection-Fix).
const updated = await db.userDevice.update({
where: { id: existing.id },
data: {
lastSeenAt: new Date(),
...(opts.model !== undefined && { model: opts.model }),
...(opts.name !== undefined && { name: opts.name }),
...(opts.osVersion !== undefined && { osVersion: opts.osVersion }),
},
select: DEVICE_SELECT,
});
return { device: updated, created: false };
}
// Merge-Heuristik: IDFV-Reset nach iOS Recovery-Restore.
// Wenn name+model matchen und Device zuletzt < 30 Tage gesehen → merge statt insert.
const mergeTarget = await findMergeCandidate(opts.userId, opts.name, opts.model);
if (mergeTarget) {
const merged = await db.userDevice.update({
where: { id: mergeTarget.id },
data: {
deviceId: opts.deviceId, // neue IDFV übernehmen
lastSeenAt: new Date(),
...(opts.osVersion !== undefined && { osVersion: opts.osVersion }),
},
select: DEVICE_SELECT,
});
return { device: merged, created: false, merged: true };
}
// Neues Device — Limit prüfen
const count = await db.userDevice.count({ where: { userId: opts.userId } });
if (count >= opts.maxDevices) {
throw Object.assign(new Error("device_limit_reached"), {
code: "DEVICE_LIMIT_REACHED",
currentCount: count,
max: opts.maxDevices,
});
}
const created = await db.userDevice.create({
data: {
userId: opts.userId,
deviceId: opts.deviceId,
platform: opts.platform,
model: opts.model ?? null,
name: opts.name ?? null,
osVersion: opts.osVersion ?? null,
},
select: DEVICE_SELECT,
});
return { device: created, created: true };
}
/** Touch lastSeenAt — wird in der Auth-Middleware bei jedem Request aufgerufen.
* Optional: backfillt name/model/osVersion wenn Headers da sind (für Devices
* die unter altem Build registriert wurden ohne diese Info). */
export async function touchDevice(
userId: string,
deviceId: string,
info?: { name?: string | null; model?: string | null; osVersion?: string | null },
): Promise<void> {
const db = usePrisma();
const data: { lastSeenAt: Date; name?: string; model?: string; osVersion?: string } = {
lastSeenAt: new Date(),
};
if (info?.name) data.name = info.name;
if (info?.model) data.model = info.model;
if (info?.osVersion) data.osVersion = info.osVersion;
await db.userDevice
.updateMany({
where: { userId, deviceId },
data,
})
.catch(() => {
/* race-safe: wenn Device gerade gelöscht wurde */
});
}
/** User entfernt ein eigenes Device — gibt Slot frei. */
export async function deleteUserDevice(userId: string, id: string): Promise<void> {
const db = usePrisma();
await db.userDevice.deleteMany({ where: { id, userId } });
}