rebreak-monorepo/backend/server/db/device-approvals.ts
chahinebrini 2e056c7257 feat(devices): Apple-style two-device approval flow + email fallback
iCloud-Sign-In Pattern: wenn ein neues Gerät versucht sich anzumelden
und das Plan-Limit erreicht ist, kann der User auf einem bereits
angemeldeten Gerät bestätigen — Code wird auf BEIDEN Geräten gezeigt
für visuellen Vergleich (verhindert Code-Forwarding-Attacken).

Backend:
- New table device_approval_requests + supabase_realtime + RLS
- POST   /api/devices/approvals               — create (new device)
- GET    /api/devices/approvals               — list pending (existing devices)
- GET    /api/devices/approvals/:id           — status poll (new device)
- POST   /api/devices/approvals/:id/approve   — approve + atomic evict
- POST   /api/devices/approvals/:id/reject    — reject
- POST   /api/devices/approvals/:id/email     — trigger email fallback
- POST   /api/devices/approvals/email/:token  — magic-link approve (no auth)
- Email-Template via Resend (lyra-neutral, security-formal)
- 10min TTL, 6-digit numeric codes (crypto-random)

Frontend (rebreak-native):
- DeviceApprovalIncomingSheet — existing devices: code + device-picker + Allow/Reject
- DeviceApprovalPendingSheet  — new device: code + spinner + 'Send via email'
- useDeviceApprovalRealtime   — postgres_changes subscription
- DeviceLimitReachedSheet     — neues CTA 'Auf anderem Gerät bestätigen'
- i18n DE/EN/FR/AR

Migration läuft automatisch via prisma migrate deploy bei push.
2026-06-01 02:36:28 +02:00

267 lines
7.7 KiB
TypeScript

