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
33 lines
1.9 KiB
SQL
33 lines
1.9 KiB
SQL
-- 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");
|