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,63 @@
|
||||
import "server-only";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSessionUser, type SessionUser } from "@/lib/auth/session";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
|
||||
/**
|
||||
* Wer gerade handelt.
|
||||
*
|
||||
* Der Umschalter für die Anmeldung lebt genau hier: ist sie ausgeschaltet,
|
||||
* liefert `getActor()` einen `system`-Actor mit Vollzugriff. Dadurch braucht
|
||||
* keine einzige Route ein `if (anmeldungAktiv)` — sie fragen alle nur die
|
||||
* Policy, und die kennt beide Betriebsarten.
|
||||
*/
|
||||
export type Actor =
|
||||
| { kind: "system" }
|
||||
| { kind: "user"; user: SessionUser }
|
||||
| { kind: "anonymous" };
|
||||
|
||||
/**
|
||||
* Ist die Anmeldung eingeschaltet?
|
||||
*
|
||||
* Bewusst eine Umgebungsvariable und kein Schalter in der Oberfläche: so
|
||||
* kann man sich nicht aussperren. Wer den Zugang verliert, setzt
|
||||
* `AUTH_ENABLED=false` in der .env und startet neu.
|
||||
*/
|
||||
export function isAuthEnabled(): boolean {
|
||||
return (process.env.AUTH_ENABLED ?? "").trim().toLowerCase() === "true";
|
||||
}
|
||||
|
||||
export async function getActor(): Promise<Actor> {
|
||||
if (!isAuthEnabled()) return { kind: "system" };
|
||||
|
||||
const user = await getSessionUser();
|
||||
return user ? { kind: "user", user } : { kind: "anonymous" };
|
||||
}
|
||||
|
||||
/** Gibt es überhaupt schon einen Benutzer? Steuert die Ersteinrichtung. */
|
||||
export async function hasAnyUser(): Promise<boolean> {
|
||||
return (await prisma.user.count()) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Ersteinrichtung ist nur offen, solange die Anmeldung aktiv ist und es
|
||||
* noch keinen Benutzer gibt. Danach schließt sie sich von selbst — sonst
|
||||
* könnte sich jederzeit jemand einen Administrator anlegen.
|
||||
*/
|
||||
export async function isSetupOpen(): Promise<boolean> {
|
||||
return isAuthEnabled() && !(await hasAnyUser());
|
||||
}
|
||||
|
||||
export function actorLabel(actor: Actor): string {
|
||||
if (actor.kind === "system") return "Ohne Anmeldung";
|
||||
if (actor.kind === "anonymous") return "Nicht angemeldet";
|
||||
return actor.user.name?.trim() || actor.user.email;
|
||||
}
|
||||
|
||||
/** Wirft, wenn niemand angemeldet ist. */
|
||||
export function requireSignedIn(actor: Actor): void {
|
||||
if (actor.kind === "anonymous") {
|
||||
throw new QrRequestError("Bitte melde dich an.", 401);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import "server-only";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { Actor } from "@/lib/auth/actor";
|
||||
import { can, visibleGroupIds, writableGroupIds } from "@/lib/auth/policy";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
|
||||
/**
|
||||
* Die Gruppe, in der Codes landen, wenn niemand angemeldet ist. Sie wird von
|
||||
* der Migration angelegt; hier steht der Notnagel für den Fall, dass jemand
|
||||
* sie gelöscht hat — sonst ließe sich plötzlich kein Code mehr anlegen.
|
||||
*/
|
||||
export const DEFAULT_GROUP_ID = "grp_standard";
|
||||
|
||||
export async function ensureDefaultGroup(): Promise<string> {
|
||||
const existing = await prisma.group.findUnique({
|
||||
where: { id: DEFAULT_GROUP_ID },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) return existing.id;
|
||||
|
||||
const created = await prisma.group.upsert({
|
||||
where: { name: "Standard" },
|
||||
create: { id: DEFAULT_GROUP_ID, name: "Standard" },
|
||||
update: {},
|
||||
select: { id: true },
|
||||
});
|
||||
return created.id;
|
||||
}
|
||||
|
||||
export type GroupOption = { id: string; name: string; canWrite: boolean };
|
||||
|
||||
/** Gruppen, die der Actor in Auswahllisten sehen soll. */
|
||||
export async function listGroupsForActor(actor: Actor): Promise<GroupOption[]> {
|
||||
const visible = visibleGroupIds(actor);
|
||||
const writable = writableGroupIds(actor);
|
||||
|
||||
const groups = await prisma.group.findMany({
|
||||
where: visible === null ? undefined : { id: { in: visible } },
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
return groups.map((group) => ({
|
||||
...group,
|
||||
canWrite: writable === null || writable.includes(group.id),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt die Gruppe, in der ein neuer Code angelegt wird.
|
||||
*
|
||||
* Ohne Anmeldung ist das immer die Standardgruppe. Mit Anmeldung entscheidet
|
||||
* die Auswahl des Benutzers; fehlt sie und gibt es genau eine beschreibbare
|
||||
* Gruppe, wird diese genommen — bei mehreren muss der Benutzer wählen.
|
||||
*/
|
||||
export async function resolveTargetGroup(
|
||||
actor: Actor,
|
||||
requestedGroupId?: string,
|
||||
): Promise<string> {
|
||||
if (actor.kind === "system") {
|
||||
return requestedGroupId ?? (await ensureDefaultGroup());
|
||||
}
|
||||
|
||||
if (requestedGroupId) {
|
||||
if (!can(actor, "codes:create", { groupId: requestedGroupId })) {
|
||||
throw new QrRequestError(
|
||||
"In dieser Gruppe darfst du keine Codes anlegen.",
|
||||
403,
|
||||
);
|
||||
}
|
||||
return requestedGroupId;
|
||||
}
|
||||
|
||||
const writable = writableGroupIds(actor);
|
||||
|
||||
if (writable === null) {
|
||||
// Instanz-Administrator ohne Auswahl: die Standardgruppe.
|
||||
return ensureDefaultGroup();
|
||||
}
|
||||
if (writable.length === 0) {
|
||||
throw new QrRequestError(
|
||||
"Du bist in keiner Gruppe, in der du Codes anlegen darfst. Bitte wende dich an einen Administrator.",
|
||||
403,
|
||||
);
|
||||
}
|
||||
if (writable.length > 1) {
|
||||
throw new QrRequestError(
|
||||
"Bitte wähle aus, in welcher Gruppe der Code angelegt werden soll.",
|
||||
);
|
||||
}
|
||||
return writable[0];
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import "server-only";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getActor, isAuthEnabled, type Actor } from "@/lib/auth/actor";
|
||||
import { can, type Action, type Scope } from "@/lib/auth/policy";
|
||||
|
||||
/**
|
||||
* Wächter für Seiten (nicht für API-Routen — die werfen stattdessen).
|
||||
*
|
||||
* Ist die Anmeldung aus, kommt ein `system`-Actor zurück und alles ist
|
||||
* erlaubt. Ist sie an und niemand angemeldet, geht es zur Anmeldeseite.
|
||||
*/
|
||||
export async function requirePageActor(): Promise<Actor> {
|
||||
const actor = await getActor();
|
||||
if (actor.kind === "anonymous") {
|
||||
redirect("/anmelden");
|
||||
}
|
||||
return actor;
|
||||
}
|
||||
|
||||
/** Wie `requirePageActor`, prüft zusätzlich eine Berechtigung. */
|
||||
export async function requirePagePermission(
|
||||
action: Action,
|
||||
scope: Scope = {},
|
||||
): Promise<Actor> {
|
||||
const actor = await requirePageActor();
|
||||
if (!can(actor, action, scope)) {
|
||||
redirect("/kein-zugriff");
|
||||
}
|
||||
return actor;
|
||||
}
|
||||
|
||||
export { isAuthEnabled };
|
||||
@@ -0,0 +1,111 @@
|
||||
import "server-only";
|
||||
|
||||
import {
|
||||
randomBytes,
|
||||
scrypt,
|
||||
timingSafeEqual,
|
||||
type ScryptOptions,
|
||||
} from "node:crypto";
|
||||
|
||||
// promisify() greift bei scrypt die Überladung ohne Optionen ab — deshalb
|
||||
// hier von Hand eingepackt.
|
||||
function scryptAsync(
|
||||
password: string,
|
||||
salt: Buffer,
|
||||
keyLength: number,
|
||||
options: ScryptOptions,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
scrypt(password, salt, keyLength, options, (error, derived) => {
|
||||
if (error) reject(error);
|
||||
else resolve(derived);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwörter mit scrypt aus der Node-Standardbibliothek.
|
||||
*
|
||||
* Warum nicht Argon2id, das heute meist zuerst empfohlen wird: dafür bräuchte
|
||||
* es ein natives Modul, das zur Plattform des Containers passen muss. scrypt
|
||||
* ist ebenfalls speicherhart, gilt als solide und kostet null Abhängigkeiten
|
||||
* und null Build-Risiko — bei einer selbst gehosteten Anwendung wiegt das
|
||||
* schwerer als der Unterschied zwischen zwei guten Verfahren.
|
||||
*
|
||||
* Format: `scrypt$N$r$p$salt$hash`, beides Base64. Die Parameter stehen im
|
||||
* Hash, damit sie sich später erhöhen lassen, ohne alte Passwörter zu
|
||||
* entwerten.
|
||||
*/
|
||||
const PARAMS = { N: 32768, r: 8, p: 1, keyLength: 64, saltLength: 16 };
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(PARAMS.saltLength);
|
||||
const derived = await scryptAsync(
|
||||
password.normalize("NFKC"),
|
||||
salt,
|
||||
PARAMS.keyLength,
|
||||
{
|
||||
N: PARAMS.N,
|
||||
r: PARAMS.r,
|
||||
p: PARAMS.p,
|
||||
// Der Standardwert reicht für N = 32768 nicht aus.
|
||||
maxmem: 256 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
|
||||
return [
|
||||
"scrypt",
|
||||
PARAMS.N,
|
||||
PARAMS.r,
|
||||
PARAMS.p,
|
||||
salt.toString("base64"),
|
||||
derived.toString("base64"),
|
||||
].join("$");
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
password: string,
|
||||
stored: string,
|
||||
): Promise<boolean> {
|
||||
const parts = stored.split("$");
|
||||
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
|
||||
|
||||
const N = Number(parts[1]);
|
||||
const r = Number(parts[2]);
|
||||
const p = Number(parts[3]);
|
||||
if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let salt: Buffer;
|
||||
let expected: Buffer;
|
||||
try {
|
||||
salt = Buffer.from(parts[4], "base64");
|
||||
expected = Buffer.from(parts[5], "base64");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
let derived: Buffer;
|
||||
try {
|
||||
derived = await scryptAsync(password.normalize("NFKC"), salt, expected.length, {
|
||||
N,
|
||||
r,
|
||||
p,
|
||||
maxmem: 256 * 1024 * 1024,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vergleich in konstanter Zeit — ein früher Abbruch würde verraten, wie
|
||||
// viele Bytes bereits stimmen.
|
||||
return derived.length === expected.length && timingSafeEqual(derived, expected);
|
||||
}
|
||||
|
||||
/** Mindestanforderungen an ein Passwort. */
|
||||
export const MIN_PASSWORD_LENGTH = 10;
|
||||
|
||||
export function describePasswordRules(): string {
|
||||
return `Mindestens ${MIN_PASSWORD_LENGTH} Zeichen.`;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import "server-only";
|
||||
|
||||
import type { Role } from "@/generated/prisma/client";
|
||||
import type { Actor } from "@/lib/auth/actor";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
|
||||
/**
|
||||
* Sämtliche Berechtigungsregeln der Anwendung — an einer Stelle.
|
||||
*
|
||||
* Jeder schreibende oder lesende Zugriff geht durch `can` bzw. `require`.
|
||||
* Die Oberfläche blendet Knöpfe zwar zusätzlich aus, aber das ist nur
|
||||
* Bequemlichkeit: verlassen wird sich ausschließlich auf diese Prüfungen.
|
||||
*/
|
||||
|
||||
export type Action =
|
||||
/** Codes einer Gruppe sehen und Bilder herunterladen. */
|
||||
| "codes:read"
|
||||
/** Dynamische Codes anlegen. */
|
||||
| "codes:create"
|
||||
/** Ziel, Name oder Gestaltung ändern. */
|
||||
| "codes:update"
|
||||
/** Codes löschen. */
|
||||
| "codes:delete"
|
||||
/** Mitglieder und Rollen einer Gruppe verwalten. */
|
||||
| "group:manage"
|
||||
/** Gruppen, Benutzer und die QR-Typ-Freigaben verwalten. */
|
||||
| "instance:manage";
|
||||
|
||||
export type Scope = { groupId?: string };
|
||||
|
||||
const ROLE_RANK: Record<Role, number> = { VIEWER: 0, EDITOR: 1, ADMIN: 2 };
|
||||
|
||||
export const ROLE_LABELS: Record<Role, string> = {
|
||||
ADMIN: "Administrator",
|
||||
EDITOR: "Bearbeiter",
|
||||
VIEWER: "Betrachter",
|
||||
};
|
||||
|
||||
export const ROLE_DESCRIPTIONS: Record<Role, string> = {
|
||||
ADMIN: "Darf alles in der Gruppe, einschließlich Mitglieder und Rollen.",
|
||||
EDITOR: "Darf Codes anlegen, ändern und löschen.",
|
||||
VIEWER: "Darf Codes ansehen und herunterladen, aber nichts ändern.",
|
||||
};
|
||||
|
||||
/** Rolle des Actors in einer bestimmten Gruppe, falls vorhanden. */
|
||||
export function roleInGroup(actor: Actor, groupId: string): Role | null {
|
||||
if (actor.kind !== "user") return null;
|
||||
return (
|
||||
actor.user.memberships.find((m) => m.groupId === groupId)?.role ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** Alle Gruppen, die der Actor sehen darf. `null` heißt: alle. */
|
||||
export function visibleGroupIds(actor: Actor): string[] | null {
|
||||
if (actor.kind === "system") return null;
|
||||
if (actor.kind === "anonymous") return [];
|
||||
if (actor.user.isInstanceAdmin) return null;
|
||||
return actor.user.memberships.map((m) => m.groupId);
|
||||
}
|
||||
|
||||
/** Gruppen, in denen der Actor Codes anlegen darf. */
|
||||
export function writableGroupIds(actor: Actor): string[] | null {
|
||||
if (actor.kind === "system") return null;
|
||||
if (actor.kind === "anonymous") return [];
|
||||
if (actor.user.isInstanceAdmin) return null;
|
||||
return actor.user.memberships
|
||||
.filter((m) => ROLE_RANK[m.role] >= ROLE_RANK.EDITOR)
|
||||
.map((m) => m.groupId);
|
||||
}
|
||||
|
||||
export function can(actor: Actor, action: Action, scope: Scope = {}): boolean {
|
||||
if (actor.kind === "system") return true;
|
||||
if (actor.kind === "anonymous") return false;
|
||||
|
||||
// Der Instanz-Administrator darf alles — er verwaltet die Installation.
|
||||
if (actor.user.isInstanceAdmin) return true;
|
||||
|
||||
if (action === "instance:manage") return false;
|
||||
|
||||
// Alle übrigen Aktionen beziehen sich auf eine Gruppe.
|
||||
if (!scope.groupId) return false;
|
||||
const role = roleInGroup(actor, scope.groupId);
|
||||
if (!role) return false;
|
||||
|
||||
switch (action) {
|
||||
case "codes:read":
|
||||
return true; // jede Rolle in der Gruppe darf lesen
|
||||
case "codes:create":
|
||||
case "codes:update":
|
||||
case "codes:delete":
|
||||
return ROLE_RANK[role] >= ROLE_RANK.EDITOR;
|
||||
case "group:manage":
|
||||
return role === "ADMIN";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const DENIED_MESSAGES: Record<Action, string> = {
|
||||
"codes:read": "Du hast keinen Zugriff auf die Codes dieser Gruppe.",
|
||||
"codes:create":
|
||||
"Zum Anlegen von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
|
||||
"codes:update":
|
||||
"Zum Ändern von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
|
||||
"codes:delete":
|
||||
"Zum Löschen von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
|
||||
"group:manage":
|
||||
"Mitglieder und Rollen darf nur ein Administrator der Gruppe verwalten.",
|
||||
"instance:manage":
|
||||
"Diesen Bereich darf nur ein Administrator der Installation verwalten.",
|
||||
};
|
||||
|
||||
/** Wie `can`, wirft aber mit passendem Status und deutscher Meldung. */
|
||||
export function requirePermission(
|
||||
actor: Actor,
|
||||
action: Action,
|
||||
scope: Scope = {},
|
||||
): void {
|
||||
if (can(actor, action, scope)) return;
|
||||
|
||||
if (actor.kind === "anonymous") {
|
||||
throw new QrRequestError("Bitte melde dich an.", 401);
|
||||
}
|
||||
throw new QrRequestError(DENIED_MESSAGES[action], 403);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import "server-only";
|
||||
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const SESSION_COOKIE = "qrgen_session";
|
||||
|
||||
/** Wie lange eine Sitzung ohne Aktivität gültig bleibt. */
|
||||
const SESSION_DAYS = 30;
|
||||
|
||||
/**
|
||||
* Der Cookie enthält ein Zufallstoken, die Datenbank nur dessen SHA-256.
|
||||
* Wer die Sitzungstabelle liest, kann sich damit also nicht anmelden.
|
||||
*/
|
||||
function hashToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
function expiryFromNow(): Date {
|
||||
return new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
export type SessionUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
isInstanceAdmin: boolean;
|
||||
memberships: { groupId: string; groupName: string; role: "ADMIN" | "EDITOR" | "VIEWER" }[];
|
||||
};
|
||||
|
||||
/** Legt eine Sitzung an und setzt den Cookie. */
|
||||
export async function createSession(userId: string): Promise<void> {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
|
||||
await prisma.session.create({
|
||||
data: { tokenHash: hashToken(token), userId, expiresAt: expiryFromNow() },
|
||||
});
|
||||
|
||||
const store = await cookies();
|
||||
store.set(SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
// `secure` nur hinter HTTPS — im LAN läuft die Anwendung oft über
|
||||
// einfaches HTTP, dort würde der Cookie sonst gar nicht gesetzt.
|
||||
secure: (process.env.BASE_URL ?? "").startsWith("https://"),
|
||||
expires: expiryFromNow(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Beendet die aktuelle Sitzung und löscht den Cookie. */
|
||||
export async function destroySession(): Promise<void> {
|
||||
const store = await cookies();
|
||||
const token = store.get(SESSION_COOKIE)?.value;
|
||||
|
||||
if (token) {
|
||||
await prisma.session
|
||||
.deleteMany({ where: { tokenHash: hashToken(token) } })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
store.delete(SESSION_COOKIE);
|
||||
}
|
||||
|
||||
/** Beendet alle Sitzungen eines Benutzers — etwa beim Deaktivieren. */
|
||||
export async function destroyAllSessions(userId: string): Promise<void> {
|
||||
await prisma.session.deleteMany({ where: { userId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die aktuelle Sitzung. Liefert `null`, wenn kein oder ein ungültiger
|
||||
* Cookie vorliegt, die Sitzung abgelaufen ist oder der Benutzer inzwischen
|
||||
* deaktiviert wurde.
|
||||
*/
|
||||
export async function getSessionUser(): Promise<SessionUser | null> {
|
||||
const store = await cookies();
|
||||
const token = store.get(SESSION_COOKIE)?.value;
|
||||
if (!token) return null;
|
||||
|
||||
const session = await prisma.session.findUnique({
|
||||
where: { tokenHash: hashToken(token) },
|
||||
include: {
|
||||
user: {
|
||||
include: { memberships: { include: { group: { select: { name: true } } } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
if (session.expiresAt.getTime() < Date.now()) {
|
||||
await prisma.session.delete({ where: { id: session.id } }).catch(() => undefined);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!session.user.isActive) return null;
|
||||
|
||||
// Gleitendes Ablaufdatum, aber höchstens einmal pro Stunde schreiben —
|
||||
// sonst gäbe es bei jedem Seitenaufruf einen Schreibvorgang.
|
||||
const anHourAgo = Date.now() - 60 * 60 * 1000;
|
||||
if (session.lastSeenAt.getTime() < anHourAgo) {
|
||||
await prisma.session
|
||||
.update({
|
||||
where: { id: session.id },
|
||||
data: { lastSeenAt: new Date(), expiresAt: expiryFromNow() },
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
return {
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name,
|
||||
isInstanceAdmin: session.user.isInstanceAdmin,
|
||||
memberships: session.user.memberships.map((membership) => ({
|
||||
groupId: membership.groupId,
|
||||
groupName: membership.group.name,
|
||||
role: membership.role,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Räumt abgelaufene Sitzungen auf. */
|
||||
export async function pruneExpiredSessions(): Promise<number> {
|
||||
const result = await prisma.session.deleteMany({
|
||||
where: { expiresAt: { lt: new Date() } },
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
Reference in New Issue
Block a user