/**
* Apple-Style Device-Approval (iCloud Sign-In Pattern).
*
* Wenn ein neues Gerät registriert wird und das Device-Limit erreicht ist,
* kann der User auf einem bereits angemeldeten Gerät den Login bestätigen
* (Variante 2 — Code wird auf BEIDEN Geräten gezeigt für visuellen Vergleich).
*
* Fallback: Wenn kein anderes Device aktiv → Email mit One-Click-Approval-Link.
*
* TTL: 10 Minuten. Codes sind 6-stellig numerisch.
*/
import crypto from "node:crypto";
import { usePrisma } from "../utils/prisma";
const APPROVAL_TTL_MS = 10 * 60 * 1000; // 10 min
/** Devices die innerhalb dieser Zeit gesehen wurden gelten als "online genug für Push". */
const ACTIVE_DEVICE_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; // 7d
export interface DeviceApprovalRecord {
id: string;
userId: string;
newDeviceId: string;
newPlatform: string;
newModel: string | null;
newName: string | null;
newOsVersion: string | null;
code: string;
status: string;
approvedByDeviceRowId: string | null;
approvedAt: Date | null;
rejectedAt: Date | null;
evictedDeviceRowId: string | null;
emailSentAt: Date | null;
createdAt: Date;
expiresAt: Date;
}
const APPROVAL_SELECT = {
id: true,
userId: true,
newDeviceId: true,
newPlatform: true,
newModel: true,
newName: true,
newOsVersion: true,
code: true,
status: true,
approvedByDeviceRowId: true,
approvedAt: true,
rejectedAt: true,
evictedDeviceRowId: true,
emailSentAt: true,
createdAt: true,
expiresAt: true,
} as const;
/** Generiert einen 6-stelligen numerischen Code (000000-999999). */
function generateCode(): string {
// 0..999999 cryptographically random
const n = crypto.randomInt(0, 1_000_000);
return n.toString().padStart(6, "0");
}
function generateEmailToken(): string {
return crypto.randomBytes(32).toString("hex");
}
/** Hat der User mind. 1 anderes Device das innerhalb des Active-Windows gesehen wurde? */
export async function hasOtherActiveDevices(
userId: string,
excludeDeviceId: string,
): Promise<boolean> {
const db = usePrisma();
const cutoff = new Date(Date.now() - ACTIVE_DEVICE_WINDOW_MS);
const count = await db.userDevice.count({
where: {
userId,
deviceId: { not: excludeDeviceId },
lastSeenAt: { gte: cutoff },
},
});
return count > 0;
}
/**
* Erstellt einen neuen Approval-Request. Cancelt alle anderen pending Requests
* für dasselbe newDeviceId (verhindert Spam wenn User mehrfach klickt).
*/
export async function createApprovalRequest(opts: {
userId: string;
newDeviceId: string;
newPlatform: string;
newModel?: string | null;
newName?: string | null;
newOsVersion?: string | null;
}): Promise<DeviceApprovalRecord> {
const db = usePrisma();
// Expire alte pending Requests für dieses Device (Cleanup).
await db.deviceApprovalRequest.updateMany({
where: {
userId: opts.userId,
newDeviceId: opts.newDeviceId,
status: "pending",
},
data: { status: "expired" },
});
const code = generateCode();
const expiresAt = new Date(Date.now() + APPROVAL_TTL_MS);
return db.deviceApprovalRequest.create({
data: {
userId: opts.userId,
newDeviceId: opts.newDeviceId,
newPlatform: opts.newPlatform,
newModel: opts.newModel ?? null,
newName: opts.newName ?? null,
newOsVersion: opts.newOsVersion ?? null,
code,
expiresAt,
},
select: APPROVAL_SELECT,
});
}
/** Holt einen Approval-Request mit Ownership-Check. */
export async function getApprovalRequest(
id: string,
userId: string,
): Promise<DeviceApprovalRecord | null> {
const db = usePrisma();
const row = await db.deviceApprovalRequest.findFirst({
where: { id, userId },
select: APPROVAL_SELECT,
});
if (!row) return null;
return maybeExpire(row);
}
/** Holt einen Approval-Request per Email-Token (für Email-Link-Approval). */
export async function getApprovalByEmailToken(
token: string,
): Promise<DeviceApprovalRecord | null> {
const db = usePrisma();
const row = await db.deviceApprovalRequest.findUnique({
where: { emailToken: token },
select: APPROVAL_SELECT,
});
if (!row) return null;
return maybeExpire(row);
}
/** Listet pending Requests für einen User (für Banner auf existierenden Devices). */
export async function listPendingApprovals(
userId: string,
): Promise<DeviceApprovalRecord[]> {
const db = usePrisma();
const rows = await db.deviceApprovalRequest.findMany({
where: {
userId,
status: "pending",
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: "desc" },
select: APPROVAL_SELECT,
});
return rows;
}
/**
* Approve: marks request as approved + (atomically) registers the new device,
* optionally evicting an existing device if the user is at the limit.
*
* Returns the approved record. Caller (endpoint) is responsible for the
* actual UserDevice creation since registerDevice() handles merge-heuristic
* + slot-check anyway — but we DO evict here if evictDeviceRowId is given
* so registerDevice doesn't fail with DEVICE_LIMIT_REACHED again.
*/
export async function approveRequest(opts: {
approvalId: string;
userId: string;
approvedByDeviceRowId: string | null; // null = via email-link
evictDeviceRowId: string | null;
}): Promise<DeviceApprovalRecord | null> {
const db = usePrisma();
const existing = await getApprovalRequest(opts.approvalId, opts.userId);
if (!existing) return null;
if (existing.status !== "pending") return existing;
// Eviction transactional with status update.
return db.$transaction(async (tx) => {
if (opts.evictDeviceRowId) {
// Make sure the device-to-evict belongs to the user (defense in depth).
await tx.userDevice.deleteMany({
where: { id: opts.evictDeviceRowId, userId: opts.userId },
});
}
return tx.deviceApprovalRequest.update({
where: { id: opts.approvalId },
data: {
status: "approved",
approvedAt: new Date(),
approvedByDeviceRowId: opts.approvedByDeviceRowId,
evictedDeviceRowId: opts.evictDeviceRowId,
},
select: APPROVAL_SELECT,
});
});
}
export async function rejectRequest(
approvalId: string,
userId: string,
): Promise<DeviceApprovalRecord | null> {
const db = usePrisma();
const existing = await getApprovalRequest(approvalId, userId);
if (!existing) return null;
if (existing.status !== "pending") return existing;
return db.deviceApprovalRequest.update({
where: { id: approvalId },
data: { status: "rejected", rejectedAt: new Date() },
select: APPROVAL_SELECT,
});
}
/**
* Markiert dass eine Email mit Approval-Link verschickt wurde. Generiert
* (idempotent) einen emailToken den der Link enthält.
* Rate-Limit: 1 Email pro Approval-Request.
*/
export async function markEmailSent(
approvalId: string,
userId: string,
): Promise<DeviceApprovalRecord | null> {
const db = usePrisma();
const existing = await getApprovalRequest(approvalId, userId);
if (!existing) return null;
if (existing.status !== "pending") return existing;
if (existing.emailSentAt) return existing; // already sent
const token = existing.emailToken ?? generateEmailToken();
return db.deviceApprovalRequest.update({
where: { id: approvalId },
data: { emailSentAt: new Date(), emailToken: token },
select: APPROVAL_SELECT,
});
}
/** Wenn expiresAt überschritten und status noch pending → setze auf expired. */
async function maybeExpire(
row: DeviceApprovalRecord,
): Promise<DeviceApprovalRecord> {
if (row.status !== "pending") return row;
if (row.expiresAt.getTime() > Date.now()) return row;
const db = usePrisma();
const updated = await db.deviceApprovalRequest.update({
where: { id: row.id },
data: { status: "expired" },
select: APPROVAL_SELECT,
});
return updated;
}