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:
+224
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user