Zehn Befunde, alle behoben. Die wichtigsten: scrypt lief mit N=2^15; das OWASP Password Storage Cheat Sheet nennt 2^17. Jetzt 2^17, alte Hashes bleiben gueltig und werden beim naechsten Anmelden still erneuert — das ist der einzige Moment, in dem das Klartextpasswort vorliegt. Passwoerter hatten keine Obergrenze. Ein Passwort von mehreren Megabyte haette scrypt beschaeftigt, und zwar an einem Endpunkt ohne Anmeldung. Jetzt 128 Zeichen, geprueft vor dem Hashen. Als CSRF-Schutz gab es nur SameSite=Lax. Das Merkblatt sagt ausdruecklich, dass das allein nicht genuegt — besonders beim Upload, der multipart entgegennimmt und damit von einem fremden Formular erreichbar waere. Jetzt wird bei jedem schreibenden Zugriff der Origin gegen den Host der Anfrage geprueft (nicht gegen BASE_URL, die Anwendung laeuft unter mehreren Domains). Es gab keinen einzigen Schutzheader — die Verwaltung liess sich einbetten und per Clickjacking fernsteuern. Jetzt frame-ancestors none, X-Frame-Options, nosniff, Referrer-Policy und Permissions-Policy; X-Powered-By ist weg. Sitzungen hatten keine absolute Obergrenze: eine gestohlene Sitzung haette durch regelmaessige Nutzung unbegrenzt weitergelebt. Jetzt SESSION_MAX_DAYS neben SESSION_IDLE_DAYS, beide einstellbar. Dazu: Secure auch hinter einem TLS-Proxy, weniger Auskunft ueber fremde Konten bei der Anmeldung, eine Bremse je Absender gegen breites Durchprobieren, Ersteinrichtung ohne Wettlauf, und abgelaufene Sitzungen werden aufgeraeumt. Die Datei heisst proxy.ts statt middleware.ts — Next 16 hat die Konvention umbenannt. /api/uploads bleibt davon ausgenommen: Der Proxy puffert Anfragekoerper nur bis 10 MB, eine Datei an der Groessengrenze kaeme sonst unvollstaendig an. Die Herkunftspruefung macht die Route dort selbst. Befund, Begruendungen und was bewusst offen bleibt: docs/sicherheit.md
340 lines
10 KiB
TypeScript
340 lines
10 KiB
TypeScript
import type { NextRequest } from "next/server";
|
|
import { z } from "zod";
|
|
|
|
import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor";
|
|
import {
|
|
describePasswordRules,
|
|
hashPassword,
|
|
MAX_PASSWORD_LENGTH,
|
|
MIN_PASSWORD_LENGTH,
|
|
needsRehash,
|
|
verifyPassword,
|
|
} from "@/lib/auth/password";
|
|
import {
|
|
createSession,
|
|
destroyAllSessions,
|
|
destroySession,
|
|
} from "@/lib/auth/session";
|
|
import {
|
|
clearAttempts,
|
|
requestOrigin,
|
|
tooManyAttempts,
|
|
} from "@/lib/auth/throttle";
|
|
import { prisma } from "@/lib/db";
|
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
|
import { QrRequestError } from "@/lib/qr/type-module";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
/** Sperrdauer nach zu vielen Fehlversuchen. */
|
|
const MAX_ATTEMPTS = 8;
|
|
const LOCK_MINUTES = 15;
|
|
|
|
/**
|
|
* Wird gegen einen nicht vorhandenen Benutzer gerechnet, damit die
|
|
* Antwortzeit nicht verrät, ob es die Adresse gibt. Die Parameter entsprechen
|
|
* denen echter Hashes — sonst wäre die Rechnung zu schnell.
|
|
*/
|
|
const DUMMY_HASH = "scrypt$131072$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAA";
|
|
|
|
const emailSchema = z
|
|
.string()
|
|
.trim()
|
|
.toLowerCase()
|
|
.min(1, { error: "Bitte gib deine E-Mail-Adresse ein." })
|
|
.max(254, { error: "Die Adresse ist zu lang." })
|
|
.refine((value) => /^[^\s@]+@[^\s@.]+\.[^\s@]+$/.test(value), {
|
|
error: "Das ist keine gültige E-Mail-Adresse.",
|
|
});
|
|
|
|
const loginSchema = z.object({
|
|
action: z.literal("login"),
|
|
email: emailSchema,
|
|
password: z
|
|
.string()
|
|
.min(1, { error: "Bitte gib dein Passwort ein." })
|
|
.max(MAX_PASSWORD_LENGTH, { error: "Das Passwort ist zu lang." }),
|
|
});
|
|
|
|
const setupSchema = z.object({
|
|
action: z.literal("setup"),
|
|
email: emailSchema,
|
|
name: z.string().trim().max(120).optional(),
|
|
password: z
|
|
.string()
|
|
.min(MIN_PASSWORD_LENGTH, {
|
|
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
|
|
})
|
|
.max(MAX_PASSWORD_LENGTH, {
|
|
error: `Das Passwort darf höchstens ${MAX_PASSWORD_LENGTH} Zeichen lang sein.`,
|
|
}),
|
|
});
|
|
|
|
const logoutSchema = z.object({ action: z.literal("logout") });
|
|
|
|
const changePasswordSchema = z.object({
|
|
action: z.literal("change-password"),
|
|
currentPassword: z
|
|
.string()
|
|
.min(1, { error: "Bitte gib dein bisheriges Passwort ein." })
|
|
.max(MAX_PASSWORD_LENGTH, { error: "Das Passwort ist zu lang." }),
|
|
newPassword: z
|
|
.string()
|
|
.min(MIN_PASSWORD_LENGTH, {
|
|
error: `Das neue Passwort ist zu kurz. ${describePasswordRules()}`,
|
|
})
|
|
.max(MAX_PASSWORD_LENGTH, {
|
|
error: `Das Passwort darf höchstens ${MAX_PASSWORD_LENGTH} Zeichen lang sein.`,
|
|
}),
|
|
});
|
|
|
|
const bodySchema = z.discriminatedUnion("action", [
|
|
loginSchema,
|
|
setupSchema,
|
|
logoutSchema,
|
|
changePasswordSchema,
|
|
]);
|
|
|
|
/** Wer ist angemeldet? Von der Oberfläche für den Kopfbereich genutzt. */
|
|
export async function GET(): Promise<Response> {
|
|
try {
|
|
const actor = await getActor();
|
|
return Response.json({
|
|
authEnabled: isAuthEnabled(),
|
|
setupOpen: await isSetupOpen(),
|
|
user:
|
|
actor.kind === "user"
|
|
? {
|
|
id: actor.user.id,
|
|
email: actor.user.email,
|
|
name: actor.user.name,
|
|
isInstanceAdmin: actor.user.isInstanceAdmin,
|
|
memberships: actor.user.memberships,
|
|
}
|
|
: null,
|
|
});
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
}
|
|
|
|
export async function POST(request: NextRequest): Promise<Response> {
|
|
try {
|
|
const parsed = bodySchema.safeParse(await request.json());
|
|
if (!parsed.success) {
|
|
return jsonError(firstZodMessage(parsed.error));
|
|
}
|
|
|
|
if (parsed.data.action === "logout") {
|
|
await destroySession();
|
|
return Response.json({ ok: true });
|
|
}
|
|
|
|
if (!isAuthEnabled()) {
|
|
throw new QrRequestError(
|
|
"Die Anmeldung ist ausgeschaltet. Setze AUTH_ENABLED=true in der .env, um sie zu aktivieren.",
|
|
);
|
|
}
|
|
|
|
// Das `await` ist hier nicht optional: ohne es verlässt das Promise den
|
|
// try-Block, bevor es sich auflöst — eine Ablehnung käme dann nie im
|
|
// catch an und würde als nackter 500er beim Aufrufer landen.
|
|
if (parsed.data.action === "change-password") {
|
|
return await handleChangePassword(parsed.data);
|
|
}
|
|
if (parsed.data.action === "setup") {
|
|
return await handleSetup(parsed.data);
|
|
}
|
|
return await handleLogin(parsed.data, requestOrigin(request.headers));
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Eigenes Passwort ändern. Das bisherige muss stimmen — sonst könnte jemand
|
|
* an einem unbeaufsichtigten Rechner das Konto übernehmen.
|
|
*/
|
|
async function handleChangePassword(
|
|
input: z.infer<typeof changePasswordSchema>,
|
|
): Promise<Response> {
|
|
const actor = await getActor();
|
|
if (actor.kind !== "user") {
|
|
throw new QrRequestError("Bitte melde dich an.", 401);
|
|
}
|
|
|
|
const user = await prisma.user.findUnique({ where: { id: actor.user.id } });
|
|
if (!user) {
|
|
throw new QrRequestError("Dieses Konto existiert nicht mehr.", 404);
|
|
}
|
|
|
|
// Konten aus dem externen Verzeichnis haben hier kein Passwort — das
|
|
// wird beim Anbieter gewechselt, nicht hier.
|
|
if (!user.passwordHash) {
|
|
throw new QrRequestError(
|
|
"Dieses Konto meldet sich über den externen Anbieter an. Das Passwort wird dort geändert.",
|
|
400,
|
|
);
|
|
}
|
|
|
|
if (!(await verifyPassword(input.currentPassword, user.passwordHash))) {
|
|
throw new QrRequestError("Das bisherige Passwort stimmt nicht.", 403);
|
|
}
|
|
|
|
if (input.currentPassword === input.newPassword) {
|
|
throw new QrRequestError(
|
|
"Das neue Passwort ist mit dem bisherigen identisch.",
|
|
);
|
|
}
|
|
|
|
await prisma.user.update({
|
|
where: { id: user.id },
|
|
data: {
|
|
passwordHash: await hashPassword(input.newPassword),
|
|
failedLogins: 0,
|
|
lockedUntil: null,
|
|
},
|
|
});
|
|
|
|
// Alle Sitzungen beenden — auch die eigene — und sofort eine neue
|
|
// aufmachen. So fliegen andere Geräte raus, ohne dass man sich hier neu
|
|
// anmelden muss.
|
|
await destroyAllSessions(user.id);
|
|
await createSession(user.id);
|
|
|
|
return Response.json({ ok: true });
|
|
}
|
|
|
|
/** Legt den ersten Benutzer an — nur solange es noch keinen gibt. */
|
|
async function handleSetup(
|
|
input: z.infer<typeof setupSchema>,
|
|
): Promise<Response> {
|
|
if (!(await isSetupOpen())) {
|
|
throw new QrRequestError(
|
|
"Die Ersteinrichtung ist bereits abgeschlossen. Bitte melde dich an.",
|
|
403,
|
|
);
|
|
}
|
|
|
|
// Das Hashen dauert; erst danach in die Transaktion, damit sie kurz bleibt.
|
|
const passwordHash = await hashPassword(input.password);
|
|
|
|
// Serialisierbar, weil zwischen der Prüfung „gibt es schon jemanden?" und
|
|
// dem Anlegen sonst ein zweiter Aufruf dazwischenkommen könnte — und dann
|
|
// gäbe es zwei Administratoren, von denen einer ungebeten ist.
|
|
const user = await prisma.$transaction(
|
|
async (tx) => {
|
|
if ((await tx.user.count()) > 0) {
|
|
throw new QrRequestError(
|
|
"Die Ersteinrichtung ist bereits abgeschlossen. Bitte melde dich an.",
|
|
403,
|
|
);
|
|
}
|
|
return tx.user.create({
|
|
data: {
|
|
email: input.email,
|
|
name: input.name && input.name.length > 0 ? input.name : null,
|
|
passwordHash,
|
|
isInstanceAdmin: true,
|
|
},
|
|
});
|
|
},
|
|
{ isolationLevel: "Serializable" },
|
|
);
|
|
|
|
await createSession(user.id);
|
|
return Response.json({ ok: true, email: user.email }, { status: 201 });
|
|
}
|
|
|
|
async function handleLogin(
|
|
input: z.infer<typeof loginSchema>,
|
|
absender: string,
|
|
): Promise<Response> {
|
|
// Vor allem anderen: Wer viele Konten durchprobiert, löst nie eine
|
|
// Kontosperre aus — dagegen hilft nur eine Bremse je Absender.
|
|
const wartezeit = tooManyAttempts(`login:${absender}`);
|
|
if (wartezeit !== null) {
|
|
throw new QrRequestError(
|
|
`Zu viele Anmeldeversuche. Bitte warte ${Math.ceil(wartezeit / 60)} Minute${wartezeit > 60 ? "n" : ""}.`,
|
|
429,
|
|
);
|
|
}
|
|
|
|
const user = await prisma.user.findUnique({ where: { email: input.email } });
|
|
|
|
// Immer dieselbe Meldung, egal ob die Adresse existiert — sonst ließe sich
|
|
// damit herausfinden, welche Konten es gibt.
|
|
const invalid = new QrRequestError(
|
|
"E-Mail-Adresse oder Passwort stimmt nicht.",
|
|
401,
|
|
);
|
|
|
|
if (!user) {
|
|
// Trotzdem einmal rechnen, damit die Antwortzeit nichts verrät.
|
|
await verifyPassword(input.password, DUMMY_HASH);
|
|
throw invalid;
|
|
}
|
|
|
|
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
|
|
const minutes = Math.ceil(
|
|
(user.lockedUntil.getTime() - Date.now()) / 60000,
|
|
);
|
|
throw new QrRequestError(
|
|
`Zu viele Fehlversuche. Bitte versuche es in ${minutes} Minute${minutes === 1 ? "" : "n"} erneut.`,
|
|
429,
|
|
);
|
|
}
|
|
|
|
// Konten aus dem Verzeichnis haben hier kein Passwort. Bewusst dieselbe
|
|
// Meldung wie bei falschen Zugangsdaten: Ein eigener Text würde verraten,
|
|
// dass es diese Adresse gibt. Der Knopf für den Anbieter steht daneben.
|
|
if (!user.passwordHash) {
|
|
await verifyPassword(input.password, DUMMY_HASH);
|
|
throw invalid;
|
|
}
|
|
|
|
if (!(await verifyPassword(input.password, user.passwordHash))) {
|
|
const failedLogins = user.failedLogins + 1;
|
|
await prisma.user.update({
|
|
where: { id: user.id },
|
|
data: {
|
|
failedLogins,
|
|
lockedUntil:
|
|
failedLogins >= MAX_ATTEMPTS
|
|
? new Date(Date.now() + LOCK_MINUTES * 60 * 1000)
|
|
: null,
|
|
},
|
|
});
|
|
throw invalid;
|
|
}
|
|
|
|
// Erst nach geprüftem Passwort: Wer bis hierher kommt, hat das Konto
|
|
// nachweislich in der Hand — ihm darf man sagen, warum es nicht weitergeht.
|
|
if (!user.isActive) {
|
|
throw new QrRequestError(
|
|
"Dieses Konto ist deaktiviert. Bitte wende dich an einen Administrator.",
|
|
403,
|
|
);
|
|
}
|
|
|
|
// Der Hash stammt womöglich aus einer Zeit mit schwächeren Parametern.
|
|
// Jetzt ist der einzige Moment, in dem das Klartextpasswort vorliegt.
|
|
const passwordHash = needsRehash(user.passwordHash)
|
|
? await hashPassword(input.password)
|
|
: undefined;
|
|
|
|
await prisma.user.update({
|
|
where: { id: user.id },
|
|
data: {
|
|
failedLogins: 0,
|
|
lockedUntil: null,
|
|
lastLoginAt: new Date(),
|
|
...(passwordHash ? { passwordHash } : {}),
|
|
},
|
|
});
|
|
|
|
clearAttempts(`login:${absender}`);
|
|
await createSession(user.id);
|
|
return Response.json({ ok: true, email: user.email });
|
|
}
|