Variante: Portal Header mit Options Row
'; + echo 'Naurua ERP
'; + echo ''; + echo ''; + echo '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 'Variante: Portal Header mit Options Row
'; + echo 'Naurua ERP
'; + echo ''; + echo ''; + echo '