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
140 lines
5.6 KiB
TypeScript
140 lines
5.6 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
}
|