From c6cff9656e66cb9c8014e97f3298ba6a7fc444d6 Mon Sep 17 00:00:00 2001 From: Andrei Laas Date: Tue, 8 Sep 2026 16:51:14 +0200 Subject: [PATCH] Passwort-Anmeldung: Provider, Formulare, drei Zustaende der Shell --- actions/auth.ts | 78 +++++++++++++-- actions/passwort.ts | 61 ++++++++++++ app/(app)/layout.tsx | 23 +++-- app/(auth)/login/page.tsx | 24 +++++ app/passwort-aendern/page.tsx | 57 +++++++++++ auth.ts | 75 ++++++++++++++- components/auth/PasswortAendernFormular.tsx | 100 ++++++++++++++++++++ components/auth/PasswortFormular.tsx | 54 +++++++++++ lib/auth/require-hr.ts | 32 ++++--- lib/passwort.ts | 46 +++++++++ lib/shell-data.ts | 49 ++++++++-- lib/types.ts | 4 + tests/unit/security.test.ts | 6 +- 13 files changed, 569 insertions(+), 40 deletions(-) create mode 100644 actions/passwort.ts create mode 100644 app/passwort-aendern/page.tsx create mode 100644 components/auth/PasswortAendernFormular.tsx create mode 100644 components/auth/PasswortFormular.tsx create mode 100644 lib/passwort.ts diff --git a/actions/auth.ts b/actions/auth.ts index 8dbda3d..d43a13a 100644 --- a/actions/auth.ts +++ b/actions/auth.ts @@ -1,15 +1,38 @@ "use server"; +import { AuthError } from "next-auth"; import { signIn, signOut } from "@/auth"; -// Anmeldung ausschliesslich über Entra ID. Es gibt bewusst keinen -// Passwort-Pfad: ein zweiter Anmeldeweg neben dem Firmenkonto hebelt jede -// Vorgabe des Mandanten aus — Mehrfaktor, bedingten Zugriff, Sperrung beim -// Austritt. +// Zwei Anmeldewege, und der zweite ist auf Zeit. // -// Die Herkunft muss hier nicht mehr aus dem Request geholt werden: Auth.js -// baut die Rückruf-Adresse selbst und akzeptiert nur Ziele auf demselben -// Host. Ein untergeschobener Host läuft also weiterhin ins Leere. +// **Entra ID ist der Hauptweg.** Was der Mandant vorgibt — Mehrfaktor, +// bedingter Zugriff, Sperrung beim Austritt — gilt nur auf diesem Weg. Ein +// zweiter Weg daneben hebelt all das aus, und deshalb stand hier bis zum +// 08.09.2026, dass es ihn bewusst nicht gibt. +// +// **Warum es ihn jetzt trotzdem gibt.** Fünf Mitarbeiterinnen des Kunden +// (@manner.com) sollen die Anwendung testen. Manner hat einen eigenen +// Entra-Mandanten; eine Gasteinladung müsste deren IT einrichten und dauert +// Wochen. Die Daten im System sind zu diesem Zeitpunkt synthetisch. +// +// **Was den Weg begrenzt** — nichts davon steht hier, alles in der Datenbank +// (Migration 20260908120000), weil eine Regel im Anwendungscode die Regel +// wäre, die sich umgehen lässt: +// +// • Zwang zum Wechsel beim ersten Mal; solange er aussteht, liefert +// is_hr_user() false und damit gibt keine einzige Policy eine Zeile her +// • sieben Tage Frist für einen nie benutzten Zugang +// • Sperre für 15 Minuten nach fünf Fehlversuchen +// • Enddatum 08.10.2026, als Prüfbedingung festgenagelt +// (chk_passwort_pfad_endet) — danach nimmt die Anmeldung kein Passwort +// mehr an, ohne dass sich jemand erinnern muss +// +// **Vor den echten Manner-Daten muss der Passwortpfad weg sein.** Das ist die +// Bedingung, unter der er entstanden ist, nicht eine Empfehlung. +// +// Die Herkunft muss hier nicht aus dem Request geholt werden: Auth.js baut die +// Rückruf-Adresse selbst und akzeptiert nur Ziele auf demselben Host. Ein +// untergeschobener Host läuft also weiterhin ins Leere. export async function signInWithEntra() { // Kehrt nicht zurück: signIn löst eine Weiterleitung aus, und die wirft in @@ -17,6 +40,47 @@ export async function signInWithEntra() { await signIn("microsoft-entra-id", { redirectTo: "/" }); } +export type AnmeldeZustand = { fehler: string | null }; + +/** + * Anmeldung mit E-Mail und Passwort. + * + * Es gibt **eine** Fehlermeldung für jeden Fehlschlag. Ob die Adresse + * unbekannt, das Passwort falsch, das Konto gesperrt oder die Frist abgelaufen + * ist, steht im Protokoll und nicht auf dem Bildschirm: das Anmeldeformular + * steht offen im Internet, und eine Meldung, die zwischen „gibt es nicht" und + * „falsches Passwort" unterscheidet, ist ein Verzeichnis der Belegschaft. + * app_passwort_pruefen() nennt aus demselben Grund keinen Grund. + */ +export async function anmeldenMitPasswort( + _zustand: AnmeldeZustand, + formular: FormData +): Promise { + const email = String(formular.get("email") ?? "").trim(); + const passwort = String(formular.get("passwort") ?? ""); + + if (!email || !passwort) { + return { fehler: "Bitte E-Mail-Adresse und Passwort eingeben." }; + } + + try { + await signIn("passwort", { email, passwort, redirectTo: "/" }); + } catch (fehler) { + // Bei Erfolg wirft signIn die Weiterleitung von Next.js. Die darf hier + // **nicht** hängenbleiben, sonst endet die Anmeldung auf der Anmeldeseite, + // obwohl das Cookie längst gesetzt ist. Nur ein AuthError ist ein + // Fehlschlag; alles andere gehört weitergereicht. + if (fehler instanceof AuthError) { + return { fehler: "E-Mail-Adresse oder Passwort stimmt nicht." }; + } + throw fehler; + } + + // Unerreichbar — signIn kehrt bei Erfolg nicht zurück. Steht da, weil der + // Typprüfer einen Rückgabewert auf jedem Pfad verlangt. + return { fehler: null }; +} + export async function logout() { await signOut({ redirectTo: "/login" }); } diff --git a/actions/passwort.ts b/actions/passwort.ts new file mode 100644 index 0000000..17aec9d --- /dev/null +++ b/actions/passwort.ts @@ -0,0 +1,61 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { currentUserId } from "@/lib/auth/session"; +import { runMutation } from "@/lib/db/rpc"; + +// Das eigene Passwort wechseln. +// +// Getrennt von actions/auth.ts, weil es etwas anderes ist: dort geht es um das +// Herstellen einer Sitzung, hier um eine Änderung an den eigenen Daten. Und +// getrennt von actions/benutzer.ts (Benutzerverwaltung), weil diese Aktion die +// einzige ist, die **ohne** HR-Rechte funktionieren muss. + +export type WechselZustand = { fehler: string | null }; + +/** + * Wechselt das Passwort der angemeldeten Person. + * + * Kein `revalidatePath`: der Weg danach ist eine Weiterleitung durch die + * Komponente, und es gibt keine zwischengespeicherte Seite, die den alten Stand + * zeigen könnte. + * + * Die Meldungen kommen aus app_passwort_aendern() und sind für die Oberfläche + * geschrieben („Das bisherige Passwort stimmt nicht.") — sie werden + * durchgereicht wie bei jeder anderen Mutation. + */ +export async function passwortAendern( + _zustand: WechselZustand, + formular: FormData +): Promise { + const altes = String(formular.get("altes_passwort") ?? ""); + const neues = String(formular.get("neues_passwort") ?? ""); + const wiederholung = String(formular.get("wiederholung") ?? ""); + + if (!altes || !neues) { + return { fehler: "Bitte das bisherige und das neue Passwort eingeben." }; + } + + // Diese eine Prüfung gehört hierher und nicht in die Datenbank: die + // Wiederholung ist ein Bedienelement gegen Vertippen, kein Datum. Die + // Datenbank sieht sie nie und soll sie auch nicht sehen. + if (neues !== wiederholung) { + return { fehler: "Die beiden neuen Passwörter stimmen nicht überein." }; + } + + const ergebnis = await runMutation(await currentUserId(), "app_passwort_aendern", { + altes_passwort: altes, + neues_passwort: neues, + }); + + if (!ergebnis.success) { + return { fehler: ergebnis.error ?? "Unbekannter Fehler." }; + } + + // Ab hier ist der Wechsel erledigt, is_hr_user() liefert wieder true und die + // Anwendung ist offen. Die Weiterleitung steht in der Aktion und nicht in der + // Komponente: `redirect()` wirft, die Aktion kehrt also nicht zurück, und es + // gibt keinen Zwischenzustand, in dem das Formular noch einmal abgeschickt + // werden könnte. + redirect("/"); +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 3211ae0..7c41637 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -13,15 +13,24 @@ export default async function AppLayout({ children }: { children: ReactNode }) { // Alles in *einer* Transaktion, weil nur dort der Sitzungskontext gilt — // und damit nebenbei auf einem einheitlichen Lesestand. Was dabei in wie // vielen Rundreisen gelesen wird, steht in lib/shell-data.ts. - const data = await withUser(userId, (tx) => loadShellData(tx, userId)); + const ergebnis = await withUser(userId, (tx) => loadShellData(tx, userId)); - // `data` ist null, wenn die Person angemeldet, aber nicht freigeschaltet - // ist. Hier — und nicht im Proxy — fällt diese Entscheidung: der Proxy hat - // keine Datenbankverbindung. Sie wird bei jedem Aufbau frisch gestellt, eine + // Hier — und nicht im Proxy — fällt diese Entscheidung: der Proxy hat keine + // Datenbankverbindung. Sie wird bei jedem Aufbau frisch gestellt, eine // entzogene Freischaltung wirkt also sofort statt erst mit dem nächsten - // Sitzungstoken. Ohne den Grund in der Adresse stünde die Person vor einer - // wortlosen Anmeldeseite und versuchte es endlos erneut. - if (!data) redirect("/login?error=no_hr_access"); + // Sitzungstoken. + // + // Beide Umleitungen sind Bedienkomfort, keine Absicherung: wer sie umgeht, + // bekommt trotzdem keine Zeile, weil die RLS-Policies dieselbe Frage stellen + // (is_hr_user()). Ohne sie stünde die Person nur vor einer leeren Anwendung + // und wüsste nicht, warum. + if (ergebnis.status === "passwort_wechseln") redirect("/passwort-aendern"); + + // Ohne den Grund in der Adresse stünde die Person vor einer wortlosen + // Anmeldeseite und versuchte es endlos erneut. + if (ergebnis.status === "kein_zugang") redirect("/login?error=no_hr_access"); + + const data = ergebnis.daten; const userLabel = data.profile.full_name || data.profile.email || ""; diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 1571a6f..e58e3bc 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,4 +1,5 @@ import { EntraSignInButton } from "@/components/auth/EntraSignInButton"; +import { PasswortFormular } from "@/components/auth/PasswortFormular"; import { logout, signInWithEntra } from "@/actions/auth"; // The query string is attacker-controlled, so the login page renders a message @@ -15,6 +16,15 @@ const ERROR_MESSAGES = { title: "Anmeldung fehlgeschlagen", body: "Die Anmeldung über das Firmenkonto konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.", }, + // Auth.js' eigener Code für eine gescheiterte Passwortanmeldung. Im Regelfall + // kommt er hier nicht an — die Server Action fängt den Fehlschlag ab und + // zeigt ihn am Formular. Ohne diesen Eintrag fiele er aber auf `sso_failed` + // zurück, und dort stünde „über das Firmenkonto", was mit dem tatsächlichen + // Weg nichts zu tun hätte. + CredentialsSignin: { + title: "Anmeldung fehlgeschlagen", + body: "E-Mail-Adresse oder Passwort stimmt nicht.", + }, } as const; type ErrorCode = keyof typeof ERROR_MESSAGES; @@ -77,6 +87,20 @@ export default async function LoginPage({ searchParams }: LoginPageProps) { + {/* Der Passwortweg steht bewusst *unter* dem Firmenkonto und ohne + eigene Überschrift: er ist die Ausnahme für die Testphase, nicht + die zweite gleichwertige Möglichkeit. Wer ein Firmenkonto hat, + soll oben klicken. */} +
+ + oder + +
+ +
+ +
+

