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
147 lines
5.8 KiB
TypeScript
147 lines
5.8 KiB
TypeScript
import { getProfile } from "../../db/profile";
|
|
import {
|
|
findActiveDeviceLock,
|
|
bindDeviceToUser,
|
|
isLockingPlan,
|
|
markDeviceLockNotified,
|
|
} from "../../db/devices";
|
|
import {
|
|
isLockNotifyRateLimited,
|
|
sendDeviceLockEmail,
|
|
} from "../../utils/device-lock-email";
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const { username, password } = await readBody(event);
|
|
|
|
if (!username || !password) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
message: "username und password erforderlich",
|
|
});
|
|
}
|
|
|
|
// ─── Device-Lock-Check (vor Auth) ──────────────────────────────────────────
|
|
// Wenn x-device-id Header gesetzt und das Device an einen anderen Pro/Legend-
|
|
// User gebunden ist → 409 DEVICE_LOCKED. Login wird NICHT durchgeführt.
|
|
//
|
|
// Warum vor Auth: wir brauchen nicht zu wissen ob die Credentials korrekt sind.
|
|
// Das würde nur Timing-Side-Channel für Account-Enumeration öffnen.
|
|
const incomingDeviceId = getHeader(event, "x-device-id");
|
|
|
|
if (incomingDeviceId) {
|
|
// Wir brauchen die user-id des einlogenden Users für den Check —
|
|
// aber wir haben noch keinen eingeloggten User. Wir übergeben eine
|
|
// Dummy-UUID die nie matcht, da wir hier nur prüfen ob das Device an
|
|
// irgendwen (außer NULL) gebunden ist. findActiveDeviceLock matched
|
|
// "deviceId + NOT userId", also matcht die Dummy-UUID nie auf eine Row.
|
|
const DUMMY_REQUESTING_USER = "00000000-0000-0000-0000-000000000000";
|
|
|
|
const lock = await findActiveDeviceLock(incomingDeviceId, DUMMY_REQUESTING_USER);
|
|
|
|
if (lock) {
|
|
// Async: Mail-Notification an Original-User (Rate-Limited auf 6h)
|
|
if (!isLockNotifyRateLimited(lock.lockNotifiedAt)) {
|
|
// Original-User-Profil laden für Mail-Details
|
|
// Alle Ressourcen hier im äußeren Scope cachen (kein async-after-response)
|
|
const config = useRuntimeConfig(event);
|
|
const supabaseCfg = (config as any).public?.supabase ?? (config as any).supabase;
|
|
const resendApiKey = (config as any).resendApiKey as string | undefined;
|
|
const supabaseServiceKey = (config as any).supabaseServiceKey as string | undefined;
|
|
|
|
void (async () => {
|
|
try {
|
|
const { getProfile: gp } = await import("../../db/profile");
|
|
const ownerProfile = await gp(lock.userId);
|
|
if (ownerProfile && resendApiKey && supabaseServiceKey) {
|
|
const { createClient } = await import("@supabase/supabase-js");
|
|
const adminClient = createClient(
|
|
supabaseCfg.url as string,
|
|
supabaseServiceKey,
|
|
);
|
|
const { data: adminUser } = await adminClient.auth.admin.getUserById(lock.userId);
|
|
const ownerEmail = adminUser?.user?.email;
|
|
|
|
if (ownerEmail) {
|
|
await sendDeviceLockEmail({
|
|
recipientNickname: ownerProfile.nickname ?? ownerProfile.username ?? "Nutzer",
|
|
recipientEmail: ownerEmail,
|
|
deviceRowId: lock.id,
|
|
deviceName: lock.name,
|
|
lockNotifiedAt: lock.lockNotifiedAt,
|
|
resendApiKey,
|
|
});
|
|
await markDeviceLockNotified(lock.id);
|
|
}
|
|
}
|
|
} catch (mailErr: any) {
|
|
console.error("[login] device-lock mail failed:", mailErr?.message ?? mailErr);
|
|
}
|
|
})();
|
|
}
|
|
|
|
// lockedUntil: release_requested_at + 24h, oder "unbestimmt" wenn kein Request
|
|
const lockedUntil = lock.releaseRequestedAt
|
|
? new Date(lock.releaseRequestedAt.getTime() + 24 * 60 * 60 * 1000).toISOString()
|
|
: null;
|
|
|
|
throw createError({
|
|
statusCode: 409,
|
|
statusMessage: "DEVICE_LOCKED",
|
|
data: {
|
|
error: "DEVICE_LOCKED",
|
|
lockedUntil,
|
|
releaseRequestable: true,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// ─── Normaler Auth-Flow ─────────────────────────────────────────────────────
|
|
const email = `${username.toLowerCase()}@rebreak.internal`;
|
|
const supabase = serverSupabaseClient(event);
|
|
|
|
const { data, error } = await supabase.auth.signInWithPassword({
|
|
email,
|
|
password,
|
|
});
|
|
if (error) throw createError({ statusCode: 401, message: error.message });
|
|
|
|
const dbProfile = await getProfile(data.user.id);
|
|
const normalizedPlan = (
|
|
dbProfile?.plan === "premium"
|
|
? "legend"
|
|
: dbProfile?.plan === "standard"
|
|
? "pro"
|
|
: dbProfile?.plan ?? "free"
|
|
) as "free" | "pro" | "legend";
|
|
|
|
// ─── Device-Binding nach erfolgreichem Login ────────────────────────────────
|
|
// Wenn Pro/Legend-User einloggt und Device-ID bekannt → Device binden.
|
|
// Free-User binden nicht (isLockingPlan filtert).
|
|
if (incomingDeviceId && isLockingPlan(dbProfile?.plan ?? "free")) {
|
|
// Fire-and-forget — Login soll nicht wegen Binding-Fehler blockieren
|
|
void bindDeviceToUser(data.user.id, incomingDeviceId, dbProfile?.plan ?? "free")
|
|
.catch((err: any) => {
|
|
console.error("[login] device-bind failed:", err?.message ?? err);
|
|
});
|
|
}
|
|
|
|
return {
|
|
session: {
|
|
access_token: data.session.access_token,
|
|
refresh_token: data.session.refresh_token,
|
|
expires_at: data.session.expires_at,
|
|
},
|
|
profile: {
|
|
id: data.user.id,
|
|
email: data.user.email ?? "",
|
|
username: dbProfile?.username ?? "",
|
|
nickname: dbProfile?.nickname ?? null,
|
|
avatar: dbProfile?.avatar ?? null,
|
|
plan: normalizedPlan,
|
|
streak: dbProfile?.streak ?? 0,
|
|
created_at: dbProfile?.createdAt?.toISOString() ?? data.user.created_at,
|
|
},
|
|
};
|
|
});
|