feat(auth): Einrichtung, Anmeldeseite und Registrierung

- /setup fuehrt beim ersten Aufruf durch das Anlegen des Administratorkontos
  und die Wahl der Betriebsart. Der Endpunkt funktioniert nur, solange es
  keinen Benutzer gibt; danach kann sich niemand mehr nachtraeglich ein
  Adminkonto erschleichen. Vorhandene Projekte ohne Besitzer werden dabei
  uebernommen und im Formular vorher angekuendigt.
- /login mit Passwort oder Passkey. Der Passkey-Knopf entfaellt, wenn APP_URL
  keinen sicheren Kontext ergibt - mit Hinweis, woran es liegt. Das Ziel nach
  der Anmeldung wird auf relative Pfade begrenzt, sonst waere der
  weiter-Parameter ein offener Redirect.
- /register nur im Mehrbenutzerbetrieb und nur, wenn ein Administrator die
  Selbstregistrierung freigegeben hat.
- Kopfzeile mit Benutzermenue: eigenes Konto, Admin-Einstellungen, Abmelden.
  Ohne Anmeldung bleibt sie auf das Logo reduziert.
- Layout liest Titel und Namen ueber generateMetadata, damit APP_NAME zur
  Laufzeit greift statt beim Build eingefroren zu werden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-25 21:30:33 +02:00
co-authored by Claude Opus 5
parent 13bc5aa0b8
commit 4f85e00016
19 changed files with 1415 additions and 40 deletions
+86
View File
@@ -0,0 +1,86 @@
import { NextResponse } from "next/server";
import { fakeVerifyDelay, verifyPassword } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session";
import { lockRemainingSeconds, noteFailedLogin, noteSuccessfulLogin } from "@/lib/auth/users";
import { prisma } from "@/lib/db";
import { LIMITS, rateLimit, resetRateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
import { loginSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/** Immer dieselbe Meldung – sonst verraet sie, welche Konten existieren. */
const GENERIC = "Benutzername oder Passwort ist falsch.";
export async function POST(request: Request) {
const ip = clientIp(request);
const byIp = rateLimit(`login:ip:${ip}`, LIMITS.login.limit, LIMITS.login.windowMs);
if (!byIp.ok) return tooManyRequests(byIp.retryAfter);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = loginSchema.safeParse(body.data);
if (!parsed.success) return jsonError(GENERIC, 401);
const { username, password } = parsed.data;
const byUser = rateLimit(
`login:user:${username}`,
LIMITS.loginUser.limit,
LIMITS.loginUser.windowMs,
);
if (!byUser.ok) return tooManyRequests(byUser.retryAfter);
const user = await prisma.user.findUnique({ where: { username } });
if (!user) {
// Gleiche Rechenzeit verbrennen wie bei einem echten Konto.
await fakeVerifyDelay();
return jsonError(GENERIC, 401);
}
const locked = lockRemainingSeconds(user.lockedUntil);
if (locked > 0) {
return jsonError(
`Das Konto ist nach mehreren Fehlversuchen noch ${Math.ceil(locked / 60)} Minuten gesperrt.`,
423,
);
}
if (!user.isActive) {
await fakeVerifyDelay();
return jsonError("Dieses Konto ist deaktiviert.", 403);
}
if (!user.passwordHash) {
await fakeVerifyDelay();
return jsonError(
"Fuer dieses Konto ist kein Passwort hinterlegt – bitte mit Passkey anmelden.",
401,
);
}
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) {
await noteFailedLogin(user.id);
return jsonError(GENERIC, 401);
}
await noteSuccessfulLogin(user.id);
resetRateLimit(`login:user:${username}`);
resetRateLimit(`login:ip:${ip}`);
const session = await createSession(user.id, {
userAgent: request.headers.get("user-agent"),
ip,
});
const response = NextResponse.json({
ok: true,
user: { id: user.id, username: user.username, role: user.role },
});
setSessionCookie(response, session.token, session.expiresAt);
return response;
}