feat: Registrierung, Anmeldung und Sitzungen
Passwörter mit bcrypt, Sitzungen als opake Token in der Datenbank — im Cookie steht das Klartext-Token, in der Tabelle nur dessen SHA-256. Damit lassen sich einzelne Geräte gezielt abmelden, was reine JWT-Sitzungen nicht hergeben. Der erste registrierte Zugang wird automatisch Administrator, sonst käme man nach dem Aufsetzen nicht in die Verwaltung. Die Registrierung ist instanzweit abschaltbar; solange es keinen Nutzer gibt, bleibt sie offen. Enthält außerdem API-Schlüssel je Nutzer als Grundlage für die spätere REST-API und die Instanz-Einstellungen in der Tabelle AppSetting.
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { destroySession } from "@/lib/auth";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/** Abmelden per POST — GET würde sonst durch Link-Prefetching ausgelöst. */
|
||||
export async function POST() {
|
||||
await destroySession();
|
||||
return NextResponse.redirect(new URL("/anmelden", env.appUrl), { status: 303 });
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { env } from "@/lib/env";
|
||||
import { createSession } from "@/lib/auth";
|
||||
import { exchangeCode } from "@/lib/oidc";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function fail(message: string) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/anmelden?fehler=${encodeURIComponent(message)}`, env.appUrl),
|
||||
);
|
||||
}
|
||||
|
||||
/** Rücksprung vom Anbieter: Code einlösen, Konto zuordnen, Sitzung anlegen. */
|
||||
export async function GET(request: Request) {
|
||||
if (!env.oidc.isConfigured) return fail("SSO ist auf dieser Instanz nicht eingerichtet.");
|
||||
|
||||
const url = new URL(request.url);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
const providerError = url.searchParams.get("error");
|
||||
|
||||
if (providerError) return fail(`Der Anbieter meldet: ${providerError}`);
|
||||
if (!code || !state) return fail("Unvollständige Antwort vom Anbieter.");
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const raw = cookieStore.get("flimmer_oidc")?.value;
|
||||
cookieStore.delete({ name: "flimmer_oidc", path: "/api/auth/oidc" });
|
||||
if (!raw) return fail("Der Anmeldevorgang ist abgelaufen. Bitte noch einmal versuchen.");
|
||||
|
||||
let stored: { state: string; verifier: string };
|
||||
try {
|
||||
stored = JSON.parse(raw);
|
||||
} catch {
|
||||
return fail("Der Anmeldevorgang war ungültig.");
|
||||
}
|
||||
if (stored.state !== state) return fail("Der Anmeldevorgang war ungültig (state).");
|
||||
|
||||
let identity;
|
||||
try {
|
||||
identity = await exchangeCode(code, stored.verifier);
|
||||
} catch (error) {
|
||||
return fail(error instanceof Error ? error.message : "SSO-Anmeldung fehlgeschlagen.");
|
||||
}
|
||||
|
||||
// 1. Bereits verknüpft?
|
||||
const link = await prisma.oidcAccount.findUnique({
|
||||
where: { issuer_subject: { issuer: identity.issuer, subject: identity.subject } },
|
||||
select: { user: { select: { id: true, isActive: true } } },
|
||||
});
|
||||
|
||||
if (link) {
|
||||
if (!link.user.isActive) return fail("Dieses Konto ist deaktiviert.");
|
||||
await createSession(link.user.id);
|
||||
return NextResponse.redirect(new URL("/", env.appUrl));
|
||||
}
|
||||
|
||||
// 2. Über die E-Mail-Adresse einem vorhandenen Konto zuordnen.
|
||||
if (identity.email) {
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { email: identity.email },
|
||||
select: { id: true, isActive: true },
|
||||
});
|
||||
if (existing) {
|
||||
if (!existing.isActive) return fail("Dieses Konto ist deaktiviert.");
|
||||
await prisma.oidcAccount.create({
|
||||
data: { userId: existing.id, issuer: identity.issuer, subject: identity.subject },
|
||||
});
|
||||
await createSession(existing.id);
|
||||
return NextResponse.redirect(new URL("/", env.appUrl));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Neu anlegen — sofern der Betreiber das erlaubt.
|
||||
if (!env.oidc.autoRegister) {
|
||||
return fail("Zu dieser SSO-Kennung gibt es kein Konto. Bitte an die Administration wenden.");
|
||||
}
|
||||
if (!identity.email) {
|
||||
return fail("Der Anbieter liefert keine E-Mail-Adresse — ein Konto lässt sich so nicht anlegen.");
|
||||
}
|
||||
|
||||
const isFirstUser = (await prisma.user.count()) === 0;
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: identity.email,
|
||||
name: identity.name ?? identity.email.split("@")[0] ?? "Unbekannt",
|
||||
role: isFirstUser ? "ADMIN" : "USER",
|
||||
oidcAccounts: {
|
||||
create: { issuer: identity.issuer, subject: identity.subject },
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await createSession(user.id);
|
||||
return NextResponse.redirect(new URL("/", env.appUrl));
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { env } from "@/lib/env";
|
||||
import { authorizationUrl, createChallenge } from "@/lib/oidc";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Startet den SSO-Anmeldevorgang. */
|
||||
export async function GET() {
|
||||
if (!env.oidc.isConfigured) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/anmelden?fehler=SSO ist auf dieser Instanz nicht eingerichtet.", env.appUrl),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { state, verifier, challenge } = createChallenge();
|
||||
const target = await authorizationUrl(state, challenge);
|
||||
|
||||
// state und Verifier begleiten den Umweg über den Anbieter in einem
|
||||
// kurzlebigen, nicht auslesbaren Cookie.
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("flimmer_oidc", JSON.stringify({ state, verifier }), {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/api/auth/oidc",
|
||||
maxAge: 600,
|
||||
});
|
||||
|
||||
return NextResponse.redirect(target);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
return NextResponse.redirect(
|
||||
new URL(`/anmelden?fehler=${encodeURIComponent(`SSO nicht erreichbar: ${message}`)}`, env.appUrl),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user