diff --git a/docs/SCHEMA_PHASE1.sql b/docs/SCHEMA_PHASE1.sql index 2674461..efaac48 100644 --- a/docs/SCHEMA_PHASE1.sql +++ b/docs/SCHEMA_PHASE1.sql @@ -59,6 +59,27 @@ CREATE TABLE contact ( CREATE INDEX idx_contact_party ON contact(party_id); +CREATE TABLE system_user ( + id BIGSERIAL PRIMARY KEY, + username TEXT NOT NULL, + email TEXT NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + failed_login_count INTEGER NOT NULL DEFAULT 0, + locked_until TIMESTAMP, + last_login_at TIMESTAMP, + created_by_user_id BIGINT REFERENCES system_user(id) ON DELETE SET NULL, + updated_by_user_id BIGINT REFERENCES system_user(id) ON DELETE SET NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT chk_system_user_role CHECK (role IN ('admin', 'user')), + CONSTRAINT chk_system_user_failed_login_count CHECK (failed_login_count >= 0) +); + +CREATE UNIQUE INDEX uq_system_user_username_ci ON system_user (LOWER(username)); +CREATE UNIQUE INDEX uq_system_user_email_ci ON system_user (LOWER(email)); + -- Lagergefuehrtes Produkt (Bestandsfuehrung, Charge, MHD) CREATE TABLE product ( id BIGSERIAL PRIMARY KEY, diff --git a/docs/architektur/database/module_db_ownership.md b/docs/architektur/database/module_db_ownership.md index 8c18a47..31747d5 100644 --- a/docs/architektur/database/module_db_ownership.md +++ b/docs/architektur/database/module_db_ownership.md @@ -41,6 +41,7 @@ Diese Referenz ordnet die aktuellen Tabellen, Views und technischen DB-Artefakte - `audit_log` - `outbound_webhook_event` +- `system_user` ## 3. Hinweise @@ -48,4 +49,3 @@ Diese Referenz ordnet die aktuellen Tabellen, Views und technischen DB-Artefakte - Fremde Module schreiben nicht direkt in diese Artefakte. - `shared` besitzt keine fachlichen Tabellen. - Neue Tabellen fuer `buchhaltung` und `kundenberatung` werden erst mit deren Modulvertraegen definiert. - diff --git a/modules/shared/auth/service.php b/modules/shared/auth/service.php new file mode 100644 index 0000000..68f7bec --- /dev/null +++ b/modules/shared/auth/service.php @@ -0,0 +1,385 @@ + 0, + 'path' => '/', + 'domain' => '', + 'secure' => auth_is_https_request(), + 'httponly' => true, + 'samesite' => 'Lax', + ]); + ini_set('session.use_only_cookies', '1'); + ini_set('session.use_strict_mode', '1'); + ini_set('session.use_trans_sid', '0'); + session_start(); + + if (!isset($_SESSION[AUTH_CSRF_TOKEN_KEY]) || !is_string($_SESSION[AUTH_CSRF_TOKEN_KEY]) || $_SESSION[AUTH_CSRF_TOKEN_KEY] === '') { + $_SESSION[AUTH_CSRF_TOKEN_KEY] = bin2hex(random_bytes(32)); + } +} + +function auth_csrf_token(): string +{ + auth_bootstrap_session(); + return (string) $_SESSION[AUTH_CSRF_TOKEN_KEY]; +} + +function auth_validate_csrf_token(?string $token): bool +{ + auth_bootstrap_session(); + if (!is_string($token) || $token === '') { + return false; + } + + return hash_equals((string) $_SESSION[AUTH_CSRF_TOKEN_KEY], $token); +} + +function auth_escape_html(string $value): string +{ + return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); +} + +function auth_normalize_identifier(string $identifier): string +{ + return trim($identifier); +} + +function auth_normalize_return_to(?string $returnTo): string +{ + $candidate = trim((string) $returnTo); + if ($candidate === '' || str_starts_with($candidate, '//') || str_contains($candidate, '://')) { + return '/'; + } + + if (!str_starts_with($candidate, '/')) { + return '/'; + } + + return $candidate; +} + +function auth_set_return_to(string $returnTo): void +{ + auth_bootstrap_session(); + $_SESSION[AUTH_LOGIN_RETURN_TO_KEY] = auth_normalize_return_to($returnTo); +} + +function auth_take_return_to(): string +{ + auth_bootstrap_session(); + $returnTo = auth_normalize_return_to($_SESSION[AUTH_LOGIN_RETURN_TO_KEY] ?? '/'); + unset($_SESSION[AUTH_LOGIN_RETURN_TO_KEY]); + return $returnTo; +} + +function auth_ensure_schema(PDO $pdo): void +{ + $requiredColumns = [ + 'id', + 'username', + 'email', + 'password_hash', + 'role', + 'is_active', + 'failed_login_count', + 'locked_until', + 'last_login_at', + 'created_by_user_id', + 'updated_by_user_id', + 'created_at', + 'updated_at', + ]; + + $stmt = $pdo->query( + "SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'system_user'" + ); + $columns = $stmt->fetchAll(PDO::FETCH_COLUMN); + $existing = array_map('strval', $columns ?: []); + $missing = array_values(array_diff($requiredColumns, $existing)); + + if ($missing !== []) { + throw new RuntimeException('DB schema not initialized. Missing auth columns: ' . implode(', ', $missing)); + } +} + +function auth_fetch_user_row_by_id(PDO $pdo, int $userId): ?array +{ + $stmt = $pdo->prepare( + 'SELECT id, username, email, role, is_active, locked_until, last_login_at, created_at, updated_at + FROM public.system_user + WHERE id = :id + LIMIT 1' + ); + $stmt->execute([':id' => $userId]); + $row = $stmt->fetch(); + + return is_array($row) ? $row : null; +} + +function auth_current_user(PDO $pdo): ?array +{ + auth_bootstrap_session(); + + $userId = $_SESSION[AUTH_SESSION_USER_ID_KEY] ?? null; + if (!is_int($userId) && !ctype_digit((string) $userId)) { + return null; + } + + $user = auth_fetch_user_row_by_id($pdo, (int) $userId); + if ($user === null) { + unset($_SESSION[AUTH_SESSION_USER_ID_KEY]); + return null; + } + + if (!((bool) $user['is_active'])) { + unset($_SESSION[AUTH_SESSION_USER_ID_KEY]); + return null; + } + + $lockedUntil = $user['locked_until'] ?? null; + if ($lockedUntil !== null && $lockedUntil !== '') { + try { + $lockedUntilAt = new DateTimeImmutable((string) $lockedUntil); + if ($lockedUntilAt > new DateTimeImmutable('now')) { + unset($_SESSION[AUTH_SESSION_USER_ID_KEY]); + return null; + } + } catch (Throwable) { + unset($_SESSION[AUTH_SESSION_USER_ID_KEY]); + return null; + } + } + + return $user; +} + +function auth_require_user(PDO $pdo): array +{ + $user = auth_current_user($pdo); + if ($user !== null) { + return $user; + } + + $requestUri = (string) ($_SERVER['REQUEST_URI'] ?? '/'); + auth_set_return_to($requestUri); + header('Location: /'); + exit; +} + +function auth_find_user_by_identifier(PDO $pdo, string $identifier): ?array +{ + $stmt = $pdo->prepare( + 'SELECT id, username, email, password_hash, role, is_active, failed_login_count, locked_until + FROM public.system_user + WHERE LOWER(username) = LOWER(:identifier_username) + OR LOWER(email) = LOWER(:identifier_email) + LIMIT 1 + FOR UPDATE' + ); + $stmt->execute([ + ':identifier_username' => $identifier, + ':identifier_email' => $identifier, + ]); + $row = $stmt->fetch(); + + return is_array($row) ? $row : null; +} + +function auth_login(PDO $pdo, string $identifier, string $password): array +{ + auth_ensure_schema($pdo); + auth_bootstrap_session(); + + $normalizedIdentifier = auth_normalize_identifier($identifier); + $password = (string) $password; + $errors = [ + 'identifier' => null, + 'password' => null, + ]; + + if ($normalizedIdentifier === '') { + $errors['identifier'] = 'Bitte Benutzername oder E-Mail eingeben.'; + } + + if ($password === '') { + $errors['password'] = 'Bitte Passwort eingeben.'; + } + + if ($errors['identifier'] !== null || $errors['password'] !== null) { + return [ + 'ok' => false, + 'errors' => $errors, + ]; + } + + $pdo->beginTransaction(); + + try { + $user = auth_find_user_by_identifier($pdo, $normalizedIdentifier); + if ($user === null) { + $pdo->commit(); + + return [ + 'ok' => false, + 'errors' => [ + 'identifier' => 'Benutzername oder E-Mail ist unbekannt.', + 'password' => null, + ], + ]; + } + + $userId = (int) $user['id']; + if (!((bool) $user['is_active'])) { + $pdo->commit(); + + return [ + 'ok' => false, + 'errors' => [ + 'identifier' => 'Benutzerkonto ist deaktiviert.', + 'password' => null, + ], + ]; + } + + $lockedUntil = $user['locked_until'] ?? null; + if ($lockedUntil !== null && $lockedUntil !== '') { + try { + $lockedUntilAt = new DateTimeImmutable((string) $lockedUntil); + if ($lockedUntilAt > new DateTimeImmutable('now')) { + $pdo->commit(); + + return [ + 'ok' => false, + 'errors' => [ + 'identifier' => 'Benutzerkonto ist vorübergehend gesperrt. Bitte später erneut versuchen.', + 'password' => null, + ], + ]; + } + } catch (Throwable) { + $pdo->commit(); + + return [ + 'ok' => false, + 'errors' => [ + 'identifier' => 'Benutzerkonto ist vorübergehend gesperrt. Bitte später erneut versuchen.', + 'password' => null, + ], + ]; + } + } + + if (!password_verify($password, (string) $user['password_hash'])) { + $failedCount = (int) ($user['failed_login_count'] ?? 0) + 1; + $lockedUntilSql = null; + if ($failedCount >= AUTH_FAILED_LOGIN_LIMIT) { + $lockedUntilSql = 'NOW() + INTERVAL \'' . AUTH_LOCK_DURATION_SECONDS . ' seconds\''; + } + + $updateSql = 'UPDATE public.system_user + SET failed_login_count = :failed_login_count, + locked_until = ' . ($lockedUntilSql !== null ? $lockedUntilSql : 'NULL') . ', + updated_at = NOW(), + updated_by_user_id = NULL + WHERE id = :id'; + $stmt = $pdo->prepare($updateSql); + $stmt->execute([ + ':failed_login_count' => $failedCount, + ':id' => $userId, + ]); + + $pdo->commit(); + + return [ + 'ok' => false, + 'errors' => [ + 'identifier' => null, + 'password' => 'Passwort ist falsch.', + ], + ]; + } + + $stmt = $pdo->prepare( + 'UPDATE public.system_user + SET failed_login_count = 0, + locked_until = NULL, + last_login_at = NOW(), + updated_at = NOW(), + updated_by_user_id = NULL + WHERE id = :id' + ); + $stmt->execute([':id' => $userId]); + + session_regenerate_id(true); + $_SESSION[AUTH_SESSION_USER_ID_KEY] = $userId; + $pdo->commit(); + + return [ + 'ok' => true, + 'errors' => [ + 'identifier' => null, + 'password' => null, + ], + ]; + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + throw $exception; + } +} + +function auth_logout(): void +{ + auth_bootstrap_session(); + + $_SESSION = []; + + if (ini_get('session.use_cookies')) { + $params = session_get_cookie_params(); + setcookie(session_name(), '', time() - 42000, [ + 'path' => $params['path'] ?? '/', + 'domain' => $params['domain'] ?? '', + 'secure' => (bool) ($params['secure'] ?? false), + 'httponly' => (bool) ($params['httponly'] ?? true), + 'samesite' => $params['samesite'] ?? 'Lax', + ]); + } + + session_destroy(); +} diff --git a/modules/shared/auth/ui/home.php b/modules/shared/auth/ui/home.php new file mode 100644 index 0000000..2979a86 --- /dev/null +++ b/modules/shared/auth/ui/home.php @@ -0,0 +1,188 @@ +'; + echo ''; + echo ''; + echo ''; + echo ''; + echo 'Naurua ERP'; + echo ''; + echo ''; + echo ''; + echo '
'; + echo '
'; + echo '

Variante: Portal Header mit Options Row

'; + echo '
'; + echo '
'; + echo '

Naurua ERP

'; + echo '
'; + echo ''; + echo '
'; + echo 'Startseite'; + echo 'Abmelden'; + echo '
'; + echo '
'; + echo ''; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + echo ''; + echo '
'; + echo '
    '; + echo ''; + echo ''; + echo ''; + echo ''; + echo '
  • Menüpunkt 5
  • '; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + echo ''; + echo '
'; + echo '
    '; + echo ''; + echo ''; + echo ''; + echo ''; + echo '
  • Menüpunkt 5
  • '; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + echo ''; + echo ''; + echo ''; + echo ''; + echo '0 Treffer'; + echo '
'; + echo '
'; + echo '
'; + echo ''; + echo ''; + echo ''; + echo 'Angemeldet als ' . $userName . ''; + echo ''; + echo '
'; + echo '
'; + echo '
'; + echo '

Willkommen im Naurua ERP

'; + echo '
'; + echo '
'; + echo ''; + echo ''; + echo '
'; + echo '
'; + echo '
'; + echo ''; + echo ''; + echo ''; + echo ''; +} diff --git a/modules/shared/auth/ui/login.php b/modules/shared/auth/ui/login.php new file mode 100644 index 0000000..5e9f008 --- /dev/null +++ b/modules/shared/auth/ui/login.php @@ -0,0 +1,86 @@ +'; + echo ''; + echo ''; + echo ''; + echo ''; + echo '' . auth_escape_html($pageTitle) . ''; + echo ''; + echo ''; + echo ''; + + echo '

Anmeldung – Naurua ERP

'; + echo '
'; + echo '
'; + echo '
'; + echo '
' . auth_escape_html($headerTitle) . '
'; + echo '
'; + echo ''; + echo '
'; + echo '
'; + echo ''; + echo ''; +} diff --git a/public/assets/help-icon-overlays.js b/public/assets/help-icon-overlays.js new file mode 100644 index 0000000..84edff9 --- /dev/null +++ b/public/assets/help-icon-overlays.js @@ -0,0 +1,129 @@ +(function initHelpIconOverlayModule() { + const CLOSE_HANDLERS = { + '.sg-pulldown-demo': (root) => { + root.querySelectorAll('.sg-pulldown-demo').forEach((demo) => { + const trigger = demo.querySelector('.sg-pulldown-demo__trigger'); + demo.dataset.open = 'false'; + if (trigger) { + trigger.setAttribute('aria-expanded', 'false'); + } + }); + }, + '.sg-sandwich-menu-wrap': (root) => { + root.querySelectorAll('.sg-sandwich-menu-wrap').forEach((wrap) => { + const button = wrap.querySelector('.sg-sandwich-button'); + wrap.dataset.open = 'false'; + if (button) { + button.setAttribute('aria-expanded', 'false'); + } + }); + }, + }; + + const getViewportWidth = () => { + if (window.visualViewport && typeof window.visualViewport.width === 'number') { + return window.visualViewport.width; + } + return window.innerWidth; + }; + + const getSafeInsetPx = () => { + const rootStyles = getComputedStyle(document.documentElement); + const spacingSmallRaw = rootStyles.getPropertyValue('--spacing-small').trim(); + const rootFontSize = parseFloat(rootStyles.fontSize) || 16; + const spacingSmallValue = parseFloat(spacingSmallRaw); + if (Number.isNaN(spacingSmallValue)) { + return 0; + } + if (spacingSmallRaw.endsWith('rem')) { + return spacingSmallValue * rootFontSize; + } + return spacingSmallValue; + }; + + const closeAllHelpIcons = (root) => { + root.querySelectorAll('.sg-help-icon-wrap').forEach((wrap) => { + const button = wrap.querySelector('.sg-help-icon'); + const panel = wrap.querySelector('.sg-help-icon-panel'); + wrap.dataset.open = 'false'; + if (panel) { + panel.style.removeProperty('transform'); + } + if (button) { + button.setAttribute('aria-expanded', 'false'); + } + }); + }; + + window.sgInitHelpIconOverlays = (options = {}) => { + const root = options.root || document; + const closeOnOpenSelectors = options.closeOnOpenSelectors || []; + const outsideClickIgnoreSelectors = options.outsideClickIgnoreSelectors || []; + + root.querySelectorAll('.sg-help-icon-wrap').forEach((wrap) => { + if (wrap.dataset.helpIconInit === 'true') { + return; + } + wrap.dataset.helpIconInit = 'true'; + + const button = wrap.querySelector('.sg-help-icon'); + const panel = wrap.querySelector('.sg-help-icon-panel'); + if (!button || !panel) { + return; + } + + button.addEventListener('click', (event) => { + event.stopPropagation(); + const nextState = wrap.dataset.open !== 'true'; + + closeAllHelpIcons(root); + closeOnOpenSelectors.forEach((selector) => { + const handler = CLOSE_HANDLERS[selector]; + if (handler) { + handler(root); + } + }); + + if (!nextState) { + return; + } + + wrap.dataset.align = 'left'; + wrap.dataset.open = 'true'; + button.setAttribute('aria-expanded', 'true'); + + const viewportWidth = getViewportWidth(); + const panelRect = panel.getBoundingClientRect(); + if (panelRect.right > viewportWidth) { + wrap.dataset.align = 'right'; + } + const alignedPanelRect = panel.getBoundingClientRect(); + if (alignedPanelRect.left < 0) { + wrap.dataset.align = 'left'; + } + + const clampedRect = panel.getBoundingClientRect(); + const safeInset = getSafeInsetPx(); + let shiftX = 0; + if (clampedRect.right > (viewportWidth - safeInset)) { + shiftX -= clampedRect.right - (viewportWidth - safeInset); + } + if ((clampedRect.left + shiftX) < safeInset) { + shiftX += safeInset - (clampedRect.left + shiftX); + } + if (shiftX !== 0) { + panel.style.transform = `translateX(${shiftX}px)`; + } + }); + }); + + document.addEventListener('click', (event) => { + const isInsideIgnoredZone = ['.sg-help-icon-wrap', ...outsideClickIgnoreSelectors] + .some((selector) => event.target.closest(selector)); + if (isInsideIgnoredZone) { + return; + } + closeAllHelpIcons(root); + }); + }; +})(); diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..51ba967 --- /dev/null +++ b/public/index.php @@ -0,0 +1,56 @@ + (string) ($_POST['identifier'] ?? ''), + 'errors' => [ + 'identifier' => 'Ungültiges Sicherheits-Token. Bitte Seite neu laden.', + 'password' => null, + ], + ]); + exit; + } + + $loginResult = auth_login( + $pdo, + (string) ($_POST['identifier'] ?? ''), + (string) ($_POST['password'] ?? '') + ); + + if (($loginResult['ok'] ?? false) === true) { + header('Location: ' . auth_take_return_to()); + exit; + } + + render_auth_login_page([ + 'identifier_value' => (string) ($_POST['identifier'] ?? ''), + 'errors' => $loginResult['errors'] ?? [], + ]); + exit; +} + +$currentUser = auth_current_user($pdo); +if ($currentUser !== null) { + render_auth_home_page($currentUser); + exit; +} + +render_auth_login_page([ + 'identifier_value' => '', + 'errors' => [ + 'identifier' => null, + 'password' => null, + ], +]); diff --git a/public/logout.php b/public/logout.php new file mode 100644 index 0000000..3adf487 --- /dev/null +++ b/public/logout.php @@ -0,0 +1,9 @@ +