rebreak-monorepo/backend/server/utils/device-lock-email.ts
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

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