Stand vor Einführung des Nacht-Agenten

This commit is contained in:
Kundencenter 2026-09-27 00:51:32 +02:00
commit 4763548bfb
168 changed files with 12726 additions and 0 deletions

View file

@ -0,0 +1,27 @@
import { randomUUID } from 'node:crypto';
import type { PoolConnection } from 'mysql2/promise';
import { run } from './db.js';
import { randomToken, sha256 } from './crypto.js';
import { config } from './config.js';
import { sendMail } from './mail.js';
export const INVITE_DAYS = 7;
/** Legt einen eingeladenen Benutzer an und liefert den Einladungslink (Token nur als Hash gespeichert). */
export async function createInvitedUser(c: PoolConnection, u: { email: string; name: string; kind: 'customer' | 'staff'; staffRole?: string | null }): Promise<{ userId: string; token: string }> {
const userId = randomUUID();
await run('INSERT INTO users (id, email, name, kind, staff_role, status) VALUES (?,?,?,?,?,\'invited\')', [userId, u.email.toLowerCase(), u.name, u.kind, u.staffRole ?? null], c);
return { userId, token: await createToken(c, userId, 'invite') };
}
export async function createToken(c: PoolConnection | undefined, userId: string, purpose: 'invite' | 'password_reset' | 'verify_email'): Promise<string> {
const token = randomToken(32);
const hours = purpose === 'invite' ? INVITE_DAYS * 24 : 1;
await run('INSERT INTO user_tokens (id, user_id, purpose, token_hash, expires_at) VALUES (?,?,?,?, DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? HOUR))', [randomUUID(), userId, purpose, sha256(token), hours], c);
return token;
}
export const inviteLink = (token: string): string => `${config.baseUrl}/einladung?token=${encodeURIComponent(token)}`;
export const resetLink = (token: string): string => `${config.baseUrl}/passwort-reset?token=${encodeURIComponent(token)}`;
export async function mailInvite(email: string, name: string, token: string): Promise<string> {
return sendMail(email, 'invite', 'Ihr Zugang zum Kundencenter', `Hallo ${name},\n\nfür Sie wurde ein Zugang eingerichtet. Bitte legen Sie hier Ihr Passwort fest (${INVITE_DAYS} Tage gültig):\n${inviteLink(token)}\n`);
}

View file

@ -0,0 +1,4 @@
/** Aktionen, die einem Kunden je Produkt/Ressource freigegeben werden können (Rolle ∧ Freigabe ∧ Anbieter-Fähigkeit müssen zusammenpassen). */
export const CUSTOMER_ACTIONS = ['suspend', 'unsuspend', 'extend', 'panel.login', 'domain.manage', 'email.manage', 'database.manage', 'ftp.manage'] as const;
export type CustomerAction = (typeof CUSTOMER_ACTIONS)[number];
export const CHILD_MANAGE: Record<string, CustomerAction | undefined> = { domain: 'domain.manage', email: 'email.manage', database: 'database.manage', ftp: 'ftp.manage' };

View file

@ -0,0 +1 @@
export * from '@kc/platform/audit';

92
apps/api/src/core/auth.ts Normal file
View file

