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
364 lines
10 KiB
TypeScript
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 } });
|
|
}
|