Prepare Kalendartool for MPM and disable invites
This commit is contained in:
45
lib/auth/mpm-provisioning.ts
Normal file
45
lib/auth/mpm-provisioning.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { prisma } from '@/lib/db/client';
|
||||
import type { AuthenticatedUser } from '@/lib/permissions/permissions';
|
||||
import type { Role } from '@prisma/client';
|
||||
|
||||
interface MpmIdentity {
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
role: 'ADMIN' | 'USER';
|
||||
}
|
||||
|
||||
/**
|
||||
* MPM is the identity and role authority in module mode. The module's local
|
||||
* user row supplies stable foreign keys for calendars and reservations.
|
||||
*/
|
||||
export async function provisionMpmUser(identity: MpmIdentity): Promise<AuthenticatedUser> {
|
||||
const hubId = `mpm:${identity.userId}`;
|
||||
const email = `mpm-${Buffer.from(identity.userId).toString('base64url')}@users.invalid`;
|
||||
const user = await prisma.user.upsert({
|
||||
where: { hubId },
|
||||
create: {
|
||||
hubId,
|
||||
email,
|
||||
username: identity.username,
|
||||
passwordHash: randomBytes(48).toString('hex'),
|
||||
role: identity.role as Role,
|
||||
},
|
||||
update: {
|
||||
email,
|
||||
username: identity.username,
|
||||
role: identity.role as Role,
|
||||
},
|
||||
select: { id: true, email: true, username: true, role: true },
|
||||
});
|
||||
|
||||
// Keep the external display name available as the profile label when MPM
|
||||
// has no separate username. Username is the stable, unique MPM account name.
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: identity.displayName || user.username,
|
||||
role: user.role,
|
||||
};
|
||||
}
|
||||
@@ -5,8 +5,9 @@
|
||||
* Kryptografie, keine Klartext-Sessions in der Datenbank.
|
||||
*/
|
||||
import { SignJWT, jwtVerify } from 'jose';
|
||||
import { cookies } from 'next/headers';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { provisionMpmUser } from '@/lib/auth/mpm-provisioning';
|
||||
import type { AuthenticatedUser } from '@/lib/permissions/permissions';
|
||||
|
||||
const SESSION_COOKIE_NAME = 'calendar_session';
|
||||
@@ -62,6 +63,18 @@ export async function clearSessionCookie(): Promise<void> {
|
||||
* Wirft UnauthorizedError, wenn keine gueltige Session existiert.
|
||||
*/
|
||||
export async function getAuthenticatedUser(): Promise<AuthenticatedUser> {
|
||||
if (process.env.MPM_AUTH_MODE === 'true') {
|
||||
const requestHeaders = await headers();
|
||||
const userId = requestHeaders.get('x-kalendartool-mpm-user-id');
|
||||
const username = requestHeaders.get('x-kalendartool-mpm-username');
|
||||
const displayName = requestHeaders.get('x-kalendartool-mpm-display-name');
|
||||
const role = requestHeaders.get('x-kalendartool-mpm-role');
|
||||
if (!userId || !username || !role || !['ADMIN', 'USER'].includes(role)) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
return provisionMpmUser({ userId, username, displayName, role: role as 'ADMIN' | 'USER' });
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||
if (!token) {
|
||||
@@ -98,4 +111,4 @@ export async function tryGetAuthenticatedUser(): Promise<AuthenticatedUser | nul
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
lib/client-fetch.ts
Normal file
8
lib/client-fetch.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** Fetch wrapper for absolute app paths when Next.js basePath is enabled. */
|
||||
export function appFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
if (typeof input !== 'string' || !input.startsWith('/') || input.startsWith('//')) {
|
||||
return fetch(input, init);
|
||||
}
|
||||
const basePath = process.env.NEXT_PUBLIC_APP_BASE_PATH ?? '';
|
||||
return fetch(`${basePath}${input}`, init);
|
||||
}
|
||||
@@ -32,8 +32,7 @@ let cachedConfig: AppConfig | undefined;
|
||||
|
||||
export interface AppConfig {
|
||||
sessionSecret: string;
|
||||
appUrl: string;
|
||||
/** Wenn false, ist die Registrierung nur ueber Invite-Links moeglich. */
|
||||
/** Wenn false, sind neue lokale Registrierungen deaktiviert. */
|
||||
allowOpenRegistration: boolean;
|
||||
/** MultiToolApp-Plattform: Basis-URL (JWKS-Abruf). null = SSO deaktiviert. */
|
||||
hubUrl: string | null;
|
||||
@@ -49,7 +48,6 @@ export function getConfig(): AppConfig {
|
||||
}
|
||||
cachedConfig = {
|
||||
sessionSecret: requireSessionSecret(),
|
||||
appUrl: process.env.APP_URL?.trim() || 'http://localhost:3000',
|
||||
allowOpenRegistration:
|
||||
(process.env.ALLOW_OPEN_REGISTRATION ?? 'true').toLowerCase() === 'true',
|
||||
hubUrl: process.env.HUB_URL?.trim() || null,
|
||||
@@ -57,4 +55,4 @@ export function getConfig(): AppConfig {
|
||||
hubIssuer: process.env.HUB_ISSUER?.trim() || null,
|
||||
};
|
||||
return cachedConfig;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* Postgres bricht bei Parallelitaets-Anomalien unter SERIALIZABLE eine
|
||||
* der beteiligten Transaktionen ab. Wiederholbare Operationen (z. B.
|
||||
* Registrierung mit Invite-Verbrauch, Rollen-Aenderung) werden dann
|
||||
* Registrierung und Rollen-Aenderung) werden dann
|
||||
* einfach erneut ausgefuehrt - der Konflikt loest sich auf, weil die
|
||||
* andere Transaktion inzwischen committet hat.
|
||||
*/
|
||||
@@ -40,4 +40,4 @@ export async function withSerializableTx<T>(
|
||||
// committet, ein erneuter Lauf sieht den aktuellen Stand.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* Invite-System (plan.md Abschnitt 3 und 9).
|
||||
*
|
||||
* - Token wird zufaellig und nicht erratbar erzeugt (crypto.randomBytes).
|
||||
* - In der Datenbank liegt NUR der SHA-256-Hash des Tokens.
|
||||
* - Der Klartext-Token wird genau einmal beim Erstellen zurueckgegeben.
|
||||
*/
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
/** Laenge des rohen Tokens in Bytes (256 Bit Entropie -> nicht erratbar). */
|
||||
const TOKEN_BYTE_LENGTH = 32;
|
||||
|
||||
/** Invite-Links laufen standardmaessig nach 7 Tagen ab. */
|
||||
export const INVITE_DEFAULT_TTL_DAYS = 7;
|
||||
|
||||
/** Erzeugt einen neuen, nicht erratbaren Invite-Token (Klartext). */
|
||||
export function generateInviteToken(): string {
|
||||
return randomBytes(TOKEN_BYTE_LENGTH).toString('base64url');
|
||||
}
|
||||
|
||||
/** Berechnet den SHA-256-Hash eines Tokens fuer die Datenbank. */
|
||||
export function hashInviteToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
/** Standard-Ablaufzeit fuer neue Invites. */
|
||||
export function defaultInviteExpiry(now: Date = new Date()): Date {
|
||||
const expiry = new Date(now);
|
||||
expiry.setDate(expiry.getDate() + INVITE_DEFAULT_TTL_DAYS);
|
||||
return expiry;
|
||||
}
|
||||
|
||||
/** Ist ein Invite gueltig (nicht abgelaufen, nicht verwendet)? */
|
||||
export function isInviteUsable(
|
||||
invite: { expiresAt: Date | null; usedAt: Date | null },
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
if (invite.usedAt !== null) {
|
||||
return false;
|
||||
}
|
||||
if (invite.expiresAt !== null && invite.expiresAt <= now) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export function canViewCalendar(
|
||||
}
|
||||
|
||||
/**
|
||||
* Darf der User den Kalender verwalten (bearbeiten, Invites erstellen,
|
||||
* Darf der User den Kalender verwalten (bearbeiten,
|
||||
* loeschen)? Admins duerfen alle Kalender verwalten (auch die anderer
|
||||
* Benutzer), normale User keinen.
|
||||
*/
|
||||
@@ -138,4 +138,4 @@ export function canModifyReservation(
|
||||
return true;
|
||||
}
|
||||
return reservation.userId === user.id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@ export const registerSchema = z
|
||||
email: emailSchema,
|
||||
password: passwordSchema,
|
||||
username: usernameSchema.optional(),
|
||||
inviteToken: z.string().trim().min(10).max(200).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -117,16 +116,8 @@ export const updateReservationSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const createInviteSchema = z
|
||||
.object({
|
||||
calendarId: z.string().trim().min(1),
|
||||
expiresInDays: z.number().int().min(1).max(90).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Mitglied per E-Mail zu einem Kalender hinzufuegen (DB-basiertes
|
||||
* Einladungssystem, ersetzt den Token-Link-Flow in der Verwaltung).
|
||||
* Vorhandenen Benutzer per E-Mail zu einem Kalender hinzufuegen.
|
||||
*/
|
||||
export const addMemberSchema = z
|
||||
.object({
|
||||
@@ -156,4 +147,4 @@ export const updateUsernameSchema = z
|
||||
.object({
|
||||
username: usernameSchema.nullable(),
|
||||
})
|
||||
.strict();
|
||||
.strict();
|
||||
|
||||
Reference in New Issue
Block a user