Passwort-Anmeldung: Provider, Formulare, drei Zustaende der Shell
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m27s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m3s

This commit is contained in:
2026-09-08 16:51:14 +02:00
parent cbde8cf3a8
commit c6cff9656e
13 changed files with 569 additions and 40 deletions

75
auth.ts
View File

@@ -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