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,10 @@
|
||||
/**
|
||||
* Konstanten, die Server und Browser gemeinsam brauchen.
|
||||
*
|
||||
* Bewusst in einer eigenen Datei ohne node:crypto-Import: password.ts laesst
|
||||
* sich in Client-Komponenten nicht einbinden, diese Werte schon.
|
||||
*/
|
||||
|
||||
export const PASSWORD_MIN_LENGTH = 10;
|
||||
export const USERNAME_MIN_LENGTH = 3;
|
||||
export const USERNAME_MAX_LENGTH = 32;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser, type SessionUser } from "@/lib/auth/session";
|
||||
import { getInstanceState } from "@/lib/auth/settings";
|
||||
|
||||
/**
|
||||
* Zugriffsschutz.
|
||||
*
|
||||
* Die Middleware prueft nur, ob ueberhaupt ein Sitzungscookie mitkommt – im
|
||||
* Edge-Runtime steht keine Datenbank zur Verfuegung. Die eigentliche
|
||||
* Entscheidung faellt hier, serverseitig, und zwar in jeder Seite und jedem
|
||||
* API-Endpunkt. Das ist die Sicherheitsgrenze.
|
||||
*/
|
||||
|
||||
export type { SessionUser };
|
||||
|
||||
/** Fuer Server Components: leitet auf /login bzw. /setup um. */
|
||||
export async function requireUserPage(): Promise<SessionUser> {
|
||||
const user = await getSessionUser();
|
||||
if (user) return user;
|
||||
|
||||
const state = await getInstanceState();
|
||||
redirect(state.needsSetup ? "/setup" : "/login");
|
||||
}
|
||||
|
||||
/** Wie requireUserPage, verlangt zusaetzlich die Administratorrolle. */
|
||||
export async function requireAdminPage(): Promise<SessionUser> {
|
||||
const user = await requireUserPage();
|
||||
if (user.role !== "ADMIN") redirect("/");
|
||||
return user;
|
||||
}
|
||||
|
||||
export type ApiAuth =
|
||||
| { ok: true; user: SessionUser }
|
||||
| { ok: false; response: NextResponse };
|
||||
|
||||
/** Fuer Route Handler: liefert entweder den Benutzer oder eine 401/403-Antwort. */
|
||||
export async function requireUserApi(): Promise<ApiAuth> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }),
|
||||
};
|
||||
}
|
||||
return { ok: true, user };
|
||||
}
|
||||
|
||||
export async function requireAdminApi(): Promise<ApiAuth> {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth;
|
||||
if (auth.user.role !== "ADMIN") {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{ error: "Diese Aktion ist Administratoren vorbehalten." },
|
||||
{ status: 403 },
|
||||
),
|
||||
};
|
||||
}
|
||||
return auth;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from "node:crypto";
|
||||
import { promisify } from "node:util";
|
||||
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
|
||||
|
||||
/**
|
||||
* Passwort-Hashing mit scrypt aus der Node-Standardbibliothek – kein
|
||||
* zusaetzliches natives Modul noetig, und scrypt ist speicherhart, also
|
||||
* deutlich unangenehmer fuer GPU-Angriffe als PBKDF2.
|
||||
*
|
||||
* Ablageformat: scrypt$<N>$<r>$<p>$<salt>$<hash> (salt/hash base64url)
|
||||
*/
|
||||
|
||||
const scrypt = promisify(scryptCallback) as (
|
||||
password: string | Buffer,
|
||||
salt: string | Buffer,
|
||||
keylen: number,
|
||||
options: { N: number; r: number; p: number; maxmem: number },
|
||||
) => Promise<Buffer>;
|
||||
|
||||
const PARAMS = { N: 32768, r: 8, p: 1 };
|
||||
const KEY_LENGTH = 64;
|
||||
// scrypt braucht rund 128 * N * r Bytes; der Standardwert von 32 MB reicht
|
||||
// dafuer nicht ganz.
|
||||
const MAX_MEM = 96 * 1024 * 1024;
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16);
|
||||
const derived = await scrypt(password.normalize("NFKC"), salt, KEY_LENGTH, {
|
||||
...PARAMS,
|
||||
maxmem: MAX_MEM,
|
||||
});
|
||||
return [
|
||||
"scrypt",
|
||||
PARAMS.N,
|
||||
PARAMS.r,
|
||||
PARAMS.p,
|
||||
salt.toString("base64url"),
|
||||
derived.toString("base64url"),
|
||||
].join("$");
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
password: string,
|
||||
stored: string | null | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!stored) return false;
|
||||
|
||||
const parts = stored.split("$");
|
||||
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
|
||||
|
||||
const N = Number(parts[1]);
|
||||
const r = Number(parts[2]);
|
||||
const p = Number(parts[3]);
|
||||
if (!Number.isFinite(N) || !Number.isFinite(r) || !Number.isFinite(p)) return false;
|
||||
|
||||
const salt = Buffer.from(parts[4], "base64url");
|
||||
const expected = Buffer.from(parts[5], "base64url");
|
||||
|
||||
let derived: Buffer;
|
||||
try {
|
||||
derived = await scrypt(password.normalize("NFKC"), salt, expected.length, {
|
||||
N,
|
||||
r,
|
||||
p,
|
||||
maxmem: MAX_MEM,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (derived.length !== expected.length) return false;
|
||||
return timingSafeEqual(derived, expected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbrennt vergleichbar viel Zeit wie eine echte Pruefung. Wird aufgerufen,
|
||||
* wenn es den Benutzernamen gar nicht gibt – sonst liesse sich an der
|
||||
* Antwortzeit ablesen, welche Konten existieren.
|
||||
*/
|
||||
export async function fakeVerifyDelay(): Promise<void> {
|
||||
await scrypt("nebula-dummy-password", "nebula-dummy-salt", KEY_LENGTH, {
|
||||
...PARAMS,
|
||||
maxmem: MAX_MEM,
|
||||
});
|
||||
}
|
||||
|
||||
const OBVIOUS = new Set([
|
||||
"password", "passwort", "12345678", "123456789", "1234567890",
|
||||
"qwertzuiop", "qwertyuiop", "administrator", "changeme", "letmein",
|
||||
"willkommen", "nebula123", "geheim123",
|
||||
]);
|
||||
|
||||
export { PASSWORD_MIN_LENGTH };
|
||||
|
||||
/** Gibt eine Fehlermeldung zurueck oder null, wenn das Passwort taugt. */
|
||||
export function validatePasswordStrength(password: string): string | null {
|
||||
if (password.length < PASSWORD_MIN_LENGTH) {
|
||||
return `Das Passwort muss mindestens ${PASSWORD_MIN_LENGTH} Zeichen haben.`;
|
||||
}
|
||||
if (password.length > 200) {
|
||||
return "Das Passwort darf hoechstens 200 Zeichen haben.";
|
||||
}
|
||||
if (OBVIOUS.has(password.toLowerCase())) {
|
||||
return "Dieses Passwort ist zu naheliegend.";
|
||||
}
|
||||
if (/^\d+$/.test(password)) {
|
||||
return "Nur Ziffern reichen nicht – bitte Buchstaben oder Zeichen ergaenzen.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { AppSettings } from "@prisma/client";
|
||||
import { APP_NAME } from "@/lib/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Einstellungen der Instanz. Es gibt genau eine Zeile mit der id "singleton";
|
||||
* sie wird beim ersten Zugriff angelegt.
|
||||
*/
|
||||
export async function getAppSettings(): Promise<AppSettings> {
|
||||
return prisma.appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
create: { id: "singleton", rpName: APP_NAME },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
export interface InstanceState {
|
||||
settings: AppSettings;
|
||||
userCount: number;
|
||||
/** Kein Konto vorhanden – die Einrichtung steht noch aus. */
|
||||
needsSetup: boolean;
|
||||
/** Darf sich jemand selbst registrieren? */
|
||||
registrationOpen: boolean;
|
||||
multiUser: boolean;
|
||||
}
|
||||
|
||||
export async function getInstanceState(): Promise<InstanceState> {
|
||||
const [settings, userCount] = await Promise.all([
|
||||
getAppSettings(),
|
||||
prisma.user.count(),
|
||||
]);
|
||||
|
||||
const multiUser = settings.mode === "MULTI_USER";
|
||||
|
||||
return {
|
||||
settings,
|
||||
userCount,
|
||||
needsSetup: userCount === 0,
|
||||
// Im Einzelbetrieb gibt es grundsaetzlich keine Selbstregistrierung.
|
||||
registrationOpen: multiUser && settings.allowRegistration,
|
||||
multiUser,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/** Benutzerfelder, die an den Browser gehen duerfen. */
|
||||
export const userSafeSelect = {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
lastLoginAt: true,
|
||||
lockedUntil: true,
|
||||
createdAt: true,
|
||||
_count: { select: { credentials: true, projects: true, sessions: true } },
|
||||
} satisfies Prisma.UserSelect;
|
||||
|
||||
export type UserListEntry = Prisma.UserGetPayload<{ select: typeof userSafeSelect }>;
|
||||
|
||||
/** Sperre nach zu vielen Fehlversuchen. */
|
||||
const LOCK_THRESHOLD = 8;
|
||||
const LOCK_MINUTES = 15;
|
||||
|
||||
export async function noteFailedLogin(userId: string): Promise<void> {
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { failedLoginCount: { increment: 1 } },
|
||||
select: { failedLoginCount: true },
|
||||
});
|
||||
|
||||
if (user.failedLoginCount >= LOCK_THRESHOLD) {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
lockedUntil: new Date(Date.now() + LOCK_MINUTES * 60 * 1000),
|
||||
failedLoginCount: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function noteSuccessfulLogin(userId: string): Promise<void> {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { lastLoginAt: new Date(), failedLoginCount: 0, lockedUntil: null },
|
||||
});
|
||||
}
|
||||
|
||||
export function lockRemainingSeconds(lockedUntil: Date | null): number {
|
||||
if (!lockedUntil) return 0;
|
||||
const remaining = lockedUntil.getTime() - Date.now();
|
||||
return remaining > 0 ? Math.ceil(remaining / 1000) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uebernimmt Projekte und Verbindungen ohne Besitzer.
|
||||
*
|
||||
* Greift genau einmal: beim Anlegen des ersten Kontos. So gehen Daten, die
|
||||
* vor der Einfuehrung der Benutzerverwaltung entstanden sind, nicht verloren.
|
||||
*/
|
||||
export async function claimOrphanedData(userId: string): Promise<{
|
||||
projects: number;
|
||||
connections: number;
|
||||
}> {
|
||||
const [projects, connections] = await prisma.$transaction([
|
||||
prisma.project.updateMany({ where: { ownerId: null }, data: { ownerId: userId } }),
|
||||
prisma.gitConnection.updateMany({ where: { ownerId: null }, data: { ownerId: userId } }),
|
||||
]);
|
||||
return { projects: projects.count, connections: connections.count };
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
AuthenticatorTransportFuture,
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
RegistrationResponseJSON,
|
||||
} from "@simplewebauthn/server";
|
||||
import type { Credential, User } from "@prisma/client";
|
||||
import { APP_NAME, WEBAUTHN_AVAILABLE, WEBAUTHN_ORIGIN, WEBAUTHN_RP_ID } from "@/lib/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Passkeys (WebAuthn).
|
||||
*
|
||||
* Die Challenge liegt in der Datenbank statt in einem Cookie: so laesst sie
|
||||
* sich nach einmaligem Gebrauch sicher loeschen und kann nicht erneut
|
||||
* eingespielt werden. Ausserdem funktioniert das ueber mehrere Prozesse.
|
||||
*
|
||||
* rpID und Origin kommen aus APP_URL. Passkeys sind an die rpID gebunden –
|
||||
* wird APP_URL auf einen anderen Hostnamen geaendert, muessen die Passkeys
|
||||
* neu angelegt werden.
|
||||
*/
|
||||
|
||||
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export class WebAuthnUnavailableError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"Passkeys benoetigen einen sicheren Kontext. Bitte APP_URL auf eine " +
|
||||
"https-Adresse (oder http://localhost) setzen.",
|
||||
);
|
||||
this.name = "WebAuthnUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
function assertAvailable() {
|
||||
if (!WEBAUTHN_AVAILABLE) throw new WebAuthnUnavailableError();
|
||||
}
|
||||
|
||||
async function storeChallenge(
|
||||
challenge: string,
|
||||
purpose: "registration" | "authentication",
|
||||
userId: string | null,
|
||||
) {
|
||||
// Abgelaufene Challenges bei der Gelegenheit entfernen.
|
||||
await prisma.webAuthnChallenge.deleteMany({ where: { expiresAt: { lt: new Date() } } });
|
||||
await prisma.webAuthnChallenge.create({
|
||||
data: {
|
||||
challenge,
|
||||
purpose,
|
||||
userId,
|
||||
expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Holt die Challenge und loescht sie sofort – jede gilt genau einmal. */
|
||||
async function consumeChallenge(
|
||||
challenge: string,
|
||||
purpose: "registration" | "authentication",
|
||||
): Promise<{ userId: string | null } | null> {
|
||||
const stored = await prisma.webAuthnChallenge.findUnique({ where: { challenge } });
|
||||
if (!stored) return null;
|
||||
|
||||
await prisma.webAuthnChallenge.delete({ where: { id: stored.id } }).catch(() => undefined);
|
||||
|
||||
if (stored.purpose !== purpose) return null;
|
||||
if (stored.expiresAt.getTime() <= Date.now()) return null;
|
||||
|
||||
return { userId: stored.userId };
|
||||
}
|
||||
|
||||
// ── Registrierung eines neuen Passkeys ───────────────────────
|
||||
|
||||
export async function buildRegistrationOptions(
|
||||
user: Pick<User, "id" | "username" | "displayName">,
|
||||
): Promise<PublicKeyCredentialCreationOptionsJSON> {
|
||||
assertAvailable();
|
||||
|
||||
const existing = await prisma.credential.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { credentialId: true, transports: true },
|
||||
});
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: APP_NAME,
|
||||
rpID: WEBAUTHN_RP_ID,
|
||||
userID: new TextEncoder().encode(user.id),
|
||||
userName: user.username,
|
||||
userDisplayName: user.displayName ?? user.username,
|
||||
attestationType: "none",
|
||||
// Bereits registrierte Passkeys ausschliessen, damit derselbe
|
||||
// Authenticator nicht doppelt hinterlegt wird.
|
||||
excludeCredentials: existing.map((credential) => ({
|
||||
id: credential.credentialId,
|
||||
transports: credential.transports as AuthenticatorTransportFuture[],
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: "preferred",
|
||||
userVerification: "preferred",
|
||||
},
|
||||
});
|
||||
|
||||
await storeChallenge(options.challenge, "registration", user.id);
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function confirmRegistration(
|
||||
userId: string,
|
||||
response: RegistrationResponseJSON,
|
||||
name: string | null,
|
||||
): Promise<Credential> {
|
||||
assertAvailable();
|
||||
|
||||
const expectedChallenge = response.response.clientDataJSON
|
||||
? decodeChallenge(response.response.clientDataJSON)
|
||||
: null;
|
||||
if (!expectedChallenge) {
|
||||
throw new Error("Die Antwort des Authenticators enthaelt keine Challenge.");
|
||||
}
|
||||
|
||||
const stored = await consumeChallenge(expectedChallenge, "registration");
|
||||
if (!stored) {
|
||||
throw new Error("Die Challenge ist abgelaufen oder wurde bereits verwendet.");
|
||||
}
|
||||
if (stored.userId !== userId) {
|
||||
throw new Error("Die Challenge gehoert zu einem anderen Konto.");
|
||||
}
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin: WEBAUTHN_ORIGIN,
|
||||
expectedRPID: WEBAUTHN_RP_ID,
|
||||
requireUserVerification: false,
|
||||
});
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
throw new Error("Der Passkey konnte nicht bestaetigt werden.");
|
||||
}
|
||||
|
||||
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
||||
|
||||
return prisma.credential.create({
|
||||
data: {
|
||||
userId,
|
||||
credentialId: credential.id,
|
||||
publicKey: Buffer.from(credential.publicKey),
|
||||
counter: credential.counter,
|
||||
transports: (credential.transports ?? []) as string[],
|
||||
deviceType: credentialDeviceType,
|
||||
backedUp: credentialBackedUp,
|
||||
name: name?.trim() || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Anmeldung per Passkey ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ohne allowCredentials: der Browser bietet alle passenden, auffindbaren
|
||||
* Passkeys an. Der Benutzername muss also nicht eingegeben werden.
|
||||
*/
|
||||
export async function buildAuthenticationOptions(): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
assertAvailable();
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: WEBAUTHN_RP_ID,
|
||||
userVerification: "preferred",
|
||||
});
|
||||
|
||||
await storeChallenge(options.challenge, "authentication", null);
|
||||
return options;
|
||||
}
|
||||
|
||||
export interface PasskeyLoginResult {
|
||||
userId: string;
|
||||
credentialId: string;
|
||||
}
|
||||
|
||||
export async function confirmAuthentication(
|
||||
response: AuthenticationResponseJSON,
|
||||
): Promise<PasskeyLoginResult> {
|
||||
assertAvailable();
|
||||
|
||||
const expectedChallenge = decodeChallenge(response.response.clientDataJSON);
|
||||
if (!expectedChallenge) {
|
||||
throw new Error("Die Antwort des Authenticators enthaelt keine Challenge.");
|
||||
}
|
||||
|
||||
const stored = await consumeChallenge(expectedChallenge, "authentication");
|
||||
if (!stored) {
|
||||
throw new Error("Die Challenge ist abgelaufen oder wurde bereits verwendet.");
|
||||
}
|
||||
|
||||
const credential = await prisma.credential.findUnique({
|
||||
where: { credentialId: response.id },
|
||||
include: { user: true },
|
||||
});
|
||||
if (!credential) {
|
||||
throw new Error("Dieser Passkey ist hier nicht hinterlegt.");
|
||||
}
|
||||
if (!credential.user.isActive) {
|
||||
throw new Error("Das Konto ist deaktiviert.");
|
||||
}
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin: WEBAUTHN_ORIGIN,
|
||||
expectedRPID: WEBAUTHN_RP_ID,
|
||||
requireUserVerification: false,
|
||||
credential: {
|
||||
id: credential.credentialId,
|
||||
publicKey: new Uint8Array(credential.publicKey),
|
||||
counter: credential.counter,
|
||||
transports: credential.transports as AuthenticatorTransportFuture[],
|
||||
},
|
||||
});
|
||||
|
||||
if (!verification.verified) {
|
||||
throw new Error("Der Passkey konnte nicht bestaetigt werden.");
|
||||
}
|
||||
|
||||
// Zaehler fortschreiben – ein Ruecksprung deutet auf einen Klon hin,
|
||||
// SimpleWebAuthn wirft in dem Fall bereits oben.
|
||||
await prisma.credential.update({
|
||||
where: { id: credential.id },
|
||||
data: {
|
||||
counter: verification.authenticationInfo.newCounter,
|
||||
lastUsedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return { userId: credential.userId, credentialId: credential.credentialId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die Challenge aus clientDataJSON. Die Bibliothek prueft sie zwar
|
||||
* selbst, wir brauchen sie aber vorher, um den passenden Datenbankeintrag
|
||||
* zu finden und zu entwerten.
|
||||
*/
|
||||
function decodeChallenge(clientDataJSON: string): string | null {
|
||||
try {
|
||||
const json = JSON.parse(Buffer.from(clientDataJSON, "base64url").toString("utf8")) as {
|
||||
challenge?: string;
|
||||
};
|
||||
return typeof json.challenge === "string" ? json.challenge : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Laufzeitkonfiguration.
|
||||
*
|
||||
* Bewusst ohne NEXT_PUBLIC_-Variablen: die wuerden beim Build fest in das
|
||||
* JavaScript-Bundle geschrieben. So laesst sich dasselbe Docker-Image ohne
|
||||
* Neubau anders benennen und unter einer anderen URL betreiben. Werte, die
|
||||
* der Browser braucht, reicht das Layout als Props weiter.
|
||||
*/
|
||||
|
||||
function trimmed(value: string | undefined): string | null {
|
||||
const result = value?.trim();
|
||||
return result ? result : null;
|
||||
}
|
||||
|
||||
/** Anzeigename der Instanz – Seitentitel, Kopfzeile, Passkey-Dialog. */
|
||||
export const APP_NAME = trimmed(process.env.APP_NAME) ?? "Nebula";
|
||||
|
||||
/** Kleine Zeile unter dem Namen in der Kopfzeile. */
|
||||
export const APP_TAGLINE = trimmed(process.env.APP_TAGLINE) ?? "Vibe Coding";
|
||||
|
||||
const RAW_APP_URL = trimmed(process.env.APP_URL) ?? "http://localhost:3000";
|
||||
|
||||
function parseAppUrl(raw: string): URL {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error(`APP_URL muss http oder https sein, ist aber "${url.protocol}"`);
|
||||
}
|
||||
return url;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`APP_URL ist keine gueltige URL: "${raw}". Beispiel: https://nebula.example.com` +
|
||||
` (${error instanceof Error ? error.message : String(error)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const APP_URL_PARSED = parseAppUrl(RAW_APP_URL);
|
||||
|
||||
/** Vollstaendige Basis-URL ohne abschliessenden Slash. */
|
||||
export const APP_URL = APP_URL_PARSED.origin;
|
||||
|
||||
/** Origin, den WebAuthn in der Assertion erwartet. */
|
||||
export const WEBAUTHN_ORIGIN = APP_URL_PARSED.origin;
|
||||
|
||||
/**
|
||||
* Relying-Party-ID fuer WebAuthn: der reine Hostname ohne Port.
|
||||
* Passkeys sind an diesen Wert gebunden – aendert er sich, sind bestehende
|
||||
* Passkeys nicht mehr verwendbar.
|
||||
*/
|
||||
export const WEBAUTHN_RP_ID = APP_URL_PARSED.hostname;
|
||||
|
||||
/**
|
||||
* WebAuthn verlangt einen sicheren Kontext. Ausnahme: localhost gilt in
|
||||
* Browsern als sicher, auch ohne TLS.
|
||||
*/
|
||||
export const WEBAUTHN_AVAILABLE =
|
||||
APP_URL_PARSED.protocol === "https:" ||
|
||||
APP_URL_PARSED.hostname === "localhost" ||
|
||||
APP_URL_PARSED.hostname === "127.0.0.1";
|
||||
|
||||
/** Cookies nur ueber TLS ausliefern, wenn die App per https laeuft. */
|
||||
export const COOKIES_SECURE = APP_URL_PARSED.protocol === "https:";
|
||||
|
||||
/** Lebensdauer einer Sitzung. */
|
||||
export const SESSION_TTL_DAYS = Number(process.env.SESSION_TTL_DAYS ?? 30);
|
||||
export const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
export const SESSION_COOKIE = "nebula_session";
|
||||
|
||||
/** Wird beim Start einmal geloggt, damit Fehlkonfiguration frueh auffaellt. */
|
||||
export function describeConfig(): string {
|
||||
return [
|
||||
`name=${APP_NAME}`,
|
||||
`url=${APP_URL}`,
|
||||
`rpId=${WEBAUTHN_RP_ID}`,
|
||||
`passkeys=${WEBAUTHN_AVAILABLE ? "moeglich" : "deaktiviert (kein sicherer Kontext)"}`,
|
||||
`secureCookies=${COOKIES_SECURE}`,
|
||||
].join(" ");
|
||||
}
|
||||
Reference in New Issue
Block a user