Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { prisma } from "@/server/db";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { buildRegistrationOptions, buildAuthenticationOptions, verifyReg, toB64Url, LOGIN_CHALLENGE_COOKIE } from "@/server/webauthn";
|
||||
import type { RegistrationResponseJSON } from "@simplewebauthn/types";
|
||||
|
||||
/**
|
||||
* SEC3-b: Passkey-Login vorbereiten (öffentlich, pre-session). Erzeugt Authentifizierungs-
|
||||
* Optionen für discoverable Credentials (leere allowCredentials → der Browser wählt einen
|
||||
* resident Passkey) und legt die Challenge in einem kurzlebigen httpOnly-Cookie ab.
|
||||
*/
|
||||
export async function beginPasskeyLogin() {
|
||||
const options = await buildAuthenticationOptions([]);
|
||||
const jar = await cookies();
|
||||
jar.set(LOGIN_CHALLENGE_COOKIE, options.challenge, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 120,
|
||||
});
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* SEC3-b: Passkey-Registrierung & -Verwaltung des angemeldeten Mandanten-Nutzers
|
||||
* (EXEMPT vom Modul-Gating — eigene Auth über requireSession). Die Challenge wird
|
||||
* zwischen Options- und Verify-Schritt in einem kurzlebigen httpOnly-Cookie gehalten.
|
||||
*/
|
||||
const REG_COOKIE = "wa_reg_challenge";
|
||||
|
||||
export async function beginPasskeyRegistration() {
|
||||
const session = await requireSession();
|
||||
// WS4b: Passkeys gehören der GLOBALEN Identity (stabile userID über Mandanten hinweg).
|
||||
const identityId = session.user.identityId;
|
||||
if (!identityId) throw new Error("Kein Identity-Kontext in der Session.");
|
||||
const creds = await prisma.webAuthnCredential.findMany({ where: { identityId }, select: { credentialId: true, transports: true } });
|
||||
|
||||
const options = await buildRegistrationOptions({
|
||||
userId: identityId,
|
||||
userName: session.user.email ?? identityId,
|
||||
userDisplayName: session.user.name ?? session.user.email ?? "Nutzer",
|
||||
existing: creds,
|
||||
});
|
||||
const jar = await cookies();
|
||||
jar.set(REG_COOKIE, options.challenge, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 300,
|
||||
});
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function finishPasskeyRegistration(response: RegistrationResponseJSON, deviceName: string) {
|
||||
const session = await requireSession();
|
||||
const jar = await cookies();
|
||||
const expectedChallenge = jar.get(REG_COOKIE)?.value;
|
||||
if (!expectedChallenge) throw new Error("Registrierung abgelaufen — bitte erneut starten.");
|
||||
|
||||
const verification = await verifyReg(response, expectedChallenge);
|
||||
if (!verification.verified || !verification.registrationInfo) throw new Error("Passkey konnte nicht verifiziert werden.");
|
||||
|
||||
const { credentialID, credentialPublicKey, counter } = verification.registrationInfo;
|
||||
const identityId = session.user.identityId;
|
||||
if (!identityId) throw new Error("Kein Identity-Kontext in der Session.");
|
||||
await prisma.webAuthnCredential.create({
|
||||
data: {
|
||||
identityId,
|
||||
credentialId: toB64Url(credentialID),
|
||||
publicKey: toB64Url(credentialPublicKey),
|
||||
counter: BigInt(counter),
|
||||
transports: response.response.transports ?? [],
|
||||
deviceName: deviceName.trim().slice(0, 60) || null,
|
||||
},
|
||||
});
|
||||
jar.delete(REG_COOKIE);
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "webauthn_credential", entityId: session.user.id, after: { deviceName: deviceName.trim() || null } });
|
||||
revalidatePath("/account");
|
||||
}
|
||||
|
||||
export async function removePasskey(credentialDbId: string) {
|
||||
const session = await requireSession();
|
||||
const identityId = session.user.identityId;
|
||||
if (!identityId) throw new Error("Kein Identity-Kontext in der Session.");
|
||||
const cred = await prisma.webAuthnCredential.findFirst({ where: { id: credentialDbId, identityId }, select: { id: true } });
|
||||
if (!cred) throw new Error("Passkey nicht gefunden.");
|
||||
await prisma.webAuthnCredential.delete({ where: { id: cred.id } });
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "webauthn_credential", entityId: cred.id });
|
||||
revalidatePath("/account");
|
||||
}
|
||||
Reference in New Issue
Block a user