@ -0,0 +1,92 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { randomUUID } from 'node:crypto';
import { one, query, run } from './db.js';
import { config } from './config.js';
import { randomToken, sha256, safeEqual } from './crypto.js';
import { forbidden, unauthorized } from './errors.js';
import { can, type Principal, type OrgRole, type StaffRole } from './policy.js';
export const COOKIE = 'kc_session';
const SESSION_HOURS = 12;
const IDLE_MINUTES = 120;
export interface AuthContext {
sessionId: string;
csrf: string;
pendingMfa: boolean;
mfaEnrolled: boolean;
user: { id: string; email: string; name: string };
principal: Principal;
}
declare module 'fastify' {
interface FastifyRequest { auth?: AuthContext; correlationId: string }
}
export async function createSession(reply: FastifyReply, userId: string, opts: { pendingMfa: boolean; ip: string; ua: string }): Promise<{ id: string; csrf: string }> {
const token = randomToken(32);
const id = randomUUID();
const csrf = randomToken(32);
await run(
`INSERT INTO sessions (id, user_id, token_hash, csrf_token, mfa_verified, pending_mfa, ip, user_agent, expires_at)
VALUES (?,?,?,?,?,?,?,?, DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? HOUR))`,
[id, userId, sha256(token), csrf, opts.pendingMfa ? 0 : 1, opts.pendingMfa ? 1 : 0, opts.ip, opts.ua.slice(0, 255), SESSION_HOURS],
);
reply.setCookie(COOKIE, token, { httpOnly: true, sameSite: 'lax', secure: config.isProd, path: '/', maxAge: SESSION_HOURS * 3600 });
return { id, csrf };
}
async function loadAuth(req: FastifyRequest): Promise<AuthContext | undefined> {
const token = req.cookies[COOKIE];
if (!token) return undefined;
const s = await one(
`SELECT s.id, s.csrf_token, s.pending_mfa, u.id AS uid, u.email, u.name, u.kind, u.staff_role, u.status,
(SELECT COUNT(*) FROM mfa_totp m WHERE m.user_id = u.id AND m.confirmed_at IS NOT NULL) AS mfa
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.revoked_at IS NULL AND s.expires_at > UTC_TIMESTAMP(3)
AND s.last_seen_at > DATE_SUB(UTC_TIMESTAMP(3), INTERVAL ? MINUTE)`,
[sha256(token), IDLE_MINUTES],
);
if (!s || s.status !== 'active') return undefined;
const ms = await query('SELECT org_id, role FROM memberships WHERE user_id = ?', [s.uid]);
await run('UPDATE sessions SET last_seen_at = UTC_TIMESTAMP(3) WHERE id = ?', [s.id]);
return {
sessionId: s.id, csrf: s.csrf_token, pendingMfa: !!s.pending_mfa, mfaEnrolled: Number(s.mfa) > 0,
user: { id: s.uid, email: s.email, name: s.name },
principal: { userId: s.uid, kind: s.kind, staffRole: (s.staff_role as StaffRole | null) ?? null, memberships: ms.map((m) => ({ orgId: m.org_id as string, role: m.role as OrgRole })) },
};
}
/** Globale Hooks: Korrelations-ID, Sitzung laden, CSRF- und Origin-Prüfung für schreibende Requests. */
export function registerAuth(app: FastifyInstance): void {
app.decorateRequest('correlationId', '');
app.decorateRequest('auth', undefined);
app.addHook('onRequest', async (req, reply) => {
const inbound = req.headers['x-correlation-id'];
req.correlationId = typeof inbound === 'string' && /^[0-9a-f-]{36}$/i.test(inbound) ? inbound : randomUUID();
reply.header('x-correlation-id', req.correlationId);
req.auth = await loadAuth(req);
if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
const origin = req.headers.origin;
if (origin && !config.allowedOrigins.has(origin)) throw forbidden('Ungültiger Origin', 'BAD_ORIGIN');
if (req.auth) {
const sent = req.headers['x-csrf-token'];
if (typeof sent !== 'string' || !safeEqual(sent, req.auth.csrf)) throw forbidden('CSRF-Token ungültig', 'BAD_CSRF');
}
}
});
}
/** Voraussetzung für geschützte Routen. Staff müssen 2FA eingerichtet haben (außer bei MFA-Einrichtung). */
export function requireAuth(req: FastifyRequest, opts: { allowPendingMfa?: boolean; allowUnenrolledStaff?: boolean } = {}): AuthContext {
const a = req.auth;
if (!a) throw unauthorized();
if (a.pendingMfa && !opts.allowPendingMfa) throw unauthorized('Zweiter Faktor erforderlich', 'MFA_REQUIRED');
if (a.principal.kind === 'staff' && !a.mfaEnrolled && !opts.allowUnenrolledStaff) throw forbidden('Für Mitarbeiter ist 2FA verpflichtend', 'MFA_ENROLL_REQUIRED');
return a;
}
export function requirePermission(req: FastifyRequest, permission: string): AuthContext {
const a = requireAuth(req);
if (!can(a.principal, permission)) throw forbidden();
return a;
}
export const clientIp = (req: FastifyRequest): string => req.ip;

View file

@ -0,0 +1,5 @@
export * from '@kc/platform/config';
import { config } from '@kc/platform/config';
/** Rate-Limits werden im Testmodus praktisch abgeschaltet, damit Tests sich nicht selbst aussperren. */
export const rl = (max: number, timeWindow: string) => ({ rateLimit: { max: config.env === 'test' ? 100000 : max, timeWindow } });

View file

@ -0,0 +1 @@
export * from '@kc/platform/crypto';

1
apps/api/src/core/db.ts Normal file
View file

@ -0,0 +1 @@
export * from '@kc/platform/db';

View file

@ -0,0 +1,8 @@
export class AppError extends Error {
constructor(public status: number, public code: string, message: string) { super(message); }
}
export const badRequest = (m: string, code = 'BAD_REQUEST') => new AppError(400, code, m);
export const unauthorized = (m = 'Nicht angemeldet', code = 'UNAUTHENTICATED') => new AppError(401, code, m);
export const forbidden = (m = 'Keine Berechtigung', code = 'FORBIDDEN') => new AppError(403, code, m);
export const notFound = (m = 'Nicht gefunden') => new AppError(404, 'NOT_FOUND', m);
export const conflict = (m: string, code = 'CONFLICT') => new AppError(409, code, m);

