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
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { Button, Feld, Formularfehler } from "@/components/ui";
|
||||
import { notfallBestaetigenAktion, type FormularZustand } from "@/server/actions/anmeldung";
|
||||
import { kontoBestaetigenAktion, type FormularZustand } from "@/server/actions/anmeldung";
|
||||
|
||||
/** Erneute Bestaetigung des Notfall-Admins vor kritischen Aktionen (#004). */
|
||||
export function BestaetigenFormular({ ziel }: { ziel: string }) {
|
||||
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(notfallBestaetigenAktion, {});
|
||||
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(kontoBestaetigenAktion, {});
|
||||
return (
|
||||
<form action={aktion} className="flex flex-col gap-4">
|
||||
<input type="hidden" name="ziel" value={ziel} />
|
||||
|
||||
+8
-5
@@ -2,11 +2,14 @@
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { Button, Feld, Formularfehler } from "@/components/ui";
|
||||
import { notfallAnmeldenAktion, type FormularZustand } from "@/server/actions/anmeldung";
|
||||
import { kontoAnmeldenAktion, type FormularZustand } from "@/server/actions/anmeldung";
|
||||
|
||||
/** Notfallzugang: Anmeldename, Passwort und – sobald eingerichtet – TOTP-Code (#004). */
|
||||
export function NotfallFormular({ ziel }: { ziel: string }) {
|
||||
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(notfallAnmeldenAktion, {});
|
||||
/**
|
||||
* Lokale Konten und Notfallzugang: Anmeldename, Passwort und – sobald eingerichtet – TOTP-Code
|
||||
* (#004, #035).
|
||||
*/
|
||||
export function KontoFormular({ ziel, lokal }: { ziel: string; lokal: boolean }) {
|
||||
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(kontoAnmeldenAktion, {});
|
||||
return (
|
||||
<form action={aktion} className="flex flex-col gap-4">
|
||||
<input type="hidden" name="ziel" value={ziel} />
|
||||
@@ -29,7 +32,7 @@ export function NotfallFormular({ ziel }: { ziel: string }) {
|
||||
hinweis="Bei der ersten Anmeldung leer lassen – dann wird TOTP eingerichtet."
|
||||
/>
|
||||
<Button type="submit" variante="sekundaer" disabled={laeuft}>
|
||||
{laeuft ? "Prüfe …" : "Mit Notfallzugang anmelden"}
|
||||
{laeuft ? "Prüfe …" : lokal ? "Mit Benutzername anmelden" : "Mit Notfallzugang anmelden"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { Button, Feld, Formularfehler } from "@/components/ui";
|
||||
import { passwortSetzenAktion, type FormularZustand } from "@/server/actions/anmeldung";
|
||||
|
||||
export function PasswortSetzenFormular() {
|
||||
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(passwortSetzenAktion, {});
|
||||
return (
|
||||
<form action={aktion} className="flex flex-col gap-4">
|
||||
<Formularfehler text={zustand.fehler} />
|
||||
<Feld
|
||||
beschriftung="Neues Passwort"
|
||||
name="passwort"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Neues Passwort wiederholen"
|
||||
name="wiederholung"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Passwort setzen"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { MonoKopieren } from "@/components/ui";
|
||||
|
||||
/** Einmal-Passwort fuer ein neues oder zurueckgesetztes Konto (#035) – wird nur jetzt gezeigt. */
|
||||
export function EinmalPasswort({ anmeldename, passwort }: { anmeldename: string; passwort: string }) {
|
||||
return (
|
||||
<section
|
||||
aria-label={`Einmal-Passwort für ${anmeldename}`}
|
||||
className="border-accent bg-raised rounded-box flex flex-col gap-2 border p-4"
|
||||
>
|
||||
<p className="text-[14px]">
|
||||
Einmal-Passwort für <span className="font-mono">{anmeldename}</span> –{" "}
|
||||
<strong>nur jetzt sichtbar</strong>. Bitte auf sicherem Weg weitergeben; bei der ersten Anmeldung wird
|
||||
ein eigenes Passwort und TOTP eingerichtet.
|
||||
</p>
|
||||
<MonoKopieren wert={passwort} beschriftung="Einmal-Passwort" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { EinmalPasswort } from "@/components/benutzer/EinmalPasswort";
|
||||
import { Button } from "@/components/ui";
|
||||
import type { KontoHerkunft } from "@/server/queries/benutzer";
|
||||
import { aktivSetzenAktion, rolleAendernAktion, zuruecksetzenAktion } from "@/server/actions/benutzer";
|
||||
|
||||
const ROLLEN = [
|
||||
{ wert: "LESER", text: "Leser" },
|
||||
{ wert: "OPERATOR", text: "Operator" },
|
||||
{ wert: "ADMIN", text: "Admin" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Aktionen je Konto (#035): Rolle (nur lokal), sperren/entsperren, zuruecksetzen (nur lokal).
|
||||
* Der Notfall-Admin und das eigene Konto bekommen keine Knoepfe – der Server lehnt es ohnehin ab.
|
||||
*/
|
||||
export function KontoAktionen({
|
||||
konto,
|
||||
selbst,
|
||||
}: {
|
||||
konto: {
|
||||
id: string;
|
||||
name: string;
|
||||
anmeldename: string | null;
|
||||
rolle: string;
|
||||
aktiv: boolean;
|
||||
herkunft: KontoHerkunft;
|
||||
};
|
||||
selbst: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [fehler, setFehler] = useState<string>();
|
||||
const [einmal, setEinmal] = useState<string>();
|
||||
const [rueckfrage, setRueckfrage] = useState(false);
|
||||
const [laeuft, starten] = useTransition();
|
||||
|
||||
if (konto.herkunft === "notfall" || selbst) {
|
||||
return <span className="text-muted text-[13px]">{selbst ? "eigenes Konto" : "unveränderlich"}</span>;
|
||||
}
|
||||
|
||||
const ausfuehren = (aktion: () => Promise<{ fehler?: string; einmalPasswort?: string }>) =>
|
||||
starten(async () => {
|
||||
setFehler(undefined);
|
||||
const antwort = await aktion();
|
||||
if (antwort.fehler) setFehler(antwort.fehler);
|
||||
if (antwort.einmalPasswort) setEinmal(antwort.einmalPasswort);
|
||||
router.refresh();
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{konto.herkunft === "lokal" && (
|
||||
<label className="sr-only" htmlFor={`rolle-${konto.id}`}>
|
||||
Rolle von {konto.name}
|
||||
</label>
|
||||
)}
|
||||
{konto.herkunft === "lokal" && (
|
||||
<select
|
||||
id={`rolle-${konto.id}`}
|
||||
className="bg-bg border-border-strong rounded-feld min-h-11 border px-2 text-[14px]"
|
||||
value={konto.rolle}
|
||||
disabled={laeuft}
|
||||
onChange={(e) => ausfuehren(() => rolleAendernAktion(konto.id, e.target.value))}
|
||||
>
|
||||
{ROLLEN.map((r) => (
|
||||
<option key={r.wert} value={r.wert}>
|
||||
{r.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button
|
||||
klein
|
||||
disabled={laeuft}
|
||||
onClick={() => ausfuehren(() => aktivSetzenAktion(konto.id, !konto.aktiv))}
|
||||
>
|
||||
{konto.aktiv ? "Sperren" : "Entsperren"}
|
||||
<span className="sr-only"> – {konto.name}</span>
|
||||
</Button>
|
||||
{konto.herkunft === "lokal" &&
|
||||
(rueckfrage ? (
|
||||
<Button
|
||||
klein
|
||||
variante="gefahr"
|
||||
disabled={laeuft}
|
||||
onClick={() => {
|
||||
setRueckfrage(false);
|
||||
ausfuehren(() => zuruecksetzenAktion(konto.id));
|
||||
}}
|
||||
>
|
||||
Wirklich zurücksetzen?
|
||||
</Button>
|
||||
) : (
|
||||
<Button klein disabled={laeuft} onClick={() => setRueckfrage(true)}>
|
||||
Zurücksetzen<span className="sr-only"> – {konto.name}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{fehler && (
|
||||
<p role="alert" className="text-danger text-[13px]">
|
||||
{fehler}
|
||||
</p>
|
||||
)}
|
||||
{einmal && konto.anmeldename && <EinmalPasswort anmeldename={konto.anmeldename} passwort={einmal} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { UserPlus } from "lucide-react";
|
||||
import { useActionState } from "react";
|
||||
import { EinmalPasswort } from "@/components/benutzer/EinmalPasswort";
|
||||
import { Auswahlliste, Button, Feld, Formularfehler } from "@/components/ui";
|
||||
import { kontoAnlegenAktion, type KontoZustand } from "@/server/actions/benutzer";
|
||||
|
||||
/** Lokales Konto anlegen (#035). Das Passwort waehlt nicht der Admin, sshkv erzeugt es. */
|
||||
export function KontoAnlegen() {
|
||||
const [zustand, aktion, laeuft] = useActionState<KontoZustand, FormData>(kontoAnlegenAktion, {});
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{zustand.einmalPasswort && zustand.anmeldename && (
|
||||
<EinmalPasswort anmeldename={zustand.anmeldename} passwort={zustand.einmalPasswort} />
|
||||
)}
|
||||
<form action={aktion} className="grid gap-4 sm:grid-cols-[1fr_1fr_12rem_auto] sm:items-end">
|
||||
<div className="sm:col-span-4 empty:hidden">
|
||||
<Formularfehler text={zustand.fehler} />
|
||||
</div>
|
||||
<Feld
|
||||
beschriftung="Anmeldename"
|
||||
name="anmeldename"
|
||||
autoComplete="off"
|
||||
required
|
||||
mono
|
||||
fehler={zustand.feldfehler?.["anmeldename"]}
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Name"
|
||||
name="name"
|
||||
autoComplete="off"
|
||||
required
|
||||
fehler={zustand.feldfehler?.["name"]}
|
||||
/>
|
||||
<Auswahlliste beschriftung="Rolle" name="rolle" defaultValue="LESER">
|
||||
<option value="LESER">Leser</option>
|
||||
<option value="OPERATOR">Operator</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
</Auswahlliste>
|
||||
<Button type="submit" variante="primaer" disabled={laeuft} icon={<UserPlus className="size-4" />}>
|
||||
{laeuft ? "Lege an …" : "Konto anlegen"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { randomInt } from "node:crypto";
|
||||
import { z } from "@/lib/validierung";
|
||||
|
||||
/**
|
||||
* Regeln fuer lokale Konten (#035): Anmeldename, Passwortlaenge, Einmal-Passwort. Rein, damit
|
||||
* testbar. Lokale Konten melden sich wie der Notfall-Admin mit Passwort und Pflicht-TOTP an.
|
||||
*/
|
||||
|
||||
/** Mit Pflicht-TOTP genuegen 12 Zeichen; der Notfall-Admin behaelt seine 16 (#004). */
|
||||
export const KONTO_PASSWORT_MINDESTLAENGE = 12;
|
||||
export const KONTO_PASSWORT_HOECHSTLAENGE = 256;
|
||||
|
||||
export const anmeldenameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.regex(/^[a-z][a-z0-9._-]{1,63}$/, {
|
||||
message:
|
||||
"2–64 Zeichen: Kleinbuchstaben, Ziffern, Punkt, Binde- und Unterstrich; mit einem Buchstaben beginnen.",
|
||||
});
|
||||
|
||||
export const kontoNameSchema = z.string().trim().min(1).max(120);
|
||||
|
||||
/** Welche Rollen ein lokales Konto haben kann – dieselben wie ueber OIDC. */
|
||||
export const kontoRolleSchema = z.enum(["ADMIN", "OPERATOR", "LESER"]);
|
||||
|
||||
export function passwortFehler(passwort: string, anmeldename?: string | null): string | null {
|
||||
if (passwort.length < KONTO_PASSWORT_MINDESTLAENGE)
|
||||
return `Das Passwort braucht mindestens ${KONTO_PASSWORT_MINDESTLAENGE} Zeichen.`;
|
||||
if (passwort.length > KONTO_PASSWORT_HOECHSTLAENGE) return "Das Passwort ist zu lang.";
|
||||
if (anmeldename && passwort.toLowerCase().includes(anmeldename.toLowerCase()))
|
||||
return "Das Passwort darf den Anmeldenamen nicht enthalten.";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Ohne leicht verwechselbare Zeichen (0/O, 1/l/I) – das Passwort wird abgetippt. */
|
||||
const ZEICHEN = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
/**
|
||||
* Einmal-Passwort fuer ein neues oder zurueckgesetztes Konto: 4 × 5 Zeichen mit Bindestrichen,
|
||||
* gut 115 Bit. Es wird genau einmal angezeigt und muss bei der ersten Anmeldung ersetzt werden.
|
||||
*/
|
||||
export function einmalPasswortErzeugen(): string {
|
||||
return Array.from({ length: 4 }, () =>
|
||||
Array.from({ length: 5 }, () => ZEICHEN[randomInt(ZEICHEN.length)]).join(""),
|
||||
).join("-");
|
||||
}
|
||||
|
||||
/** Lokale Konten lassen sich per `SSHKV_LOKALE_KONTEN=0` abschalten; der Notfall-Admin bleibt. */
|
||||
export function lokaleKontenAktiv(
|
||||
umgebung: Readonly<Record<string, string | undefined>> = process.env,
|
||||
): boolean {
|
||||
const wert = umgebung["SSHKV_LOKALE_KONTEN"]?.trim().toLowerCase();
|
||||
return !(wert === "0" || wert === "false" || wert === "nein" || wert === "aus");
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
export const AKTIONSGRUPPEN = {
|
||||
anmeldung: "Anmeldung",
|
||||
benutzer: "Benutzer",
|
||||
hostkey: "Host-Key",
|
||||
ausrollen: "Ausrollen",
|
||||
schluessel: "Schlüssel",
|
||||
@@ -44,6 +45,17 @@ export const AKTIONEN: Readonly<Record<string, Beschreibung>> = {
|
||||
"anmeldung.oidc.erstmals": a("Erste Anmeldung (OIDC)", "anmeldung"),
|
||||
"anmeldung.verweigert": a("Anmeldung verweigert", "anmeldung", false),
|
||||
"anmeldung.notfall": a("Anmeldung Notfall-Admin", "anmeldung", false),
|
||||
"anmeldung.lokal": a("Anmeldung (lokales Konto)", "anmeldung", false),
|
||||
"anmeldung.lokal.fehlgeschlagen": a("Anmeldung (lokal) fehlgeschlagen", "anmeldung", false),
|
||||
"anmeldung.lokal.gesperrt": a("Anmeldung (lokal) gesperrt – zu viele Fehlversuche", "anmeldung", false),
|
||||
"anmeldung.lokal.totp_falsch": a("Anmeldung (lokal): falscher TOTP-Code", "anmeldung", false),
|
||||
"konto.passwort_gesetzt": a("Eigenes Passwort gesetzt", "benutzer"),
|
||||
"konto.totp_eingerichtet": a("TOTP eingerichtet", "anmeldung"),
|
||||
"benutzer.angelegt": a("Lokales Konto angelegt", "benutzer"),
|
||||
"benutzer.rolle_geaendert": a("Rolle geändert", "benutzer"),
|
||||
"benutzer.gesperrt": a("Konto gesperrt", "benutzer"),
|
||||
"benutzer.entsperrt": a("Konto entsperrt", "benutzer"),
|
||||
"benutzer.zurueckgesetzt": a("Konto zurückgesetzt (Passwort und TOTP)", "benutzer"),
|
||||
"anmeldung.notfall.fehlgeschlagen": a("Notfall-Anmeldung fehlgeschlagen", "anmeldung", false),
|
||||
"anmeldung.notfall.gesperrt": a("Notfall-Anmeldung gesperrt", "anmeldung", false),
|
||||
"anmeldung.notfall.totp_falsch": a("Notfall-Anmeldung: TOTP falsch", "anmeldung", false),
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@ export const SITZUNG_COOKIE = "sshkv_sitzung";
|
||||
*/
|
||||
export const SITZUNG_LAUFZEIT_STUNDEN = 8;
|
||||
|
||||
export type SitzungsHerkunft = "notfall" | "oidc";
|
||||
export type SitzungsHerkunft = "notfall" | "lokal" | "oidc";
|
||||
|
||||
export type Sitzung = {
|
||||
benutzerId: string;
|
||||
@@ -45,7 +45,7 @@ const ALGORITHMUS = "HS256";
|
||||
|
||||
const MINDESTLAENGE = 32;
|
||||
|
||||
const ERLAUBTE_HERKUNFT: readonly SitzungsHerkunft[] = ["notfall", "oidc"];
|
||||
const ERLAUBTE_HERKUNFT: readonly SitzungsHerkunft[] = ["notfall", "lokal", "oidc"];
|
||||
|
||||
export class SitzungsFehler extends Error {
|
||||
constructor(nachricht: string, optionen?: ErrorOptions) {
|
||||
|
||||
@@ -7,16 +7,21 @@ import { totpGeheimnisErzeugen } from "@/lib/totp";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { sicheresZiel, sitzungSetzen, ueberHttps } from "@/server/auth/anmeldung";
|
||||
import { angemeldet } from "@/server/auth/sitzungspflicht";
|
||||
import type { Benutzer } from "@/generated/prisma/client";
|
||||
import {
|
||||
EINRICHTUNG_SEKUNDEN,
|
||||
EINRICHTUNG_ZWECK,
|
||||
PASSWORT_SETZEN_COOKIE,
|
||||
PASSWORT_SETZEN_ZWECK,
|
||||
TOTP_EINRICHTUNG_COOKIE,
|
||||
passwortSetzenLesen,
|
||||
totpEinrichtungLesen,
|
||||
} from "@/server/auth/totp-einrichtung";
|
||||
import {
|
||||
AnmeldeFehler,
|
||||
notfallAnmelden,
|
||||
notfallBestaetigen,
|
||||
eigenesPasswortSetzen,
|
||||
kontoAnmelden,
|
||||
kontoBestaetigen,
|
||||
totpEinrichtungAbschliessen,
|
||||
} from "@/server/services/anmeldung";
|
||||
|
||||
@@ -27,48 +32,97 @@ import {
|
||||
*/
|
||||
export type FormularZustand = { readonly fehler?: string };
|
||||
|
||||
const notfallSchema = z.object({
|
||||
const kontoSchema = z.object({
|
||||
anmeldename: z.string().trim().min(1).max(64),
|
||||
passwort: z.string().min(1).max(256),
|
||||
code: z.string().trim().max(10).default(""),
|
||||
ziel: z.string().max(512).optional(),
|
||||
});
|
||||
|
||||
export async function notfallAnmeldenAktion(
|
||||
const herkunft = (benutzer: Benutzer) => (benutzer.istNotfall ? "notfall" : "lokal");
|
||||
|
||||
/** Kurzlebige httpOnly-Cookies fuer die Einrichtungsschritte – nur auf /anmelden gueltig. */
|
||||
async function einrichtungsCookie(name: string, token: string) {
|
||||
(await cookies()).set(name, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: ueberHttps(),
|
||||
path: "/anmelden",
|
||||
maxAge: EINRICHTUNG_SEKUNDEN,
|
||||
});
|
||||
}
|
||||
|
||||
async function totpEinrichtungStarten(benutzer: Benutzer, ziel: string): Promise<never> {
|
||||
// Das neue Geheimnis lebt bis zur Bestaetigung nur in einem signierten, kurzlebigen
|
||||
// httpOnly-Cookie dieses Browsers – gespeichert wird es erst, wenn ein Code passt.
|
||||
const token = await kurzzeitTokenErzeugen(
|
||||
EINRICHTUNG_ZWECK,
|
||||
{ benutzerId: benutzer.id, geheimnis: totpGeheimnisErzeugen(), ziel },
|
||||
EINRICHTUNG_SEKUNDEN,
|
||||
);
|
||||
await einrichtungsCookie(TOTP_EINRICHTUNG_COOKIE, token);
|
||||
redirect("/anmelden/totp");
|
||||
}
|
||||
|
||||
export async function kontoAnmeldenAktion(
|
||||
_vorher: FormularZustand,
|
||||
formular: FormData,
|
||||
): Promise<FormularZustand> {
|
||||
const eingabe = notfallSchema.safeParse(Object.fromEntries(formular));
|
||||
const eingabe = kontoSchema.safeParse(Object.fromEntries(formular));
|
||||
if (!eingabe.success) return { fehler: "Bitte Anmeldename und Passwort angeben." };
|
||||
const ziel = sicheresZiel(eingabe.data.ziel);
|
||||
|
||||
let ergebnis;
|
||||
try {
|
||||
ergebnis = await notfallAnmelden(eingabe.data.anmeldename, eingabe.data.passwort, eingabe.data.code);
|
||||
ergebnis = await kontoAnmelden(eingabe.data.anmeldename, eingabe.data.passwort, eingabe.data.code);
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
if (ergebnis.art === "totpEinrichten") {
|
||||
// Das neue Geheimnis lebt bis zur Bestaetigung nur in einem signierten, kurzlebigen
|
||||
// httpOnly-Cookie dieses Browsers – gespeichert wird es erst, wenn ein Code passt.
|
||||
if (ergebnis.art === "passwortSetzen") {
|
||||
const token = await kurzzeitTokenErzeugen(
|
||||
EINRICHTUNG_ZWECK,
|
||||
{ benutzerId: ergebnis.benutzer.id, geheimnis: totpGeheimnisErzeugen(), ziel },
|
||||
PASSWORT_SETZEN_ZWECK,
|
||||
{ benutzerId: ergebnis.benutzer.id, ziel },
|
||||
EINRICHTUNG_SEKUNDEN,
|
||||
);
|
||||
(await cookies()).set(TOTP_EINRICHTUNG_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: ueberHttps(),
|
||||
path: "/anmelden",
|
||||
maxAge: EINRICHTUNG_SEKUNDEN,
|
||||
});
|
||||
redirect("/anmelden/totp");
|
||||
await einrichtungsCookie(PASSWORT_SETZEN_COOKIE, token);
|
||||
redirect("/anmelden/passwort");
|
||||
}
|
||||
if (ergebnis.art === "totpEinrichten") await totpEinrichtungStarten(ergebnis.benutzer, ziel);
|
||||
|
||||
await sitzungSetzen(ergebnis.benutzer, "notfall");
|
||||
await sitzungSetzen(ergebnis.benutzer, herkunft(ergebnis.benutzer));
|
||||
redirect(ziel);
|
||||
}
|
||||
|
||||
const passwortSchema = z.object({
|
||||
passwort: z.string().max(256),
|
||||
wiederholung: z.string().max(256),
|
||||
});
|
||||
|
||||
/** Einmal-Passwort ersetzen (#035); danach geht es zur TOTP-Einrichtung. */
|
||||
export async function passwortSetzenAktion(
|
||||
_vorher: FormularZustand,
|
||||
formular: FormData,
|
||||
): Promise<FormularZustand> {
|
||||
const ausstehend = await passwortSetzenLesen();
|
||||
if (!ausstehend)
|
||||
return { fehler: "Die Sitzung zum Passwortwechsel ist abgelaufen. Bitte erneut anmelden." };
|
||||
const eingabe = passwortSchema.safeParse(Object.fromEntries(formular));
|
||||
if (!eingabe.success) return { fehler: "Bitte das neue Passwort zweimal eingeben." };
|
||||
if (eingabe.data.passwort !== eingabe.data.wiederholung)
|
||||
return { fehler: "Die beiden Eingaben stimmen nicht überein." };
|
||||
let benutzer;
|
||||
try {
|
||||
benutzer = await eigenesPasswortSetzen(ausstehend.benutzerId, eingabe.data.passwort);
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
|
||||
throw fehler;
|
||||
}
|
||||
(await cookies()).delete({ name: PASSWORT_SETZEN_COOKIE, path: "/anmelden" });
|
||||
const ziel = sicheresZiel(ausstehend.ziel);
|
||||
if (!benutzer.totpEingerichtetAm) await totpEinrichtungStarten(benutzer, ziel);
|
||||
await sitzungSetzen(benutzer, herkunft(benutzer));
|
||||
redirect(ziel);
|
||||
}
|
||||
|
||||
@@ -88,7 +142,7 @@ export async function totpEinrichtenAktion(
|
||||
}
|
||||
const laden = await cookies();
|
||||
laden.delete({ name: TOTP_EINRICHTUNG_COOKIE, path: "/anmelden" });
|
||||
await sitzungSetzen(benutzer, "notfall");
|
||||
await sitzungSetzen(benutzer, herkunft(benutzer));
|
||||
redirect(sicheresZiel(einrichtung.ziel));
|
||||
}
|
||||
|
||||
@@ -98,7 +152,7 @@ const bestaetigenSchema = z.object({
|
||||
ziel: z.string().max(512).optional(),
|
||||
});
|
||||
|
||||
export async function notfallBestaetigenAktion(
|
||||
export async function kontoBestaetigenAktion(
|
||||
_vorher: FormularZustand,
|
||||
formular: FormData,
|
||||
): Promise<FormularZustand> {
|
||||
@@ -107,7 +161,7 @@ export async function notfallBestaetigenAktion(
|
||||
const eingabe = bestaetigenSchema.safeParse(Object.fromEntries(formular));
|
||||
if (!eingabe.success) return { fehler: "Bitte Passwort und Code angeben." };
|
||||
try {
|
||||
await notfallBestaetigen(vorhanden.benutzer, eingabe.data.passwort, eingabe.data.code);
|
||||
await kontoBestaetigen(vorhanden.benutzer, eingabe.data.passwort, eingabe.data.code);
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
|
||||
throw fehler;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { anmeldenameSchema, kontoNameSchema, kontoRolleSchema } from "@/lib/lokale-konten";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { akteurVon, fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular";
|
||||
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import {
|
||||
kontoAktivSetzen,
|
||||
kontoZuruecksetzen,
|
||||
lokalesKontoAnlegen,
|
||||
rolleAendern,
|
||||
} from "@/server/services/benutzer";
|
||||
|
||||
/** Benutzerverwaltung (#035). Duenn: Recht mit Re-Auth, Eingabe, Service. */
|
||||
|
||||
const PFAD = "/einstellungen/benutzer";
|
||||
const id = z.string().min(1).max(64);
|
||||
|
||||
/** Das Einmal-Passwort geht genau einmal an den Browser des Admins – nie ins Protokoll. */
|
||||
export type KontoZustand = FormularZustand & {
|
||||
readonly einmalPasswort?: string;
|
||||
readonly anmeldename?: string;
|
||||
};
|
||||
|
||||
export async function kontoAnlegenAktion(_vorher: KontoZustand, formular: FormData): Promise<KontoZustand> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("benutzerVerwalten");
|
||||
const eingabe = z
|
||||
.object({ anmeldename: anmeldenameSchema, name: kontoNameSchema, rolle: kontoRolleSchema })
|
||||
.safeParse({
|
||||
anmeldename: textfeld(formular, "anmeldename") ?? "",
|
||||
name: textfeld(formular, "name") ?? "",
|
||||
rolle: textfeld(formular, "rolle") ?? "LESER",
|
||||
});
|
||||
if (!eingabe.success) {
|
||||
const feldfehler: Record<string, string> = {};
|
||||
for (const fehler of eingabe.error.issues) feldfehler[String(fehler.path[0])] ??= fehler.message;
|
||||
return { feldfehler };
|
||||
}
|
||||
const { einmalPasswort } = await lokalesKontoAnlegen(eingabe.data, akteurVon(benutzer));
|
||||
revalidatePath(PFAD);
|
||||
return { einmalPasswort, anmeldename: eingabe.data.anmeldename };
|
||||
} catch (fehler) {
|
||||
return fehlerZustand(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rolleAendernAktion(kontoId: string, rolle: string): Promise<{ fehler?: string }> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("benutzerVerwalten");
|
||||
await rolleAendern(id.parse(kontoId), kontoRolleSchema.parse(rolle), akteurVon(benutzer));
|
||||
revalidatePath(PFAD);
|
||||
return {};
|
||||
} catch (fehler) {
|
||||
return fehlerZustand(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function aktivSetzenAktion(kontoId: string, aktiv: boolean): Promise<{ fehler?: string }> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("benutzerVerwalten");
|
||||
await kontoAktivSetzen(id.parse(kontoId), z.boolean().parse(aktiv), akteurVon(benutzer));
|
||||
revalidatePath(PFAD);
|
||||
return {};
|
||||
} catch (fehler) {
|
||||
return fehlerZustand(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function zuruecksetzenAktion(
|
||||
kontoId: string,
|
||||
): Promise<{ fehler?: string; einmalPasswort?: string }> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("benutzerVerwalten");
|
||||
const { einmalPasswort } = await kontoZuruecksetzen(id.parse(kontoId), akteurVon(benutzer));
|
||||
revalidatePath(PFAD);
|
||||
return { einmalPasswort };
|
||||
} catch (fehler) {
|
||||
return fehlerZustand(fehler);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,8 @@ export type Recht =
|
||||
| "deploymentKeyVerwalten"
|
||||
| "massenwiderruf"
|
||||
| "zielkontenVerwalten"
|
||||
| "einstellungenVerwalten";
|
||||
| "einstellungenVerwalten"
|
||||
| "benutzerVerwalten";
|
||||
|
||||
export const RECHTE: Readonly<Record<Recht, readonly Rolle[]>> = {
|
||||
lesen: ["ADMIN", "OPERATOR", "LESER"],
|
||||
@@ -40,6 +41,9 @@ export const RECHTE: Readonly<Record<Recht, readonly Rolle[]>> = {
|
||||
massenwiderruf: ["ADMIN"],
|
||||
zielkontenVerwalten: ["ADMIN"],
|
||||
einstellungenVerwalten: ["ADMIN"],
|
||||
// Lokale Konten anlegen, Rollen vergeben, zuruecksetzen (#035) – wer das darf, kann sich jede
|
||||
// Rolle verschaffen; deshalb nur Admins und nur mit Re-Auth.
|
||||
benutzerVerwalten: ["ADMIN"],
|
||||
};
|
||||
|
||||
/** Rechte, die zusaetzlich eine frische Bestaetigung (Re-Auth) verlangen. */
|
||||
@@ -49,6 +53,7 @@ export const BESTAETIGUNG_NOETIG: ReadonlySet<Recht> = new Set<Recht>([
|
||||
"deploymentKeyVerwalten",
|
||||
"massenwiderruf",
|
||||
"zielkontenVerwalten",
|
||||
"benutzerVerwalten",
|
||||
]);
|
||||
|
||||
export function darf(rolle: Rolle, recht: Recht): boolean {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Benutzer } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { SITZUNG_COOKIE, sitzungLesen, type Sitzung } from "@/lib/sitzung";
|
||||
import { BESTAETIGUNG_NOETIG, darf, type Recht } from "@/server/auth/rechte";
|
||||
import { passwortKonto } from "@/server/services/anmeldung";
|
||||
|
||||
/**
|
||||
* Wer ist angemeldet und was darf er (#004, Muster aus Laufzettel).
|
||||
@@ -74,9 +75,13 @@ export const angemeldet = cache(async (): Promise<Angemeldet | undefined> => {
|
||||
const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } });
|
||||
const urteil = sitzungBewerten(sitzung, benutzer);
|
||||
if (!urteil.gueltig || !benutzer) return undefined;
|
||||
// Ein Notfall-Admin ohne eingerichtetes TOTP ist nur halb angemeldet und kommt nicht
|
||||
// ueber die Einrichtung hinaus – siehe /anmelden/totp.
|
||||
if (benutzer.istNotfall && !benutzer.totpEingerichtetAm) return undefined;
|
||||
// Ein Passwort-Konto ohne eigenes Passwort oder ohne TOTP ist nur halb angemeldet und kommt
|
||||
// nicht ueber die Einrichtung hinaus (/anmelden/passwort, /anmelden/totp). Abgeschaltete lokale
|
||||
// Konten verlieren ihre Sitzung sofort (#035).
|
||||
if (benutzer.passwortHash && !benutzer.oidcSub) {
|
||||
if (!passwortKonto(benutzer) || benutzer.passwortWechselNoetig || !benutzer.totpEingerichtetAm)
|
||||
return undefined;
|
||||
}
|
||||
return { benutzer, sitzung };
|
||||
});
|
||||
|
||||
|
||||
@@ -18,3 +18,17 @@ export async function totpEinrichtungLesen(): Promise<
|
||||
if (!werte?.["benutzerId"] || !werte["geheimnis"]) return undefined;
|
||||
return { benutzerId: werte["benutzerId"], geheimnis: werte["geheimnis"], ziel: werte["ziel"] ?? "/" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ausstehender Passwortwechsel eines lokalen Kontos (#035): ausgestellt nur nach einem
|
||||
* richtigen Einmal-Passwort, gilt so lange wie die TOTP-Einrichtung.
|
||||
*/
|
||||
export const PASSWORT_SETZEN_COOKIE = "sshkv_passwort_setzen";
|
||||
export const PASSWORT_SETZEN_ZWECK = "passwort-setzen";
|
||||
|
||||
export async function passwortSetzenLesen(): Promise<{ benutzerId: string; ziel: string } | undefined> {
|
||||
const token = (await cookies()).get(PASSWORT_SETZEN_COOKIE)?.value;
|
||||
const werte = await kurzzeitTokenLesen(PASSWORT_SETZEN_ZWECK, token);
|
||||
if (!werte?.["benutzerId"]) return undefined;
|
||||
return { benutzerId: werte["benutzerId"], ziel: werte["ziel"] ?? "/" };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/** Lesemodell der Benutzerverwaltung (#035). Nie Hashes oder TOTP-Geheimnisse – nur Zustaende. */
|
||||
export type KontoHerkunft = "notfall" | "lokal" | "oidc";
|
||||
|
||||
export async function benutzerListe() {
|
||||
const konten = await prisma.benutzer.findMany({
|
||||
orderBy: [{ aktiv: "desc" }, { name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
anmeldename: true,
|
||||
email: true,
|
||||
rolle: true,
|
||||
aktiv: true,
|
||||
istNotfall: true,
|
||||
oidcSub: true,
|
||||
passwortWechselNoetig: true,
|
||||
totpEingerichtetAm: true,
|
||||
letzteAnmeldungAm: true,
|
||||
angelegtAm: true,
|
||||
},
|
||||
});
|
||||
return konten.map(({ oidcSub, istNotfall, totpEingerichtetAm, passwortWechselNoetig, ...k }) => {
|
||||
const herkunft: KontoHerkunft = istNotfall ? "notfall" : oidcSub ? "oidc" : "lokal";
|
||||
return {
|
||||
...k,
|
||||
herkunft,
|
||||
// Nur fuer Passwort-Konten von Bedeutung: noch nicht fertig eingerichtet.
|
||||
einrichtungOffen: herkunft !== "oidc" && (passwortWechselNoetig || !totpEingerichtetAm),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export type BenutzerZeile = Awaited<ReturnType<typeof benutzerListe>>[number];
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Benutzer } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
|
||||
import { passwortPruefen } from "@/lib/passwort-hash";
|
||||
import { lokaleKontenAktiv, passwortFehler } from "@/lib/lokale-konten";
|
||||
import { passwortHashen, passwortPruefen } from "@/lib/passwort-hash";
|
||||
import { totpPruefen } from "@/lib/totp";
|
||||
import type { OidcAnspruecke } from "@/server/auth/oidc";
|
||||
import { rolleAusGruppen } from "@/server/auth/oidc";
|
||||
@@ -9,7 +10,7 @@ import { protokollieren } from "@/server/services/protokoll";
|
||||
import { versuchZaehlen, zaehlerLoeschen } from "@/server/services/ratenbegrenzung";
|
||||
|
||||
/**
|
||||
* Anmeldelogik ohne Next (#004): OIDC-Konto uebernehmen, Notfall-Admin pruefen, TOTP
|
||||
* Anmeldelogik ohne Next (#004, #035): OIDC-Konto uebernehmen, Passwort-Konten pruefen, TOTP
|
||||
* einrichten, erneute Bestaetigung. Die Next-Schicht setzt danach nur noch das Cookie.
|
||||
*/
|
||||
|
||||
@@ -27,8 +28,8 @@ export class AnmeldeFehler extends Error {
|
||||
}
|
||||
|
||||
/** Fuenf Fehlversuche je Anmeldename in 15 Minuten. */
|
||||
const NOTFALL_VERSUCHE = 5;
|
||||
const NOTFALL_FENSTER_SEKUNDEN = 15 * 60;
|
||||
const KONTO_VERSUCHE = 5;
|
||||
const KONTO_FENSTER_SEKUNDEN = 15 * 60;
|
||||
|
||||
/** Allgemeine Meldung – verraet nicht, ob der Name, das Passwort oder der Code falsch war. */
|
||||
const FALSCHE_ANGABEN = "Anmeldung fehlgeschlagen. Bitte Angaben prüfen.";
|
||||
@@ -115,53 +116,67 @@ export async function oidcBestaetigungVermerken(
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Notfall-Admin
|
||||
// ---------------------------------------------------------------- Lokale Konten
|
||||
|
||||
export type NotfallErgebnis =
|
||||
/**
|
||||
* Passwort-Konten: der Notfall-Admin (#004) und von Admins angelegte lokale Konten (#035).
|
||||
* Beide melden sich mit Anmeldename, Passwort und Pflicht-TOTP an.
|
||||
*/
|
||||
export type KontoErgebnis =
|
||||
| { readonly art: "angemeldet"; readonly benutzer: Benutzer }
|
||||
| { readonly art: "passwortSetzen"; readonly benutzer: Benutzer }
|
||||
| { readonly art: "totpEinrichten"; readonly benutzer: Benutzer };
|
||||
|
||||
/** Darf sich dieses Konto mit Passwort anmelden? OIDC-Konten nie, lokale nur wenn eingeschaltet. */
|
||||
export function passwortKonto(benutzer: Pick<Benutzer, "passwortHash" | "oidcSub" | "istNotfall">): boolean {
|
||||
return Boolean(benutzer.passwortHash) && !benutzer.oidcSub && (benutzer.istNotfall || lokaleKontenAktiv());
|
||||
}
|
||||
|
||||
/** Protokoll-Praefix: der Notfall-Admin bleibt im Audit-Log als solcher erkennbar. */
|
||||
const praefix = (benutzer: Pick<Benutzer, "istNotfall"> | null) =>
|
||||
benutzer?.istNotfall ? "anmeldung.notfall" : "anmeldung.lokal";
|
||||
|
||||
/**
|
||||
* Prueft Anmeldename, Passwort und – sobald eingerichtet – den TOTP-Code.
|
||||
*
|
||||
* Ohne eingerichtetes TOTP fuehrt ein richtiges Passwort nur zur Einrichtung, nie direkt
|
||||
* in die Anwendung (Freigabe Frage 5: TOTP ist Pflicht).
|
||||
* Ein Einmal-Passwort fuehrt nur zum Setzen eines eigenen, ein Konto ohne TOTP nur zur
|
||||
* Einrichtung – nie direkt in die Anwendung (Freigabe Frage 5: TOTP ist Pflicht).
|
||||
*/
|
||||
export async function notfallAnmelden(
|
||||
export async function kontoAnmelden(
|
||||
anmeldename: string,
|
||||
passwort: string,
|
||||
code: string,
|
||||
jetzt = new Date(),
|
||||
): Promise<NotfallErgebnis> {
|
||||
const zaehler = `notfall:${anmeldename.toLowerCase()}`;
|
||||
const versuch = await versuchZaehlen(zaehler, NOTFALL_VERSUCHE, NOTFALL_FENSTER_SEKUNDEN);
|
||||
): Promise<KontoErgebnis> {
|
||||
const name = anmeldename.trim().toLowerCase();
|
||||
const zaehler = `konto:${name}`;
|
||||
const versuch = await versuchZaehlen(zaehler, KONTO_VERSUCHE, KONTO_FENSTER_SEKUNDEN);
|
||||
const benutzer = await prisma.benutzer.findUnique({ where: { anmeldename: name } });
|
||||
if (!versuch.erlaubt) {
|
||||
await protokollieren({
|
||||
akteur: "System",
|
||||
aktion: "anmeldung.notfall.gesperrt",
|
||||
aktion: `${praefix(benutzer)}.gesperrt`,
|
||||
ergebnis: "VERWEIGERT",
|
||||
details: { anmeldename },
|
||||
details: { anmeldename: name },
|
||||
});
|
||||
throw new AnmeldeFehler(
|
||||
`Zu viele Fehlversuche. Bitte in ${Math.ceil(versuch.wiederInSekunden / 60)} Minuten erneut versuchen.`,
|
||||
);
|
||||
}
|
||||
|
||||
const benutzer = await prisma.benutzer.findUnique({ where: { anmeldename } });
|
||||
const passwortStimmt =
|
||||
benutzer?.istNotfall && benutzer.passwortHash
|
||||
? await passwortPruefen(benutzer.passwortHash, passwort)
|
||||
: false;
|
||||
benutzer && passwortKonto(benutzer) ? await passwortPruefen(benutzer.passwortHash!, passwort) : false;
|
||||
if (!benutzer || !passwortStimmt || !benutzer.aktiv) {
|
||||
await protokollieren({
|
||||
akteur: "System",
|
||||
aktion: "anmeldung.notfall.fehlgeschlagen",
|
||||
aktion: `${praefix(benutzer)}.fehlgeschlagen`,
|
||||
ergebnis: "VERWEIGERT",
|
||||
details: { anmeldename },
|
||||
details: { anmeldename: name },
|
||||
});
|
||||
throw new AnmeldeFehler(FALSCHE_ANGABEN);
|
||||
}
|
||||
|
||||
if (benutzer.passwortWechselNoetig) return { art: "passwortSetzen", benutzer };
|
||||
if (!benutzer.totpGeheimnis || !benutzer.totpEingerichtetAm) {
|
||||
return { art: "totpEinrichten", benutzer };
|
||||
}
|
||||
@@ -175,7 +190,7 @@ export async function notfallAnmelden(
|
||||
if (schritt === undefined) {
|
||||
await protokollieren({
|
||||
akteur: { id: benutzer.id, name: benutzer.name },
|
||||
aktion: "anmeldung.notfall.totp_falsch",
|
||||
aktion: `${praefix(benutzer)}.totp_falsch`,
|
||||
ergebnis: "VERWEIGERT",
|
||||
});
|
||||
throw new AnmeldeFehler(FALSCHE_ANGABEN);
|
||||
@@ -188,16 +203,42 @@ export async function notfallAnmelden(
|
||||
});
|
||||
await protokollieren({
|
||||
akteur: { id: benutzer.id, name: benutzer.name },
|
||||
aktion: "anmeldung.notfall",
|
||||
aktion: praefix(benutzer),
|
||||
ergebnis: "ERFOLG",
|
||||
});
|
||||
return { art: "angemeldet", benutzer: aktualisiert };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ersetzt das Einmal-Passwort durch ein eigenes (#035). Nur moeglich, solange der Wechsel
|
||||
* aussteht – das Kurzzeit-Token dafuer stellt allein ein richtiges Einmal-Passwort aus.
|
||||
*/
|
||||
export async function eigenesPasswortSetzen(benutzerId: string, neu: string): Promise<Benutzer> {
|
||||
const benutzer = await prisma.benutzer.findUnique({ where: { id: benutzerId } });
|
||||
if (!benutzer || !passwortKonto(benutzer) || !benutzer.passwortWechselNoetig || !benutzer.aktiv) {
|
||||
throw new AnmeldeFehler("Für dieses Konto steht kein Passwortwechsel aus.");
|
||||
}
|
||||
const fehler = passwortFehler(neu, benutzer.anmeldename);
|
||||
if (fehler) throw new AnmeldeFehler(fehler);
|
||||
if (await passwortPruefen(benutzer.passwortHash!, neu)) {
|
||||
throw new AnmeldeFehler("Bitte ein anderes Passwort als das Einmal-Passwort wählen.");
|
||||
}
|
||||
const aktualisiert = await prisma.benutzer.update({
|
||||
where: { id: benutzerId },
|
||||
data: { passwortHash: await passwortHashen(neu), passwortWechselNoetig: false },
|
||||
});
|
||||
await protokollieren({
|
||||
akteur: { id: benutzer.id, name: benutzer.name },
|
||||
aktion: "konto.passwort_gesetzt",
|
||||
ergebnis: "ERFOLG",
|
||||
});
|
||||
return aktualisiert;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schliesst die TOTP-Einrichtung ab. Das Geheimnis kommt aus dem kurzlebigen
|
||||
* Einrichtungs-Token und wird erst gespeichert, wenn ein Code dazu passt – sonst saesse
|
||||
* der Notfall-Admin mit einem Geheimnis fest, das in keiner App steht.
|
||||
* das Konto mit einem Geheimnis fest, das in keiner App steht.
|
||||
*/
|
||||
export async function totpEinrichtungAbschliessen(
|
||||
benutzerId: string,
|
||||
@@ -206,7 +247,12 @@ export async function totpEinrichtungAbschliessen(
|
||||
jetzt = new Date(),
|
||||
): Promise<Benutzer> {
|
||||
const benutzer = await prisma.benutzer.findUnique({ where: { id: benutzerId } });
|
||||
if (!benutzer?.istNotfall || benutzer.totpEingerichtetAm) {
|
||||
if (
|
||||
!benutzer ||
|
||||
!passwortKonto(benutzer) ||
|
||||
benutzer.totpEingerichtetAm ||
|
||||
benutzer.passwortWechselNoetig
|
||||
) {
|
||||
throw new AnmeldeFehler("TOTP ist für dieses Konto bereits eingerichtet.");
|
||||
}
|
||||
const schritt = totpPruefen(geheimnisBase32, code, jetzt.getTime());
|
||||
@@ -227,18 +273,18 @@ export async function totpEinrichtungAbschliessen(
|
||||
});
|
||||
await protokollieren({
|
||||
akteur: { id: benutzer.id, name: benutzer.name },
|
||||
aktion: "notfall.totp_eingerichtet",
|
||||
aktion: benutzer.istNotfall ? "notfall.totp_eingerichtet" : "konto.totp_eingerichtet",
|
||||
ergebnis: "ERFOLG",
|
||||
});
|
||||
return aktualisiert;
|
||||
}
|
||||
|
||||
/** Erneute Bestaetigung des Notfall-Admins: Passwort und TOTP noch einmal. */
|
||||
export async function notfallBestaetigen(benutzer: Benutzer, passwort: string, code: string): Promise<void> {
|
||||
if (!benutzer.istNotfall || !benutzer.anmeldename) {
|
||||
/** Erneute Bestaetigung eines Passwort-Kontos: Passwort und TOTP noch einmal. */
|
||||
export async function kontoBestaetigen(benutzer: Benutzer, passwort: string, code: string): Promise<void> {
|
||||
if (!passwortKonto(benutzer) || !benutzer.anmeldename) {
|
||||
throw new AnmeldeFehler("Dieses Konto bestätigt sich über den Identitätsanbieter.");
|
||||
}
|
||||
const ergebnis = await notfallAnmelden(benutzer.anmeldename, passwort, code);
|
||||
const ergebnis = await kontoAnmelden(benutzer.anmeldename, passwort, code);
|
||||
if (ergebnis.art !== "angemeldet" || ergebnis.benutzer.id !== benutzer.id) {
|
||||
throw new AnmeldeFehler(FALSCHE_ANGABEN);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { Rolle } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { einmalPasswortErzeugen, lokaleKontenAktiv } from "@/lib/lokale-konten";
|
||||
import { passwortHashen } from "@/lib/passwort-hash";
|
||||
import { FachFehler } from "@/server/services/fachfehler";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
|
||||
/**
|
||||
* Benutzerverwaltung (#035). Lokale Konten legt ein Admin an; sshkv erzeugt ein Einmal-Passwort,
|
||||
* das genau einmal angezeigt wird – Admins waehlen also nie Passwoerter fuer andere. Bei der
|
||||
* ersten Anmeldung setzt der Benutzer ein eigenes Passwort und richtet TOTP ein.
|
||||
*
|
||||
* OIDC-Konten bekommen ihre Rolle aus den Gruppen des Anbieters; hier lassen sie sich nur
|
||||
* sperren und entsperren. Der Notfall-Admin ist unantastbar – er ist der Weg zurueck, wenn
|
||||
* alles andere versagt.
|
||||
*/
|
||||
type Akteur = { readonly id: string; readonly name: string };
|
||||
|
||||
const KONTO = {
|
||||
id: true,
|
||||
name: true,
|
||||
anmeldename: true,
|
||||
rolle: true,
|
||||
aktiv: true,
|
||||
istNotfall: true,
|
||||
oidcSub: true,
|
||||
passwortHash: true,
|
||||
} as const;
|
||||
|
||||
async function kontoLaden(id: string) {
|
||||
const konto = await prisma.benutzer.findUnique({ where: { id }, select: KONTO });
|
||||
if (!konto) throw new FachFehler("Dieses Konto gibt es nicht.");
|
||||
return konto;
|
||||
}
|
||||
|
||||
function lokal(konto: { istNotfall: boolean; oidcSub: string | null; passwortHash: string | null }) {
|
||||
return !konto.istNotfall && !konto.oidcSub && Boolean(konto.passwortHash);
|
||||
}
|
||||
|
||||
function nichtSelbst(konto: { id: string }, akteur: Akteur, was: string) {
|
||||
if (konto.id === akteur.id) throw new FachFehler(`Das eigene Konto lässt sich nicht ${was}.`);
|
||||
}
|
||||
|
||||
function kontenErlaubt() {
|
||||
if (!lokaleKontenAktiv()) {
|
||||
throw new FachFehler("Lokale Konten sind abgeschaltet (SSHKV_LOKALE_KONTEN=0).");
|
||||
}
|
||||
}
|
||||
|
||||
export async function lokalesKontoAnlegen(
|
||||
eingabe: { anmeldename: string; name: string; rolle: Rolle },
|
||||
akteur: Akteur,
|
||||
): Promise<{ id: string; einmalPasswort: string }> {
|
||||
kontenErlaubt();
|
||||
if (
|
||||
await prisma.benutzer.findUnique({ where: { anmeldename: eingabe.anmeldename }, select: { id: true } })
|
||||
) {
|
||||
throw new FachFehler("Diesen Anmeldenamen gibt es schon.", { anmeldename: "Bereits vergeben." });
|
||||
}
|
||||
const einmalPasswort = einmalPasswortErzeugen();
|
||||
const konto = await prisma.benutzer.create({
|
||||
data: {
|
||||
anmeldename: eingabe.anmeldename,
|
||||
name: eingabe.name,
|
||||
rolle: eingabe.rolle,
|
||||
passwortHash: await passwortHashen(einmalPasswort),
|
||||
passwortWechselNoetig: true,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
await protokollieren({
|
||||
akteur,
|
||||
aktion: "benutzer.angelegt",
|
||||
ergebnis: "ERFOLG",
|
||||
details: { anmeldename: eingabe.anmeldename, name: eingabe.name, rolle: eingabe.rolle },
|
||||
});
|
||||
return { id: konto.id, einmalPasswort };
|
||||
}
|
||||
|
||||
/** Rolle nur fuer lokale Konten – bei OIDC entscheidet die Gruppe im Anbieter. */
|
||||
export async function rolleAendern(id: string, rolle: Rolle, akteur: Akteur): Promise<void> {
|
||||
const konto = await kontoLaden(id);
|
||||
if (!lokal(konto)) {
|
||||
throw new FachFehler(
|
||||
konto.istNotfall
|
||||
? "Der Notfall-Admin bleibt Admin."
|
||||
: "Die Rolle dieses Kontos kommt aus den Gruppen des Identitätsanbieters.",
|
||||
);
|
||||
}
|
||||
nichtSelbst(konto, akteur, "herabstufen");
|
||||
if (konto.rolle === rolle) return;
|
||||
await prisma.benutzer.update({
|
||||
where: { id },
|
||||
// Neue Rolle gilt sofort; alte Sitzungen sollen nicht mit gespeicherter Bestaetigung weiterlaufen.
|
||||
data: { rolle, bestaetigtAm: null },
|
||||
});
|
||||
await protokollieren({
|
||||
akteur,
|
||||
aktion: "benutzer.rolle_geaendert",
|
||||
ergebnis: "ERFOLG",
|
||||
details: { anmeldename: konto.anmeldename, name: konto.name, vorher: konto.rolle, nachher: rolle },
|
||||
});
|
||||
}
|
||||
|
||||
/** Sperren beendet alle Sitzungen sofort; gilt auch fuer OIDC-Konten. */
|
||||
export async function kontoAktivSetzen(id: string, aktiv: boolean, akteur: Akteur): Promise<void> {
|
||||
const konto = await kontoLaden(id);
|
||||
if (konto.istNotfall) throw new FachFehler("Der Notfall-Admin lässt sich nicht sperren.");
|
||||
nichtSelbst(konto, akteur, "sperren");
|
||||
if (konto.aktiv === aktiv) return;
|
||||
await prisma.benutzer.update({
|
||||
where: { id },
|
||||
data: aktiv ? { aktiv } : { aktiv, sitzungenGueltigAb: new Date(), bestaetigtAm: null },
|
||||
});
|
||||
await protokollieren({
|
||||
akteur,
|
||||
aktion: aktiv ? "benutzer.entsperrt" : "benutzer.gesperrt",
|
||||
ergebnis: "ERFOLG",
|
||||
details: { anmeldename: konto.anmeldename, name: konto.name },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort und TOTP eines lokalen Kontos zuruecksetzen – etwa nach verlorenem Telefon. Es gibt
|
||||
* ein neues Einmal-Passwort; alle Sitzungen enden.
|
||||
*/
|
||||
export async function kontoZuruecksetzen(id: string, akteur: Akteur): Promise<{ einmalPasswort: string }> {
|
||||
kontenErlaubt();
|
||||
const konto = await kontoLaden(id);
|
||||
if (!lokal(konto)) throw new FachFehler("Nur lokale Konten lassen sich zurücksetzen.");
|
||||
nichtSelbst(konto, akteur, "zurücksetzen");
|
||||
const einmalPasswort = einmalPasswortErzeugen();
|
||||
await prisma.benutzer.update({
|
||||
where: { id },
|
||||
data: {
|
||||
passwortHash: await passwortHashen(einmalPasswort),
|
||||
passwortWechselNoetig: true,
|
||||
totpGeheimnis: null,
|
||||
totpEingerichtetAm: null,
|
||||
totpLetzterSchritt: null,
|
||||
sitzungenGueltigAb: new Date(),
|
||||
bestaetigtAm: null,
|
||||
},
|
||||
});
|
||||
await protokollieren({
|
||||
akteur,
|
||||
aktion: "benutzer.zurueckgesetzt",
|
||||
ergebnis: "ERFOLG",
|
||||
details: { anmeldename: konto.anmeldename, name: konto.name },
|
||||
});
|
||||
return { einmalPasswort };
|
||||
}
|
||||
Reference in New Issue
Block a user