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
176 lines
5.0 KiB
TypeScript
176 lines
5.0 KiB
TypeScript
import type { NextRequest } from "next/server";
|
|
import { z } from "zod";
|
|
|
|
import { getActor } from "@/lib/auth/actor";
|
|
import {
|
|
describePasswordRules,
|
|
hashPassword,
|
|
MAX_PASSWORD_LENGTH,
|
|
MIN_PASSWORD_LENGTH,
|
|
} from "@/lib/auth/password";
|
|
import { requirePermission } from "@/lib/auth/policy";
|
|
import { destroyAllSessions } from "@/lib/auth/session";
|
|
import { prisma } from "@/lib/db";
|
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
|
import { QrRequestError } from "@/lib/qr/type-module";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
type Context = { params: Promise<{ id: string }> };
|
|
|
|
const updateSchema = z.object({
|
|
name: z.string().trim().max(120).nullable().optional(),
|
|
isActive: z.boolean().optional(),
|
|
isInstanceAdmin: z.boolean().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.`,
|
|
})
|
|
.optional(),
|
|
/** Sperre nach Fehlversuchen aufheben. */
|
|
unlock: z.boolean().optional(),
|
|
});
|
|
|
|
/**
|
|
* Verhindert, dass sich die Installation selbst aussperrt: es muss immer
|
|
* mindestens ein aktiver Administrator übrig bleiben.
|
|
*/
|
|
async function assertNotLastAdmin(userId: string, action: string) {
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { isInstanceAdmin: true, isActive: true },
|
|
});
|
|
if (!user?.isInstanceAdmin || !user.isActive) return;
|
|
|
|
const others = await prisma.user.count({
|
|
where: { isInstanceAdmin: true, isActive: true, id: { not: userId } },
|
|
});
|
|
if (others === 0) {
|
|
throw new QrRequestError(
|
|
`Das ist der letzte aktive Administrator — ${action} würde die Installation unbedienbar machen. Lege zuerst einen weiteren Administrator an.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function PATCH(
|
|
request: NextRequest,
|
|
context: Context,
|
|
): Promise<Response> {
|
|
try {
|
|
const actor = await getActor();
|
|
requirePermission(actor, "instance:manage");
|
|
|
|
const { id } = await context.params;
|
|
const parsed = updateSchema.safeParse(await request.json());
|
|
if (!parsed.success) {
|
|
return jsonError(firstZodMessage(parsed.error));
|
|
}
|
|
|
|
const target = await prisma.user.findUnique({ where: { id } });
|
|
if (!target) {
|
|
throw new QrRequestError("Dieser Benutzer existiert nicht (mehr).", 404);
|
|
}
|
|
|
|
const data: {
|
|
name?: string | null;
|
|
isActive?: boolean;
|
|
isInstanceAdmin?: boolean;
|
|
passwordHash?: string;
|
|
failedLogins?: number;
|
|
lockedUntil?: Date | null;
|
|
} = {};
|
|
|
|
if (parsed.data.name !== undefined) {
|
|
data.name = parsed.data.name || null;
|
|
}
|
|
|
|
if (parsed.data.isActive === false) {
|
|
await assertNotLastAdmin(id, "das Deaktivieren");
|
|
data.isActive = false;
|
|
} else if (parsed.data.isActive === true) {
|
|
data.isActive = true;
|
|
}
|
|
|
|
if (parsed.data.isInstanceAdmin === false) {
|
|
await assertNotLastAdmin(id, "der Entzug der Administratorrechte");
|
|
data.isInstanceAdmin = false;
|
|
} else if (parsed.data.isInstanceAdmin === true) {
|
|
data.isInstanceAdmin = true;
|
|
}
|
|
|
|
if (parsed.data.password) {
|
|
data.passwordHash = await hashPassword(parsed.data.password);
|
|
data.failedLogins = 0;
|
|
data.lockedUntil = null;
|
|
}
|
|
|
|
if (parsed.data.unlock) {
|
|
data.failedLogins = 0;
|
|
data.lockedUntil = null;
|
|
}
|
|
|
|
if (Object.keys(data).length === 0) {
|
|
return jsonError("Es wurden keine Änderungen übermittelt.");
|
|
}
|
|
|
|
const updated = await prisma.user.update({ where: { id }, data });
|
|
|
|
// Deaktivieren oder ein neues Passwort beenden laufende Sitzungen —
|
|
// sonst bliebe der Betroffene angemeldet.
|
|
if (data.isActive === false || data.passwordHash) {
|
|
await destroyAllSessions(id);
|
|
}
|
|
|
|
return Response.json({
|
|
user: {
|
|
id: updated.id,
|
|
email: updated.email,
|
|
isActive: updated.isActive,
|
|
isInstanceAdmin: updated.isInstanceAdmin,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
}
|
|
|
|
export async function DELETE(
|
|
_request: NextRequest,
|
|
context: Context,
|
|
): Promise<Response> {
|
|
try {
|
|
const actor = await getActor();
|
|
requirePermission(actor, "instance:manage");
|
|
|
|
const { id } = await context.params;
|
|
|
|
if (actor.kind === "user" && actor.user.id === id) {
|
|
throw new QrRequestError(
|
|
"Du kannst dein eigenes Konto nicht löschen.",
|
|
);
|
|
}
|
|
|
|
const target = await prisma.user.findUnique({
|
|
where: { id },
|
|
select: { id: true },
|
|
});
|
|
if (!target) {
|
|
throw new QrRequestError("Dieser Benutzer existiert nicht (mehr).", 404);
|
|
}
|
|
|
|
await assertNotLastAdmin(id, "das Löschen");
|
|
|
|
// Die Codes des Benutzers bleiben bestehen; nur der Ersteller entfällt
|
|
// (onDelete: SetNull). Gedruckte Codes dürfen nicht daran hängen, wer
|
|
// sie einmal angelegt hat.
|
|
await prisma.user.delete({ where: { id } });
|
|
return new Response(null, { status: 204 });
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
}
|