Passwort-Anmeldung: Provider, Formulare, drei Zustaende der Shell
This commit is contained in:
75
auth.ts
75
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
|
||||
|
||||
Reference in New Issue
Block a user