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.
267 lines
7.7 KiB
TypeScript
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;
|
|
}
|