Phase B1: Anmeldung, Benutzer, Gruppen und Rollen
Umschaltbar ueber AUTH_ENABLED in der .env. Steht sie auf false, verhaelt
sich alles wie bisher - geprueft mit den 200 bestehenden Pruefungen.
Kern ist eine Actor- und Policy-Schicht: getActor() liefert bei
ausgeschalteter Anmeldung einen system-Actor mit Vollzugriff, sonst den
angemeldeten Benutzer. Dadurch braucht keine einzige Route ein
'if (anmeldungAktiv)' - sie fragen alle nur can() bzw. requirePermission().
- Datenmodell: User, Session, Group, Membership; Codes bekommen Gruppe und
Ersteller. Die Migration legt eine Standardgruppe an und ordnet ihr
bestehende Codes zu, bevor die Spalte verpflichtend wird - sonst
scheiterte sie auf jeder bereits benutzten Installation.
- Passwoerter mit scrypt aus der Node-Standardbibliothek. Bewusst kein
Argon2: das braeuchte ein natives Modul passend zur Container-Plattform.
- Sitzungen in der Datenbank, im Cookie nur ein Zufallstoken; gespeichert
wird dessen SHA-256. Deaktivieren oder ein neues Passwort beendet alle
offenen Sitzungen sofort.
- Aussperrschutz: der letzte aktive Administrator kann sich nicht selbst
entmachten, deaktivieren oder loeschen. Der Schalter liegt in der .env,
nicht in der Oberflaeche - so bleibt der Weg zurueck immer offen.
- Ersteinrichtung legt den ersten Administrator an und schliesst sich danach.
- Die Weiterleitung /r/{slug} bleibt oeffentlich, sonst liesse sich kein
gedruckter Code mehr scannen.
Zwei Fehler dabei gefunden und behoben:
- 'return handleLogin(...)' innerhalb eines try-Blocks gibt das Promise
zurueck, bevor es sich aufloest. Die Ablehnung kam nie im catch an und
wurde zu einem nackten 500er statt einer 401. Jetzt mit await.
- Die Fehlerklassen wurden ueber instanceof erkannt. type-module.ts landet
aber sowohl im Server- als auch im Client-Buendel, es gibt also zwei
Klassenidentitaeten - saubere 400er wurden dadurch still zu 500ern. Die
Erkennung laeuft jetzt ueber eine Eigenschaft.
Geprueft: 51 neue Pruefungen fuer Anmeldung und die vollstaendige
Rollenmatrix, dazu die 200 bestehenden bei ausgeschalteter Anmeldung.
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor";
|
||||
import {
|
||||
describePasswordRules,
|
||||
hashPassword,
|
||||
MIN_PASSWORD_LENGTH,
|
||||
verifyPassword,
|
||||
} from "@/lib/auth/password";
|
||||
import { createSession, destroySession } 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";
|
||||
|
||||
/** Sperrdauer nach zu vielen Fehlversuchen. */
|
||||
const MAX_ATTEMPTS = 8;
|
||||
const LOCK_MINUTES = 15;
|
||||
|
||||
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." }),
|
||||
});
|
||||
|
||||
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()}`,
|
||||
}),
|
||||
});
|
||||
|
||||
const logoutSchema = z.object({ action: z.literal("logout") });
|
||||
|
||||
const bodySchema = z.discriminatedUnion("action", [
|
||||
loginSchema,
|
||||
setupSchema,
|
||||
logoutSchema,
|
||||
]);
|
||||
|
||||
/** 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 === "setup") {
|
||||
return await handleSetup(parsed.data);
|
||||
}
|
||||
return await handleLogin(parsed.data);
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: input.email,
|
||||
name: input.name && input.name.length > 0 ? input.name : null,
|
||||
passwordHash: await hashPassword(input.password),
|
||||
isInstanceAdmin: true,
|
||||
},
|
||||
});
|
||||
|
||||
await createSession(user.id);
|
||||
return Response.json({ ok: true, email: user.email }, { status: 201 });
|
||||
}
|
||||
|
||||
async function handleLogin(
|
||||
input: z.infer<typeof loginSchema>,
|
||||
): Promise<Response> {
|
||||
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, "scrypt$32768$8$1$AAAA$AAAA");
|
||||
throw invalid;
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
throw new QrRequestError(
|
||||
"Dieses Konto ist deaktiviert. Bitte wende dich an einen Administrator.",
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { failedLogins: 0, lockedUntil: null, lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
await createSession(user.id);
|
||||
return Response.json({ ok: true, email: user.email });
|
||||
}
|
||||
Reference in New Issue
Block a user