feat(auth): Anmeldung mit Passwort und Passkey
Kern der Benutzerverwaltung. Passwoerter: - scrypt aus der Node-Standardbibliothek (N=32768, r=8, p=1), speicherhart und ohne zusaetzliches natives Modul - Format scrypt$N$r$p$salt$hash, damit sich die Parameter spaeter anheben lassen - fakeVerifyDelay verbrennt bei unbekanntem Benutzernamen dieselbe Rechenzeit, sonst liesse sich an der Antwortzeit ablesen, welche Konten existieren Passkeys ueber SimpleWebAuthn 13: - Anmeldung ohne Benutzernamen ueber auffindbare Credentials - Challenges liegen in der Datenbank und werden beim Einloesen geloescht, koennen also nicht erneut eingespielt werden - rpID und Origin kommen aus APP_URL Sitzungen: - Zufallstoken im HttpOnly-Cookie, in der Datenbank nur dessen Hash - Abmelden und Sperren wirken sofort, weil bei jeder Anfrage nachgeschlagen wird - anders als bei einem selbsttragenden Token Middleware: CSRF-Pruefung ueber Sec-Fetch-Site bzw. Origin, Sicherheitskopfzeilen inklusive CSP mit Per-Request-Nonce und strict-dynamic, dazu eine grobe Zugangsschranke. Letztere ist ausdruecklich keine Sicherheitsgrenze - im Edge-Runtime gibt es keine Datenbank. Verbindlich entscheiden requireUserPage und requireUserApi, serverseitig, in jeder Seite und jedem Endpunkt. config.ts liest APP_NAME, APP_TAGLINE und APP_URL zur Laufzeit statt ueber NEXT_PUBLIC_-Variablen. Die waeren beim Build fest ins Bundle geschrieben; so laesst sich dasselbe Docker-Image ohne Neubau umbenennen und unter einer anderen Adresse betreiben. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { cookies } from "next/headers";
|
||||
import type { NextResponse } from "next/server";
|
||||
import type { User } from "@prisma/client";
|
||||
import { COOKIES_SECURE, SESSION_COOKIE, SESSION_TTL_MS } from "@/lib/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Serverseitige Sitzungen.
|
||||
*
|
||||
* Der Cookie enthaelt ein Zufallstoken, die Datenbank nur dessen SHA-256-Hash.
|
||||
* Wer die Datenbank liest, kann damit keine Sitzung uebernehmen. Abmelden und
|
||||
* Sperren wirken sofort, weil bei jeder Anfrage nachgeschlagen wird – anders
|
||||
* als bei einem selbsttragenden Token.
|
||||
*/
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("base64url");
|
||||
}
|
||||
|
||||
export interface SessionUser {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
email: string | null;
|
||||
role: User["role"];
|
||||
hasPassword: boolean;
|
||||
}
|
||||
|
||||
function toSessionUser(user: User): SessionUser {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
hasPassword: Boolean(user.passwordHash),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createSession(
|
||||
userId: string,
|
||||
meta: { userAgent?: string | null; ip?: string | null } = {},
|
||||
): Promise<{ token: string; expiresAt: Date }> {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
|
||||
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
userId,
|
||||
tokenHash: hashToken(token),
|
||||
expiresAt,
|
||||
userAgent: meta.userAgent?.slice(0, 300) ?? null,
|
||||
ip: meta.ip?.slice(0, 60) ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// Gelegenheit nutzen und abgelaufene Sitzungen wegraeumen.
|
||||
void prisma.session
|
||||
.deleteMany({ where: { expiresAt: { lt: new Date() } } })
|
||||
.catch(() => undefined);
|
||||
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
export function setSessionCookie(response: NextResponse, token: string, expiresAt: Date) {
|
||||
response.cookies.set({
|
||||
name: SESSION_COOKIE,
|
||||
value: token,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: COOKIES_SECURE,
|
||||
path: "/",
|
||||
expires: expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSessionCookie(response: NextResponse) {
|
||||
response.cookies.set({
|
||||
name: SESSION_COOKIE,
|
||||
value: "",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: COOKIES_SECURE,
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die Sitzung aus dem Cookie und prueft sie gegen die Datenbank.
|
||||
* Gibt null zurueck, wenn kein oder ein ungueltiges Token vorliegt oder das
|
||||
* Konto deaktiviert wurde.
|
||||
*/
|
||||
export async function getSessionUser(): Promise<SessionUser | null> {
|
||||
const store = await cookies();
|
||||
const token = store.get(SESSION_COOKIE)?.value;
|
||||
if (!token) return null;
|
||||
|
||||
const session = await prisma.session.findUnique({
|
||||
where: { tokenHash: hashToken(token) },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
// Hoechstens einmal pro Stunde schreiben, damit nicht jede Anfrage die
|
||||
// Datenbank anfasst.
|
||||
if (Date.now() - session.lastSeenAt.getTime() > 60 * 60 * 1000) {
|
||||
void prisma.session
|
||||
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
return toSessionUser(session.user);
|
||||
}
|
||||
|
||||
export async function destroyCurrentSession(): Promise<void> {
|
||||
const store = await cookies();
|
||||
const token = store.get(SESSION_COOKIE)?.value;
|
||||
if (!token) return;
|
||||
await prisma.session
|
||||
.deleteMany({ where: { tokenHash: hashToken(token) } })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Meldet ein Konto ueberall ab – nach Passwortwechsel oder Sperrung. */
|
||||
export async function destroyAllSessions(userId: string): Promise<void> {
|
||||
await prisma.session.deleteMany({ where: { userId } });
|
||||
}
|
||||
Reference in New Issue
Block a user