Stand vor Einführung des Nacht-Agenten
This commit is contained in:
commit
4763548bfb
168 changed files with 12726 additions and 0 deletions
27
apps/api/src/core/accounts.ts
Normal file
27
apps/api/src/core/accounts.ts
Normal 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`);
|
||||
}
|
||||
4
apps/api/src/core/actions.ts
Normal file
4
apps/api/src/core/actions.ts
Normal 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' };
|
||||
1
apps/api/src/core/audit.ts
Normal file
1
apps/api/src/core/audit.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from '@kc/platform/audit';
|
||||
92
apps/api/src/core/auth.ts
Normal file
92
apps/api/src/core/auth.ts
Normal 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;
|
||||
5
apps/api/src/core/config.ts
Normal file
5
apps/api/src/core/config.ts
Normal 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 } });
|
||||
1
apps/api/src/core/crypto.ts
Normal file
1
apps/api/src/core/crypto.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from '@kc/platform/crypto';
|
||||
1
apps/api/src/core/db.ts
Normal file
1
apps/api/src/core/db.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from '@kc/platform/db';
|
||||
8
apps/api/src/core/errors.ts
Normal file
8
apps/api/src/core/errors.ts
Normal 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);
|
||||
1
apps/api/src/core/jobs.ts
Normal file
1
apps/api/src/core/jobs.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from '@kc/platform/jobs';
|
||||
23
apps/api/src/core/mail.ts
Normal file
23
apps/api/src/core/mail.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
10
apps/api/src/core/module.ts
Normal file
10
apps/api/src/core/module.ts
Normal 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>;
|
||||
}
|
||||
42
apps/api/src/core/policy.ts
Normal file
42
apps/api/src/core/policy.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue