175 lines
4.8 KiB
TypeScript
175 lines
4.8 KiB
TypeScript
// Web Crypto API – kompatibel mit Edge Runtime + Node.js
|
||
import { cookies } from "next/headers";
|
||
import { NextResponse } from "next/server";
|
||
import { isSessionTokenRevoked, isActionTokenUsed } from "./token-blacklist";
|
||
|
||
const encoder = new TextEncoder();
|
||
|
||
interface AdminSession {
|
||
id: string;
|
||
email: string;
|
||
name: string;
|
||
exp: number;
|
||
}
|
||
|
||
interface ActionToken {
|
||
anfrageId: string;
|
||
status: "bestaetigt" | "abgelehnt" | "abgeschlossen";
|
||
exp: number;
|
||
}
|
||
|
||
function toBase64Url(buffer: ArrayBuffer | Uint8Array): string {
|
||
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
||
return btoa(String.fromCharCode(...bytes))
|
||
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
||
}
|
||
|
||
function fromBase64Url(str: string): ArrayBuffer {
|
||
const b64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
||
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||
return bytes.buffer as ArrayBuffer;
|
||
}
|
||
|
||
async function getKey(secret: string): Promise<CryptoKey> {
|
||
return crypto.subtle.importKey(
|
||
"raw",
|
||
encoder.encode(secret),
|
||
{ name: "HMAC", hash: "SHA-256" },
|
||
false,
|
||
["sign", "verify"]
|
||
);
|
||
}
|
||
|
||
export async function createSessionToken(
|
||
session: Omit<AdminSession, "exp">
|
||
): Promise<string> {
|
||
const secret = process.env.ADMIN_SESSION_TOKEN!;
|
||
// ✅ Token expiration: 2 Stunden (matches cookie maxAge in login route)
|
||
const payload: AdminSession = {
|
||
...session,
|
||
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 2,
|
||
};
|
||
const data = toBase64Url(encoder.encode(JSON.stringify(payload)));
|
||
const key = await getKey(secret);
|
||
const sig = toBase64Url(await crypto.subtle.sign("HMAC", key, encoder.encode(data)));
|
||
return `${data}.${sig}`;
|
||
}
|
||
|
||
/**
|
||
* Auth-Wrapper für Admin-API-Routes.
|
||
*
|
||
* Prüft den admin_session-Cookie, verifiziert das HMAC-Token und gibt die
|
||
* Session zurück ODER eine fertige 401-Response (wenn nicht authentifiziert).
|
||
*
|
||
* Verwendung am Anfang einer Admin-API-Route:
|
||
* const check = await requireAdmin();
|
||
* if (check instanceof NextResponse) return check;
|
||
* const session = check; // AdminSession
|
||
*/
|
||
export async function requireAdmin(): Promise<AdminSession | NextResponse> {
|
||
const cookieStore = await cookies();
|
||
const token = cookieStore.get("admin_session")?.value;
|
||
|
||
if (!token) {
|
||
return NextResponse.json(
|
||
{ error: "Nicht authentifiziert" },
|
||
{ status: 401 }
|
||
);
|
||
}
|
||
|
||
const session = await verifySessionToken(token);
|
||
if (!session) {
|
||
return NextResponse.json(
|
||
{ error: "Session ungültig" },
|
||
{ status: 401 }
|
||
);
|
||
}
|
||
|
||
return session;
|
||
}
|
||
|
||
export async function verifySessionToken(token: string): Promise<AdminSession | null> {
|
||
try {
|
||
const secret = process.env.ADMIN_SESSION_TOKEN!;
|
||
const [data, sig] = token.split(".");
|
||
if (!data || !sig) return null;
|
||
|
||
// ✅ Prüfe ob Token revoked wurde
|
||
if (await isSessionTokenRevoked(sig)) {
|
||
return null;
|
||
}
|
||
|
||
const key = await getKey(secret);
|
||
const valid = await crypto.subtle.verify(
|
||
"HMAC",
|
||
key,
|
||
fromBase64Url(sig),
|
||
encoder.encode(data)
|
||
);
|
||
if (!valid) return null;
|
||
|
||
const session: AdminSession = JSON.parse(
|
||
new TextDecoder().decode(new Uint8Array(fromBase64Url(data)))
|
||
);
|
||
if (session.exp < Math.floor(Date.now() / 1000)) return null;
|
||
|
||
return session;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ──── Action-Tokens für Email-Links (Anfrage bestätigen/ablehnen/abschließen) ────
|
||
export async function createActionToken(
|
||
anfrageId: string,
|
||
status: "bestaetigt" | "abgelehnt" | "abgeschlossen",
|
||
ablaufTage = 7
|
||
): Promise<string> {
|
||
const secret = process.env.ADMIN_SESSION_TOKEN!;
|
||
const payload: ActionToken = {
|
||
anfrageId,
|
||
status,
|
||
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * ablaufTage,
|
||
};
|
||
const data = toBase64Url(encoder.encode(JSON.stringify(payload)));
|
||
const key = await getKey(secret);
|
||
const sig = toBase64Url(await crypto.subtle.sign("HMAC", key, encoder.encode(data)));
|
||
return `${data}.${sig}`;
|
||
}
|
||
|
||
export async function verifyActionToken(
|
||
token: string
|
||
): Promise<{ anfrageId: string; status: string } | null> {
|
||
try {
|
||
const secret = process.env.ADMIN_SESSION_TOKEN!;
|
||
const [data, sig] = token.split(".");
|
||
if (!data || !sig) return null;
|
||
|
||
// ✅ Prüfe ob Token bereits verwendet wurde (One-Time-Use)
|
||
if (await isActionTokenUsed(sig)) {
|
||
return null;
|
||
}
|
||
|
||
const key = await getKey(secret);
|
||
const valid = await crypto.subtle.verify(
|
||
"HMAC",
|
||
key,
|
||
fromBase64Url(sig),
|
||
encoder.encode(data)
|
||
);
|
||
if (!valid) return null;
|
||
|
||
const actionToken: ActionToken = JSON.parse(
|
||
new TextDecoder().decode(new Uint8Array(fromBase64Url(data)))
|
||
);
|
||
if (actionToken.exp < Math.floor(Date.now() / 1000)) return null;
|
||
|
||
return {
|
||
anfrageId: actionToken.anfrageId,
|
||
status: actionToken.status,
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|