feat(auth): Anmeldung mit Passwort und OIDC, Schutz aller Seiten (#013)
Signierte Sitzungs-Cookies mit jose, OIDC ueber openid-client (authentik), Schutz in proxy.ts, Anmeldeseite, Audit-Log-Ansicht, Sicherheits-Kopfzeilen und Rate-Limit. Kein Auth.js, weil Version 5 Beta ist. 147 Tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import Link from "next/link";
|
||||
import { AnmeldeFormular } from "@/components/auth/AnmeldeFormular";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { oidcEinstellungen } from "@/server/auth/oidc";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = { title: "Anmelden" };
|
||||
|
||||
type SuchParameter = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
export default async function AnmeldeSeite({ searchParams }: { searchParams: SuchParameter }) {
|
||||
const roh = await searchParams;
|
||||
const einzeln = (wert: string | string[] | undefined) => (Array.isArray(wert) ? wert[0] : wert);
|
||||
const ziel = einzeln(roh.ziel) ?? "/";
|
||||
const fehler = einzeln(roh.fehler);
|
||||
const oidc = oidcEinstellungen();
|
||||
|
||||
return (
|
||||
<main className="d-flex align-items-center justify-content-center min-vh-100 p-3">
|
||||
<div style={{ width: "min(24rem, 100%)" }}>
|
||||
<div className="text-center mb-4">
|
||||
<Logo groesse={64} titel="ProxLedger" />
|
||||
<h1 className="h4 mt-3 mb-1" style={{ fontWeight: 300 }}>
|
||||
ProxLedger
|
||||
</h1>
|
||||
<p className="text-body-secondary small mb-0">Proxmox Subscription Dashboard</p>
|
||||
</div>
|
||||
|
||||
<div className="card shadow-sm">
|
||||
<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."}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{oidc ? (
|
||||
<>
|
||||
<Link
|
||||
href={`/api/auth/oidc/start?ziel=${encodeURIComponent(ziel)}`}
|
||||
className="btn btn-primary w-100 pl-touch-target mb-3"
|
||||
>
|
||||
<i className="bi bi-box-arrow-in-right me-1" aria-hidden="true" />
|
||||
Mit {oidc.bezeichnung} anmelden
|
||||
</Link>
|
||||
<div className="text-center text-body-secondary small mb-3">oder mit Passwort</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<AnmeldeFormular ziel={ziel} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-body-secondary small mt-3 mb-0">
|
||||
Der lokale Zugang bleibt auch dann nutzbar, wenn der Anmeldedienst ausfällt.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import Link from "next/link";
|
||||
import { ContentHeader } from "@/components/layout/ContentHeader";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { formatDatumZeit } from "@/lib/format";
|
||||
|
||||
export const metadata = { title: "Protokoll" };
|
||||
|
||||
type SuchParameter = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
export default async function AuditSeite({ searchParams }: { searchParams: SuchParameter }) {
|
||||
const roh = await searchParams;
|
||||
const einzeln = (wert: string | string[] | undefined) => (Array.isArray(wert) ? wert[0] : wert);
|
||||
const aktion = einzeln(roh.aktion);
|
||||
const entitaet = einzeln(roh.entitaet);
|
||||
|
||||
const [eintraege, aktionen] = await Promise.all([
|
||||
prisma.auditEintrag.findMany({
|
||||
where: {
|
||||
...(aktion ? { aktion } : {}),
|
||||
...(entitaet ? { entitaet } : {}),
|
||||
},
|
||||
orderBy: { erstelltAm: "desc" },
|
||||
take: 200,
|
||||
include: { benutzer: { select: { name: true, email: true } } },
|
||||
}),
|
||||
prisma.auditEintrag.groupBy({ by: ["aktion"], _count: { _all: true }, orderBy: { aktion: "asc" } }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentHeader titel="Protokoll" brotkrumen={[{ titel: "Einstellungen" }, { titel: "Protokoll" }]} />
|
||||
|
||||
<div className="app-content">
|
||||
<div className="container-fluid">
|
||||
<Card titel={`Letzte ${eintraege.length} Einträge`} rahmen="primary" tabelle>
|
||||
<form className="row g-2 p-3 border-bottom">
|
||||
<div className="col-md-4">
|
||||
<select name="aktion" defaultValue={aktion ?? ""} className="form-select" aria-label="Aktion">
|
||||
<option value="">Alle Aktionen</option>
|
||||
{aktionen.map((eintrag) => (
|
||||
<option key={eintrag.aktion} value={eintrag.aktion}>
|
||||
{eintrag.aktion} ({eintrag._count._all})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<input
|
||||
name="entitaet"
|
||||
defaultValue={entitaet ?? ""}
|
||||
className="form-control"
|
||||
placeholder="Entität, z. B. Host"
|
||||
aria-label="Entität"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<button type="submit" className="btn btn-primary w-100">
|
||||
Filtern
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-md-3 d-flex align-items-center">
|
||||
{aktion || entitaet ? <Link href="/einstellungen/audit">Filter zurücksetzen</Link> : null}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Zeitpunkt</th>
|
||||
<th scope="col">Aktion</th>
|
||||
<th scope="col">Entität</th>
|
||||
<th scope="col">Benutzer</th>
|
||||
<th scope="col">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{eintraege.map((eintrag) => (
|
||||
<tr key={eintrag.id}>
|
||||
<td className="small">{formatDatumZeit(eintrag.erstelltAm)}</td>
|
||||
<td className="pl-mono small">{eintrag.aktion}</td>
|
||||
<td className="small">
|
||||
{eintrag.entitaet}
|
||||
{eintrag.entitaetId ? (
|
||||
<div className="text-body-secondary pl-mono">{eintrag.entitaetId}</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="small">{eintrag.benutzer?.name ?? "System"}</td>
|
||||
<td className="small text-body-secondary" style={{ maxWidth: "28rem" }}>
|
||||
<span className="d-inline-block text-truncate w-100">
|
||||
{eintrag.aenderung ? JSON.stringify(eintrag.aenderung) : "–"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{eintraege.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center text-body-secondary py-4">
|
||||
Keine Einträge gefunden.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung";
|
||||
import { kennzahlen, offeneAlerts, suchIndex } from "@/server/queries/hosts";
|
||||
|
||||
/**
|
||||
@@ -9,9 +12,11 @@ import { kennzahlen, offeneAlerts, suchIndex } from "@/server/queries/hosts";
|
||||
*/
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const BENUTZER = "Florian";
|
||||
|
||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
// Der Schutz liegt in proxy.ts; hier wird zusätzlich geprüft, damit kein Zustand ohne Benutzer entsteht.
|
||||
const sitzung = await sitzungLesen((await cookies()).get(SITZUNGS_COOKIE)?.value);
|
||||
if (!sitzung) redirect("/login");
|
||||
|
||||
const [zahlen, alerts, suche] = await Promise.all([kennzahlen(), offeneAlerts(), suchIndex()]);
|
||||
|
||||
return (
|
||||
@@ -25,13 +30,13 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
hostId: alert.hostId,
|
||||
}))}
|
||||
suchEintraege={suche}
|
||||
benutzer={BENUTZER}
|
||||
benutzer={sitzung.name}
|
||||
/>
|
||||
<Sidebar
|
||||
hostsAnzahl={zahlen.hostsGesamt}
|
||||
offeneRechnungspunkte={zahlen.offeneRechnungspunkte}
|
||||
offeneWarnungen={zahlen.offeneAlerts}
|
||||
benutzer={BENUTZER}
|
||||
benutzer={sitzung.name}
|
||||
/>
|
||||
<main className="app-main">{children}</main>
|
||||
<Footer />
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { SITZUNGS_COOKIE } from "@/lib/sitzung";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Abmelden per Link; löscht das Sitzungs-Cookie. */
|
||||
export async function GET(request: Request) {
|
||||
(await cookies()).delete(SITZUNGS_COOKIE);
|
||||
return Response.redirect(new URL("/login", request.url), 302);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
|
||||
import { mitOidcAnmelden } from "@/server/auth/benutzer";
|
||||
import { anmeldungAbschliessen, oidcEinstellungen } from "@/server/auth/oidc";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Nimmt die Rückkehr vom Anmeldedienst entgegen und legt die Sitzung an. */
|
||||
export async function GET(request: Request) {
|
||||
const einstellungen = oidcEinstellungen();
|
||||
const speicher = await cookies();
|
||||
const pruefwert = speicher.get("pl_oidc_pruefwert")?.value;
|
||||
const zustand = speicher.get("pl_oidc_zustand")?.value;
|
||||
const ziel = speicher.get("pl_oidc_ziel")?.value ?? "/";
|
||||
|
||||
for (const name of ["pl_oidc_pruefwert", "pl_oidc_zustand", "pl_oidc_ziel"]) {
|
||||
speicher.delete(name);
|
||||
}
|
||||
|
||||
if (!einstellungen || !pruefwert || !zustand) {
|
||||
return Response.redirect(new URL("/login?fehler=oidc", request.url), 302);
|
||||
}
|
||||
|
||||
try {
|
||||
const angaben = await anmeldungAbschliessen(einstellungen, new URL(request.url), pruefwert, zustand);
|
||||
const benutzer = await mitOidcAnmelden(angaben.subject, angaben.email, angaben.name);
|
||||
|
||||
const token = await sitzungErzeugen({
|
||||
benutzerId: benutzer.id,
|
||||
email: benutzer.email,
|
||||
name: benutzer.name,
|
||||
herkunft: "oidc",
|
||||
});
|
||||
speicher.set(SITZUNGS_COOKIE, token, cookieOptionen());
|
||||
|
||||
return Response.redirect(new URL(ziel.startsWith("/") ? ziel : "/", request.url), 302);
|
||||
} catch (fehler) {
|
||||
console.error("OIDC-Rückkehr fehlgeschlagen:", fehler);
|
||||
return Response.redirect(new URL("/login?fehler=oidc", request.url), 302);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { anmeldungStarten, oidcEinstellungen } from "@/server/auth/oidc";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Schickt den Browser zum Anmeldedienst und merkt sich PKCE-Prüfwert und Zustand im Cookie. */
|
||||
export async function GET(request: Request) {
|
||||
const einstellungen = oidcEinstellungen();
|
||||
if (!einstellungen) {
|
||||
return Response.redirect(new URL("/login?fehler=oidc", request.url), 302);
|
||||
}
|
||||
|
||||
const ziel = new URL(request.url).searchParams.get("ziel") ?? "/";
|
||||
|
||||
try {
|
||||
const start = await anmeldungStarten(einstellungen);
|
||||
const speicher = await cookies();
|
||||
const optionen = {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 600,
|
||||
};
|
||||
|
||||
speicher.set("pl_oidc_pruefwert", start.pruefwert, optionen);
|
||||
speicher.set("pl_oidc_zustand", start.zustand, optionen);
|
||||
speicher.set("pl_oidc_ziel", ziel.startsWith("/") ? ziel : "/", optionen);
|
||||
|
||||
return Response.redirect(start.url, 302);
|
||||
} catch (fehler) {
|
||||
console.error("OIDC-Start fehlgeschlagen:", fehler);
|
||||
return Response.redirect(new URL("/login?fehler=oidc", request.url), 302);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user