Die Anmeldung allein erteilt keinen Zugriff. HR-Rechte vergibt die Personalabteilung — bis dahin bleiben alle Personaldaten verschlossen. diff --git a/app/passwort-aendern/page.tsx b/app/passwort-aendern/page.tsx new file mode 100644 index 0000000..167517a --- /dev/null +++ b/app/passwort-aendern/page.tsx @@ -0,0 +1,57 @@ +import { redirect } from "next/navigation"; +import { PasswortAendernFormular } from "@/components/auth/PasswortAendernFormular"; +import { CARD_CLASS } from "@/components/ui/Card"; +import { currentUserId } from "@/lib/auth/session"; +import { sql, withUser } from "@/lib/db"; + +// Diese Seite liegt **ausserhalb** der Gruppe (app), und das ist der Punkt. +// +// app/(app)/layout.tsx lässt nur durch, wer aktive HR-Person ist — und genau +// das ist man nicht, solange ein Passwortwechsel aussteht: is_hr_user() liefert +// dann false (Migration 20260908120000). Läge diese Seite unter (app), wäre sie +// für die einzigen Menschen gesperrt, die sie brauchen. +// +// Angemeldet sein muss man trotzdem: darum kümmert sich proxy.ts, der jede +// Adresse ausser /login und /api/auth hinter eine Sitzung stellt. Die Prüfung +// unten ist die zweite Linie für den Fall, dass jemand den Matcher ändert. + +export const metadata = { title: "Passwort ändern" }; + +export default async function PasswortAendernPage() { + const userId = await currentUserId(); + if (!userId) redirect("/login"); + + // Erzwungen oder freiwillig — davon hängt nur die Beschriftung ab und ob es + // einen Weg zurück gibt. + // + // Ob dieses Konto überhaupt ein Passwort hat, wird hier *nicht* gefragt: + // app_passwoerter ist für die Anwendungsrolle unerreichbar (Policy + // `using (false)`), und eine Auskunftsfunktion nur für diese Anzeige wäre + // eine Funktion zu viel. Wer kein Passwort hat, bekommt beim Absenden die + // Meldung aus app_passwort_aendern(). + const erzwungen = await withUser(userId, async (tx) => { + const ergebnis = await sql<{ wechseln: boolean }>`select app_muss_passwort_wechseln() as wechseln`.execute(tx); + return ergebnis.rows[0]?.wechseln === true; + }); + + return ( +

