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,171 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getActor } from "@/lib/auth/actor";
|
||||
import {
|
||||
describePasswordRules,
|
||||
hashPassword,
|
||||
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()}`,
|
||||
})
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getActor } from "@/lib/auth/actor";
|
||||
import {
|
||||
describePasswordRules,
|
||||
hashPassword,
|
||||
MIN_PASSWORD_LENGTH,
|
||||
} from "@/lib/auth/password";
|
||||
import { requirePermission } from "@/lib/auth/policy";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const createSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.min(1, { error: "Bitte gib eine 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.",
|
||||
}),
|
||||
name: z.string().trim().max(120).optional(),
|
||||
password: z.string().min(MIN_PASSWORD_LENGTH, {
|
||||
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
|
||||
}),
|
||||
isInstanceAdmin: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
try {
|
||||
requirePermission(await getActor(), "instance:manage");
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
orderBy: [{ isInstanceAdmin: "desc" }, { email: "asc" }],
|
||||
include: {
|
||||
memberships: { include: { group: { select: { id: true, name: true } } } },
|
||||
_count: { select: { sessions: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json({
|
||||
users: users.map((user) => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
isInstanceAdmin: user.isInstanceAdmin,
|
||||
isActive: user.isActive,
|
||||
isLocked: Boolean(
|
||||
user.lockedUntil && user.lockedUntil.getTime() > Date.now(),
|
||||
),
|
||||
lastLoginAt: user.lastLoginAt?.toISOString() ?? null,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
activeSessions: user._count.sessions,
|
||||
memberships: user.memberships.map((m) => ({
|
||||
groupId: m.group.id,
|
||||
groupName: m.group.name,
|
||||
role: m.role,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<Response> {
|
||||
try {
|
||||
requirePermission(await getActor(), "instance:manage");
|
||||
|
||||
const parsed = createSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return jsonError(firstZodMessage(parsed.error));
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { email: parsed.data.email },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
throw new QrRequestError(
|
||||
"Diese E-Mail-Adresse ist bereits vergeben.",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: parsed.data.email,
|
||||
name: parsed.data.name || null,
|
||||
passwordHash: await hashPassword(parsed.data.password),
|
||||
isInstanceAdmin: parsed.data.isInstanceAdmin,
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json(
|
||||
{ user: { id: user.id, email: user.email } },
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user