Modul-Review 1: Auth-Nachbesserungen (Sicherheit & UX)
Deploy App / deploy (push) Successful in 1m10s
Deploy App / deploy (push) Successful in 1m10s
Ergebnis der ersten Test- und Review-Runde zum Modul "Zugang & App-Rahmen": - Login-Timing-Ausgleich: unbekannter Benutzer wird gegen Dummy-bcrypt-Hash geprueft -> Antwortzeit verraet nicht mehr, ob ein Name existiert - Zurueck-Knopf nach Logout: pageshow-Waechter prueft die Session erneut und leitet die aus dem bfcache zurueckgeholte Ansicht auf /login - Rate-Limiting (neues lib/rate-limit.ts): Login 10/15min, Registrierung 5/h je IP, Passwortaenderung 10/15min je Benutzer; 429 + Retry-After - Passwort-Dialog laeuft neu ueber Modal -> schliesst auf Esc (Fokus-Falle, aria-modal inklusive) - Registrierungs-Fehler getrennt: nur belegter Name = 409 mit freundlicher Meldung, sonst 500 statt roher Prisma-Meldung SPEZIFIKATION 0.27 (3.1.2/3/4, neues 3.1.6). 261 -> 267 Tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { changeUserPassword } from "@/lib/users";
|
||||
import { rateLimit, tooManyRequests } from "@/lib/rate-limit";
|
||||
|
||||
const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
@@ -14,6 +15,11 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
}
|
||||
|
||||
// Bremse gegen das Durchprobieren des aktuellen Passworts: 10 Versuche je Benutzer und
|
||||
// 15 Minuten (nach dem Auth-Check, damit der Schlüssel die echte Benutzer-ID ist).
|
||||
const limited = rateLimit(`change-pw:${userId}`, 10, 15 * 60 * 1000);
|
||||
if (!limited.ok) return tooManyRequests(limited.retryAfterSeconds);
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = changePasswordSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/auth";
|
||||
import { verifyUserCredentials } from "@/lib/users";
|
||||
import { clientIp, rateLimit, tooManyRequests } from "@/lib/rate-limit";
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
@@ -9,6 +10,10 @@ const loginSchema = z.object({
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// Bremse gegen Brute-Force: 10 Versuche je IP und 15 Minuten.
|
||||
const limited = rateLimit(`login:${clientIp(request)}`, 10, 15 * 60 * 1000);
|
||||
if (!limited.ok) return tooManyRequests(limited.retryAfterSeconds);
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = loginSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/auth";
|
||||
import { registerUser, validateUsername } from "@/lib/users";
|
||||
import { registerUser, UsernameTakenError, validateUsername } from "@/lib/users";
|
||||
import { clientIp, rateLimit, tooManyRequests } from "@/lib/rate-limit";
|
||||
|
||||
const registerSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
@@ -9,6 +10,10 @@ const registerSchema = z.object({
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// Bremse gegen Massen-Registrierung: 5 Konten je IP und Stunde.
|
||||
const limited = rateLimit(`register:${clientIp(request)}`, 5, 60 * 60 * 1000);
|
||||
if (!limited.ok) return tooManyRequests(limited.retryAfterSeconds);
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = registerSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
@@ -25,10 +30,13 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
user = await registerUser(parsed.data.username, parsed.data.password);
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: e instanceof Error ? e.message : "Registrierung fehlgeschlagen." },
|
||||
{ status: 409 }
|
||||
);
|
||||
// Nur der "Name vergeben"-Fall ist ein 409 mit freundlicher Meldung; alles andere
|
||||
// (z. B. DB kurzzeitig weg) ist ein echter Serverfehler und darf nicht als Konflikt
|
||||
// mit roher Prisma-Meldung durchgereicht werden.
|
||||
if (e instanceof UsernameTakenError) {
|
||||
return NextResponse.json({ error: e.message }, { status: 409 });
|
||||
}
|
||||
return NextResponse.json({ error: "Registrierung fehlgeschlagen." }, { status: 500 });
|
||||
}
|
||||
|
||||
const token = await createSessionToken(user.id);
|
||||
|
||||
@@ -17,6 +17,21 @@ export default function Home() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Nach dem Abmelden führt der Zurück-Knopf zu dieser Seite aus dem Browser-Cache (bfcache)
|
||||
// zurück -- OHNE neue Anfrage, also ohne dass Middleware oder Session-Prüfung greifen. Die
|
||||
// eingefrorene Ansicht darf nicht stehen bleiben: Bei einer aus dem bfcache zurückgeholten
|
||||
// Seite die Session erneut prüfen und bei fehlender Anmeldung sofort auf /login gehen.
|
||||
useEffect(() => {
|
||||
function onPageShow(e: PageTransitionEvent) {
|
||||
if (!e.persisted) return;
|
||||
api.get("/api/auth/me").catch(() => {
|
||||
window.location.href = "/login";
|
||||
});
|
||||
}
|
||||
window.addEventListener("pageshow", onPageShow);
|
||||
return () => window.removeEventListener("pageshow", onPageShow);
|
||||
}, []);
|
||||
|
||||
if (username === null) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { KeyRound, LogOut, Palette, UserCircle2 } from "lucide-react";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { Button, Modal } from "@/components/ui";
|
||||
import { getEffectiveTheme, setTheme, THEMES, type Theme } from "@/lib/theme";
|
||||
|
||||
export function ProfileMenu({ username }: { username: string }) {
|
||||
@@ -140,28 +141,21 @@ function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg shadow-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25";
|
||||
|
||||
// Über die zentrale Modal-Komponente: bringt Esc, Fokus-Falle und aria-modal mit
|
||||
// (SPEZIFIKATION 3.7.6) -- der Dialog war zuvor von Hand gebaut und ignorierte Esc.
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
||||
>
|
||||
<h2 className="text-base font-semibold text-fg">Passwort ändern</h2>
|
||||
<Modal title="Passwort ändern" onClose={onClose}>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<input type="password" placeholder="Aktuelles Passwort" autoComplete="current-password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} className={inputClass} />
|
||||
<input type="password" placeholder="Neues Passwort" autoComplete="new-password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className={inputClass} />
|
||||
<input type="password" placeholder="Neues Passwort bestätigen" autoComplete="new-password" value={newPasswordConfirm} onChange={(e) => setNewPasswordConfirm(e.target.value)} className={inputClass} />
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
{done && <p className="text-sm text-success">Passwort geändert.</p>}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button type="submit" disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50">
|
||||
{saving ? "..." : "Speichern"}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2">
|
||||
Abbrechen
|
||||
</button>
|
||||
<Button type="submit" disabled={saving}>{saving ? "..." : "Speichern"}</Button>
|
||||
<Button type="button" variant="secondary" onClick={onClose}>Abbrechen</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { rateLimit, clientIp } from "@/lib/rate-limit";
|
||||
|
||||
describe("rateLimit", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("erlaubt bis zum Limit und blockt danach", () => {
|
||||
const key = `k-${Math.random()}`;
|
||||
for (let i = 0; i < 3; i++) expect(rateLimit(key, 3, 1000).ok).toBe(true);
|
||||
const blocked = rateLimit(key, 3, 1000);
|
||||
expect(blocked.ok).toBe(false);
|
||||
expect(blocked.retryAfterSeconds).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("startet nach Ablauf des Fensters neu", () => {
|
||||
vi.useFakeTimers();
|
||||
const key = `k-${Math.random()}`;
|
||||
expect(rateLimit(key, 1, 1000).ok).toBe(true);
|
||||
expect(rateLimit(key, 1, 1000).ok).toBe(false);
|
||||
vi.advanceTimersByTime(1001);
|
||||
expect(rateLimit(key, 1, 1000).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("zaehlt je Schluessel getrennt", () => {
|
||||
const a = `a-${Math.random()}`;
|
||||
const b = `b-${Math.random()}`;
|
||||
expect(rateLimit(a, 1, 1000).ok).toBe(true);
|
||||
expect(rateLimit(a, 1, 1000).ok).toBe(false);
|
||||
expect(rateLimit(b, 1, 1000).ok).toBe(true); // b unberuehrt
|
||||
});
|
||||
});
|
||||
|
||||
describe("clientIp", () => {
|
||||
const req = (headers: Record<string, string>) => new Request("http://x", { headers });
|
||||
|
||||
it("nimmt den ersten Eintrag aus X-Forwarded-For", () => {
|
||||
expect(clientIp(req({ "x-forwarded-for": "1.2.3.4, 5.6.7.8" }))).toBe("1.2.3.4");
|
||||
});
|
||||
|
||||
it("faellt auf X-Real-IP zurueck", () => {
|
||||
expect(clientIp(req({ "x-real-ip": "9.9.9.9" }))).toBe("9.9.9.9");
|
||||
});
|
||||
|
||||
it("liefert 'unknown' ohne Header", () => {
|
||||
expect(clientIp(req({}))).toBe("unknown");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// Einfache In-Memory-Bremse gegen wiederholtes Durchprobieren (Login/Registrierung).
|
||||
//
|
||||
// Bewusst schlicht: Das Tool läuft in EINEM Node-Container (kein Cluster), ein Speicher-
|
||||
// Zähler je Schlüssel genügt. Nach einem Deploy ist der Zähler leer -- das ist akzeptabel,
|
||||
// ein Angreifer gewinnt dadurch nichts Nennenswertes. Für einen echten Mehrinstanz-Betrieb
|
||||
// (Roadmap #34/Skalierung) müsste das nach Redis o. Ä. wandern.
|
||||
|
||||
interface Entry {
|
||||
count: number;
|
||||
resetAt: number; // Zeitpunkt (ms), an dem das Fenster neu beginnt
|
||||
}
|
||||
|
||||
const store = new Map<string, Entry>();
|
||||
|
||||
// Verhindert unbegrenztes Wachsen: abgelaufene Einträge werden entfernt, sobald die Map
|
||||
// eine harmlose Grösse überschreitet.
|
||||
function pruneIfNeeded(now: number) {
|
||||
if (store.size < 1000) return;
|
||||
for (const [key, entry] of store) {
|
||||
if (entry.resetAt <= now) store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RateLimitResult {
|
||||
ok: boolean;
|
||||
retryAfterSeconds: number;
|
||||
}
|
||||
|
||||
// Fixed-Window-Zähler: erlaubt `limit` Aufrufe je `windowMs` und Schlüssel.
|
||||
export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult {
|
||||
const now = Date.now();
|
||||
pruneIfNeeded(now);
|
||||
|
||||
const entry = store.get(key);
|
||||
if (!entry || entry.resetAt <= now) {
|
||||
store.set(key, { count: 1, resetAt: now + windowMs });
|
||||
return { ok: true, retryAfterSeconds: 0 };
|
||||
}
|
||||
if (entry.count >= limit) {
|
||||
return { ok: false, retryAfterSeconds: Math.ceil((entry.resetAt - now) / 1000) };
|
||||
}
|
||||
entry.count += 1;
|
||||
return { ok: true, retryAfterSeconds: 0 };
|
||||
}
|
||||
|
||||
// Client-IP hinter dem Reverse Proxy (Traefik setzt X-Forwarded-For). Erster Eintrag ist
|
||||
// die ursprüngliche Client-Adresse; ohne Header fallen alle auf denselben Eimer zurück --
|
||||
// im Zweifel bremst das etwas zu stark, nie zu schwach.
|
||||
export function clientIp(request: Request): string {
|
||||
const xff = request.headers.get("x-forwarded-for");
|
||||
if (xff) return xff.split(",")[0]!.trim();
|
||||
return request.headers.get("x-real-ip")?.trim() || "unknown";
|
||||
}
|
||||
|
||||
// Baut die 429-Antwort samt lesbarer Meldung und Retry-After-Header.
|
||||
export function tooManyRequests(retryAfterSeconds: number) {
|
||||
const minutes = Math.max(1, Math.ceil(retryAfterSeconds / 60));
|
||||
return Response.json(
|
||||
{ error: `Zu viele Versuche. Bitte versuche es in ${minutes} Minute${minutes === 1 ? "" : "n"} erneut.` },
|
||||
{ status: 429, headers: { "Retry-After": String(retryAfterSeconds) } }
|
||||
);
|
||||
}
|
||||
+36
-5
@@ -1,4 +1,5 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
// Nur von API-Routen (Node.js-Runtime) verwendet -- niemals von middleware.ts
|
||||
@@ -6,6 +7,20 @@ import { prisma } from "@/lib/db";
|
||||
|
||||
const USERNAME_PATTERN = /^[a-zA-Z0-9._-]{3,32}$/;
|
||||
|
||||
// Vergleichs-Hash für den Timing-Ausgleich (siehe verifyUserCredentials). Wird beim ersten
|
||||
// Auth-Aufruf einmalig berechnet -- ein bewusst gültiger bcrypt-Hash mit Cost 12, damit der
|
||||
// Vergleich exakt so lange dauert wie gegen einen echten Benutzer.
|
||||
const TIMING_DUMMY_HASH = bcrypt.hashSync("timing-equalizer-not-a-real-password", 12);
|
||||
|
||||
// Eigene Fehlerklasse, damit die Route den "Name vergeben"-Fall (409, freundliche Meldung)
|
||||
// sauber von echten Server-Fehlern (500) trennen kann.
|
||||
export class UsernameTakenError extends Error {
|
||||
constructor() {
|
||||
super("Dieser Benutzername ist bereits vergeben.");
|
||||
this.name = "UsernameTakenError";
|
||||
}
|
||||
}
|
||||
|
||||
export function validateUsername(username: string): string | null {
|
||||
if (!USERNAME_PATTERN.test(username)) {
|
||||
return "Benutzername: 3-32 Zeichen, nur Buchstaben, Zahlen, Punkt, Unterstrich, Bindestrich.";
|
||||
@@ -14,17 +29,33 @@ export function validateUsername(username: string): string | null {
|
||||
}
|
||||
|
||||
export async function registerUser(username: string, password: string) {
|
||||
// Vorprüfung für den Normalfall (freundliche Meldung ohne Ausnahme). Der eigentliche
|
||||
// Schutz gegen ein Rennen ist die DB-Eindeutigkeitsregel unten -- ohne sie könnten zwei
|
||||
// gleichzeitige Anfragen beide durch diese Prüfung rutschen.
|
||||
const existing = await prisma.user.findUnique({ where: { username } });
|
||||
if (existing) {
|
||||
throw new Error("Dieser Benutzername ist bereits vergeben.");
|
||||
}
|
||||
if (existing) throw new UsernameTakenError();
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
return prisma.user.create({ data: { username, passwordHash } });
|
||||
try {
|
||||
return await prisma.user.create({ data: { username, passwordHash } });
|
||||
} catch (e) {
|
||||
// P2002 = Verletzung einer Eindeutigkeits-Constraint (hier: username @unique).
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
||||
throw new UsernameTakenError();
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyUserCredentials(username: string, password: string) {
|
||||
const user = await prisma.user.findUnique({ where: { username } });
|
||||
if (!user) return null;
|
||||
if (!user) {
|
||||
// Gegen einen Dummy-Hash vergleichen, damit ein unbekannter Benutzer GENAU so lange
|
||||
// dauert wie ein bekannter mit falschem Passwort. Sonst liesse sich aus der Antwortzeit
|
||||
// ablesen, ob ein Benutzername existiert (siehe SPEZIFIKATION 3.1.2).
|
||||
await bcrypt.compare(password, TIMING_DUMMY_HASH);
|
||||
return null;
|
||||
}
|
||||
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||
return valid ? user : null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user