View file

@ -0,0 +1 @@
export * from '@kc/platform/jobs';

23
apps/api/src/core/mail.ts Normal file
View file

@ -0,0 +1,23 @@
import nodemailer from 'nodemailer';
import { config } from './config.js';
import { run } from './db.js';
const transport = config.smtp
? nodemailer.createTransport({ host: config.smtp.host, port: config.smtp.port, secure: config.smtp.port === 465, auth: config.smtp.user ? { user: config.smtp.user, pass: config.smtp.pass } : undefined })
: null;
/** Versand mit Protokoll. Ohne SMTP wird nur "not_configured" protokolliert (Inhalt bewusst nicht gespeichert). */
export async function sendMail(to: string, template: string, subject: string, text: string): Promise<'sent' | 'failed' | 'not_configured'> {
if (!transport || !config.smtp) {
await run('INSERT INTO mail_log (to_email, template, status) VALUES (?,?,?)', [to, template, 'not_configured']);
return 'not_configured';
}
try {
await transport.sendMail({ from: config.smtp.from, to, subject, text });
await run('INSERT INTO mail_log (to_email, template, status) VALUES (?,?,?)', [to, template, 'sent']);
return 'sent';
} catch (e) {
await run('INSERT INTO mail_log (to_email, template, status, error) VALUES (?,?,?,?)', [to, template, 'failed', String((e as Error).message).slice(0, 300)]);
return 'failed';
}
}

View file

@ -0,0 +1,10 @@
import type { FastifyInstance } from 'fastify';
import type { OrgRole, StaffRole } from './policy.js';
/** Vertrag für Anwendungsmodule. Module kommunizieren nur über core/* und Domain-Events (Jobs), nie über fremde Module. */
export interface KcModule {
name: string;
/** Zusätzliche Rechte, die das Modul in die Policy-Schicht einträgt. */
permissions?: { staff?: Partial<Record<StaffRole, string[]>>; org?: Partial<Record<OrgRole, string[]>> };
register(app: FastifyInstance): void | Promise<void>;
}

View file

@ -0,0 +1,42 @@
/** Zentrale Policy-Schicht: RBAC (Staff-Rollen, Org-Rollen) + objektbezogene Prüfung. */
export type StaffRole = 'support' | 'accounting' | 'admin' | 'superadmin';
export type OrgRole = 'owner' | 'admin' | 'member';
const STAFF: Record<StaffRole, string[]> = {
support: ['customers.read', 'users.read'],
accounting: ['customers.read'],
admin: ['customers.read', 'customers.write', 'users.read', 'users.write', 'audit.read', 'jobs.read'],
superadmin: ['customers.read', 'customers.write', 'users.read', 'users.write', 'users.write_privileged', 'audit.read', 'jobs.read', 'settings.write'],
};
const ORG: Record<OrgRole, string[]> = {
owner: ['org.read', 'org.members.read', 'org.members.invite'],
admin: ['org.read', 'org.members.read', 'org.members.invite'],
member: ['org.read'],
};
/** Module dürfen weitere Rechte registrieren (siehe KcModule.permissions). */
const extra: { staff: Partial<Record<StaffRole, string[]>>; org: Partial<Record<OrgRole, string[]>> } = { staff: {}, org: {} };
export function registerPermissions(p: { staff?: Partial<Record<StaffRole, string[]>>; org?: Partial<Record<OrgRole, string[]>> }): void {
for (const [r, l] of Object.entries(p.staff ?? {})) extra.staff[r as StaffRole] = [...(extra.staff[r as StaffRole] ?? []), ...l];
for (const [r, l] of Object.entries(p.org ?? {})) extra.org[r as OrgRole] = [...(extra.org[r as OrgRole] ?? []), ...l];
}
export interface Principal {
userId: string;
kind: 'customer' | 'staff';
staffRole: StaffRole | null;
memberships: { orgId: string; role: OrgRole }[];
}
export function staffPermissions(role: StaffRole | null): string[] {
return role ? [...STAFF[role], ...(extra.staff[role] ?? [])] : [];
}
/** Globales Recht (nur Staff). */
export function can(p: Principal, permission: string): boolean {
return p.kind === 'staff' && staffPermissions(p.staffRole).includes(permission);
}
/** Objektbezogen: Staff mit globalem Recht ODER Mitglied der Organisation mit passender Org-Rolle. */
export function canInOrg(p: Principal, orgId: string, orgPermission: string, staffPermission: string): boolean {
if (can(p, staffPermission)) return true;
const m = p.memberships.find((x) => x.orgId === orgId);
return !!m && [...ORG[m.role], ...(extra.org[m.role] ?? [])].includes(orgPermission);
}