+
+

Passwort ändern

+ + {erzwungen ? ( +

+ Ihr Zugang wurde mit einem gemeinsamen Startpasswort eingerichtet. Solange es gilt, bleiben alle + Personaldaten verschlossen — bitte vergeben Sie jetzt ein eigenes. +

+ ) : ( +

Sie können hier jederzeit ein neues Passwort vergeben.

+ )} + +
+ +
+
+
+ ); +} diff --git a/auth.ts b/auth.ts index 2d62ff6..d49a89f 100644 --- a/auth.ts +++ b/auth.ts @@ -1,5 +1,6 @@ import "server-only"; import NextAuth from "next-auth"; +import Credentials from "next-auth/providers/credentials"; import { authConfig } from "@/lib/auth/config"; import { asSystem, sql } from "@/lib/db"; @@ -9,6 +10,14 @@ import { asSystem, sql } from "@/lib/db"; // Hier kommt das dazu, was einmal pro Anmeldung passieren muss: aus der // Kennung, die Entra ausstellt, eine Kennung machen, die diese Anwendung // versteht. +// +// Seit 08.09.2026 steht daneben ein zweiter Weg: Anmeldung mit Passwort, für +// die Testphase und mit eingebautem Ende (siehe Migration 20260908120000). +// Auch er gehört **hierher** und nicht nach lib/auth/config.ts, denn auch er +// spricht mit der Datenbank. Der Proxy sieht ihn nicht und braucht ihn nicht: +// er liest nur das Sitzungscookie, und das ist ein signiertes Token, dessen +// Entschlüsselung von der Anbieterliste unabhängig ist. Welcher Weg zur +// Sitzung geführt hat, steht darin nicht — und muss es auch nicht. /** * Legt die app_users-Zeile an oder frischt sie auf und liefert die Kennung, @@ -31,21 +40,81 @@ async function upsertAppUser(externalId: string, email: string, fullName: string return row.id; } +/** + * Anmeldung mit Passwort. + * + * Die Prüfung selbst steht vollständig in app_passwort_pruefen(): Sperre nach + * fünf Fehlversuchen, Frist für das Initialpasswort, Enddatum des + * Passwortpfads, Protokolleintrag. Hier bleibt nur das Weiterreichen — und + * das ist Absicht. Eine zweite Fassung der Regeln im Anwendungscode wäre die + * Fassung, die zuerst veraltet, und sie liesse sich umgehen, indem jemand die + * Datenbankfunktion direkt aufruft. + * + * Die Funktion liefert eine Kennung oder null und **nie einen Grund**. Was + * hier ankommt, reicht deshalb nicht aus, um zu unterscheiden, ob die Adresse + * unbekannt, das Passwort falsch oder das Konto gesperrt war. Genau so soll + * die Anmeldemaske antworten. + */ +const passwortAnbieter = Credentials({ + id: "passwort", + name: "Passwort", + credentials: { + email: { label: "E-Mail", type: "email" }, + passwort: { label: "Passwort", type: "password" }, + }, + async authorize(eingabe) { + const email = typeof eingabe?.email === "string" ? eingabe.email : ""; + const passwort = typeof eingabe?.passwort === "string" ? eingabe.passwort : ""; + if (!email || !passwort) return null; + + // asSystem, weil es noch keine angemeldete Person gibt — die entsteht ja + // erst aus dem Ergebnis. Dieselbe Rolle ohne BYPASSRLS wie überall; die + // Funktion ist SECURITY DEFINER und darf genau das eine. + const zeile = await asSystem(async (tx) => { + const ergebnis = await sql<{ id: string | null }>` + select app_passwort_pruefen(${email}, ${passwort}) as id + `.execute(tx); + return ergebnis.rows[0]; + }); + + if (!zeile?.id) return null; + return { id: zeile.id, email, name: null }; + }, +}); + export const { handlers, auth, signIn, signOut } = NextAuth(() => { const base = authConfig(); return { ...base, + // Entra bleibt der erste Eintrag und damit der Hauptweg. Die Basisliste + // wird ergänzt, nicht ersetzt: `providers: [passwortAnbieter]` hätte die + // Firmenanmeldung stillschweigend entfernt. + providers: [...base.providers, passwortAnbieter], callbacks: { // Die Rückrufe aus der Basis **behalten**, nicht ersetzen: dort liegt // session(), das die Kennung aus dem Token auf die Sitzung legt. Ein // schlichtes `callbacks: { jwt }` hätte es stillschweigend entfernt. ...base.callbacks, - async jwt({ token, profile }) { + async jwt({ token, profile, user }) { + // Drei Fälle, und die Reihenfolge trägt sie: + // + // 1. Entra, erster Durchlauf → `profile` liegt vor (und `user` auch) + // 2. Passwort, erster Durchlauf → nur `user`, aus authorize() + // 3. jeder weitere Aufruf → keins von beiden, Token durchreichen + // + // Deshalb wird `profile` zuerst geprüft: bei der Anmeldung über Entra + // ist `user` ebenfalls gesetzt, und eine Prüfung auf `user` zuerst + // führte den Entra-Weg an app_upsert_user() vorbei — die Kennung wäre + // dann die `oid` statt app_users.id, und keine einzige Policy fände + // dazu eine Zeile. + if (!profile) { + if (user?.id) token.uid = user.id; + return token; + } + // `profile` liegt nur beim ersten Durchlauf nach der Rückkehr von Entra // vor. Danach wird das Token nur noch weitergereicht — die Datenbank // wird also einmal pro Anmeldung befragt, nicht einmal pro Aufruf. - if (!profile) return token; - const externalId = typeof profile.oid === "string" ? profile.oid : null; const email = [profile.email, profile.preferred_username, profile.upn].find( (v): v is string => typeof v === "string" && v.length > 0 diff --git a/components/auth/PasswortAendernFormular.tsx b/components/auth/PasswortAendernFormular.tsx new file mode 100644 index 0000000..ef176c4 --- /dev/null +++ b/components/auth/PasswortAendernFormular.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useActionState, useState } from "react"; +import { passwortAendern, type WechselZustand } from "@/actions/passwort"; +import { Button } from "@/components/ui/Button"; +import { CONTROL_CLASS, Field } from "@/components/ui/Field"; +import { PASSWORT_REGELN, passwortBeanstandung } from "@/lib/passwort"; + +// Das eigene Passwort wechseln. +// +// Die Regeln stehen schon beim Tippen da, statt erst nach dem Absenden — beim +// erzwungenen Wechsel ist das der erste Bildschirm, den jemand in dieser +// Anwendung sieht, und dreimal abgewiesen zu werden ist ein schlechter Anfang. +// Abgewiesen oder angenommen wird trotzdem in der Datenbank; hier steht nur +// eine Vorabanzeige (lib/passwort.ts). + +const START: WechselZustand = { fehler: null }; + +export function PasswortAendernFormular({ erzwungen }: { erzwungen: boolean }) { + const [zustand, aktion, laeuft] = useActionState(passwortAendern, START); + const [neues, setNeues] = useState(""); + const [wiederholung, setWiederholung] = useState(""); + + // Erst beanstanden, wenn etwas dasteht: ein leeres Feld ist noch kein Fehler. + const beanstandung = neues ? passwortBeanstandung(neues) : null; + const passtNichtZusammen = wiederholung.length > 0 && neues !== wiederholung; + + return ( +
+ {zustand.fehler && ( +

+ {zustand.fehler} +

+ )} + + + {(p) => ( + + )} + + + + {(p) => ( + setNeues(e.target.value)} + className={CONTROL_CLASS} + /> + )} + + + + {(p) => ( + setWiederholung(e.target.value)} + className={CONTROL_CLASS} + /> + )} + + + {/* Gesperrt, solange die Vorabanzeige etwas zu beanstanden hat. Das ist + Bequemlichkeit, keine Absicherung — wer die Schaltfläche freischaltet, + landet in app_passwort_regeln() und wird dort abgewiesen. */} + + + {!erzwungen && ( + + Zurück zur Anwendung + + )} +
+ ); +} diff --git a/components/auth/PasswortFormular.tsx b/components/auth/PasswortFormular.tsx new file mode 100644 index 0000000..34c2ece --- /dev/null +++ b/components/auth/PasswortFormular.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useActionState } from "react"; +import { anmeldenMitPasswort, type AnmeldeZustand } from "@/actions/auth"; +import { Button } from "@/components/ui/Button"; +import { CONTROL_CLASS, Field } from "@/components/ui/Field"; + +// Anmeldung mit Passwort — der zweite Weg neben dem Firmenkonto, auf Zeit +// (siehe actions/auth.ts). +// +// Die Meldung kommt aus der Server Action und ist für jeden Fehlschlag +// dieselbe. Kein `autoFocus`: die Seite bietet zwei Wege an, und der obere ist +// der gemeinte — den Cursor unten hineinzusetzen kehrte das um. + +const START: AnmeldeZustand = { fehler: null }; + +export function PasswortFormular() { + const [zustand, aktion, laeuft] = useActionState(anmeldenMitPasswort, START); + + return ( +
+ {zustand.fehler && ( +

+ {zustand.fehler} +

+ )} + + + {(p) => ( + + )} + + + + {(p) => ( + + )} + + + +
+ ); +} diff --git a/lib/auth/require-hr.ts b/lib/auth/require-hr.ts index 2497e11..d3a8ba7 100644 --- a/lib/auth/require-hr.ts +++ b/lib/auth/require-hr.ts @@ -1,13 +1,22 @@ import "server-only"; import { NextResponse } from "next/server"; -import { withUser } from "@/lib/db"; +import { sql, withUser } from "@/lib/db"; import { currentUserId } from "./session"; -// Route Handlers under /api/export/* are outside the App Router layout tree, -// so app/(app)/layout.tsx's HR gate never runs for them — each one has to -// re-establish that the caller is an active HR user itself. RLS is still the -// real boundary (an unauthorized session simply reads nothing); this exists -// so those routes answer 401/403 instead of handing back an empty workbook. +// Route Handlers under /api/export/* and /api/import are outside the App +// Router layout tree, so app/(app)/layout.tsx's HR gate never runs for them — +// each one has to re-establish that the caller is an active HR user itself. +// RLS is still the real boundary (an unauthorized session simply reads +// nothing); this exists so those routes answer 401/403 instead of handing back +// an empty workbook. +// +// Gefragt wird is_hr_user() und nicht mehr profiles.role/is_active von Hand. +// Der Unterschied ist nicht kosmetisch: is_hr_user() ist dieselbe Funktion, die +// alle 25 RLS-Policies aufrufen. Was immer sie künftig zusätzlich prüft, gilt +// hier automatisch mit — beim ausstehenden Passwortwechsel ist genau das schon +// passiert (Migration 20260908120000). Die Handfassung hätte davon nichts +// gewusst und einen Export ausgeliefert, den die Policies darunter leer +// gelassen hätten. export type HrGate = { denied: NextResponse } | { userId: string }; @@ -15,12 +24,11 @@ export async function requireHrUser(): Promise { const userId = await currentUserId(); if (!userId) return { denied: NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }) }; - const profile = await withUser(userId, (tx) => - tx.selectFrom("profiles").select(["role", "is_active"]).where("id", "=", userId).executeTakeFirst() - ); + const erlaubt = await withUser(userId, async (tx) => { + const ergebnis = await sql<{ ok: boolean }>`select is_hr_user() as ok`.execute(tx); + return ergebnis.rows[0]?.ok === true; + }); - if (profile?.role !== "hr" || profile.is_active !== true) { - return { denied: NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }) }; - } + if (!erlaubt) return { denied: NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }) }; return { userId }; } diff --git a/lib/passwort.ts b/lib/passwort.ts new file mode 100644 index 0000000..f77ccd2 --- /dev/null +++ b/lib/passwort.ts @@ -0,0 +1,46 @@ +// Die Passwortregeln — noch einmal, für die Oberfläche. +// +// Verbindlich ist app_passwort_regeln() in der Datenbank +// (20260908120000). Hier stehen dieselben Regeln ein zweites Mal, damit die +// Maske sie beim Tippen anzeigen kann statt erst nach dem Absenden. Dieselbe +// Doppelung wie in lib/history.ts, aus demselben Grund und mit derselben +// Rangfolge: läuft eine Seite der anderen davon, gewinnt die Datenbank — sie +// weist ab, und die Meldung von dort wird angezeigt. + +/** Was in der Maske als Hinweis steht. Reihenfolge wie in der SQL-Funktion. */ +export const PASSWORT_REGELN = [ + "mindestens 12 Zeichen", + "mindestens ein Grossbuchstabe", + "mindestens ein Kleinbuchstabe", + "mindestens eine Ziffer", +] as const; + +/** + * Liefert die Beanstandung im Klartext oder null. + * + * Wortgleich mit app_passwort_regeln(). Ein Unterschied bleibt und ist + * unvermeidbar: `length()` in PostgreSQL zählt Zeichen, `String.length` in + * JavaScript zählt UTF-16-Einheiten. Bei einem Emoji im Passwort weichen die + * beiden um eins ab. Das betrifft nur die Vorabanzeige — abgewiesen oder + * angenommen wird in der Datenbank. + */ +export function passwortBeanstandung(passwort: string): string | null { + if (passwort.length < 12) return "Das Passwort muss mindestens 12 Zeichen lang sein."; + if (passwort.length > 72) return "Das Passwort darf höchstens 72 Zeichen lang sein."; + if (!/\p{Lu}/u.test(passwort)) return "Das Passwort muss mindestens einen Grossbuchstaben enthalten."; + if (!/\p{Ll}/u.test(passwort)) return "Das Passwort muss mindestens einen Kleinbuchstaben enthalten."; + if (!/\p{Nd}/u.test(passwort)) return "Das Passwort muss mindestens eine Ziffer enthalten."; + return null; +} + +// Das gemeinsame Initialpasswort steht **nicht** in dieser Datei. +// +// Sie wird von den Anmelde- und Wechselformularen eingebunden, und die laufen +// im Browser. Jede Konstante hier landete damit im ausgelieferten Bündel — und +// das Initialpasswort wäre für jede Besucherin der offenen Anmeldeseite +// lesbar, ohne dass sie auch nur eine E-Mail-Adresse kennen müsste. Heute +// braucht ein Fremder beides. +// +// Es liegt deshalb in actions/benutzer.ts, also in einem Modul mit +// "use server", das nie in den Browser gelangt. Dort wird es auch nur +// gebraucht: beim Anlegen und beim Zurücksetzen. diff --git a/lib/shell-data.ts b/lib/shell-data.ts index f387db5..923fdc1 100644 --- a/lib/shell-data.ts +++ b/lib/shell-data.ts @@ -1,3 +1,4 @@ +import { sql } from "kysely"; import type { Tx } from "./db"; import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json"; import { todayIso } from "./format"; @@ -30,14 +31,27 @@ export type ShellData = { }; /** - * `null` heisst: angemeldet, aber nicht als HR freigeschaltet. + * Drei Ausgänge, nicht zwei. * + * Seit es die Anmeldung mit Passwort gibt, ist „darf nicht hinein" nicht mehr + * dasselbe wie „hat keinen Zugang": wer sein Startpasswort noch nicht + * gewechselt hat, hat einen Zugang und kommt trotzdem an keine Zeile, weil + * is_hr_user() das mitprüft (Migration 20260908120000). Unterschieden werden + * muss es, weil die beiden Fälle verschiedene Auswege haben — der eine wartet + * auf HR, der andere ist in einer Minute erledigt. + */ +export type ShellErgebnis = + | { status: "kein_zugang" } + | { status: "passwort_wechseln" } + | { status: "ok"; daten: ShellData }; + +/** * Die Zugangsprüfung fragt gleichzeitig mit dem Rest statt davor. Das liest * ein paar Zeilen mehr, als eine gesperrte Person sehen dürfte, wirft sie aber * weg, ohne sie je auszuliefern — und die eigentliche Grenze ist ohnehin RLS, * nicht die Reihenfolge hier. */ -export async function loadShellData(tx: Tx, userId: string): Promise { +export async function loadShellData(tx: Tx, userId: string): Promise { const asOf = todayIso(); const gelesen = await tx @@ -45,6 +59,10 @@ export async function loadShellData(tx: Tx, userId: string): Promise`app_muss_passwort_wechseln()`.as("passwortWechseln"), ...orgMapsAbfragen(eb), jsonArrayFrom(offeneStellenAbfrage(eb, asOf)).as("open"), jsonArrayFrom(offeneNotizenAbfrage(eb)).as("notes"), @@ -60,17 +78,28 @@ export async function loadShellData(tx: Tx, userId: string): Promise }; Returns: void }; add_employee_note: { Args: { payload: Record }; Returns: string }; complete_employee_note: { Args: { payload: Record }; Returns: void }; + // Eigenes Passwort wechseln. Die einzige mutierende Funktion **ohne** + // require_hr_admin() — sonst käme niemand aus dem erzwungenen Wechsel + // heraus, denn solange er aussteht, ist is_hr_user() false. + app_passwort_aendern: { Args: { payload: Record }; Returns: void }; // Nimmt eine irrtümliche Stammdaten-/Vertragsänderung zurück. Der einzige // Weg an der fehlenden delete-Policy auf employee_history vorbei. delete_history_entry: { Args: { payload: Record }; Returns: void }; diff --git a/tests/unit/security.test.ts b/tests/unit/security.test.ts index 3d96887..1748518 100644 --- a/tests/unit/security.test.ts +++ b/tests/unit/security.test.ts @@ -19,7 +19,11 @@ import { // also auch hier. Der Riegel ist im Betrieb richtig; für den Test wird das // Modul zu einer leeren Hülle. vi.mock("server-only", () => ({})); -vi.mock("@/lib/db", () => ({ asSystem: vi.fn(), withUser: vi.fn() })); +// `sql` gehört mit in die Attrappe, seit lib/auth/require-hr.ts es einbindet +// (es fragt is_hr_user() statt profiles von Hand). Der Pfad hier erreicht es +// nie — die Route antwortet mit 401, bevor eine Abfrage entsteht —, aber +// Vitest wirft beim blossen Zugriff auf einen nicht gestellten Export. +vi.mock("@/lib/db", () => ({ asSystem: vi.fn(), withUser: vi.fn(), sql: vi.fn() })); vi.mock("@/lib/db/rpc", () => ({ callFunction: vi.fn() })); describe("sanitizeForSpreadsheetCell", () => {