Merge branch 'phase/016-handbuch' (Anmeldefehler)
@@ -17,6 +17,14 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
### 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
|
||||
|
||||
### Geändert
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
||||
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
||||
**Version:** 0.11.0
|
||||
**Version:** 0.11.1
|
||||
|
||||
### Erledigt
|
||||
- Auftrag gelesen, Rückfragen gestellt
|
||||
@@ -109,6 +109,7 @@
|
||||
- **Server Actions rufen Services auf**, damit die Regeln ohne Next.js-Laufzeit testbar bleiben.
|
||||
|
||||
### 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).
|
||||
- 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).
|
||||
|
||||
|
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.
|
||||
- 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).
|
||||
- **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",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -23,7 +23,8 @@
|
||||
"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:all": "npm run build && npm run build:worker",
|
||||
"lasttest": "tsx scripts/lasttest.ts"
|
||||
"lasttest": "tsx scripts/lasttest.ts",
|
||||
"screenshots": "node scripts/screenshots.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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">
|
||||
{fehler ? (
|
||||
<div className="alert alert-danger py-2" role="alert">
|
||||
{fehler === "oidc"
|
||||
? "Die Anmeldung über den Anbieter hat nicht geklappt."
|
||||
: "Anmeldung nicht möglich."}
|
||||
{fehler === "oidc" ? "Die Anmeldung über den Anbieter hat nicht geklappt." : fehler}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -184,9 +184,7 @@ export default async function DashboardSeite() {
|
||||
titel="Aufgaben"
|
||||
rahmen="warning"
|
||||
werkzeuge={
|
||||
<span className="badge pl-bg-warning">
|
||||
{aufgabenDaten.gesamt + ohneKunde.length}
|
||||
</span>
|
||||
<span className="badge pl-bg-warning">{aufgabenDaten.gesamt + ohneKunde.length}</span>
|
||||
}
|
||||
>
|
||||
<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 { ueberHttps } from "@/lib/sitzung";
|
||||
import { anmeldungStarten, oidcEinstellungen } from "@/server/auth/oidc";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -18,7 +19,7 @@ export async function GET(request: Request) {
|
||||
const optionen = {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
secure: ueberHttps(),
|
||||
path: "/",
|
||||
maxAge: 600,
|
||||
};
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { anmelden } from "@/server/actions/auth";
|
||||
import type { AktionsErgebnis } from "@/server/actions/kunden";
|
||||
|
||||
const LEER: AktionsErgebnis = {};
|
||||
|
||||
/**
|
||||
* Anmeldeformular. Es sendet ganz gewöhnlich an eine Route – dadurch funktioniert die
|
||||
* Anmeldung auch ohne JavaScript, und das Sitzungs-Cookie kommt mit einer normalen Antwort.
|
||||
*/
|
||||
export function AnmeldeFormular({ ziel }: { ziel: string }) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState(anmelden, LEER);
|
||||
|
||||
return (
|
||||
<form action={aktion}>
|
||||
<form method="post" action="/api/auth/anmelden">
|
||||
<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">
|
||||
<label className="form-label" htmlFor="email">
|
||||
E-Mail-Adresse
|
||||
@@ -47,8 +35,8 @@ export function AnmeldeFormular({ ziel }: { ziel: string }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary w-100 pl-touch-target" disabled={laeuft}>
|
||||
{laeuft ? "Meldet an …" : "Anmelden"}
|
||||
<button type="submit" className="btn btn-primary w-100 pl-touch-target">
|
||||
Anmelden
|
||||
</button>
|
||||
</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() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
secure: ueberHttps(),
|
||||
path: "/",
|
||||
maxAge: SITZUNGSDAUER_STUNDEN * 3600,
|
||||
};
|
||||
|
||||
@@ -1,47 +1,13 @@
|
||||
"use server";
|
||||
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
|
||||
import { clientIp } from "@/lib/ratelimit";
|
||||
import { SITZUNGS_COOKIE } from "@/lib/sitzung";
|
||||
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() {
|
||||
const speicher = await cookies();
|
||||
speicher.delete(SITZUNGS_COOKIE);
|
||||
(await cookies()).delete(SITZUNGS_COOKIE);
|
||||
await protokolliere("abmeldung", "Benutzer", null, {});
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
@@ -54,7 +54,18 @@ describe("Sitzungen", () => {
|
||||
).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().sameSite).toBe("lax");
|
||||
});
|
||||
|
||||