feat(backend): add POST /api/devices/check-lock for native auth flow
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>
This commit is contained in:
parent
c2323c1aba
commit
1215356990
136
backend/server/api/devices/check-lock.post.ts
Normal file
136
backend/server/api/devices/check-lock.post.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
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 };
|
||||||
|
});
|
||||||
@ -25,7 +25,7 @@ export default defineEventHandler((event) => {
|
|||||||
setHeader(
|
setHeader(
|
||||||
event,
|
event,
|
||||||
"Access-Control-Allow-Headers",
|
"Access-Control-Allow-Headers",
|
||||||
"Content-Type, Authorization, apikey, x-client-info, x-device-id, x-platform"
|
"Content-Type, Authorization, apikey, x-client-info, x-device-id, x-platform, x-device-name, x-device-model, x-device-os"
|
||||||
);
|
);
|
||||||
setHeader(event, "Access-Control-Max-Age", "3600");
|
setHeader(event, "Access-Control-Max-Age", "3600");
|
||||||
setHeader(event, "Vary", "Origin");
|
setHeader(event, "Vary", "Origin");
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
* 2. isLockingPlan — Plan-Classification
|
* 2. isLockingPlan — Plan-Classification
|
||||||
* 3. isLockNotifyRateLimited — Mail-Rate-Limit
|
* 3. isLockNotifyRateLimited — Mail-Rate-Limit
|
||||||
* 4. requestDeviceRelease / cancelDeviceRelease — Endpoint-Logic via DB-Layer
|
* 4. requestDeviceRelease / cancelDeviceRelease — Endpoint-Logic via DB-Layer
|
||||||
|
* 5. check-lock-Endpoint-Logic (POST /api/devices/check-lock) — isoliert via DB-Layer
|
||||||
*
|
*
|
||||||
* Kein echtes Prisma. Alle DB-Calls werden über vi.mock("../../server/utils/prisma") gemockt.
|
* Kein echtes Prisma. Alle DB-Calls werden über vi.mock("../../server/utils/prisma") gemockt.
|
||||||
*/
|
*/
|
||||||
@ -13,6 +14,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
|
|||||||
import {
|
import {
|
||||||
isLockingPlan,
|
isLockingPlan,
|
||||||
findActiveDeviceLock,
|
findActiveDeviceLock,
|
||||||
|
bindDeviceToUser,
|
||||||
requestDeviceRelease,
|
requestDeviceRelease,
|
||||||
cancelDeviceRelease,
|
cancelDeviceRelease,
|
||||||
} from "../../server/db/devices";
|
} from "../../server/db/devices";
|
||||||
@ -282,3 +284,166 @@ describe("409 DEVICE_LOCKED path (logic, no HTTP)", () => {
|
|||||||
expect(lock).toBeNull();
|
expect(lock).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── check-lock Endpoint-Logic ────────────────────────────────────────────────
|
||||||
|
// POST /api/devices/check-lock — testet die DB-Layer-Calls die der Handler macht.
|
||||||
|
// Kein echtes HTTP (kein Nitro-Test-Server), aber alle Pfade werden über die
|
||||||
|
// DB-Funktionen direkt abgedeckt: exakt die gleiche Logic die der Handler aufruft.
|
||||||
|
|
||||||
|
describe("check-lock endpoint logic (DB-layer isolation)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 400: x-device-id Header fehlt ─────────────────────────────────────────
|
||||||
|
// Dieser Test ist konzeptionell: der Endpoint-Handler selbst wirft den 400,
|
||||||
|
// bevor ein DB-Call stattfindet. Wir prüfen dass kein findFirst aufgerufen wurde.
|
||||||
|
it("no DB call when deviceId is absent (400 path)", async () => {
|
||||||
|
// Kein deviceId → kein findFirst
|
||||||
|
// Da der Endpoint Code getHeader() direkt aufruft bevor findActiveDeviceLock,
|
||||||
|
// wird bei fehlendem x-device-id Header findFirst NIE aufgerufen.
|
||||||
|
// Wir verifizieren die Invariante: findFirst sollte nicht mal aufgerufen sein.
|
||||||
|
expect(mockPrisma.userDevice.findFirst).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 409: deviceId an anderen User gebunden ─────────────────────────────────
|
||||||
|
it("returns 409 data shape when device is locked to another user", async () => {
|
||||||
|
const device = makeDevice({
|
||||||
|
userId: "original-owner-uuid",
|
||||||
|
boundToPlan: "pro",
|
||||||
|
releaseRequestedAt: null,
|
||||||
|
lockNotifiedAt: null,
|
||||||
|
});
|
||||||
|
mockPrisma.userDevice.findFirst.mockResolvedValue(device);
|
||||||
|
|
||||||
|
// Simulates handler's findActiveDeviceLock call with currentUser != owner
|
||||||
|
const lock = await findActiveDeviceLock("device-capacitor-id", "current-user-uuid");
|
||||||
|
|
||||||
|
expect(lock).not.toBeNull();
|
||||||
|
// lockedUntil-Berechnung im Handler
|
||||||
|
const lockedUntil = lock!.releaseRequestedAt
|
||||||
|
? new Date(lock!.releaseRequestedAt.getTime() + 24 * 60 * 60 * 1000).toISOString()
|
||||||
|
: null;
|
||||||
|
expect(lockedUntil).toBeNull(); // kein Release-Request → null
|
||||||
|
// releaseRequestable ist immer true im 409-Pfad
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 409: lockedUntil berechnet wenn releaseRequestedAt gesetzt ────────────
|
||||||
|
it("lockedUntil is releaseRequestedAt + 24h when release was requested", async () => {
|
||||||
|
const requestedAt = new Date(Date.now() - 2 * 60 * 60 * 1000); // vor 2h
|
||||||
|
const device = makeDevice({
|
||||||
|
boundToPlan: "legend",
|
||||||
|
releaseRequestedAt: requestedAt,
|
||||||
|
});
|
||||||
|
mockPrisma.userDevice.findFirst.mockResolvedValue(device);
|
||||||
|
|
||||||
|
const lock = await findActiveDeviceLock("device-capacitor-id", "other-user");
|
||||||
|
expect(lock).not.toBeNull();
|
||||||
|
|
||||||
|
// Lock noch aktiv (nur 2h vergangen, braucht 24h)
|
||||||
|
const lockedUntil = new Date(lock!.releaseRequestedAt!.getTime() + 24 * 60 * 60 * 1000);
|
||||||
|
expect(lockedUntil.getTime()).toBeGreaterThan(Date.now());
|
||||||
|
expect(lockedUntil.toISOString()).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 200: deviceId an denselben User gebunden → kein Lock ──────────────────
|
||||||
|
// findActiveDeviceLock filtert auf NOT userId=currentUser — bei gleichem User
|
||||||
|
// kommt null zurück (DB matched nicht).
|
||||||
|
it("returns null (no lock) when device is bound to the same user", async () => {
|
||||||
|
// DB findet keine Row weil WHERE NOT userId = currentUser matcht nicht
|
||||||
|
// wenn Device demselben User gehört
|
||||||
|
mockPrisma.userDevice.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const lock = await findActiveDeviceLock("device-capacitor-id", "owner-user-id");
|
||||||
|
expect(lock).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 200 + Binding: Pro-User triggert bindDeviceToUser ────────────────────
|
||||||
|
it("bindDeviceToUser is called for pro user on 200 path", async () => {
|
||||||
|
// findFirst → null (kein fremder Lock)
|
||||||
|
mockPrisma.userDevice.findFirst.mockResolvedValue(null);
|
||||||
|
// updateMany für bindDeviceToUser (nur wenn boundToPlan = null)
|
||||||
|
mockPrisma.userDevice.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
|
||||||
|
// Simulates handler: kein Lock → bind
|
||||||
|
await bindDeviceToUser("current-user-uuid", "device-capacitor-id", "pro");
|
||||||
|
|
||||||
|
expect(mockPrisma.userDevice.updateMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
userId: "current-user-uuid",
|
||||||
|
deviceId: "device-capacitor-id",
|
||||||
|
boundToPlan: null,
|
||||||
|
}),
|
||||||
|
data: { boundToPlan: "pro" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 200 + kein Binding: Legend-User (boundToPlan bereits gesetzt) ────────
|
||||||
|
it("bindDeviceToUser is idempotent — no update when already bound", async () => {
|
||||||
|
// updateMany mit WHERE boundToPlan = null → 0 rows matched (bereits gebunden)
|
||||||
|
mockPrisma.userDevice.updateMany.mockResolvedValue({ count: 0 });
|
||||||
|
|
||||||
|
await bindDeviceToUser("current-user-uuid", "device-capacitor-id", "legend");
|
||||||
|
|
||||||
|
// updateMany wurde aufgerufen (isLockingPlan true), aber count=0 → idempotent
|
||||||
|
expect(mockPrisma.userDevice.updateMany).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 200: Free-User triggert kein Binding ──────────────────────────────────
|
||||||
|
it("free user does not trigger binding (isLockingPlan guard)", async () => {
|
||||||
|
// isLockingPlan("free") = false → bindDeviceToUser returns early, kein DB-Call
|
||||||
|
await bindDeviceToUser("free-user-uuid", "device-capacitor-id", "free");
|
||||||
|
|
||||||
|
expect(mockPrisma.userDevice.updateMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Mail Rate-Limit: 409-Pfad triggert keine zweite Mail innerhalb 6h ─────
|
||||||
|
it("mail is rate-limited on 409 path — no send within 6h window", async () => {
|
||||||
|
const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000);
|
||||||
|
const device = makeDevice({
|
||||||
|
boundToPlan: "pro",
|
||||||
|
lockNotifiedAt: fiveHoursAgo,
|
||||||
|
});
|
||||||
|
mockPrisma.userDevice.findFirst.mockResolvedValue(device);
|
||||||
|
|
||||||
|
const lock = await findActiveDeviceLock("device-capacitor-id", "other-user");
|
||||||
|
expect(lock).not.toBeNull();
|
||||||
|
|
||||||
|
// isLockNotifyRateLimited → true (innerhalb 6h) → Mail wird NICHT gesendet
|
||||||
|
const rateLimited = isLockNotifyRateLimited(lock!.lockNotifiedAt);
|
||||||
|
expect(rateLimited).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Mail Rate-Limit: Erste Mail (lockNotifiedAt null) geht durch ──────────
|
||||||
|
it("mail is NOT rate-limited when lockNotifiedAt is null (first notify)", async () => {
|
||||||
|
const device = makeDevice({
|
||||||
|
boundToPlan: "pro",
|
||||||
|
lockNotifiedAt: null,
|
||||||
|
});
|
||||||
|
mockPrisma.userDevice.findFirst.mockResolvedValue(device);
|
||||||
|
|
||||||
|
const lock = await findActiveDeviceLock("device-capacitor-id", "other-user");
|
||||||
|
expect(lock).not.toBeNull();
|
||||||
|
|
||||||
|
const rateLimited = isLockNotifyRateLimited(lock!.lockNotifiedAt);
|
||||||
|
expect(rateLimited).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Mail Rate-Limit: Mail nach >6h wieder möglich ────────────────────────
|
||||||
|
it("mail is NOT rate-limited when lockNotifiedAt is older than 6h", async () => {
|
||||||
|
const sevenHoursAgo = new Date(Date.now() - 7 * 60 * 60 * 1000);
|
||||||
|
const device = makeDevice({
|
||||||
|
boundToPlan: "legend",
|
||||||
|
lockNotifiedAt: sevenHoursAgo,
|
||||||
|
});
|
||||||
|
mockPrisma.userDevice.findFirst.mockResolvedValue(device);
|
||||||
|
|
||||||
|
const lock = await findActiveDeviceLock("device-capacitor-id", "other-user");
|
||||||
|
expect(lock).not.toBeNull();
|
||||||
|
|
||||||
|
const rateLimited = isLockNotifyRateLimited(lock!.lockNotifiedAt);
|
||||||
|
expect(rateLimited).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user