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:
Florian Wilde
2026-09-01 18:46:27 +02:00
parent 3e11e4ffb1
commit 01c0f9bc9f
10 changed files with 812 additions and 0 deletions
+224
View File
@@ -0,0 +1,224 @@
import "server-only";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import bcrypt from "bcryptjs";
import { prisma } from "./db";
import type { Role, User } from "@/generated/prisma/client";
export const SESSION_COOKIE = "flimmer_session";
const SESSION_DAYS = 30;
/** lastSeen nur stündlich fortschreiben — sonst schreibt jeder Seitenaufruf. */
const LAST_SEEN_INTERVAL_MS = 60 * 60 * 1000;
// ---------------------------------------------------------------------------
// Passwörter
// ---------------------------------------------------------------------------
export async function hashPassword(plain: string): Promise<string> {
return bcrypt.hash(plain, 12);
}
export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
return bcrypt.compare(plain, hash);
}
/**
* Mindestanforderung an Passwörter. Bewusst schlicht gehalten: Länge schlägt
* Zeichenklassen-Akrobatik.
*/
export function checkPasswordStrength(password: string): string | null {
if (password.length < 10) return "Das Passwort muss mindestens 10 Zeichen lang sein.";
if (password.length > 200) return "Das Passwort darf höchstens 200 Zeichen lang sein.";
if (/^\s|\s$/.test(password)) return "Das Passwort darf nicht mit einem Leerzeichen beginnen oder enden.";
return null;
}
// ---------------------------------------------------------------------------
// Sitzungen
// ---------------------------------------------------------------------------
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
/**
* Legt eine Sitzung an und setzt das Cookie.
* Im Klartext existiert das Token nur einmal — in der Datenbank liegt der Hash.
*/
export async function createSession(userId: string): Promise<void> {
const token = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
const headerList = await headers();
await prisma.session.create({
data: {
userId,
tokenHash: hashToken(token),
expiresAt,
userAgent: headerList.get("user-agent")?.slice(0, 255) ?? null,
ip:
headerList.get("x-forwarded-for")?.split(",")[0]?.trim() ??
headerList.get("x-real-ip") ??
null,
},
});
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
expires: expiresAt,
});
}
export type SessionUser = Pick<
User,
| "id"
| "email"
| "name"
| "role"
| "region"
| "locale"
| "publicProfile"
| "profileSlug"
| "isActive"
>;
const SESSION_USER_FIELDS = {
id: true,
email: true,
name: true,
role: true,
region: true,
locale: true,
publicProfile: true,
profileSlug: true,
isActive: true,
} as const;
/** Liefert den angemeldeten Nutzer oder null. Wirft nie. */
export async function getSessionUser(): Promise<SessionUser | null> {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (!token) return null;
const session = await prisma.session.findUnique({
where: { tokenHash: hashToken(token) },
select: {
id: true,
expiresAt: true,
lastSeen: true,
user: { select: SESSION_USER_FIELDS },
},
});
if (!session) return null;
if (session.expiresAt.getTime() < Date.now()) {
await prisma.session.delete({ where: { id: session.id } }).catch(() => undefined);
return null;
}
if (!session.user.isActive) return null;
if (Date.now() - session.lastSeen.getTime() > LAST_SEEN_INTERVAL_MS) {
await prisma.session
.update({ where: { id: session.id }, data: { lastSeen: new Date() } })
.catch(() => undefined);
}
return session.user;
}
/** Für Seiten, die eine Anmeldung voraussetzen. */
export async function requireUser(returnTo?: string): Promise<SessionUser> {
const user = await getSessionUser();
if (!user) {
const target = returnTo ? `?weiter=${encodeURIComponent(returnTo)}` : "";
redirect(`/anmelden${target}`);
}
return user;
}
export async function requireAdmin(): Promise<SessionUser> {
const user = await requireUser();
if (user.role !== ("ADMIN" satisfies Role)) {
redirect("/");
}
return user;
}
export async function destroySession(): Promise<void> {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (token) {
await prisma.session
.deleteMany({ where: { tokenHash: hashToken(token) } })
.catch(() => undefined);
}
cookieStore.delete(SESSION_COOKIE);
}
/** Meldet alle Geräte eines Nutzers ab (z. B. nach Passwortwechsel). */
export async function destroyAllSessions(userId: string): Promise<void> {
await prisma.session.deleteMany({ where: { userId } });
}
// ---------------------------------------------------------------------------
// API-Schlüssel
// ---------------------------------------------------------------------------
const API_KEY_PREFIX = "flm";
/** Erzeugt einen neuen API-Schlüssel. Der Klartext wird nur hier zurückgegeben. */
export async function createApiKey(userId: string, name: string) {
const secret = randomBytes(24).toString("base64url");
const prefix = `${API_KEY_PREFIX}_${randomBytes(4).toString("hex")}`;
const plain = `${prefix}.${secret}`;
await prisma.apiKey.create({
data: { userId, name, prefix, keyHash: hashToken(plain) },
});
return { plain, prefix };
}
/** Prüft einen Bearer-Token aus dem Authorization-Header. */
export async function authenticateApiKey(rawHeader: string | null): Promise<SessionUser | null> {
const match = rawHeader?.match(/^Bearer\s+(.+)$/i);
if (!match?.[1]) return null;
const plain = match[1].trim();
const prefix = plain.split(".")[0];
if (!prefix) return null;
const key = await prisma.apiKey.findUnique({
where: { prefix },
select: { id: true, keyHash: true, revokedAt: true, user: { select: SESSION_USER_FIELDS } },
});
if (!key || key.revokedAt) return null;
const given = Buffer.from(hashToken(plain));
const stored = Buffer.from(key.keyHash);
if (given.length !== stored.length || !timingSafeEqual(given, stored)) return null;
if (!key.user.isActive) return null;
await prisma.apiKey
.update({ where: { id: key.id }, data: { lastUsedAt: new Date() } })
.catch(() => undefined);
return key.user;
}
/**
* Nutzer für einen API-Request: erst Bearer-Token, sonst Sitzungscookie.
* So funktionieren dieselben Endpunkte für Skripte und für das eigene Frontend.
*/
export async function getApiUser(request: Request): Promise<SessionUser | null> {
const viaKey = await authenticateApiKey(request.headers.get("authorization"));
if (viaKey) return viaKey;
return getSessionUser();
}
+68
View File
@@ -0,0 +1,68 @@
import "server-only";
import { prisma } from "./db";
import { env } from "./env";
/**
* Instanzweite Einstellungen.
*
* Was der Betreiber im laufenden Betrieb ändern können muss, liegt in der
* Datenbank (Tabelle AppSetting) — nicht in der .env, denn dafür müsste der
* Container neu gestartet werden. Die .env liefert nur die Erstbelegung.
*/
export type AppSettings = {
/** Dürfen sich neue Nutzer selbst registrieren? */
registrationEnabled: boolean;
/** Name der Instanz, erscheint im Header und in Benachrichtigungen. */
instanceName: string;
/** Öffentliche Profilseiten instanzweit erlauben. */
publicProfilesEnabled: boolean;
};
const DEFAULTS: AppSettings = {
registrationEnabled: env.allowRegistrationDefault,
instanceName: env.appName,
publicProfilesEnabled: true,
};
export async function getSettings(): Promise<AppSettings> {
const rows = await prisma.appSetting.findMany();
const stored = Object.fromEntries(rows.map((r) => [r.key, r.value])) as Partial<AppSettings>;
return {
registrationEnabled:
typeof stored.registrationEnabled === "boolean"
? stored.registrationEnabled
: DEFAULTS.registrationEnabled,
instanceName:
typeof stored.instanceName === "string" && stored.instanceName.trim()
? stored.instanceName
: DEFAULTS.instanceName,
publicProfilesEnabled:
typeof stored.publicProfilesEnabled === "boolean"
? stored.publicProfilesEnabled
: DEFAULTS.publicProfilesEnabled,
};
}
export async function setSetting<K extends keyof AppSettings>(
key: K,
value: AppSettings[K],
): Promise<void> {
await prisma.appSetting.upsert({
where: { key },
create: { key, value: value as never },
update: { value: value as never },
});
}
/**
* Registrierung ist offen, solange es noch gar keinen Nutzer gibt — sonst
* käme man nach dem Aufsetzen nicht mehr an den ersten Admin-Zugang.
*/
export async function isRegistrationOpen(): Promise<boolean> {
const userCount = await prisma.user.count();
if (userCount === 0) return true;
const settings = await getSettings();
return settings.registrationEnabled;
}