Native app uses supabase.auth.signInWithPassword directly, bypassing /api/auth/login. This authenticated endpoint runs the same device-lock check post-auth: 409 DEVICE_LOCKED if bound to another user, 200+bind if Pro/Legend user, no-op bind for Free users. CORS headers extended to include x-device-name/model/os. 34 tests green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
137 lines
5.5 KiB
TypeScript
137 lines
5.5 KiB
TypeScript
import { getProfile } from "../../db/profile";
|
|
import {
|
|
findActiveDeviceLock,
|
|
bindDeviceToUser,
|
|
isLockingPlan,
|
|
markDeviceLockNotified,
|
|
} from "../../db/devices";
|
|
import {
|
|
isLockNotifyRateLimited,
|
|
sendDeviceLockEmail,
|
|
} from "../../utils/device-lock-email";
|
|
|
|
/**
|
|
* POST /api/devices/check-lock
|
|
*
|
|
* Authenticated Device-Account-Lock-Check für Native-Auth-Flow.
|
|
*
|
|
* Die native App nutzt supabase.auth.signInWithPassword direkt (BFF-Pattern
|
|
* gilt für token-storage, nicht für den Auth-Call selbst). Nach dem Sign-In
|
|
* ruft der Auth-Store diesen Endpoint mit dem frischen JWT auf.
|
|
*
|
|
* Warum separater Endpoint statt /api/auth/login:
|
|
* - /api/auth/login ist für username+password (BFF-Signin mit internal-email-trick)
|
|
* - Native-Auth geht direkt via Supabase-SDK → JWT vorhanden, kein Password-Re-Entry
|
|
* - Dieser Endpoint prüft POST-AUTH ob das Device an einen anderen Account gebunden ist
|
|
*
|
|
* Auth: requireUser mit skipDeviceCheck=true — kein Chicken-Egg-Problem.
|
|
* - skipDeviceCheck=true: verhindert dass requireUser selbst einen 403 wirft
|
|
* wenn das Device noch nicht registriert ist (Lock-Check muss VOR Register laufen)
|
|
*
|
|
* Request Headers:
|
|
* - Authorization: Bearer <access_token>
|
|
* - x-device-id: <persistent device UUID>
|
|
*
|
|
* Response:
|
|
* - 200: { ok: true } — kein Lock, Device ggf. an currentUser gebunden (Pro/Legend)
|
|
* - 400: { error: "DEVICE_ID_REQUIRED" } — x-device-id Header fehlt
|
|
* - 409: { error: "DEVICE_LOCKED", lockedUntil, releaseRequestable }
|
|
*
|
|
* Side-effects bei 200 (Pro/Legend only):
|
|
* - bindDeviceToUser — idempotent, nur wenn noch nicht gebunden
|
|
*
|
|
* Side-effects bei 409:
|
|
* - sendDeviceLockEmail — Rate-Limited auf 1 Mail / 6h via lockNotifiedAt
|
|
* - markDeviceLockNotified — schreibt lockNotifiedAt in DB
|
|
*/
|
|
export default defineEventHandler(async (event) => {
|
|
// skipDeviceCheck=true: requireUser darf nicht selbst Device-Logic auslösen —
|
|
// dieser Endpoint IST der Device-Check. Ohne diesen Flag: 403-Loop möglich wenn
|
|
// Device noch nicht in UserDevice-Table (Erstlogin vor register-Endpoint).
|
|
const user = await requireUser(event, { skipDeviceCheck: true });
|
|
|
|
const deviceId = getHeader(event, "x-device-id");
|
|
|
|
if (!deviceId) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
data: { error: "DEVICE_ID_REQUIRED" },
|
|
});
|
|
}
|
|
|
|
// ─── Lock-Check ───────────────────────────────────────────────────────────
|
|
// findActiveDeviceLock matched: deviceId + NOT userId=currentUser.
|
|
// Wenn kein Lock für einen anderen User → null → weiter.
|
|
const lock = await findActiveDeviceLock(deviceId, user.id);
|
|
|
|
if (lock) {
|
|
// ─── Mail-Notification an Original-User (Rate-Limited 6h) ─────────────
|
|
if (!isLockNotifyRateLimited(lock.lockNotifiedAt)) {
|
|
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;
|
|
|
|
// Fire-and-forget — 409 wird sofort geworfen, Mail läuft async
|
|
void (async () => {
|
|
try {
|
|
const ownerProfile = await getProfile(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("[check-lock] device-lock mail failed:", mailErr?.message ?? mailErr);
|
|
}
|
|
})();
|
|
}
|
|
|
|
// lockedUntil: releaseRequestedAt + 24h, oder null wenn kein Release beantragt
|
|
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,
|
|
},
|
|
});
|
|
}
|
|
|
|
// ─── Kein Lock — ggf. Device an currentUser binden ───────────────────────
|
|
// Pro/Legend-User: idempotentes Binding (boundToPlan=null → setzen).
|
|
// Free-User: kein Binding (isLockingPlan-Guard in bindDeviceToUser).
|
|
// Fire-and-forget — 200 soll nicht durch Binding-Fehler blockiert werden.
|
|
const profile = await getProfile(user.id);
|
|
const plan = profile?.plan ?? "free";
|
|
|
|
if (isLockingPlan(plan)) {
|
|
void bindDeviceToUser(user.id, deviceId, plan).catch((err: any) => {
|
|
console.error("[check-lock] device-bind failed:", err?.message ?? err);
|
|
});
|
|
}
|
|
|
|
return { ok: true };
|
|
});
|