Drei Rollen: Administrator, Techniker, Kunde. Jede der 28 Aktionen nennt das Recht, das sie verlangt; die Rechte stehen an einer Stelle. Kundenkonten haengen an genau einem Kunden und sehen nur dessen Hosts und Rechnungen - ohne Einkaufspreise und Marge. Die Einschraenkung sitzt in den Abfragen selbst, damit keine Seite vergessen kann, sie weiterzugeben; sechs Tests pruefen das. Dabei gefunden: Neu ueber OIDC angelegte Konten haetten die Vorgabe des Datenmodells geerbt - ADMIN. Jetzt gibt es dafuer eine Einstellung mit der Vorgabe Techniker. Passkeys (#028) als gleichwertiger, passwortloser Anmeldeweg neben Passwort und OIDC. Mehrere je Konto, Aufgabe in einem kurzlebigen Cookie, rpID aus der oeffentlichen Adresse. Ohne HTTPS sagt die Seite es, statt still zu scheitern. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { AnmeldeFormular } from "@/components/auth/AnmeldeFormular";
|
||||
import { PasskeyAnmeldung } from "@/components/auth/PasskeyAnmeldung";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { oidcEinstellungen } from "@/server/auth/oidc";
|
||||
|
||||
@@ -43,10 +44,14 @@ export default async function AnmeldeSeite({ searchParams }: { searchParams: Suc
|
||||
<i className="bi bi-box-arrow-in-right me-1" aria-hidden="true" />
|
||||
Mit {oidc.bezeichnung} anmelden
|
||||
</Link>
|
||||
<div className="text-center text-body-secondary small mb-3">oder mit Passwort</div>
|
||||
<div className="text-center text-body-secondary small mb-3">oder</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<PasskeyAnmeldung ziel={ziel} />
|
||||
|
||||
<div className="text-center text-body-secondary small mb-3">oder mit Passwort</div>
|
||||
|
||||
<AnmeldeFormular ziel={ziel} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { BenutzerVerwaltung } from "@/components/einstellungen/BenutzerVerwaltung";
|
||||
import { ContentHeader } from "@/components/layout/ContentHeader";
|
||||
import { Callout } from "@/components/ui/Callout";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata = { title: "Benutzer" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function BenutzerSeite() {
|
||||
const ich = await sitzungErforderlich("verwaltung");
|
||||
|
||||
const [benutzer, kunden] = await Promise.all([
|
||||
prisma.benutzer.findMany({
|
||||
orderBy: [{ rolle: "asc" }, { name: "asc" }],
|
||||
include: { kunde: { select: { name: true } }, _count: { select: { passkeys: true } } },
|
||||
}),
|
||||
prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||
]);
|
||||
|
||||
const zeilen = benutzer.map((eintrag) => ({
|
||||
id: eintrag.id,
|
||||
email: eintrag.email,
|
||||
name: eintrag.name,
|
||||
rolle: eintrag.rolle,
|
||||
kundeId: eintrag.kundeId,
|
||||
kundeName: eintrag.kunde?.name ?? null,
|
||||
aktiv: eintrag.aktiv,
|
||||
hatPasswort: eintrag.passwortHash !== null,
|
||||
hatOidc: eintrag.oidcSubject !== null,
|
||||
passkeys: eintrag._count.passkeys,
|
||||
letzterLoginAm: eintrag.letzterLoginAm,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentHeader
|
||||
titel="Benutzer"
|
||||
brotkrumen={[{ titel: "Einstellungen" }, { titel: "Benutzer" }]}
|
||||
/>
|
||||
<div className="app-content">
|
||||
<div className="container-fluid">
|
||||
<Callout farbe="info" titel="Rollen">
|
||||
<strong>Administrator</strong> darf alles, auch Benutzer und Systemeinstellungen.{" "}
|
||||
<strong>Techniker</strong> darf alles Fachliche, aber keine Benutzer anlegen.{" "}
|
||||
<strong>Kunde</strong> sieht nur die Hosts und Rechnungen des eigenen Kunden – ohne
|
||||
Einkaufspreise – und darf Notizen schreiben sowie Warnungen quittieren.
|
||||
</Callout>
|
||||
|
||||
<BenutzerVerwaltung benutzer={zeilen} kunden={kunden} ichId={ich.benutzerId} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { headers } from "next/headers";
|
||||
import { PasskeyVerwaltung } from "@/components/auth/PasskeyVerwaltung";
|
||||
import { ContentHeader } from "@/components/layout/ContentHeader";
|
||||
import { oeffentlicheBasis } from "@/lib/adresse";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { passkeysMoeglich } from "@/server/auth/passkeys";
|
||||
import { einstellungen } from "@/server/einstellungen";
|
||||
|
||||
export const metadata = { title: "Meine Passkeys" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PasskeySeite() {
|
||||
const ich = await sitzungErforderlich();
|
||||
const { dashboardUrl } = await einstellungen();
|
||||
|
||||
const [passkeys, kopfzeilen] = await Promise.all([
|
||||
prisma.passkey.findMany({
|
||||
where: { benutzerId: ich.benutzerId },
|
||||
orderBy: { erstelltAm: "desc" },
|
||||
select: { id: true, bezeichnung: true, erstelltAm: true, zuletztAm: true },
|
||||
}),
|
||||
headers(),
|
||||
]);
|
||||
|
||||
// Die Seite kennt die Anfrage nur über die Kopfzeilen; daraus entsteht dieselbe Adresse,
|
||||
// die auch die Passkey-Endpunkte verwenden.
|
||||
const basis = oeffentlicheBasis(new Request("http://platzhalter.invalid", { headers: kopfzeilen }), dashboardUrl);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentHeader
|
||||
titel="Meine Passkeys"
|
||||
brotkrumen={[{ titel: "Einstellungen" }, { titel: "Passkeys" }]}
|
||||
/>
|
||||
<div className="app-content">
|
||||
<div className="container-fluid">
|
||||
<PasskeyVerwaltung
|
||||
passkeys={passkeys}
|
||||
moeglich={passkeysMoeglich(basis.origin)}
|
||||
adresse={basis.origin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { kennzahlen, offeneAlerts, suchIndex } from "@/server/queries/hosts";
|
||||
|
||||
/**
|
||||
@@ -13,9 +11,9 @@ import { kennzahlen, offeneAlerts, suchIndex } from "@/server/queries/hosts";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
// Der Schutz liegt in proxy.ts; hier wird zusätzlich geprüft, damit kein Zustand ohne Benutzer entsteht.
|
||||
const sitzung = await sitzungLesen((await cookies()).get(SITZUNGS_COOKIE)?.value);
|
||||
if (!sitzung) redirect("/login");
|
||||
// Der Schutz liegt in proxy.ts; hier wird zusätzlich geprüft, damit kein Zustand ohne Benutzer
|
||||
// entsteht – und weil hier Rolle und Kundenbindung herkommen.
|
||||
const sitzung = await sitzungErforderlich();
|
||||
|
||||
const [zahlen, alerts, suche] = await Promise.all([kennzahlen(), offeneAlerts(), suchIndex()]);
|
||||
|
||||
@@ -31,12 +29,14 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
}))}
|
||||
suchEintraege={suche}
|
||||
benutzer={sitzung.name}
|
||||
rolle={sitzung.rolle}
|
||||
/>
|
||||
<Sidebar
|
||||
hostsAnzahl={zahlen.hostsGesamt}
|
||||
offeneRechnungspunkte={zahlen.offeneRechnungspunkte}
|
||||
offeneWarnungen={zahlen.offeneAlerts}
|
||||
benutzer={sitzung.name}
|
||||
rolle={sitzung.rolle}
|
||||
/>
|
||||
<main className="app-main">{children}</main>
|
||||
<Footer />
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen, ueberHttps } from "@/lib/sitzung";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { anmeldungPruefen, anmeldungVorbereiten, herkunft } from "@/server/auth/passkeys";
|
||||
import { einstellungen } from "@/server/einstellungen";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CHALLENGE_COOKIE = "pl_passkey_anmeldung";
|
||||
|
||||
function challengeOptionen() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "strict" as const,
|
||||
secure: ueberHttps(),
|
||||
path: "/",
|
||||
maxAge: 300,
|
||||
};
|
||||
}
|
||||
|
||||
/** Schritt 1: Aufgabe für die Anmeldung erzeugen – ohne Benutzernamen, der Browser wählt den Schlüssel. */
|
||||
export async function GET(request: Request) {
|
||||
const optionen = await anmeldungVorbereiten(await herkunft(request));
|
||||
(await cookies()).set(CHALLENGE_COOKIE, optionen.challenge, challengeOptionen());
|
||||
return Response.json(optionen);
|
||||
}
|
||||
|
||||
/** Schritt 2: Antwort prüfen und die Sitzung anlegen. */
|
||||
export async function POST(request: Request) {
|
||||
const speicher = await cookies();
|
||||
const challenge = speicher.get(CHALLENGE_COOKIE)?.value;
|
||||
if (!challenge) {
|
||||
return Response.json({ fehler: "Die Aufgabe ist abgelaufen. Bitte erneut versuchen." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const antwort = await request.json();
|
||||
const benutzer = await anmeldungPruefen(antwort, challenge, await herkunft(request));
|
||||
|
||||
const { sitzungsdauerStunden } = await einstellungen();
|
||||
const token = await sitzungErzeugen(
|
||||
{
|
||||
benutzerId: benutzer.id,
|
||||
email: benutzer.email,
|
||||
name: benutzer.name,
|
||||
herkunft: "passkey",
|
||||
},
|
||||
sitzungsdauerStunden,
|
||||
);
|
||||
speicher.set(SITZUNGS_COOKIE, token, cookieOptionen(sitzungsdauerStunden));
|
||||
|
||||
await prisma.benutzer.update({ where: { id: benutzer.id }, data: { letzterLoginAm: new Date() } });
|
||||
await protokolliere("anmeldung.erfolgreich", "Benutzer", benutzer.id, { weg: "passkey" });
|
||||
|
||||
return Response.json({ erfolg: true });
|
||||
} catch (fehler) {
|
||||
console.error("Passkey-Anmeldung fehlgeschlagen:", fehler);
|
||||
// Bewusst unscharf: Ob es den Passkey gibt, geht einen Unbekannten nichts an.
|
||||
return Response.json({ fehler: "Anmeldung mit diesem Passkey nicht möglich." }, { status: 400 });
|
||||
} finally {
|
||||
speicher.delete(CHALLENGE_COOKIE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { ueberHttps } from "@/lib/sitzung";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { angemeldet } from "@/server/auth/sitzungspflicht";
|
||||
import { herkunft, registrierungAbschliessen, registrierungVorbereiten } from "@/server/auth/passkeys";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Die Aufgabe („challenge“) darf nur für diesen Browser und nur kurz gelten. */
|
||||
const CHALLENGE_COOKIE = "pl_passkey_registrierung";
|
||||
|
||||
function challengeOptionen() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "strict" as const,
|
||||
secure: ueberHttps(),
|
||||
path: "/",
|
||||
maxAge: 300,
|
||||
};
|
||||
}
|
||||
|
||||
/** Schritt 1: Aufgabe erzeugen. */
|
||||
export async function GET(request: Request) {
|
||||
const benutzer = await angemeldet();
|
||||
if (!benutzer) return new Response("Nicht angemeldet", { status: 401 });
|
||||
|
||||
const optionen = await registrierungVorbereiten(benutzer.benutzerId, await herkunft(request));
|
||||
(await cookies()).set(CHALLENGE_COOKIE, optionen.challenge, challengeOptionen());
|
||||
|
||||
return Response.json(optionen);
|
||||
}
|
||||
|
||||
/** Schritt 2: Antwort des Browsers prüfen und den Passkey hinterlegen. */
|
||||
export async function POST(request: Request) {
|
||||
const benutzer = await angemeldet();
|
||||
if (!benutzer) return new Response("Nicht angemeldet", { status: 401 });
|
||||
|
||||
const speicher = await cookies();
|
||||
const challenge = speicher.get(CHALLENGE_COOKIE)?.value;
|
||||
if (!challenge) return Response.json({ fehler: "Die Aufgabe ist abgelaufen. Bitte erneut versuchen." }, { status: 400 });
|
||||
|
||||
try {
|
||||
const { antwort, bezeichnung } = await request.json();
|
||||
const passkey = await registrierungAbschliessen(
|
||||
benutzer.benutzerId,
|
||||
antwort,
|
||||
challenge,
|
||||
await herkunft(request),
|
||||
typeof bezeichnung === "string" ? bezeichnung : "Passkey",
|
||||
);
|
||||
|
||||
await protokolliere("passkey.angelegt", "Passkey", passkey.id, { bezeichnung: passkey.bezeichnung });
|
||||
return Response.json({ erfolg: true, bezeichnung: passkey.bezeichnung });
|
||||
} catch (fehler) {
|
||||
console.error("Passkey-Registrierung fehlgeschlagen:", fehler);
|
||||
return Response.json(
|
||||
{ fehler: fehler instanceof Error ? fehler.message : "Der Passkey ließ sich nicht hinterlegen." },
|
||||
{ status: 400 },
|
||||
);
|
||||
} finally {
|
||||
speicher.delete(CHALLENGE_COOKIE);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user