Härtung der öffentlich downloadbaren Magic-Apps gegen Reverse Engineering (Assessment: docs/specs/magic-re-hardening.md): - Windows: protection.json per ACL auf SYSTEM+Admins (DNS-Token nicht mehr von Standard-Usern lesbar) — setup.rs - Mac: MacProfileInstaller.remove() + Debug-Supervision-Modi/Reset nur noch #if DEBUG (kein Removal-/Debug-Pfad im Release-Binary) - Mac: staging-URL einmal als Konstante statt 4x Literal; interne Infra-Notizen aus String-Literalen raus - Backend: Rate-Limit (10/IP/min) auf /api/magic/pair/redeem NUR Backend-Teil deployt via Push; Mac/Win brauchen Xcode-/Cargo-Release-Build (zied) + Smoke-Tests vor Release. MagicAPIClient.swift trägt etwas vorbestehenden WIP mit (gleiche Magic-Client-Domäne). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
123 lines
3.1 KiB
TypeScript
123 lines
3.1 KiB
TypeScript
import { randomBytes } from "crypto";
|
|
|
|
/**
|
|
* POST /api/magic/pair/redeem
|
|
*
|
|
* KEIN auth required — Mac-App hat noch keinen Token.
|
|
* Body: { code: "482913", label?: "MacBook Pro" }
|
|
*
|
|
* Tauscht einen 6-stelligen Pairing-Code (single-use, 10min TTL) gegen einen
|
|
* MagicSession-Token ("mgc_<48 char>"). Token wird in Mac-Keychain gespeichert
|
|
* und ersetzt Supabase-JWT für alle /api/magic/* Endpoints.
|
|
*
|
|
* Rate-Limit: max. 10 Requests pro IP pro 60s (In-Memory, Nitro Single-Process).
|
|
*/
|
|
|
|
// In-Memory Rate-Limit-Store. Key = IP, Value = { count, windowStart (ms) }.
|
|
// Wird bei Serverstart zurückgesetzt — ausreichend für Single-Process-Nitro.
|
|
const RATE_WINDOW_MS = 60_000; // 1 Minute
|
|
const RATE_MAX_REQUESTS = 10;
|
|
|
|
interface RateEntry {
|
|
count: number;
|
|
windowStart: number;
|
|
}
|
|
|
|
const rateLimitStore = new Map<string, RateEntry>();
|
|
|
|
function checkRateLimit(ip: string): boolean {
|
|
const now = Date.now();
|
|
const entry = rateLimitStore.get(ip);
|
|
|
|
if (!entry || now - entry.windowStart >= RATE_WINDOW_MS) {
|
|
// Neues Fenster starten
|
|
rateLimitStore.set(ip, { count: 1, windowStart: now });
|
|
return true; // erlaubt
|
|
}
|
|
|
|
if (entry.count >= RATE_MAX_REQUESTS) {
|
|
return false; // geblockt
|
|
}
|
|
|
|
entry.count += 1;
|
|
return true; // erlaubt
|
|
}
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
// Rate-Limit prüfen (vor jeder anderen Logik)
|
|
const ip =
|
|
getRequestHeader(event, "x-forwarded-for")?.split(",")[0].trim() ||
|
|
getRequestHeader(event, "x-real-ip") ||
|
|
event.node.req.socket?.remoteAddress ||
|
|
"unknown";
|
|
|
|
if (!checkRateLimit(ip)) {
|
|
throw createError({
|
|
statusCode: 429,
|
|
message: "Zu viele Versuche. Bitte warte eine Minute.",
|
|
});
|
|
}
|
|
const body = await readBody(event);
|
|
const { code, label } = body as { code?: string; label?: string };
|
|
|
|
if (!code || !/^\d{6}$/.test(code)) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
message: "code muss 6 Ziffern enthalten",
|
|
});
|
|
}
|
|
|
|
const db = usePrisma();
|
|
const pairingCode = await db.magicPairingCode.findUnique({
|
|
where: { code },
|
|
select: {
|
|
id: true,
|
|
userId: true,
|
|
expiresAt: true,
|
|
redeemedAt: true,
|
|
},
|
|
});
|
|
|
|
if (!pairingCode) {
|
|
throw createError({ statusCode: 404, message: "Code ungültig" });
|
|
}
|
|
|
|
if (pairingCode.redeemedAt !== null) {
|
|
throw createError({ statusCode: 410, message: "Code bereits verwendet" });
|
|
}
|
|
|
|
if (pairingCode.expiresAt < new Date()) {
|
|
throw createError({ statusCode: 410, message: "Code abgelaufen" });
|
|
}
|
|
|
|
// Generiere Session-Token
|
|
const token = "mgc_" + randomBytes(36).toString("base64url");
|
|
|
|
const session = await db.magicSession.create({
|
|
data: {
|
|
userId: pairingCode.userId,
|
|
token,
|
|
label: label?.trim() || null,
|
|
},
|
|
select: { id: true, createdAt: true },
|
|
});
|
|
|
|
// Code als redeemed markieren (single-use)
|
|
await db.magicPairingCode.update({
|
|
where: { id: pairingCode.id },
|
|
data: {
|
|
redeemedAt: new Date(),
|
|
sessionId: session.id,
|
|
},
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
token,
|
|
sessionId: session.id,
|
|
createdAt: session.createdAt.toISOString(),
|
|
},
|
|
};
|
|
});
|