Merge branch 'phase/016-handbuch' (Anmeldefehler)
@@ -17,6 +17,14 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
|
## [0.11.1] – 2026-09-17
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Nach der Anmeldung führte jeder Klick zurück zur Anmeldeseite: Das Sitzungs-Cookie erhielt im Produktionsbau immer das Merkmal `Secure` und wurde über `http://` vom Browser verworfen. Maßgeblich ist jetzt die tatsächliche Adresse (`APP_URL`) (#013)
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- Die Anmeldung läuft über ein gewöhnliches Formular statt über eine Server-Action; damit funktioniert sie auch ohne JavaScript (#013)
|
||||||
|
|
||||||
## [0.11.0] – 2026-09-17
|
## [0.11.0] – 2026-09-17
|
||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
|
|
||||||
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
||||||
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
||||||
**Version:** 0.11.0
|
**Version:** 0.11.1
|
||||||
|
|
||||||
### Erledigt
|
### Erledigt
|
||||||
- Auftrag gelesen, Rückfragen gestellt
|
- Auftrag gelesen, Rückfragen gestellt
|
||||||
@@ -109,6 +109,7 @@
|
|||||||
- **Server Actions rufen Services auf**, damit die Regeln ohne Next.js-Laufzeit testbar bleiben.
|
- **Server Actions rufen Services auf**, damit die Regeln ohne Next.js-Laufzeit testbar bleiben.
|
||||||
|
|
||||||
### Probleme / offene Fragen
|
### Probleme / offene Fragen
|
||||||
|
- Von Florian im Betrieb gemeldet: Nach der Anmeldung führte jeder Klick zurück zur Anmeldeseite. Ursache: `Secure`-Cookie über `http://`. Behoben in 0.11.1.
|
||||||
- Der LXC-Installer ist ungetestet (kein Proxmox-Host verfügbar); gleiches gilt für den Agent im Feld (#018).
|
- Der LXC-Installer ist ungetestet (kein Proxmox-Host verfügbar); gleiches gilt für den Agent im Feld (#018).
|
||||||
- Prisma-Client erzeugte in der Produktionsumgebung bei jedem Zugriff einen neuen Verbindungspool → „too many clients“. Behoben: Client wird im Modul festgehalten.
|
- Prisma-Client erzeugte in der Produktionsumgebung bei jedem Zugriff einen neuen Verbindungspool → „too many clients“. Behoben: Client wird im Modul festgehalten.
|
||||||
- Docker wurde von Florian installiert; die Entwicklungsdatenbank läuft auf Port 5433 (5432 bleibt für lokale Instanzen frei).
|
- Docker wurde von Florian installiert; die Entwicklungsdatenbank läuft auf Port 5433 (5432 bleibt für lokale Instanzen frei).
|
||||||
|
|||||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 231 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 271 KiB After Width: | Height: | Size: 32 KiB |
@@ -33,3 +33,8 @@ Zugriff auf das Dashboard absichern und Änderungen nachvollziehbar machen.
|
|||||||
freigeschaltet ist, soll auch hier arbeiten können.
|
freigeschaltet ist, soll auch hier arbeiten können.
|
||||||
- Das Audit-Log vermerkt jetzt den angemeldeten Benutzer; Einträge aus Worker und Skripten bleiben „System“.
|
- Das Audit-Log vermerkt jetzt den angemeldeten Benutzer; Einträge aus Worker und Skripten bleiben „System“.
|
||||||
- **Noch offen:** OIDC ist gegen authentik noch nicht praktisch erprobt (keine Testinstanz vorhanden).
|
- **Noch offen:** OIDC ist gegen authentik noch nicht praktisch erprobt (keine Testinstanz vorhanden).
|
||||||
|
- **Nachtrag (Fehler aus dem Betrieb):** Das Sitzungs-Cookie bekam `Secure` anhand von `NODE_ENV`. Ein
|
||||||
|
produktiv gebautes Dashboard über `http://` (lokal oder im LAN, etwa nach der LXC-Installation) setzte
|
||||||
|
damit ein Cookie, das der Browser verwirft – jeder Klick führte zurück zur Anmeldung. Jetzt entscheidet
|
||||||
|
das Schema in `APP_URL`. Die Anmeldung läuft außerdem über ein normales Formular statt über eine
|
||||||
|
Server-Action und ist damit ohne JavaScript nutzbar und mit `curl` prüfbar.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxledger",
|
"name": "proxledger",
|
||||||
"version": "0.11.0",
|
"version": "0.11.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -23,7 +23,8 @@
|
|||||||
"alerts:check": "tsx scripts/alerts-pruefen.ts",
|
"alerts:check": "tsx scripts/alerts-pruefen.ts",
|
||||||
"build:worker": "esbuild src/worker/index.ts --bundle --platform=node --target=node24 --format=esm --outfile=dist/worker.mjs --banner:js=\"$(cat deploy/worker-banner.js)\" --external:@prisma/client --external:@node-rs/argon2 --external:pg-native",
|
"build:worker": "esbuild src/worker/index.ts --bundle --platform=node --target=node24 --format=esm --outfile=dist/worker.mjs --banner:js=\"$(cat deploy/worker-banner.js)\" --external:@prisma/client --external:@node-rs/argon2 --external:pg-native",
|
||||||
"build:all": "npm run build && npm run build:worker",
|
"build:all": "npm run build && npm run build:worker",
|
||||||
"lasttest": "tsx scripts/lasttest.ts"
|
"lasttest": "tsx scripts/lasttest.ts",
|
||||||
|
"screenshots": "node scripts/screenshots.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@node-rs/argon2": "^2.2.1",
|
"@node-rs/argon2": "^2.2.1",
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Erzeugt die Bilder für das Handbuch (docs/anleitung/bilder/).
|
||||||
|
*
|
||||||
|
* npm run screenshots # heller Modus, Desktop
|
||||||
|
* npm run screenshots -- --alle # zusätzlich dunkler Modus und Mobilansicht
|
||||||
|
*
|
||||||
|
* Voraussetzung: Das Dashboard läuft (npm start) und die Datenbank enthält Daten.
|
||||||
|
* Die Anmeldung erledigt das Skript selbst, indem es ein Sitzungs-Cookie erzeugt
|
||||||
|
* und in ein eigenes Firefox-Profil schreibt – so bleibt der Schutz der Anwendung unangetastet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
import { SignJWT } from "jose";
|
||||||
|
import "dotenv/config";
|
||||||
|
|
||||||
|
const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000";
|
||||||
|
const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder");
|
||||||
|
const ALLE = process.argv.includes("--alle");
|
||||||
|
|
||||||
|
// Firefox aus dem Snap-Paket kommt nicht an versteckte Verzeichnisse – daher im Home sichtbar ablegen.
|
||||||
|
const PROFIL_BASIS = path.join(homedir(), "proxledger-screenshots");
|
||||||
|
|
||||||
|
const SEITEN = [
|
||||||
|
{ name: "dashboard", pfad: "/", titel: "Dashboard" },
|
||||||
|
{ name: "hosts", pfad: "/hosts", titel: "Host-Tabelle" },
|
||||||
|
{ name: "host-detail", pfad: "/hosts?q=pve-brandl-01", titel: "Host-Suche" },
|
||||||
|
{ name: "kunden", pfad: "/kunden", titel: "Kundenübersicht" },
|
||||||
|
{ name: "nicht-zugeordnet", pfad: "/hosts/nicht-zugeordnet", titel: "Nicht zugeordnete Hosts" },
|
||||||
|
{ name: "rechnungen", pfad: "/rechnungen", titel: "Rechnungen" },
|
||||||
|
{ name: "jahresbahn", pfad: "/jahresbahn", titel: "Jahresbahn" },
|
||||||
|
{ name: "warnungen", pfad: "/warnungen", titel: "Warnungen" },
|
||||||
|
{ name: "warnregeln", pfad: "/einstellungen/warnregeln", titel: "Warnregeln" },
|
||||||
|
{ name: "tokens", pfad: "/einstellungen/tokens", titel: "Agent-Tokens" },
|
||||||
|
{ name: "benachrichtigungen", pfad: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen" },
|
||||||
|
{ name: "audit", pfad: "/einstellungen/audit", titel: "Protokoll" },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function sitzungsToken() {
|
||||||
|
const geheimnis = process.env.APP_SECRET;
|
||||||
|
if (!geheimnis || geheimnis.length < 32) {
|
||||||
|
throw new Error("APP_SECRET fehlt oder ist zu kurz – ohne Sitzung keine Bilder.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SignJWT({
|
||||||
|
benutzerId: "screenshots",
|
||||||
|
email: process.env.ADMIN_EMAIL ?? "admin@example.de",
|
||||||
|
name: process.env.ADMIN_NAME ?? "Florian",
|
||||||
|
herkunft: "passwort",
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setSubject("screenshots")
|
||||||
|
.setIssuedAt()
|
||||||
|
.setIssuer("proxledger")
|
||||||
|
.setExpirationTime("1h")
|
||||||
|
.sign(new TextEncoder().encode(geheimnis));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legt ein frisches Profil mit Sitzungs-Cookie und gewünschtem Farbschema an. */
|
||||||
|
function profilVorbereiten(name, token, dunkel) {
|
||||||
|
const verzeichnis = path.join(PROFIL_BASIS, name);
|
||||||
|
rmSync(verzeichnis, { recursive: true, force: true });
|
||||||
|
mkdirSync(verzeichnis, { recursive: true });
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
path.join(verzeichnis, "user.js"),
|
||||||
|
[
|
||||||
|
`user_pref("ui.systemUsesDarkTheme", ${dunkel ? 1 : 0});`,
|
||||||
|
`user_pref("layout.css.prefers-color-scheme.content-override", ${dunkel ? 0 : 1});`,
|
||||||
|
'user_pref("browser.shell.checkDefaultBrowser", false);',
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { hostname, protocol } = new URL(BASIS);
|
||||||
|
const datenbank = new DatabaseSync(path.join(verzeichnis, "cookies.sqlite"));
|
||||||
|
datenbank.exec(`
|
||||||
|
CREATE TABLE moz_cookies (
|
||||||
|
id INTEGER PRIMARY KEY, originAttributes TEXT NOT NULL DEFAULT '', name TEXT, value TEXT,
|
||||||
|
host TEXT, path TEXT, expiry INTEGER, lastAccessed INTEGER, creationTime INTEGER,
|
||||||
|
isSecure INTEGER, isHttpOnly INTEGER, inBrowserElement INTEGER DEFAULT 0,
|
||||||
|
sameSite INTEGER DEFAULT 0, schemeMap INTEGER DEFAULT 0,
|
||||||
|
isPartitionedAttributeSet INTEGER DEFAULT 0, updateTime INTEGER DEFAULT 0,
|
||||||
|
CONSTRAINT moz_uniqueid UNIQUE (name, host, path, originAttributes)
|
||||||
|
)`);
|
||||||
|
|
||||||
|
const jetzt = Date.now();
|
||||||
|
datenbank
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO moz_cookies
|
||||||
|
(originAttributes,name,value,host,path,expiry,lastAccessed,creationTime,isSecure,isHttpOnly,inBrowserElement,sameSite,schemeMap,isPartitionedAttributeSet,updateTime)
|
||||||
|
VALUES ('', 'pl_sitzung', ?, ?, '/', ?, ?, ?, ?, 1, 0, 1, ?, 0, ?)`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
token,
|
||||||
|
hostname,
|
||||||
|
Math.floor(jetzt / 1000) + 3600,
|
||||||
|
jetzt * 1000,
|
||||||
|
jetzt * 1000,
|
||||||
|
protocol === "https:" ? 1 : 0,
|
||||||
|
protocol === "https:" ? 2 : 1,
|
||||||
|
jetzt * 1000,
|
||||||
|
);
|
||||||
|
datenbank.close();
|
||||||
|
|
||||||
|
return verzeichnis;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aufnehmen(profil, pfad, datei, breite, hoehe) {
|
||||||
|
execFileSync(
|
||||||
|
"firefox",
|
||||||
|
[
|
||||||
|
"--profile",
|
||||||
|
profil,
|
||||||
|
"--headless",
|
||||||
|
"--window-size",
|
||||||
|
`${breite},${hoehe}`,
|
||||||
|
"--screenshot",
|
||||||
|
datei,
|
||||||
|
`${BASIS}${pfad}`,
|
||||||
|
],
|
||||||
|
{ stdio: "ignore", timeout: 120_000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const antwort = await fetch(`${BASIS}/login`).catch(() => null);
|
||||||
|
if (!antwort?.ok) throw new Error(`${BASIS} ist nicht erreichbar – läuft das Dashboard?`);
|
||||||
|
|
||||||
|
mkdirSync(ZIEL, { recursive: true });
|
||||||
|
const token = await sitzungsToken();
|
||||||
|
|
||||||
|
const hell = profilVorbereiten("hell", token, false);
|
||||||
|
console.log(`Bilder nach ${ZIEL}`);
|
||||||
|
|
||||||
|
// Die Anmeldeseite zeigt sich auch mit gültiger Sitzung, sie ist öffentlich.
|
||||||
|
aufnehmen(hell, "/login", path.join(ZIEL, "login.png"), 1280, 900);
|
||||||
|
console.log(" login.png");
|
||||||
|
|
||||||
|
for (const seite of SEITEN) {
|
||||||
|
aufnehmen(hell, seite.pfad, path.join(ZIEL, `${seite.name}.png`), 1680, 1150);
|
||||||
|
console.log(` ${seite.name}.png – ${seite.titel}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ALLE) {
|
||||||
|
const dunkel = profilVorbereiten("dunkel", token, true);
|
||||||
|
for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) {
|
||||||
|
const eintrag = SEITEN.find((s) => s.name === seite);
|
||||||
|
aufnehmen(dunkel, eintrag.pfad, path.join(ZIEL, `${seite}-dunkel.png`), 1680, 1150);
|
||||||
|
console.log(` ${seite}-dunkel.png`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const seite of ["dashboard", "hosts"]) {
|
||||||
|
const eintrag = SEITEN.find((s) => s.name === seite);
|
||||||
|
aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500);
|
||||||
|
console.log(` mobil-${seite}.png`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(PROFIL_BASIS)) rmSync(PROFIL_BASIS, { recursive: true, force: true });
|
||||||
|
console.log("Fertig.");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((fehler) => {
|
||||||
|
console.error(fehler.message ?? fehler);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -30,9 +30,7 @@ export default async function AnmeldeSeite({ searchParams }: { searchParams: Suc
|
|||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
{fehler ? (
|
{fehler ? (
|
||||||
<div className="alert alert-danger py-2" role="alert">
|
<div className="alert alert-danger py-2" role="alert">
|
||||||
{fehler === "oidc"
|
{fehler === "oidc" ? "Die Anmeldung über den Anbieter hat nicht geklappt." : fehler}
|
||||||
? "Die Anmeldung über den Anbieter hat nicht geklappt."
|
|
||||||
: "Anmeldung nicht möglich."}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -184,9 +184,7 @@ export default async function DashboardSeite() {
|
|||||||
titel="Aufgaben"
|
titel="Aufgaben"
|
||||||
rahmen="warning"
|
rahmen="warning"
|
||||||
werkzeuge={
|
werkzeuge={
|
||||||
<span className="badge pl-bg-warning">
|
<span className="badge pl-bg-warning">{aufgabenDaten.gesamt + ohneKunde.length}</span>
|
||||||
{aufgabenDaten.gesamt + ohneKunde.length}
|
|
||||||
</span>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TodoList aufgaben={aufgaben} />
|
<TodoList aufgaben={aufgaben} />
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { cookies, headers } from "next/headers";
|
||||||
|
import { clientIp } from "@/lib/ratelimit";
|
||||||
|
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
|
||||||
|
import { AnmeldeFehler, mitPasswortAnmelden } from "@/server/auth/benutzer";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anmeldung als gewöhnliches Formular. Bewusst kein Server-Action-Weg: So funktioniert die
|
||||||
|
* Anmeldung auch ohne JavaScript, und das Sitzungs-Cookie hängt an einer normalen Antwort.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const formular = await request.formData();
|
||||||
|
const email = formular.get("email")?.toString().trim() ?? "";
|
||||||
|
const passwort = formular.get("passwort")?.toString() ?? "";
|
||||||
|
const zielEingabe = formular.get("ziel")?.toString() || "/";
|
||||||
|
// Nur eigene Pfade zulassen, damit die Anmeldung nicht auf fremde Seiten führt.
|
||||||
|
const ziel = zielEingabe.startsWith("/") && !zielEingabe.startsWith("//") ? zielEingabe : "/";
|
||||||
|
|
||||||
|
function zurueckZurAnmeldung(meldung: string) {
|
||||||
|
const adresse = new URL("/login", request.url);
|
||||||
|
adresse.searchParams.set("fehler", meldung);
|
||||||
|
if (ziel !== "/") adresse.searchParams.set("ziel", ziel);
|
||||||
|
return Response.redirect(adresse, 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email || !passwort) {
|
||||||
|
return zurueckZurAnmeldung("E-Mail-Adresse und Passwort eingeben.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const kopfzeilen = await headers();
|
||||||
|
const benutzer = await mitPasswortAnmelden(email, passwort, clientIp(kopfzeilen));
|
||||||
|
|
||||||
|
const token = await sitzungErzeugen({
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
email: benutzer.email,
|
||||||
|
name: benutzer.name,
|
||||||
|
herkunft: "passwort",
|
||||||
|
});
|
||||||
|
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen());
|
||||||
|
|
||||||
|
return Response.redirect(new URL(ziel, request.url), 303);
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof AnmeldeFehler) return zurueckZurAnmeldung(fehler.message);
|
||||||
|
console.error("Anmeldung fehlgeschlagen:", fehler);
|
||||||
|
return zurueckZurAnmeldung("Anmeldung derzeit nicht möglich.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
import { ueberHttps } from "@/lib/sitzung";
|
||||||
import { anmeldungStarten, oidcEinstellungen } from "@/server/auth/oidc";
|
import { anmeldungStarten, oidcEinstellungen } from "@/server/auth/oidc";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -18,7 +19,7 @@ export async function GET(request: Request) {
|
|||||||
const optionen = {
|
const optionen = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax" as const,
|
sameSite: "lax" as const,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: ueberHttps(),
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: 600,
|
maxAge: 600,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,24 +1,12 @@
|
|||||||
"use client";
|
/**
|
||||||
|
* Anmeldeformular. Es sendet ganz gewöhnlich an eine Route – dadurch funktioniert die
|
||||||
import { useActionState } from "react";
|
* Anmeldung auch ohne JavaScript, und das Sitzungs-Cookie kommt mit einer normalen Antwort.
|
||||||
import { anmelden } from "@/server/actions/auth";
|
*/
|
||||||
import type { AktionsErgebnis } from "@/server/actions/kunden";
|
|
||||||
|
|
||||||
const LEER: AktionsErgebnis = {};
|
|
||||||
|
|
||||||
export function AnmeldeFormular({ ziel }: { ziel: string }) {
|
export function AnmeldeFormular({ ziel }: { ziel: string }) {
|
||||||
const [ergebnis, aktion, laeuft] = useActionState(anmelden, LEER);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={aktion}>
|
<form method="post" action="/api/auth/anmelden">
|
||||||
<input type="hidden" name="ziel" value={ziel} />
|
<input type="hidden" name="ziel" value={ziel} />
|
||||||
|
|
||||||
{ergebnis.fehler ? (
|
|
||||||
<div className="alert alert-danger py-2" role="alert">
|
|
||||||
{ergebnis.fehler}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="form-label" htmlFor="email">
|
<label className="form-label" htmlFor="email">
|
||||||
E-Mail-Adresse
|
E-Mail-Adresse
|
||||||
@@ -47,8 +35,8 @@ export function AnmeldeFormular({ ziel }: { ziel: string }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" className="btn btn-primary w-100 pl-touch-target" disabled={laeuft}>
|
<button type="submit" className="btn btn-primary w-100 pl-touch-target">
|
||||||
{laeuft ? "Meldet an …" : "Anmelden"}
|
Anmelden
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -53,11 +53,22 @@ export async function sitzungLesen(token: string | undefined): Promise<Sitzung |
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `Secure` richtet sich nach der tatsächlichen Adresse, nicht nach NODE_ENV:
|
||||||
|
* Ein produktiv gebautes Dashboard, das über http:// läuft (etwa im LAN oder beim lokalen Prüfen),
|
||||||
|
* würde sonst ein Cookie setzen, das der Browser verwirft – die Anmeldung liefe dann ins Leere.
|
||||||
|
*/
|
||||||
|
export function ueberHttps(): boolean {
|
||||||
|
const adresse = process.env.APP_URL;
|
||||||
|
if (adresse) return adresse.startsWith("https://");
|
||||||
|
return process.env.NODE_ENV === "production";
|
||||||
|
}
|
||||||
|
|
||||||
export function cookieOptionen() {
|
export function cookieOptionen() {
|
||||||
return {
|
return {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax" as const,
|
sameSite: "lax" as const,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: ueberHttps(),
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: SITZUNGSDAUER_STUNDEN * 3600,
|
maxAge: SITZUNGSDAUER_STUNDEN * 3600,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,47 +1,13 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
|
import { SITZUNGS_COOKIE } from "@/lib/sitzung";
|
||||||
import { clientIp } from "@/lib/ratelimit";
|
|
||||||
import { protokolliere } from "@/server/audit";
|
import { protokolliere } from "@/server/audit";
|
||||||
import { AnmeldeFehler, mitPasswortAnmelden } from "@/server/auth/benutzer";
|
|
||||||
import type { AktionsErgebnis } from "./kunden";
|
|
||||||
|
|
||||||
/** Anmeldung mit E-Mail und Passwort (Rückfallweg, wenn OIDC nicht erreichbar ist). */
|
|
||||||
export async function anmelden(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
|
|
||||||
const email = formular.get("email")?.toString().trim() ?? "";
|
|
||||||
const passwort = formular.get("passwort")?.toString() ?? "";
|
|
||||||
const ziel = formular.get("ziel")?.toString() || "/";
|
|
||||||
|
|
||||||
if (!email || !passwort) {
|
|
||||||
return { fehler: "Bitte E-Mail-Adresse und Passwort eingeben." };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const kopfzeilen = await headers();
|
|
||||||
const benutzer = await mitPasswortAnmelden(email, passwort, clientIp(kopfzeilen));
|
|
||||||
|
|
||||||
const token = await sitzungErzeugen({
|
|
||||||
benutzerId: benutzer.id,
|
|
||||||
email: benutzer.email,
|
|
||||||
name: benutzer.name,
|
|
||||||
herkunft: "passwort",
|
|
||||||
});
|
|
||||||
|
|
||||||
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen());
|
|
||||||
} catch (fehler) {
|
|
||||||
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
|
|
||||||
throw fehler;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nur eigene Pfade als Ziel zulassen, damit die Anmeldung nicht auf fremde Seiten führt.
|
|
||||||
redirect(ziel.startsWith("/") && !ziel.startsWith("//") ? ziel : "/");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/** Abmelden aus dem Benutzermenü. Die Anmeldung selbst läuft über /api/auth/anmelden. */
|
||||||
export async function abmelden() {
|
export async function abmelden() {
|
||||||
const speicher = await cookies();
|
(await cookies()).delete(SITZUNGS_COOKIE);
|
||||||
speicher.delete(SITZUNGS_COOKIE);
|
|
||||||
await protokolliere("abmeldung", "Benutzer", null, {});
|
await protokolliere("abmeldung", "Benutzer", null, {});
|
||||||
redirect("/login");
|
redirect("/login");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,18 @@ describe("Sitzungen", () => {
|
|||||||
).rejects.toThrow(/APP_SECRET/);
|
).rejects.toThrow(/APP_SECRET/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("setzt das Cookie nur über HTTPS, wenn produktiv gebaut wurde", () => {
|
it("setzt Secure nur, wenn das Dashboard über HTTPS läuft", () => {
|
||||||
|
const vorher = process.env.APP_URL;
|
||||||
|
|
||||||
|
process.env.APP_URL = "https://proxledger.example.de";
|
||||||
|
expect(cookieOptionen().secure).toBe(true);
|
||||||
|
|
||||||
|
// Produktiv gebaut, aber über http erreichbar: sonst verwirft der Browser das Cookie
|
||||||
|
// und die Anmeldung landet sofort wieder auf der Anmeldeseite.
|
||||||
|
process.env.APP_URL = "http://192.168.1.50:3000";
|
||||||
|
expect(cookieOptionen().secure).toBe(false);
|
||||||
|
|
||||||
|
process.env.APP_URL = vorher;
|
||||||
expect(cookieOptionen().httpOnly).toBe(true);
|
expect(cookieOptionen().httpOnly).toBe(true);
|
||||||
expect(cookieOptionen().sameSite).toBe("lax");
|
expect(cookieOptionen().sameSite).toBe("lax");
|
||||||
});
|
});
|
||||||
|
|||||||