feat(anmeldung): lokale Benutzerkonten mit Pflicht-TOTP (#035)
Ohne authentik gab es nur den Notfall-Admin – ein geteiltes Konto ohne Rollen und ohne erkennbare Akteure im Audit-Log. Admins legen jetzt lokale Konten an; sshkv erzeugt ein Einmal-Passwort, das bei der ersten Anmeldung ersetzt wird, danach ist TOTP Pflicht. Rollen, Sperren (beendet Sitzungen sofort) und Zurücksetzen unter Einstellungen → Benutzer, nur für Admins mit frischer Bestätigung. SSHKV_LOKALE_KONTEN=0 schaltet lokale Konten ab; der Notfall-Admin bleibt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { KontoAktionen } from "@/components/benutzer/KontoAktionen";
|
||||
import { KontoAnlegen } from "@/components/benutzer/KontoAnlegen";
|
||||
import { Badge, Breadcrumb, Karte, Seitenkopf, Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui";
|
||||
import { datumZeit } from "@/lib/format";
|
||||
import { lokaleKontenAktiv } from "@/lib/lokale-konten";
|
||||
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { benutzerListe, type KontoHerkunft } from "@/server/queries/benutzer";
|
||||
|
||||
export const metadata: Metadata = { title: "Benutzer" };
|
||||
|
||||
const ROLLE: Record<string, string> = { ADMIN: "Admin", OPERATOR: "Operator", LESER: "Leser" };
|
||||
const HERKUNFT: Record<KontoHerkunft, string> = {
|
||||
notfall: "Notfall-Admin",
|
||||
lokal: "lokal",
|
||||
oidc: process.env["SSHKV_OIDC_NAME"] ?? "OIDC",
|
||||
};
|
||||
|
||||
/**
|
||||
* Benutzerverwaltung (#035): lokale Konten anlegen, Rollen vergeben, sperren, zuruecksetzen.
|
||||
* OIDC-Konten erscheinen nach ihrer ersten Anmeldung; ihre Rolle kommt aus dem Anbieter.
|
||||
*/
|
||||
export default async function BenutzerSeite() {
|
||||
const { benutzer } = await sitzungErforderlich("benutzerVerwalten", "/einstellungen/benutzer");
|
||||
const konten = await benutzerListe();
|
||||
const lokal = lokaleKontenAktiv();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
oben={
|
||||
<Breadcrumb pfad={[{ titel: "Einstellungen", href: "/einstellungen" }, { titel: "Benutzer" }]} />
|
||||
}
|
||||
titel="Benutzer"
|
||||
untertitel="Lokale Konten mit Passwort und TOTP – neben oder statt des Identitätsanbieters."
|
||||
/>
|
||||
|
||||
{!bestaetigungFrisch(benutzer.bestaetigtAm) && (
|
||||
<p className="text-accent text-[13.5px]">
|
||||
Änderungen an Konten verlangen eine frische Bestätigung Ihrer Anmeldung.{" "}
|
||||
<Link href="/bestaetigen?ziel=%2Feinstellungen%2Fbenutzer" className="underline">
|
||||
Jetzt bestätigen
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{lokal ? (
|
||||
<Karte
|
||||
titel="Lokales Konto anlegen"
|
||||
untertitel="sshkv erzeugt ein Einmal-Passwort; bei der ersten Anmeldung setzt die Person ein eigenes und richtet TOTP ein."
|
||||
>
|
||||
<KontoAnlegen />
|
||||
</Karte>
|
||||
) : (
|
||||
<p className="text-muted text-[14px]">
|
||||
Lokale Konten sind abgeschaltet (<code className="font-mono">SSHKV_LOKALE_KONTEN=0</code>).
|
||||
Vorhandene lokale Konten können sich nicht anmelden.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Karte titel="Konten" untertitel={konten.length === 1 ? "1 Konto" : `${konten.length} Konten`}>
|
||||
<Tabelle
|
||||
beschriftung="Konten"
|
||||
spalten={[
|
||||
{ titel: "Name" },
|
||||
{ titel: "Anmeldung" },
|
||||
{ titel: "Rolle" },
|
||||
{ titel: "Zuletzt angemeldet", versteckt: true },
|
||||
{ titel: "Aktionen", rechts: true },
|
||||
]}
|
||||
>
|
||||
{konten.map((k) => (
|
||||
<TabellenZeile key={k.id}>
|
||||
<TabellenZelle>
|
||||
<span className="block font-medium">{k.name}</span>
|
||||
<span className="text-muted block font-mono text-[12.5px]">
|
||||
{k.anmeldename ?? k.email ?? "–"}
|
||||
</span>
|
||||
</TabellenZelle>
|
||||
<TabellenZelle className="whitespace-nowrap">
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
<Badge>{HERKUNFT[k.herkunft]}</Badge>
|
||||
{!k.aktiv && <Badge variante="gefahr">gesperrt</Badge>}
|
||||
{k.aktiv && k.einrichtungOffen && <Badge variante="messing">Einrichtung offen</Badge>}
|
||||
</span>
|
||||
</TabellenZelle>
|
||||
<TabellenZelle>{ROLLE[k.rolle]}</TabellenZelle>
|
||||
<TabellenZelle className="text-muted text-[13px] whitespace-nowrap">
|
||||
{k.letzteAnmeldungAm ? datumZeit(k.letzteAnmeldungAm) : "noch nie"}
|
||||
</TabellenZelle>
|
||||
<TabellenZelle rechts>
|
||||
<KontoAktionen konto={k} selbst={k.id === benutzer.id} />
|
||||
</TabellenZelle>
|
||||
</TabellenZeile>
|
||||
))}
|
||||
</Tabelle>
|
||||
</Karte>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -188,6 +188,18 @@ export default async function Einstellungen() {
|
||||
<span className="text-muted">nicht eingerichtet</span>
|
||||
)}
|
||||
</Info>
|
||||
<Info titel="Benutzer">
|
||||
{darf(benutzer.rolle, "benutzerVerwalten") ? (
|
||||
<Link
|
||||
href="/einstellungen/benutzer"
|
||||
className="text-accent inline-flex min-h-11 items-center text-[14px] font-medium hover:underline"
|
||||
>
|
||||
Konten verwalten
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted">nur für Admins</span>
|
||||
)}
|
||||
</Info>
|
||||
<Info titel="Notfall-Admin">
|
||||
{!instanz.notfall.vorhanden ? (
|
||||
<span className="text-muted">nicht angelegt</span>
|
||||
|
||||
@@ -11,6 +11,10 @@ export async function huellenBenutzerErmitteln(): Promise<HuellenBenutzer> {
|
||||
name: benutzer.name,
|
||||
rolle: benutzer.rolle,
|
||||
herkunft:
|
||||
sitzung.herkunft === "notfall" ? "Notfallzugang" : (process.env["SSHKV_OIDC_NAME"] ?? "authentik"),
|
||||
sitzung.herkunft === "notfall"
|
||||
? "Notfallzugang"
|
||||
: sitzung.herkunft === "lokal"
|
||||
? "lokales Konto"
|
||||
: (process.env["SSHKV_OIDC_NAME"] ?? "authentik"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
|
||||
import { NotfallFormular } from "@/components/anmeldung/NotfallFormular";
|
||||
import { KontoFormular } from "@/components/anmeldung/KontoFormular";
|
||||
import { buttonKlassen, Formularfehler } from "@/components/ui";
|
||||
import { lokaleKontenAktiv } from "@/lib/lokale-konten";
|
||||
import { sicheresZiel } from "@/server/auth/anmeldung";
|
||||
import { oidcAktiv, oidcName } from "@/server/auth/oidc";
|
||||
import { angemeldet } from "@/server/auth/sitzungspflicht";
|
||||
@@ -33,6 +34,7 @@ export default async function AnmeldenSeite({
|
||||
if (await angemeldet()) redirect(ziel);
|
||||
|
||||
const oidc = oidcAktiv();
|
||||
const lokal = lokaleKontenAktiv();
|
||||
const fehler = parameter.fehler ? (FEHLER[parameter.fehler] ?? FEHLER["allgemein"]) : undefined;
|
||||
|
||||
return (
|
||||
@@ -53,15 +55,17 @@ export default async function AnmeldenSeite({
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-muted text-[14px]">
|
||||
Kein Identitätsanbieter eingerichtet – nur der Notfallzugang ist aktiv.
|
||||
{lokal
|
||||
? "Kein Identitätsanbieter eingerichtet – Anmeldung mit lokalem Konto."
|
||||
: "Kein Identitätsanbieter eingerichtet – nur der Notfallzugang ist aktiv."}
|
||||
</p>
|
||||
)}
|
||||
<details className="group border-border rounded-box border" open={!oidc}>
|
||||
<summary className="text-muted hover:text-text flex min-h-11 cursor-pointer items-center px-4 text-[14px] font-medium">
|
||||
Notfallzugang
|
||||
{lokal ? "Lokales Konto oder Notfallzugang" : "Notfallzugang"}
|
||||
</summary>
|
||||
<div className="border-border border-t p-4">
|
||||
<NotfallFormular ziel={ziel} />
|
||||
<KontoFormular ziel={ziel} lokal={lokal} />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
|
||||
import { PasswortSetzenFormular } from "@/components/anmeldung/PasswortSetzenFormular";
|
||||
import { KONTO_PASSWORT_MINDESTLAENGE } from "@/lib/lokale-konten";
|
||||
import { passwortSetzenLesen } from "@/server/auth/totp-einrichtung";
|
||||
|
||||
export const metadata: Metadata = { title: "Eigenes Passwort setzen" };
|
||||
|
||||
/**
|
||||
* Erste Anmeldung eines lokalen Kontos (#035): das Einmal-Passwort vom Admin wird durch ein
|
||||
* eigenes ersetzt. Nur mit dem Cookie erreichbar, das ein richtiges Einmal-Passwort ausstellt.
|
||||
*/
|
||||
export default async function PasswortSetzenSeite() {
|
||||
if (!(await passwortSetzenLesen())) redirect("/anmelden");
|
||||
return (
|
||||
<AnmeldeRahmen
|
||||
titel="Eigenes Passwort setzen"
|
||||
text={`Das Einmal-Passwort gilt nur für diese erste Anmeldung. Bitte ein eigenes mit mindestens ${KONTO_PASSWORT_MINDESTLAENGE} Zeichen wählen – danach wird der zweite Faktor eingerichtet.`}
|
||||
>
|
||||
<PasswortSetzenFormular />
|
||||
</AnmeldeRahmen>
|
||||
);
|
||||
}
|
||||
@@ -4,20 +4,30 @@ import QRCode from "qrcode";
|
||||
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
|
||||
import { TotpFormular } from "@/components/anmeldung/TotpFormular";
|
||||
import { MonoKopieren } from "@/components/ui";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { totpAdresse } from "@/lib/totp";
|
||||
import { totpEinrichtungLesen } from "@/server/auth/totp-einrichtung";
|
||||
|
||||
export const metadata: Metadata = { title: "TOTP einrichten" };
|
||||
|
||||
/**
|
||||
* Erste Anmeldung des Notfall-Admins: TOTP ist Pflicht (Freigabe Frage 5). Die Seite ist
|
||||
* nur mit dem Einrichtungs-Cookie erreichbar, das ein richtiges Passwort ausgestellt hat.
|
||||
* Erste Anmeldung eines Passwort-Kontos (Notfall-Admin, lokale Konten #035): TOTP ist Pflicht
|
||||
* (Freigabe Frage 5). Die Seite ist nur mit dem Einrichtungs-Cookie erreichbar, das ein
|
||||
* richtiges Passwort ausgestellt hat.
|
||||
*/
|
||||
export default async function TotpSeite() {
|
||||
const einrichtung = await totpEinrichtungLesen();
|
||||
if (!einrichtung) redirect("/anmelden");
|
||||
|
||||
const adresse = totpAdresse(einrichtung.geheimnis, "Notfall-Admin");
|
||||
const konto = await prisma.benutzer.findUnique({
|
||||
where: { id: einrichtung.benutzerId },
|
||||
select: { anmeldename: true, istNotfall: true },
|
||||
});
|
||||
// In der Authenticator-App soll stehen, welches Konto das ist.
|
||||
const adresse = totpAdresse(
|
||||
einrichtung.geheimnis,
|
||||
konto?.istNotfall ? "Notfall-Admin" : (konto?.anmeldename ?? "Konto"),
|
||||
);
|
||||
// Als SVG im Server gerendert: kein Bild-Endpunkt, keine Bibliothek im Browser.
|
||||
const svg = await QRCode.toString(adresse, {
|
||||
type: "svg",
|
||||
|
||||
@@ -22,7 +22,7 @@ export default async function BestaetigenSeite({
|
||||
titel="Anmeldung bestätigen"
|
||||
text="Diese Aktion ist kritisch. Bitte bestätigen Sie Ihre Anmeldung – die Bestätigung gilt fünf Minuten."
|
||||
>
|
||||
{benutzer.istNotfall ? (
|
||||
{benutzer.passwortHash && !benutzer.oidcSub ? (
|
||||
<BestaetigenFormular ziel={ziel} />
|
||||
) : (
|
||||
<a
|
||||
|
||||
Reference in New Issue
Block a user