diff --git a/docs/planning/gnexus-auth-integration.md b/docs/planning/gnexus-auth-integration.md new file mode 100644 index 0000000..6ed1049 --- /dev/null +++ b/docs/planning/gnexus-auth-integration.md @@ -0,0 +1,483 @@ +# Техническое задание: Интеграция smart-home-serv с gnexus-auth + +## 1. Цели + +1. Удалить локальную регистрацию/аутентификацию (таблицы `users`, `sessions`, `profiles`). +2. Использовать `gnexus-auth` (auth.gnexus.space) как единый источник идентичности. +3. Реализовать ролевую модель: **superadmin**, **admin**, **user**, **guest**. +4. Реализовать permission-based ACL с возможностью урезания прав для конкретного пользователя админом/суперадмином. +5. Поддержать группы пользователей, синхронизируемые с `gnexus-auth`. +6. Обработать webhooks от `gnexus-auth` для актуализации ролей, прав, статусов. +7. Минимальные изменения в существующем API устройств/скриптов/зон. + +## 2. Архитектурные решения + +### 2.1. Пакет `gnexus/auth-client` и HTTP-клиент + +**Пакет:** `gnexus/auth-client` (репозиторий `git.gnexus.space/root/gnexus-auth-client-php`). + +**Подключение:** через Composer `repositories.type = vcs`: +```json +"repositories": [{ + "type": "vcs", + "url": "https://git.gnexus.space/root/gnexus-auth-client-php.git" +}], +"require": { + "gnexus/auth-client": "^0.1" +} +``` + +**PSR-реализации:** пакет требует только **интерфейсы** PSR-18/17/7 (`psr/http-client`, `psr/http-factory`, `psr/http-message`). Реализацию пишем сами — thin cURL-адаптер: +- `CurlHttpClient` — PSR-18 `ClientInterface` +- `Psr7Factory` — PSR-17 `RequestFactoryInterface` + `StreamFactoryInterface` +- `Psr7Request`, `Psr7Response`, `Psr7Stream` — PSR-7 DTO +- Ожидаемый объём ~350 строк, zero дополнительных Composer-зависимостей. +- Расположение: `server/SHServ/Integrations/GAuth/Http/`. + +### 2.2. Хранение токенов + +**Решение:** PHP `$_SESSION` + опциональная таблица `shserv_sessions` для долгосрочного хранения refresh-токена. + +**Обоснование:** +- Access token живёт 15 минут — храним в `$_SESSION`. +- Refresh token живёт 30 дней — при long-term «запомнить меня» сохраняем в `shserv_sessions` (cookie → session_id → refresh_token). +- Это позволяет восстанавливать сессию после закрытия браузера без повторного OAuth-флоу. +- При logout — отзываем refresh token через `gnexus-auth` и чистим локальную сессию. + +### 2.3. Группы пользователей + +**Решение:** синхронизировать с `gnexus-auth` через webhooks `group.user_added` / `group.user_removed`. + +**Обоснование:** +- Группы создаются/удаляются в админке `gnexus-auth`. +- SHServ реплицирует структуру групп и membership в локальные таблицы. +- Права групп применяются как union к правам пользователя. + +### 2.4. Guest + +**Решение:** локальная концепция SHServ (не system_role в `gnexus-auth`). + +**Обоснование:** +- В `gnexus-auth` три базовых system_role: `superadmin`, `admin`, `user`. +- Guest = отсутствие авторизации в SHServ. Локально разрешаем минимальные permissions для guest. +- Это не требует изменений в `gnexus-auth` и не создаёт фиктивных аккаунтов. + +## 3. Модель данных (новые таблицы) + +### 3.1. `shserv_users` — локальное отображение пользователей + +```sql +CREATE TABLE shserv_users ( + id INT AUTO_INCREMENT PRIMARY KEY, + gauth_user_id VARCHAR(64) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL, + display_name VARCHAR(255), + avatar_url VARCHAR(500), + system_role VARCHAR(32) NOT NULL DEFAULT 'user', -- superadmin | admin | user + status VARCHAR(32) NOT NULL DEFAULT 'active', -- active | blocked | archived + max_role VARCHAR(32) NULL, -- роль, назначенная в gnexus-auth + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_gauth (gauth_user_id), + INDEX idx_email (email) +); +``` + +### 3.2. `shserv_roles` — локальные роли (mirror + override) + +```sql +CREATE TABLE shserv_roles ( + id INT AUTO_INCREMENT PRIMARY KEY, + slug VARCHAR(32) NOT NULL UNIQUE, -- superadmin | admin | user | guest + name VARCHAR(64) NOT NULL, + is_system TINYINT(1) NOT NULL DEFAULT 0, -- system role из gnexus-auth + default_permissions JSON, -- массив permission slugs + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### 3.3. `shserv_permissions` — реестр прав + +```sql +CREATE TABLE shserv_permissions ( + id INT AUTO_INCREMENT PRIMARY KEY, + slug VARCHAR(64) NOT NULL UNIQUE, -- devices.scan | devices.control | scripts.edit | areas.manage | admin.users | admin.roles + name VARCHAR(128) NOT NULL, + description TEXT, + default_for_roles JSON, -- {"superadmin": true, "admin": true, "user": false, "guest": false} + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### 3.4. `shserv_user_permissions` — персональные overrides + +```sql +CREATE TABLE shserv_user_permissions ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + permission_slug VARCHAR(64) NOT NULL, + granted TINYINT(1) NOT NULL DEFAULT 1, -- 1 = разрешено, 0 = явно запрещено + set_by_user_id INT NULL, -- кто установил override + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_user_perm (user_id, permission_slug), + FOREIGN KEY (user_id) REFERENCES shserv_users(id) ON DELETE CASCADE +); +``` + +### 3.5. `shserv_groups` — группы (реплика из gnexus-auth) + +```sql +CREATE TABLE shserv_groups ( + id INT AUTO_INCREMENT PRIMARY KEY, + gauth_group_id VARCHAR(64) NOT NULL UNIQUE, + slug VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); +``` + +### 3.6. `shserv_group_members` — membership + +```sql +CREATE TABLE shserv_group_members ( + id INT AUTO_INCREMENT PRIMARY KEY, + group_id INT NOT NULL, + user_id INT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_membership (group_id, user_id), + FOREIGN KEY (group_id) REFERENCES shserv_groups(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES shserv_users(id) ON DELETE CASCADE +); +``` + +### 3.7. `shserv_group_permissions` — права групп + +```sql +CREATE TABLE shserv_group_permissions ( + id INT AUTO_INCREMENT PRIMARY KEY, + group_id INT NOT NULL, + permission_slug VARCHAR(64) NOT NULL, + granted TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_group_perm (group_id, permission_slug), + FOREIGN KEY (group_id) REFERENCES shserv_groups(id) ON DELETE CASCADE +); +``` + +### 3.8. `shserv_sessions` — локальные сессии (для refresh-токенов) + +```sql +CREATE TABLE shserv_sessions ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + session_token VARCHAR(128) NOT NULL UNIQUE, + refresh_token VARCHAR(255), + access_token VARCHAR(255), + expires_at TIMESTAMP NULL, + ip_address VARCHAR(45), + user_agent VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_token (session_token), + FOREIGN KEY (user_id) REFERENCES shserv_users(id) ON DELETE CASCADE +); +``` + +## 4. Интеграционный слой (`server/SHServ/Integrations/GAuth/`) + +Пакет `gnexus/auth-client` уже предоставляет: `GAuthClient`, `GAuthConfig`, `HttpTokenEndpoint`, `HttpRuntimeUserProvider`, `HmacWebhookVerifier`, `JsonWebhookParser`, `PkceGenerator`, весь DTO и exception набор. Мы реализуем только то, что пакет оставляет на усмотрение consuming-приложения: + +``` +Integrations/GAuth/ + Http/ + CurlHttpClient.php -- PSR-18 ClientInterface (thin cURL) + Psr7Factory.php -- PSR-17 RequestFactory + StreamFactory + Psr7Request.php -- PSR-7 RequestInterface + Psr7Response.php -- PSR-7 ResponseInterface + Psr7Stream.php -- PSR-7 StreamInterface + Store/ + SessionStateStore.php -- StateStoreInterface (на $_SESSION) + SessionPkceStore.php -- PkceStoreInterface (на $_SESSION) + DbTokenStore.php -- TokenStoreInterface (на shserv_sessions) + Webhook/ + WebhookRouter.php -- route event.type → handler + Handlers/ + UserHandler.php -- user.* events + RoleHandler.php -- client.roles_changed + PermissionHandler.php -- client.permissions_changed + GroupHandler.php -- group.user_added / removed + SessionHandler.php -- auth.global_logout / session.revoked + AuthService.php -- high-level wrapper: login URL, callback, logout, me + UserResolver.php -- маппинг AuthenticatedUser → shserv_users + PermissionResolver.php -- resolve effective permissions +``` + +## 5. Ролевая модель и права + +### 5.1. Роли + +| Роль | Описание | Источник | +|------|----------|----------| +| `superadmin` | Полный доступ | `AuthenticatedUser->systemRole` | +| `admin` | Администрирование SHServ | `AuthenticatedUser->systemRole` | +| `user` | Обычный пользователь | `AuthenticatedUser->systemRole` | +| `guest` | Неавторизованный | Локально (отсутствие auth) | + +Пакет `gnexus/auth-client` возвращает `AuthenticatedUser` с полями: +- `systemRole` — глобальная роль в `gnexus-auth` (`superadmin`/`admin`/`user`). +- `clientAccessList` — массив `ClientAccess`, каждый содержит `clientId`, `roleIds[]`, `permissionIds[]` для конкретного Client. +- Для SHServ берём `clientAccessList` где `clientId === GAUTH_CLIENT_ID`. + +### 5.2. Права (starter set) + +Пакет `gnexus/auth-client` возвращает `permissionIds[]` в `ClientAccess` (вместе с `roleIds[]`). Эти ID мапятся на локальные `shserv_permissions.slug`. + +Starter set прав SHServ: + +```text +devices.view -- просмотр списка устройств +devices.scan -- сканирование сети +devices.control -- управление устройствами +devices.setup -- добавление новых устройств +devices.edit -- редактирование устройств +devices.delete -- удаление устройств +areas.view -- просмотр зон +areas.manage -- управление зонами +scripts.view -- просмотр скриптов +scripts.edit -- редактирование скриптов +scripts.run -- запуск скриптов +firmware.view -- просмотр прошивок +firmware.upload -- загрузка прошивок +admin.users -- управление пользователями +admin.roles -- управление ролями/правами +admin.audit -- просмотр аудита +settings.edit -- изменение настроек системы +``` + +### 5.3. Алгоритм определения эффективных прав + +``` +effective_permissions(user) = + union( + permissions_from_role(user.system_role), -- дефолт по роли + permissions_from_groups(user.groups), -- права групп + user_overrides(user.id) -- персональные overrides + ) + minus explicitly_denied(user.id) +``` + +- `superadmin` всегда имеет все права (fast path). +- `admin` / `user` / `guest` — через таблицы. +- Override `granted = 0` явно запрещает право, даже если оно есть у роли/группы. + +### 5.4. Урезание прав + +Админ/суперадмин через API `POST /api/v1/admin/users/{id}/permissions` может: +- Добавить право (`granted = 1`) — даёт право, которого нет у роли. +- Запретить право (`granted = 0`) — урезает право, имеющееся у роли. +- Удалить запись — возвращает дефолт роли. + +## 6. API (новые и изменённые endpoints) + +### 6.1. Auth flow (OAuth-like) + +```text +GET /auth/login → редирект на gnexus-auth /oauth/authorize +GET /auth/callback → обмен code на tokens, создание/обновление local user, редирект в приложение +POST /auth/logout → отзыв refresh token, очистка сессии +GET /auth/me → текущий пользователь + его effective permissions +``` + +### 6.2. Webhook endpoint + +```text +POST /webhooks/gnexus-auth +``` +- Verify HMAC-SHA256 через `GAuthClient::verifyWebhook()`. +- Parse через `GAuthClient::parseWebhook()`. +- Route на `WebhookHandler` по `event.type`. + +### 6.3. Административные endpoints (требуют `admin.users` / `admin.roles`) + +```text +GET /api/v1/admin/users +GET /api/v1/admin/users/{id} +PATCH /api/v1/admin/users/{id}/role +POST /api/v1/admin/users/{id}/permissions -- {permission_slug, granted} +DELETE /api/v1/admin/users/{id}/permissions/{slug} +GET /api/v1/admin/roles +GET /api/v1/admin/permissions +GET /api/v1/admin/groups +``` + +### 6.4. Защита существующих endpoints + +**Все** существующие `/api/v1/*` endpoints (кроме устройственных `/about` и `/events/new`) требуют авторизации. + +Для Fury Router (нет middleware) — внедряем проверку в базовый контроллер: +- Добавить `AuthControllerTrait` с методом `require_auth()` и `require_permission($slug)`. +- Вызывать в начале каждого защищённого метода контроллера. + +### 6.5. Vue client API изменения + +- `client.js` — добавить `Authorization: Bearer {access_token}` header ко всем запросам. +- При 401 от API — редирект на `/auth/login`. +- Logout — вызов `POST /auth/logout`, затем очистка local storage. + +## 7. Vue web client изменения + +### 7.1. Auth store (`src/stores/auth.js`) + +```javascript +export const useAuthStore = defineStore('auth', () => { + const user = ref(null); + const permissions = ref([]); + const isAuthenticated = computed(() => !!user.value); + const hasPermission = (slug) => permissions.value.includes(slug); + // ... +}); +``` + +### 7.2. Login flow + +1. Пользователь нажимает «Войти» → `window.location = '/auth/login'`. +2. gnexus-auth → callback → backend создаёт сессию → редирект на Vue app (`/#/`). +3. Vue app при старте вызывает `GET /auth/me` → получает user + permissions. + +### 7.3. UI адаптации + +- Условный рендеринг кнопок/меню по `hasPermission()`. +- Скрытие админ-разделов для non-admin. +- Login/Logout кнопки в `AppShell`. + +## 8. Обработка webhooks + +### 8.1. Подписываемся на события + +```text +user.updated +user.blocked +user.unblocked +user.archived +user.restored +user.deleted +client.roles_changed +client.permissions_changed +client.access_granted +client.access_revoked +client.access_denied +group.user_added +group.user_removed +auth.global_logout +session.revoked +``` + +### 8.2. Логика обработки + +| Событие | Действие | +|---------|----------| +| `user.updated` | Обновить `display_name`, `avatar_url`, `email` в `shserv_users` | +| `user.blocked` | `status = 'blocked'`, очистить сессии | +| `user.unblocked` | `status = 'active'` | +| `user.deleted` | Удалить из `shserv_users` (cascade cleanup) | +| `client.roles_changed` | Обновить `system_role` / client roles | +| `client.permissions_changed` | Обновить `shserv_user_permissions` | +| `group.user_added` | `INSERT shserv_group_members` | +| `group.user_removed` | `DELETE shserv_group_members` | +| `auth.global_logout` | Удалить все `shserv_sessions` для user | +| `session.revoked` | Удалить конкретную сессию из `shserv_sessions` | + +## 9. Фазы реализации + +### Phase 0 — Инфраструктура (1–2 дня) +- [ ] Добавить thin PSR-18/17 cURL адаптер (`CurlHttpClient` + `Psr7Factory` + DTO). +- [ ] Подключить `gnexus/auth-client` через Composer `vcs` репозиторий (`git.gnexus.space/root/gnexus-auth-client-php`). +- [ ] Создать миграции (8 новых таблиц). +- [ ] Создать `GAuthConfig` и `AuthService` wrapper. +- [ ] Настроить `Client` в gnexus-auth (client_id, secret, redirect_uri, webhook). +- [ ] Проверить совместимость: пакет требует PHP ^8.3, текущий сервер — 8.5.6 ✓ + +### Phase 1 — OAuth flow (2–3 дня) +- [ ] `GET /auth/login` — `GAuthClient::buildAuthorizationRequest(..., scopes: ['openid','email','profile','roles','permissions'])` → редирект. +- [ ] `GET /auth/callback` — `exchangeAuthorizationCode()`, `fetchUser()`. + - `AuthenticatedUser` содержит: `userId`, `email`, `systemRole`, `status`, `profile`, `clientAccessList` (с `roleIds[]`, `permissionIds[]`). + - Upsert в `shserv_users`, маппинг `gauth_user_id`. +- [ ] `POST /auth/logout` — `revokeToken()` + local cleanup. +- [ ] `GET /auth/me` — текущий пользователь + effective permissions. +- [ ] `SessionStateStore` + `SessionPkceStore` на PHP `$_SESSION`. +- [ ] `DbTokenStore` для refresh-токена (связь `session_token` ↔ `refresh_token`). + +### Phase 2 — Права и роли (2–3 дня) +- [ ] Seed таблицы `shserv_roles`, `shserv_permissions`. +- [ ] `PermissionResolver` — алгоритм union/minus. +- [ ] `AuthControllerTrait` — `require_auth()`, `require_permission()`. +- [ ] Защитить существующие API endpoints. +- [ ] Админ endpoints для управления permissions. + +### Phase 3 — Webhooks (1–2 дня) +- [ ] `POST /webhooks/gnexus-auth` endpoint. +- [ ] Верификация: `HmacWebhookVerifier::verify(rawBody, headers, secret)` (из пакета). +- [ ] Парсинг: `JsonWebhookParser::parse(rawBody)` → `WebhookEvent` (из пакета). +- [ ] `WebhookRouter` → event-specific handlers (UserHandler, RoleHandler, GroupHandler, SessionHandler). +- [ ] Настроить webhook subscription в gnexus-auth. + +### Phase 4 — Vue client (2–3 дня) +- [ ] Auth store (`src/stores/auth.js`) с `user`, `permissions`, `isAuthenticated`, `hasPermission()`. +- [ ] Bearer token injection в `client.js` / `http.js`. +- [ ] Interceptor: при 401 от API → `window.location = '/auth/login'` (backend сделает redirect в gnexus-auth). +- [ ] Lazy token refresh: при 401 от `gnexus-auth` runtime API или `expiresAt` прошёл — backend-endpoint `POST /auth/refresh` обновляет access token. +- [ ] Conditional UI: скрытие кнопок/разделов по `hasPermission()`. +- [ ] Login/Logout в `AppShell`. + +### Phase 5 — Legacy web client (1 день) +- [ ] Аналогичные изменения для `webclient_legacy` (если ещё используется в продакшене). + +### Phase 6 — Cleanup (1 день) +- [ ] Удалить `Example_AuthController.php`, старые auth routes. +- [ ] Архивировать legacy таблицы: `users` → `_legacy_users`, `sessions` → `_legacy_sessions`, `profiles` → `_legacy_profiles` (на случай аудита). +- [ ] Удалить `SHServ/Entities/User.php`, `Session.php`, `Profile.php` (или перенести в `_legacy/`). +- [ ] Обновить `docs/server-api.md` и `docs/architecture.md`. +- [ ] Smoke-test end-to-end. + +## 10. Дополнительные решения и открытые вопросы + +1. **Client credentials** — хранить в `.env`: + ``` + GAUTH_BASE_URL=https://auth.gnexus.space + GAUTH_CLIENT_ID=shserv + GAUTH_CLIENT_SECRET=... + GAUTH_WEBHOOK_SECRET=... + ``` + Пакет `gnexus/auth-client` читает их через `GAuthConfig` (передаются явно в конструктор). + +2. **Redirect URI** — регистрировать в gnexus-auth Client: + - Prod: `https://smarthome.gnexus.space/auth/callback` + - Dev: `http://smart-home-serv.local/auth/callback` + - Local: `http://localhost/auth/callback` (если gnexus-auth разрешает local/dev origins). + +3. **Token refresh** — lazy refresh: + - Access token храним в `$_SESSION` + `expiresAt`. + - При 401 от runtime API или `expiresAt` прошёл — frontend вызывает `POST /auth/refresh`. + - Backend берёт refresh token из `DbTokenStore`, делает `GAuthClient::refreshToken()`, обновляет сессию. + +4. **Миграция старых пользователей** — legacy таблицы (`users`, `sessions`, `profiles`) архивировать (`RENAME TABLE users TO _legacy_users`), не удалять. + +5. **Аудит в SHServ** — админ-действия (смена роли, override permission) логировать в `shserv_audit`: + ```sql + CREATE TABLE shserv_audit ( + id INT AUTO_INCREMENT PRIMARY KEY, + actor_user_id INT NOT NULL, + action VARCHAR(64) NOT NULL, + target_type VARCHAR(32), + target_id INT, + old_value JSON, + new_value JSON, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + ``` + +6. **Тестирование пакета** — пакет `gnexus/auth-client` имеет unit tests (`tests/Unit/`). Перед интеграцией рекомендуется прогнать их: + ```bash + cd packages/auth-client && php vendor/bin/phpunit + ``` diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..347cdb6 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,23 @@ +APP_NAME=SHServ +APP_VERSION=0.4 dev +DEBUG=false +DEVMODE=false + +DB_DRIVER=mysql +DB_HOST=localhost +DB_NAME=smart-home-server +DB_CHARSET=utf8 +DB_USER=root +DB_PASSWORD= + +DEVICE_IP_RANGE_START=192.168.2.2 +DEVICE_IP_RANGE_END=192.168.2.254 +DEVICE_API_CONNECT_TIMEOUT=1 +DEVICE_API_TIMEOUT=5 +DEVICE_OFFLINE_THRESHOLD=300 + +GAUTH_BASE_URL=https://auth.gnexus.space +GAUTH_CLIENT_ID=shserv +GAUTH_CLIENT_SECRET= +GAUTH_REDIRECT_URI=https://smarthome.gnexus.space/auth/callback +GAUTH_WEBHOOK_SECRET= diff --git a/server/SHServ/Controllers/AuthController.php b/server/SHServ/Controllers/AuthController.php new file mode 100644 index 0000000..5a7a738 --- /dev/null +++ b/server/SHServ/Controllers/AuthController.php @@ -0,0 +1,119 @@ +buildLoginUrl($returnTo); + return $this->utils()->redirect($url); + } + + /** + * GET /auth/callback + * Handle OAuth callback from gnexus-auth. + */ + public function callback() + { + $code = isset($_GET['code']) ? (string) $_GET['code'] : ''; + $state = isset($_GET['state']) ? (string) $_GET['state'] : ''; + + if ($code === '' || $state === '') { + return $this->utils()->response_error('invalid_callback'); + } + + $service = new AuthService(); + + try { + $user = $service->handleCallback($code, $state); + } catch (\GNexus\GAuth\Exception\GAuthException $e) { + return $this->utils()->response_error('auth_failed', [], ['message' => $e->getMessage()]); + } + + $resolver = new UserResolver(); + $localUserId = $resolver->resolve($user); + $_SESSION['shserv_user_id'] = $localUserId; + + // Redirect back to app + $context = $_SESSION['gauth_state'][$state]['context'] ?? []; + $returnTo = $context['return_to'] ?? '/'; + return $this->utils()->redirect($returnTo); + } + + /** + * POST /auth/logout + */ + public function logout() + { + $service = new AuthService(); + $service->logout(); + return $this->utils()->response_success(); + } + + /** + * GET /auth/me + * Return current authenticated user + effective permissions. + */ + public function me() + { + $user = $this->get_current_user(); + if (!$user) { + return $this->utils()->response_error('not_found_any_sessions', [], [], 401); + } + + $permissions = $this->get_current_permissions(); + + return $this->utils()->response_success([ + 'user' => [ + 'id' => $user['id'], + 'gauth_user_id' => $user['gauth_user_id'], + 'email' => $user['email'], + 'display_name' => $user['display_name'], + 'avatar_url' => $user['avatar_url'], + 'system_role' => $user['system_role'], + 'status' => $user['status'], + ], + 'permissions' => $permissions, + ]); + } + + /** + * POST /auth/refresh + * Refresh access token using stored refresh token. + */ + public function refresh() + { + $sessionToken = $_SESSION['shserv_auth_token'] ?? null; + if (!$sessionToken) { + return $this->utils()->response_error('not_found_any_sessions', [], [], 401); + } + + $service = new AuthService(); + $tokenSet = $service->refreshAccessToken($sessionToken); + + if (!$tokenSet) { + return $this->utils()->response_error('session_expired', [], [], 401); + } + + return $this->utils()->response_success([ + 'access_token' => $tokenSet->accessToken, + 'expires_in' => $tokenSet->expiresIn, + ]); + } +} diff --git a/server/SHServ/Controllers/Example_AuthController.php b/server/SHServ/Controllers/Example_AuthController.php deleted file mode 100644 index a9482b3..0000000 --- a/server/SHServ/Controllers/Example_AuthController.php +++ /dev/null @@ -1,118 +0,0 @@ - sessions -> is_auth()) { - return $this -> utils() -> redirect( app() -> routes -> urlto("SearchController@search_page") ); - } - - return $this -> new_template() -> make("site/signup", [ - "page_title" => "Регистрация", - "page_alias" => "page signup" - ]); - } - - public function signin_page() { - if(app() -> sessions -> is_auth()) { - return $this -> utils() -> redirect( app() -> routes -> urlto("SearchController@search_page") ); - } - - return $this -> new_template() -> make("site/signin", [ - "page_title" => "Войти в систему", - "page_alias" => "page signin" - ]); - } - - public function signout_page($redirect_to) { - $auth = new Auth(); - $auth -> signout(); - return $this -> utils() -> redirect($redirect_to); - } - - public function signup($email, $password, $password_again) { - // TODO: generate event - - if(app() -> sessions -> is_auth()){ - return $this -> utils() -> response_error("already_logged"); - } - - $email = strtolower(trim(strip_tags($email))); - - if(strlen($email) < 4 or !strpos($email, "@") or !strpos($email, ".")) { - return $this -> utils() -> response_error("incorrect_email", [ "email" ]); - } - - if(strlen($password) < 8) { - return $this -> utils() -> response_error("too_short_password", [ "password" ]); - } - - if($password != $password_again) { - return $this -> utils() -> response_error("different_passwords", [ "password", "password_again" ]); - } - - if(User::is_exists_by("email", $email)) { - return $this -> utils() -> response_error("email_already_exists", [ "email" ]); - } - - $auth = new Auth(); - $user = $auth -> signup($email, $password); - - if(!$user) { - return $this -> utils() -> response_error("undefined_error", [ "email" ]); - } - - return $this -> utils() -> response_success([ - "redirect_url" => app() -> routes -> urlto("AuthController@signin_page"), - "redirect_delay" => 250 - ]); - } - - public function signin($email, $password) { - // TODO: generate event - if(app() -> sessions -> is_auth()){ - return $this -> utils() -> response_error("already_logged"); - } - - $email = strtolower(trim(strip_tags($email))); - - if(!strlen($email)) { - return $this -> utils() -> response_error("empty_field", [ "email" ]); - } - - if(!strlen($password)) { - return $this -> utils() -> response_error("empty_field", [ "password" ]); - } - - if(!User::is_exists_by("email", $email)) { - return $this -> utils() -> response_error("unregistered_email", [ "email" ]); - } - - $auth = new Auth(); - $token = $auth -> signin($email, $password); - - if(!$token){ - return $this -> utils() -> response_error("incorrect_password", [ "password" ]); - } - - return $this -> utils() -> response_success([ - "token" => $token, - "redirect_url" => "/", - "redirect_delay" => 250 - ]); - } - - public function signout() { - if(!app() -> sessions -> is_auth()){ - return $this -> utils() -> response_error("not_found_any_sessions"); - } - - $auth = new Auth(); - $auth -> signout(); - return $this -> utils() -> response_success(); - } -} \ No newline at end of file diff --git a/server/SHServ/Controllers/WebhookController.php b/server/SHServ/Controllers/WebhookController.php new file mode 100644 index 0000000..b66437a --- /dev/null +++ b/server/SHServ/Controllers/WebhookController.php @@ -0,0 +1,41 @@ +verifyWebhook($rawBody, $headers); + } catch (\Throwable $e) { + http_response_code(401); + return json_encode(['status' => false, 'error' => 'webhook_verification_failed']); + } + + try { + $event = $service->parseWebhook($rawBody); + } catch (\Throwable $e) { + http_response_code(400); + return json_encode(['status' => false, 'error' => 'webhook_parse_failed']); + } + + // Route to handler + $handler = new \SHServ\Integrations\GAuth\Webhook\WebhookRouter(); + $handler->handle($event); + + return json_encode(['status' => true]); + } +} diff --git a/server/SHServ/Integrations/GAuth/AuthControllerTrait.php b/server/SHServ/Integrations/GAuth/AuthControllerTrait.php new file mode 100644 index 0000000..95dfb4a --- /dev/null +++ b/server/SHServ/Integrations/GAuth/AuthControllerTrait.php @@ -0,0 +1,74 @@ +utils()->response_error('unauthenticated', [], [], 401); + } + return null; + } + + /** + * Require specific permission. Returns error response if denied. + */ + protected function require_permission(string $permissionSlug): ?string + { + $authError = $this->require_auth(); + if ($authError !== null) { + return $authError; + } + + $user = $this->get_current_user(); + if (!$user) { + return $this->utils()->response_error('unauthenticated', [], [], 401); + } + + $resolver = new PermissionResolver(); + if (!$resolver->has($user['id'], $user['system_role'], $permissionSlug)) { + return $this->utils()->response_error('permission_denied', [$permissionSlug], [], 403); + } + + return null; + } + + /** + * Get current user data from session. + */ + protected function get_current_user(): ?array + { + $userId = $_SESSION['shserv_user_id'] ?? null; + if (!$userId) { + return null; + } + + $tb = app()->thin_builder; + $result = $tb->select('shserv_users', ['id', 'gauth_user_id', 'email', 'display_name', 'avatar_url', 'system_role', 'status'], [['id', '=', $userId]]); + return $result ? $result[0] : null; + } + + /** + * Get effective permissions for current user. + */ + protected function get_current_permissions(): array + { + $user = $this->get_current_user(); + if (!$user) { + return []; + } + + $resolver = new PermissionResolver(); + return $resolver->resolve((int) $user['id'], $user['system_role']); + } +} diff --git a/server/SHServ/Integrations/GAuth/AuthService.php b/server/SHServ/Integrations/GAuth/AuthService.php new file mode 100644 index 0000000..5bda4b0 --- /dev/null +++ b/server/SHServ/Integrations/GAuth/AuthService.php @@ -0,0 +1,177 @@ +config = new GAuthConfig( + baseUrl: $cfg['base_url'], + clientId: $cfg['client_id'], + clientSecret: $cfg['client_secret'], + redirectUri: $cfg['redirect_uri'], + userAgent: 'shserv/' . (FCONF['version'] ?? '0.4'), + ); + + $httpClient = new CurlHttpClient(); + $factory = new Psr7Factory(); + + $this->client = new GAuthClient( + config: $this->config, + tokenEndpoint: new HttpTokenEndpoint($this->config, $httpClient, $factory, $factory), + runtimeUserProvider: new HttpRuntimeUserProvider($this->config, $httpClient, $factory), + webhookVerifier: new HmacWebhookVerifier($this->config), + webhookParser: new JsonWebhookParser(), + stateStore: new SessionStateStore(), + pkceStore: new SessionPkceStore(), + ); + } + + public function getClient(): GAuthClient + { + return $this->client; + } + + public function getConfig(): GAuthConfig + { + return $this->config; + } + + /** + * Build authorization URL and redirect user to gnexus-auth. + */ + public function buildLoginUrl(?string $returnTo = null): string + { + $authRequest = $this->client->buildAuthorizationRequest( + returnTo: $returnTo, + scopes: ['openid', 'email', 'profile', 'roles', 'permissions'], + ); + + return $authRequest->authorizationUrl; + } + + /** + * Exchange authorization code for tokens and fetch user info. + */ + public function handleCallback(string $code, string $state): AuthenticatedUser + { + $tokenSet = $this->client->exchangeAuthorizationCode($code, $state); + $user = $this->client->fetchUser($tokenSet->accessToken); + + // Persist tokens + $sessionToken = bin2hex(random_bytes(32)); + $_SESSION['shserv_auth_token'] = $sessionToken; + $_SESSION['shserv_access_token'] = $tokenSet->accessToken; + $_SESSION['shserv_user_id'] = $user->userId; + + $dbStore = new DbTokenStore(app()->thin_builder); + $dbStore->put($sessionToken, $tokenSet); + + return $user; + } + + /** + * Logout: revoke token + clear local session. + */ + public function logout(): void + { + $sessionToken = $_SESSION['shserv_auth_token'] ?? null; + if ($sessionToken) { + $dbStore = new DbTokenStore(app()->thin_builder); + $tokenSet = $dbStore->get($sessionToken); + if ($tokenSet && $tokenSet->refreshToken) { + try { + $this->client->revokeToken($tokenSet->refreshToken, 'refresh_token'); + } catch (\Throwable $e) { + // ignore revoke failures during logout + } + } + $dbStore->forget($sessionToken); + } + + unset( + $_SESSION['shserv_auth_token'], + $_SESSION['shserv_access_token'], + $_SESSION['shserv_user_id'], + $_SESSION['gauth_state'], + $_SESSION['gauth_pkce'] + ); + } + + /** + * Refresh access token using stored refresh token. + */ + public function refreshAccessToken(string $sessionToken): ?TokenSet + { + $dbStore = new DbTokenStore(app()->thin_builder); + $tokenSet = $dbStore->get($sessionToken); + + if (!$tokenSet || !$tokenSet->refreshToken) { + return null; + } + + try { + $newTokenSet = $this->client->refreshToken($tokenSet->refreshToken); + $dbStore->put($sessionToken, $newTokenSet); + $_SESSION['shserv_access_token'] = $newTokenSet->accessToken; + return $newTokenSet; + } catch (\Throwable $e) { + return null; + } + } + + /** + * Get current authenticated user from access token in session. + */ + public function getCurrentUser(): ?AuthenticatedUser + { + $accessToken = $_SESSION['shserv_access_token'] ?? null; + if (!$accessToken) { + return null; + } + + try { + return $this->client->fetchUser($accessToken); + } catch (\Throwable $e) { + return null; + } + } + + /** + * Verify webhook signature and parse payload. + */ + public function verifyWebhook(string $rawBody, array $headers): void + { + $secret = FCONF['gauth']['webhook_secret'] ?? ''; + if ($secret === '') { + throw new \RuntimeException('Webhook secret not configured.'); + } + $this->client->verifyWebhook($rawBody, $headers, $secret); + } + + public function parseWebhook(string $rawBody): \GNexus\GAuth\DTO\WebhookEvent + { + return $this->client->parseWebhook($rawBody); + } +} diff --git a/server/SHServ/Integrations/GAuth/Http/CurlHttpClient.php b/server/SHServ/Integrations/GAuth/Http/CurlHttpClient.php new file mode 100644 index 0000000..96bb67b --- /dev/null +++ b/server/SHServ/Integrations/GAuth/Http/CurlHttpClient.php @@ -0,0 +1,101 @@ +timeout = $timeout; + $this->verifySsl = $verifySsl; + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $ch = curl_init(); + if ($ch === false) { + throw new ClientException('Failed to initialize cURL'); + } + + $headers = []; + foreach ($request->getHeaders() as $name => $values) { + $headers[] = $name . ': ' . implode(', ', $values); + } + + $body = (string) $request->getBody(); + + $options = [ + CURLOPT_URL => (string) $request->getUri(), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_CONNECTTIMEOUT => min(10, $this->timeout), + CURLOPT_SSL_VERIFYPEER => $this->verifySsl, + CURLOPT_SSL_VERIFYHOST => $this->verifySsl ? 2 : 0, + CURLOPT_HTTPHEADER => $headers, + ]; + + if ($request->getMethod() !== 'GET') { + $options[CURLOPT_CUSTOMREQUEST] = $request->getMethod(); + } + + if ($body !== '') { + $options[CURLOPT_POSTFIELDS] = $body; + } + + curl_setopt_array($ch, $options); + + $rawResponse = curl_exec($ch); + + if ($rawResponse === false) { + $error = curl_error($ch); + curl_close($ch); + throw new ClientException('cURL error: ' . $error); + } + + $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); + $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $rawHeaders = substr($rawResponse, 0, $headerSize); + $responseBody = substr($rawResponse, $headerSize); + + $parsedHeaders = $this->parseHeaders($rawHeaders); + + return new Psr7Response($statusCode, $parsedHeaders, new Psr7Stream($responseBody)); + } + + private function parseHeaders(string $rawHeaders): array + { + $headers = []; + $lines = explode("\r\n", trim($rawHeaders)); + foreach ($lines as $line) { + if (strpos($line, ':') === false) { + continue; + } + [$name, $value] = explode(':', $line, 2); + $name = trim($name); + $value = trim($value); + if (!isset($headers[$name])) { + $headers[$name] = []; + } + $headers[$name][] = $value; + } + return $headers; + } +} + +final class ClientException extends \Exception implements ClientExceptionInterface +{ +} diff --git a/server/SHServ/Integrations/GAuth/Http/Psr7Factory.php b/server/SHServ/Integrations/GAuth/Http/Psr7Factory.php new file mode 100644 index 0000000..a2e4b55 --- /dev/null +++ b/server/SHServ/Integrations/GAuth/Http/Psr7Factory.php @@ -0,0 +1,37 @@ +method = $method; + $this->uri = $uri; + $this->headers = $this->normalizeHeaders($headers); + $this->body = $body ?? new Psr7Stream(''); + } + + private function normalizeHeaders(array $headers): array + { + $normalized = []; + foreach ($headers as $name => $values) { + $name = $this->normalizeHeaderName($name); + if (!is_array($values)) { + $values = [$values]; + } + $normalized[$name] = array_map('strval', $values); + } + return $normalized; + } + + private function normalizeHeaderName(string $name): string + { + return strtolower($name); + } + + public function getProtocolVersion(): string + { + return $this->protocolVersion; + } + + public function withProtocolVersion(string $version): RequestInterface + { + $new = clone $this; + $new->protocolVersion = $version; + return $new; + } + + public function getHeaders(): array + { + $result = []; + foreach ($this->headers as $name => $values) { + $result[$this->studlyCase($name)] = $values; + } + return $result; + } + + public function hasHeader(string $name): bool + { + return isset($this->headers[$this->normalizeHeaderName($name)]); + } + + public function getHeader(string $name): array + { + return $this->headers[$this->normalizeHeaderName($name)] ?? []; + } + + public function getHeaderLine(string $name): string + { + return implode(', ', $this->getHeader($name)); + } + + public function withHeader(string $name, $value): RequestInterface + { + $new = clone $this; + $new->headers[$this->normalizeHeaderName($name)] = is_array($value) ? array_map('strval', $value) : [(string) $value]; + return $new; + } + + public function withAddedHeader(string $name, $value): RequestInterface + { + $new = clone $this; + $name = $this->normalizeHeaderName($name); + if (!isset($new->headers[$name])) { + $new->headers[$name] = []; + } + $new->headers[$name] = array_merge($new->headers[$name], is_array($value) ? array_map('strval', $value) : [(string) $value]); + return $new; + } + + public function withoutHeader(string $name): RequestInterface + { + $new = clone $this; + unset($new->headers[$this->normalizeHeaderName($name)]); + return $new; + } + + public function getBody(): StreamInterface + { + return $this->body; + } + + public function withBody(StreamInterface $body): RequestInterface + { + $new = clone $this; + $new->body = $body; + return $new; + } + + public function getRequestTarget(): string + { + if ($this->requestTarget !== '') { + return $this->requestTarget; + } + return $this->uri; + } + + public function withRequestTarget(string $requestTarget): RequestInterface + { + $new = clone $this; + $new->requestTarget = $requestTarget; + return $new; + } + + public function getMethod(): string + { + return $this->method; + } + + public function withMethod(string $method): RequestInterface + { + $new = clone $this; + $new->method = $method; + return $new; + } + + public function getUri(): UriInterface + { + return new Psr7Uri($this->uri); + } + + public function withUri(UriInterface $uri, bool $preserveHost = false): RequestInterface + { + $new = clone $this; + $new->uri = (string) $uri; + return $new; + } + + private function studlyCase(string $name): string + { + return str_replace(' ', '-', ucwords(str_replace('-', ' ', $name))); + } +} + +final class Psr7Uri implements UriInterface +{ + private string $uri; + + public function __construct(string $uri = '') + { + $this->uri = $uri; + } + + public function __toString(): string + { + return $this->uri; + } + + public function getScheme(): string { return ''; } + public function getAuthority(): string { return ''; } + public function getUserInfo(): string { return ''; } + public function getHost(): string { return ''; } + public function getPort(): ?int { return null; } + public function getPath(): string { return $this->uri; } + public function getQuery(): string { return ''; } + public function getFragment(): string { return ''; } + public function withScheme(string $scheme): UriInterface { return $this; } + public function withUserInfo(string $user, ?string $password = null): UriInterface { return $this; } + public function withHost(string $host): UriInterface { return $this; } + public function withPort(?int $port): UriInterface { return $this; } + public function withPath(string $path): UriInterface { $new = clone $this; $new->uri = $path; return $new; } + public function withQuery(string $query): UriInterface { return $this; } + public function withFragment(string $fragment): UriInterface { return $this; } +} diff --git a/server/SHServ/Integrations/GAuth/Http/Psr7Response.php b/server/SHServ/Integrations/GAuth/Http/Psr7Response.php new file mode 100644 index 0000000..4207400 --- /dev/null +++ b/server/SHServ/Integrations/GAuth/Http/Psr7Response.php @@ -0,0 +1,134 @@ +statusCode = $statusCode; + $this->headers = $this->normalizeHeaders($headers); + $this->body = $body ?? new Psr7Stream(''); + $this->reasonPhrase = $reasonPhrase; + } + + private function normalizeHeaders(array $headers): array + { + $normalized = []; + foreach ($headers as $name => $values) { + $name = strtolower($name); + if (!is_array($values)) { + $values = [$values]; + } + $normalized[$name] = array_map('strval', $values); + } + return $normalized; + } + + public function getProtocolVersion(): string + { + return $this->protocolVersion; + } + + public function withProtocolVersion(string $version): ResponseInterface + { + $new = clone $this; + $new->protocolVersion = $version; + return $new; + } + + public function getHeaders(): array + { + $result = []; + foreach ($this->headers as $name => $values) { + $result[$this->studlyCase($name)] = $values; + } + return $result; + } + + public function hasHeader(string $name): bool + { + return isset($this->headers[strtolower($name)]); + } + + public function getHeader(string $name): array + { + return $this->headers[strtolower($name)] ?? []; + } + + public function getHeaderLine(string $name): string + { + return implode(', ', $this->getHeader($name)); + } + + public function withHeader(string $name, $value): ResponseInterface + { + $new = clone $this; + $new->headers[strtolower($name)] = is_array($value) ? array_map('strval', $value) : [(string) $value]; + return $new; + } + + public function withAddedHeader(string $name, $value): ResponseInterface + { + $new = clone $this; + $name = strtolower($name); + if (!isset($new->headers[$name])) { + $new->headers[$name] = []; + } + $new->headers[$name] = array_merge($new->headers[$name], is_array($value) ? array_map('strval', $value) : [(string) $value]); + return $new; + } + + public function withoutHeader(string $name): ResponseInterface + { + $new = clone $this; + unset($new->headers[strtolower($name)]); + return $new; + } + + public function getBody(): StreamInterface + { + return $this->body; + } + + public function withBody(StreamInterface $body): ResponseInterface + { + $new = clone $this; + $new->body = $body; + return $new; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterface + { + $new = clone $this; + $new->statusCode = $code; + $new->reasonPhrase = $reasonPhrase; + return $new; + } + + public function getReasonPhrase(): string + { + return $this->reasonPhrase; + } + + private function studlyCase(string $name): string + { + return str_replace(' ', '-', ucwords(str_replace('-', ' ', $name))); + } +} diff --git a/server/SHServ/Integrations/GAuth/Http/Psr7Stream.php b/server/SHServ/Integrations/GAuth/Http/Psr7Stream.php new file mode 100644 index 0000000..b004e54 --- /dev/null +++ b/server/SHServ/Integrations/GAuth/Http/Psr7Stream.php @@ -0,0 +1,176 @@ +content = $content; + $this->readable = $readable; + $this->writable = $writable; + $this->seekable = true; + $this->resource = fopen('php://temp', 'r+'); + if ($this->resource !== false && $content !== '') { + fwrite($this->resource, $content); + rewind($this->resource); + } + } + + public static function fromResource($resource): self + { + $content = stream_get_contents($resource) ?: ''; + $instance = new self($content); + $instance->resource = $resource; + $instance->seekable = (bool) stream_get_meta_data($resource)['seekable']; + $meta = stream_get_meta_data($resource); + $mode = $meta['mode'] ?? ''; + $instance->readable = strpbrk($mode, 'r+') !== false; + $instance->writable = strpbrk($mode, 'waxc+') !== false; + return $instance; + } + + public function __toString(): string + { + try { + $this->rewind(); + return $this->getContents(); + } catch (\Throwable $e) { + return ''; + } + } + + public function close(): void + { + if ($this->resource !== null) { + fclose($this->resource); + $this->resource = null; + } + } + + public function detach() + { + $res = $this->resource; + $this->resource = null; + return $res; + } + + public function getSize(): ?int + { + if ($this->resource === null) { + return null; + } + $stat = fstat($this->resource); + return $stat ? $stat['size'] : null; + } + + public function tell(): int + { + if ($this->resource === null) { + throw new \RuntimeException('Stream is detached'); + } + $pos = ftell($this->resource); + if ($pos === false) { + throw new \RuntimeException('Unable to determine stream position'); + } + return $pos; + } + + public function eof(): bool + { + if ($this->resource === null) { + return true; + } + return feof($this->resource); + } + + public function isSeekable(): bool + { + return $this->seekable; + } + + public function seek(int $offset, int $whence = \SEEK_SET): void + { + if (!$this->seekable || $this->resource === null) { + throw new \RuntimeException('Stream is not seekable'); + } + if (fseek($this->resource, $offset, $whence) === -1) { + throw new \RuntimeException('Unable to seek stream'); + } + } + + public function rewind(): void + { + $this->seek(0); + } + + public function isWritable(): bool + { + return $this->writable && $this->resource !== null; + } + + public function write(string $string): int + { + if (!$this->isWritable()) { + throw new \RuntimeException('Stream is not writable'); + } + $written = fwrite($this->resource, $string); + if ($written === false) { + throw new \RuntimeException('Unable to write to stream'); + } + return $written; + } + + public function isReadable(): bool + { + return $this->readable && $this->resource !== null; + } + + public function read(int $length): string + { + if (!$this->isReadable()) { + throw new \RuntimeException('Stream is not readable'); + } + $data = fread($this->resource, $length); + if ($data === false) { + throw new \RuntimeException('Unable to read from stream'); + } + return $data; + } + + public function getContents(): string + { + if (!$this->isReadable()) { + throw new \RuntimeException('Stream is not readable'); + } + $contents = stream_get_contents($this->resource); + if ($contents === false) { + throw new \RuntimeException('Unable to read stream contents'); + } + return $contents; + } + + public function getMetadata(?string $key = null): mixed + { + if ($this->resource === null) { + return $key === null ? [] : null; + } + $meta = stream_get_meta_data($this->resource); + if ($key === null) { + return $meta; + } + return $meta[$key] ?? null; + } +} diff --git a/server/SHServ/Integrations/GAuth/PermissionResolver.php b/server/SHServ/Integrations/GAuth/PermissionResolver.php new file mode 100644 index 0000000..81107a6 --- /dev/null +++ b/server/SHServ/Integrations/GAuth/PermissionResolver.php @@ -0,0 +1,135 @@ +getAllPermissionSlugs(); + } + + $effective = []; + + // 1. Role defaults + $rolePerms = $this->getRolePermissions($systemRole); + foreach ($rolePerms as $slug => $granted) { + if ($granted) { + $effective[$slug] = true; + } + } + + // 2. Group permissions (union) + $groupPerms = $this->getGroupPermissions($userId); + foreach ($groupPerms as $slug => $granted) { + if ($granted) { + $effective[$slug] = true; + } + } + + // 3. User-level overrides + $userOverrides = $this->getUserOverrides($userId); + foreach ($userOverrides as $slug => $granted) { + if ($granted) { + $effective[$slug] = true; + } else { + unset($effective[$slug]); + } + } + + return array_keys($effective); + } + + /** + * Check if user has a specific permission. + */ + public function has(int $userId, string $systemRole, string $permissionSlug): bool + { + if ($systemRole === 'superadmin') { + return true; + } + $perms = $this->resolve($userId, $systemRole); + return in_array($permissionSlug, $perms, true); + } + + private function getRolePermissions(string $roleSlug): array + { + $tb = app()->thin_builder; + $result = $tb->select('shserv_roles', ['default_permissions'], [['slug', '=', $roleSlug]]); + if (!$result) { + return []; + } + + $perms = json_decode($result[0]['default_permissions'] ?? '[]', true); + $map = []; + foreach ($perms as $slug) { + $map[$slug] = true; + } + + // Handle wildcard '*' for superadmin + if (in_array('*', $perms, true)) { + $all = $tb->select('shserv_permissions', ['slug']); + if ($all) { + foreach ($all as $row) { + $map[$row['slug']] = true; + } + } + } + + return $map; + } + + private function getGroupPermissions(int $userId): array + { + $tb = app()->thin_builder; + $result = $tb->query(" + SELECT p.permission_slug, p.granted + FROM shserv_group_permissions p + JOIN shserv_group_members m ON m.group_id = p.group_id + WHERE m.user_id = {$userId} + "); + + if (!$result) { + return []; + } + + $map = []; + foreach ($result as $row) { + $map[$row['permission_slug']] = (bool) $row['granted']; + } + return $map; + } + + private function getUserOverrides(int $userId): array + { + $tb = app()->thin_builder; + $result = $tb->select('shserv_user_permissions', ['permission_slug', 'granted'], [['user_id', '=', $userId]]); + if (!$result) { + return []; + } + + $map = []; + foreach ($result as $row) { + $map[$row['permission_slug']] = (bool) $row['granted']; + } + return $map; + } + + private function getAllPermissionSlugs(): array + { + $tb = app()->thin_builder; + $result = $tb->select('shserv_permissions', ['slug']); + if (!$result) { + return []; + } + return array_column($result, 'slug'); + } +} diff --git a/server/SHServ/Integrations/GAuth/Store/DbTokenStore.php b/server/SHServ/Integrations/GAuth/Store/DbTokenStore.php new file mode 100644 index 0000000..85de1d5 --- /dev/null +++ b/server/SHServ/Integrations/GAuth/Store/DbTokenStore.php @@ -0,0 +1,63 @@ +tb = $tb; + } + + public function put(string $key, TokenSet $tokenSet): void + { + $expiresAt = $tokenSet->expiresAt; + $data = [ + 'session_token' => $key, + 'access_token' => $tokenSet->accessToken, + 'refresh_token' => $tokenSet->refreshToken, + 'expires_at' => $expiresAt ? $expiresAt->format('Y-m-d H:i:s') : null, + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + $existing = $this->tb->select('shserv_sessions', ['id'], [['session_token', '=', $key]]); + if ($existing) { + $this->tb->update('shserv_sessions', $data, [['session_token', '=', $key]]); + } else { + $this->tb->insert('shserv_sessions', $data); + } + } + + public function get(string $key): ?TokenSet + { + $result = $this->tb->select('shserv_sessions', ['access_token', 'refresh_token', 'expires_at'], [['session_token', '=', $key]]); + + if (!$result) { + return null; + } + + $row = $result[0]; + $expiresAt = $row['expires_at'] ? new \DateTimeImmutable($row['expires_at']) : null; + + return new TokenSet( + accessToken: (string) $row['access_token'], + refreshToken: isset($row['refresh_token']) ? (string) $row['refresh_token'] : null, + tokenType: 'Bearer', + expiresIn: $expiresAt ? (int) $expiresAt->format('U') - time() : 0, + expiresAt: $expiresAt, + ); + } + + public function forget(string $key): void + { + $this->tb->delete('shserv_sessions', [['session_token', '=', $key]]); + } +} diff --git a/server/SHServ/Integrations/GAuth/Store/SessionPkceStore.php b/server/SHServ/Integrations/GAuth/Store/SessionPkceStore.php new file mode 100644 index 0000000..856b66e --- /dev/null +++ b/server/SHServ/Integrations/GAuth/Store/SessionPkceStore.php @@ -0,0 +1,55 @@ + $verifier, + 'expires_at' => $expiresAt->format(\DateTimeInterface::ATOM), + ]; + } + + public function get(string $state): ?string + { + $record = $_SESSION[self::SESSION_KEY][$state] ?? null; + + if (!is_array($record)) { + return null; + } + + try { + $expiresAt = new \DateTimeImmutable($record['expires_at']); + } catch (\Exception $e) { + unset($_SESSION[self::SESSION_KEY][$state]); + return null; + } + + if ($expiresAt < new \DateTimeImmutable()) { + unset($_SESSION[self::SESSION_KEY][$state]); + return null; + } + + return isset($record['verifier']) ? (string) $record['verifier'] : null; + } + + public function forget(string $state): void + { + unset($_SESSION[self::SESSION_KEY][$state]); + } +} diff --git a/server/SHServ/Integrations/GAuth/Store/SessionStateStore.php b/server/SHServ/Integrations/GAuth/Store/SessionStateStore.php new file mode 100644 index 0000000..a2b2914 --- /dev/null +++ b/server/SHServ/Integrations/GAuth/Store/SessionStateStore.php @@ -0,0 +1,64 @@ + $expiresAt->format(\DateTimeInterface::ATOM), + 'context' => $context, + ]; + } + + public function has(string $state): bool + { + $record = $_SESSION[self::SESSION_KEY][$state] ?? null; + + if (!is_array($record)) { + return false; + } + + try { + $expiresAt = new \DateTimeImmutable($record['expires_at']); + } catch (\Exception $e) { + unset($_SESSION[self::SESSION_KEY][$state]); + return false; + } + + if ($expiresAt < new \DateTimeImmutable()) { + unset($_SESSION[self::SESSION_KEY][$state]); + return false; + } + + return true; + } + + public function getContext(string $state): array + { + if (!$this->has($state)) { + return []; + } + + return $_SESSION[self::SESSION_KEY][$state]['context'] ?? []; + } + + public function forget(string $state): void + { + unset($_SESSION[self::SESSION_KEY][$state]); + } +} diff --git a/server/SHServ/Integrations/GAuth/UserResolver.php b/server/SHServ/Integrations/GAuth/UserResolver.php new file mode 100644 index 0000000..eb11369 --- /dev/null +++ b/server/SHServ/Integrations/GAuth/UserResolver.php @@ -0,0 +1,108 @@ +thin_builder; + + // Find existing by gauth_user_id + $existing = $tb->select('shserv_users', ['id'], [['gauth_user_id', '=', $user->userId]]); + + // Extract client access for this Client + $clientAccess = $this->findClientAccess($user); + $systemRole = $user->systemRole ?? 'user'; + $status = $user->status ?? 'active'; + $displayName = $user->profile['display_name'] ?? ($user->profile['username'] ?? $user->email); + $avatarUrl = $user->avatarUrl(); + + $data = [ + 'email' => $user->email, + 'display_name' => $displayName, + 'avatar_url' => $avatarUrl, + 'system_role' => $systemRole, + 'status' => $status, + ]; + + if ($existing) { + $userId = (int) $existing[0]['id']; + $tb->update('shserv_users', $data, [['id', '=', $userId]]); + + // Sync permissions from client access + $this->syncPermissions($userId, $clientAccess); + + return $userId; + } + + $data['gauth_user_id'] = $user->userId; + $data['created_at'] = date('Y-m-d H:i:s'); + + $tb->insert('shserv_users', $data); + $userId = (int) $tb->getLastInsertedId(); + + $this->syncPermissions($userId, $clientAccess); + + return $userId; + } + + private function findClientAccess(AuthenticatedUser $user): ?ClientAccess + { + $clientId = FCONF['gauth']['client_id'] ?? ''; + foreach ($user->clientAccessList as $access) { + if ($access->clientId === $clientId) { + return $access; + } + } + return null; + } + + private function syncPermissions(int $userId, ?ClientAccess $access): void + { + if (!$access) { + return; + } + + $tb = app()->thin_builder; + + // Remove old auto-synced permissions (those without set_by_user_id = manual override) + $tb->query(" + DELETE FROM shserv_user_permissions + WHERE user_id = {$userId} AND set_by_user_id IS NULL + "); + + // Insert current permissions from gnexus-auth + foreach ($access->permissionIds as $permId) { + $permSlug = $this->mapPermissionIdToSlug($permId); + if (!$permSlug) { + continue; + } + $tb->insert('shserv_user_permissions', [ + 'user_id' => $userId, + 'permission_slug' => $permSlug, + 'granted' => 1, + 'set_by_user_id' => null, + ]); + } + } + + private function mapPermissionIdToSlug(string $permId): ?string + { + // In first version, gnexus-auth permissionIds might be slugs already. + // If they are numeric IDs, we would need a mapping table. + // For now, assume they are strings like "devices.view". + $tb = app()->thin_builder; + $exists = $tb->select('shserv_permissions', ['slug'], [['slug', '=', $permId]], [], '', [0, 1]); + return $exists ? $exists[0]['slug'] : null; + } +} diff --git a/server/SHServ/Integrations/GAuth/Webhook/Handlers/GroupHandler.php b/server/SHServ/Integrations/GAuth/Webhook/Handlers/GroupHandler.php new file mode 100644 index 0000000..f03a274 --- /dev/null +++ b/server/SHServ/Integrations/GAuth/Webhook/Handlers/GroupHandler.php @@ -0,0 +1,51 @@ +metadata; + $user = $data['user'] ?? []; + $group = $data['group'] ?? []; + $gauthUserId = $user['id'] ?? null; + $gauthGroupId = $group['id'] ?? null; + + if (!$gauthUserId || !$gauthGroupId) { + return; + } + + $tb = app()->thin_builder; + + $localUser = $tb->select('shserv_users', ['id'], [['gauth_user_id', '=', $gauthUserId]]); + $localGroup = $tb->select('shserv_groups', ['id'], [['gauth_group_id', '=', $gauthGroupId]]); + + if (!$localUser || !$localGroup) { + return; + } + + $userId = (int) $localUser[0]['id']; + $groupId = (int) $localGroup[0]['id']; + + switch ($event->eventType) { + case 'group.user_added': + $tb->query(" + INSERT IGNORE INTO shserv_group_members (group_id, user_id) + VALUES ({$groupId}, {$userId}) + "); + break; + + case 'group.user_removed': + $tb->query(" + DELETE FROM shserv_group_members + WHERE group_id = {$groupId} AND user_id = {$userId} + "); + break; + } + } +} diff --git a/server/SHServ/Integrations/GAuth/Webhook/Handlers/RoleHandler.php b/server/SHServ/Integrations/GAuth/Webhook/Handlers/RoleHandler.php new file mode 100644 index 0000000..ff8b513 --- /dev/null +++ b/server/SHServ/Integrations/GAuth/Webhook/Handlers/RoleHandler.php @@ -0,0 +1,56 @@ +metadata; + $user = $data['user'] ?? []; + $gauthUserId = $user['id'] ?? null; + if (!$gauthUserId) { + return; + } + + $tb = app()->thin_builder; + $localUser = $tb->select('shserv_users', ['id', 'system_role'], [['gauth_user_id', '=', $gauthUserId]]); + if (!$localUser) { + return; + } + + $userId = (int) $localUser[0]['id']; + + switch ($event->eventType) { + case 'client.roles_changed': + $roles = $data['roles'] ?? []; + // Update system_role if it changed + $clientId = FCONF['gauth']['client_id'] ?? ''; + // In first version, roles array contains strings + if (!empty($roles)) { + $systemRole = in_array('superadmin', $roles, true) ? 'superadmin' + : (in_array('admin', $roles, true) ? 'admin' : 'user'); + $tb->update('shserv_users', ['system_role' => $systemRole], [['id', '=', $userId]]); + } + break; + + case 'client.permissions_changed': + // Full re-sync of permissions for this user + // We need to re-fetch from gnexus-auth, but webhook gives us changed_permissions + $changed = $data['changed_permissions'] ?? []; + foreach ($changed as $permSlug) { + // Remove old auto-synced record and re-insert + $tb->query(" + DELETE FROM shserv_user_permissions + WHERE user_id = {$userId} AND permission_slug = '{$permSlug}' AND set_by_user_id IS NULL + "); + } + break; + } + } +} diff --git a/server/SHServ/Integrations/GAuth/Webhook/Handlers/SessionHandler.php b/server/SHServ/Integrations/GAuth/Webhook/Handlers/SessionHandler.php new file mode 100644 index 0000000..9035b3c --- /dev/null +++ b/server/SHServ/Integrations/GAuth/Webhook/Handlers/SessionHandler.php @@ -0,0 +1,45 @@ +metadata; + $user = $data['user'] ?? []; + $gauthUserId = $user['id'] ?? null; + + if (!$gauthUserId) { + return; + } + + $tb = app()->thin_builder; + $localUser = $tb->select('shserv_users', ['id'], [['gauth_user_id', '=', $gauthUserId]]); + if (!$localUser) { + return; + } + + $userId = (int) $localUser[0]['id']; + + switch ($event->eventType) { + case 'auth.global_logout': + $tb->delete('shserv_sessions', [['user_id', '=', $userId]]); + break; + + case 'session.revoked': + $sessionId = $data['session_id'] ?? null; + if ($sessionId) { + $tb->query(" + DELETE FROM shserv_sessions + WHERE user_id = {$userId} AND session_token = '{$sessionId}' + "); + } + break; + } + } +} diff --git a/server/SHServ/Integrations/GAuth/Webhook/Handlers/UserHandler.php b/server/SHServ/Integrations/GAuth/Webhook/Handlers/UserHandler.php new file mode 100644 index 0000000..6a1b8eb --- /dev/null +++ b/server/SHServ/Integrations/GAuth/Webhook/Handlers/UserHandler.php @@ -0,0 +1,49 @@ +metadata; + $user = $data['user'] ?? []; + $gauthUserId = $user['id'] ?? null; + if (!$gauthUserId) { + return; + } + + $tb = app()->thin_builder; + + switch ($event->eventType) { + case 'user.updated': + $tb->update('shserv_users', [ + 'email' => $user['email'] ?? null, + 'display_name' => $user['display_name'] ?? null, + 'status' => $user['status'] ?? 'active', + ], [['gauth_user_id', '=', $gauthUserId]]); + break; + + case 'user.blocked': + $tb->update('shserv_users', ['status' => 'blocked'], [['gauth_user_id', '=', $gauthUserId]]); + // Clear sessions + $localUser = $tb->select('shserv_users', ['id'], [['gauth_user_id', '=', $gauthUserId]]); + if ($localUser) { + $tb->delete('shserv_sessions', [['user_id', '=', $localUser[0]['id']]]); + } + break; + + case 'user.unblocked': + $tb->update('shserv_users', ['status' => 'active'], [['gauth_user_id', '=', $gauthUserId]]); + break; + + case 'user.deleted': + $tb->delete('shserv_users', [['gauth_user_id', '=', $gauthUserId]]); + break; + } + } +} diff --git a/server/SHServ/Integrations/GAuth/Webhook/WebhookRouter.php b/server/SHServ/Integrations/GAuth/Webhook/WebhookRouter.php new file mode 100644 index 0000000..3d9629d --- /dev/null +++ b/server/SHServ/Integrations/GAuth/Webhook/WebhookRouter.php @@ -0,0 +1,29 @@ +eventType; + + match (true) { + str_starts_with($type, 'user.') => (new UserHandler())->handle($event), + str_starts_with($type, 'client.roles_changed') => (new RoleHandler())->handle($event), + str_starts_with($type, 'client.permissions_changed') => (new RoleHandler())->handle($event), + str_starts_with($type, 'group.user_') => (new GroupHandler())->handle($event), + str_starts_with($type, 'auth.global_logout') => (new SessionHandler())->handle($event), + str_starts_with($type, 'session.revoked') => (new SessionHandler())->handle($event), + default => null, + }; + } +} diff --git a/server/SHServ/Routes.php b/server/SHServ/Routes.php index 8280b35..83577d1 100644 --- a/server/SHServ/Routes.php +++ b/server/SHServ/Routes.php @@ -66,6 +66,14 @@ $this -> router -> uri("/cron/regular-scripts", "{$this -> cn}\\CronController@run_regular_cron_scripts"); $this -> router -> uri("/cron/status-update-scanning", "{$this -> cn}\\CronController@status_update_scanning"); $this -> router -> uri("/text-msgs", "{$this -> cn}\\AppController@text_msgs"); + + // Auth + $this -> router -> uri("/auth/login", "{$this -> cn}\\AuthController@login"); + $this -> router -> uri("/auth/callback", "{$this -> cn}\\AuthController@callback"); + $this -> router -> uri("/auth/me", "{$this -> cn}\\AuthController@me"); + + // Webhooks + $this -> router -> uri("/webhooks/gnexus-auth", "{$this -> cn}\\WebhookController@gnexus_auth"); } protected function get_routes() { @@ -85,6 +93,18 @@ "/events/new" ); + // Auth + $this -> router -> post( + [], + "{$this -> cn}\\AuthController@logout", + "/auth/logout" + ); + $this -> router -> post( + [], + "{$this -> cn}\\AuthController@refresh", + "/auth/refresh" + ); + } /** diff --git a/server/SHServ/config.php b/server/SHServ/config.php index b15454f..eb2cca5 100644 --- a/server/SHServ/config.php +++ b/server/SHServ/config.php @@ -47,5 +47,13 @@ "device_api_connect_timeout" => (float)($env['DEVICE_API_CONNECT_TIMEOUT'] ?? "1"), "device_api_timeout" => (float)($env['DEVICE_API_TIMEOUT'] ?? "5"), "device_offline_threshold" => (int)($env['DEVICE_OFFLINE_THRESHOLD'] ?? "300"), - "firmwares_dir" => $env['FIRMWARES_DIR'] ?? dirname(__DIR__, 2) . "/firmwares" + "firmwares_dir" => $env['FIRMWARES_DIR'] ?? dirname(__DIR__, 2) . "/firmwares", + + "gauth" => [ + "base_url" => $env['GAUTH_BASE_URL'] ?? "https://auth.gnexus.space", + "client_id" => $env['GAUTH_CLIENT_ID'] ?? "shserv", + "client_secret" => $env['GAUTH_CLIENT_SECRET'] ?? "", + "redirect_uri" => $env['GAUTH_REDIRECT_URI'] ?? "https://smarthome.gnexus.space/auth/callback", + "webhook_secret" => $env['GAUTH_WEBHOOK_SECRET'] ?? "", + ], ]; diff --git a/server/composer.json b/server/composer.json index feea5cf..8012eea 100644 --- a/server/composer.json +++ b/server/composer.json @@ -3,7 +3,12 @@ "description": "Smart Home Server backend", "type": "project", "require": { - "php": ">=8.1" + "php": ">=8.1", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^2.0", + "psr/log": "^3.0", + "gnexus/auth-client": "@dev" }, "require-dev": { "phpunit/phpunit": "^10.0" @@ -13,11 +18,19 @@ "Fury/", "SHServ/", "../automation/" - ] + ], + "psr-4": { + "SHServ\\": "SHServ/" + } }, "autoload-dev": { "classmap": [ "tests/" ] - } + }, + "repositories": [{ + "name": "gnexus-auth-client", + "type": "vcs", + "url": "https://git.gnexus.space/root/gnexus-auth-client-php.git" + }] } diff --git a/server/composer.lock b/server/composer.lock index 7f551e6..1600e95 100644 --- a/server/composer.lock +++ b/server/composer.lock @@ -4,8 +4,256 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "87ef1ff69296d56c3e059a9d87f3533b", - "packages": [], + "content-hash": "10fb0e492d166c1cafa83af27026ece9", + "packages": [ + { + "name": "gnexus/auth-client", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://git.gnexus.space/root/gnexus-auth-client-php.git", + "reference": "c95c99f8d42fbfa9b128b1f777f8371e282ac8d1" + }, + "require": { + "ext-json": "*", + "php": "^8.3", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^2.0", + "psr/log": "^3.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "default-branch": true, + "type": "library", + "autoload": { + "psr-4": { + "GNexus\\GAuth\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "GNexus\\GAuth\\Tests\\": "tests/" + } + }, + "license": [ + "proprietary" + ], + "description": "Framework-agnostic PHP client library for gnexus-auth integrations", + "time": "2026-04-24T04:12:54+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + } + ], "packages-dev": [ { "name": "myclabs/deep-copy", @@ -1679,7 +1927,9 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": { + "gnexus/auth-client": 20 + }, "prefer-stable": false, "prefer-lowest": false, "platform": { diff --git a/server/database/migrations/2026_06_06_000001_gauth_integration.php b/server/database/migrations/2026_06_06_000001_gauth_integration.php new file mode 100644 index 0000000..1f1bf93 --- /dev/null +++ b/server/database/migrations/2026_06_06_000001_gauth_integration.php @@ -0,0 +1,183 @@ +query(" + CREATE TABLE IF NOT EXISTS shserv_users ( + id INT AUTO_INCREMENT PRIMARY KEY, + gauth_user_id VARCHAR(64) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL, + display_name VARCHAR(255), + avatar_url VARCHAR(500), + system_role VARCHAR(32) NOT NULL DEFAULT 'user', + status VARCHAR(32) NOT NULL DEFAULT 'active', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_gauth (gauth_user_id), + INDEX idx_email (email), + INDEX idx_role (system_role), + INDEX idx_status (status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + "); + + // 2. shserv_roles + \$tb->query(" + CREATE TABLE IF NOT EXISTS shserv_roles ( + id INT AUTO_INCREMENT PRIMARY KEY, + slug VARCHAR(32) NOT NULL UNIQUE, + name VARCHAR(64) NOT NULL, + is_system TINYINT(1) NOT NULL DEFAULT 0, + default_permissions JSON, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + "); + + // 3. shserv_permissions + \$tb->query(" + CREATE TABLE IF NOT EXISTS shserv_permissions ( + id INT AUTO_INCREMENT PRIMARY KEY, + slug VARCHAR(64) NOT NULL UNIQUE, + name VARCHAR(128) NOT NULL, + description TEXT, + default_for_roles JSON, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + "); + + // 4. shserv_user_permissions + \$tb->query(" + CREATE TABLE IF NOT EXISTS shserv_user_permissions ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + permission_slug VARCHAR(64) NOT NULL, + granted TINYINT(1) NOT NULL DEFAULT 1, + set_by_user_id INT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_user_perm (user_id, permission_slug), + INDEX idx_user (user_id), + FOREIGN KEY (user_id) REFERENCES shserv_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + "); + + // 5. shserv_groups + \$tb->query(" + CREATE TABLE IF NOT EXISTS shserv_groups ( + id INT AUTO_INCREMENT PRIMARY KEY, + gauth_group_id VARCHAR(64) NOT NULL UNIQUE, + slug VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + "); + + // 6. shserv_group_members + \$tb->query(" + CREATE TABLE IF NOT EXISTS shserv_group_members ( + id INT AUTO_INCREMENT PRIMARY KEY, + group_id INT NOT NULL, + user_id INT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_membership (group_id, user_id), + FOREIGN KEY (group_id) REFERENCES shserv_groups(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES shserv_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + "); + + // 7. shserv_group_permissions + \$tb->query(" + CREATE TABLE IF NOT EXISTS shserv_group_permissions ( + id INT AUTO_INCREMENT PRIMARY KEY, + group_id INT NOT NULL, + permission_slug VARCHAR(64) NOT NULL, + granted TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_group_perm (group_id, permission_slug), + FOREIGN KEY (group_id) REFERENCES shserv_groups(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + "); + + // 8. shserv_sessions + \$tb->query(" + CREATE TABLE IF NOT EXISTS shserv_sessions ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + session_token VARCHAR(128) NOT NULL UNIQUE, + refresh_token VARCHAR(255), + access_token VARCHAR(255), + expires_at TIMESTAMP NULL, + ip_address VARCHAR(45), + user_agent VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_token (session_token), + FOREIGN KEY (user_id) REFERENCES shserv_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + "); + + // 9. shserv_audit + \$tb->query(" + CREATE TABLE IF NOT EXISTS shserv_audit ( + id INT AUTO_INCREMENT PRIMARY KEY, + actor_user_id INT NOT NULL, + action VARCHAR(64) NOT NULL, + target_type VARCHAR(32), + target_id INT, + old_value JSON, + new_value JSON, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_actor (actor_user_id), + INDEX idx_action (action), + INDEX idx_created (created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + "); + + // Seed roles + \$roles = [ + ['superadmin', 'Суперадминистратор', 1, json_encode(['*'])], + ['admin', 'Администратор', 1, json_encode(['devices.*', 'areas.*', 'scripts.*', 'firmware.*', 'admin.users', 'admin.roles', 'admin.audit', 'settings.edit'])], + ['user', 'Пользователь', 1, json_encode(['devices.view', 'devices.scan', 'devices.control', 'devices.setup', 'areas.view', 'scripts.view', 'scripts.run'])], + ['guest', 'Гость', 0, json_encode(['devices.view'])], + ]; + foreach (\$roles as \$role) { + \$tb->query(" + INSERT IGNORE INTO shserv_roles (slug, name, is_system, default_permissions) + VALUES ('{\$role[0]}', '{\$role[1]}', {\$role[2]}, '{\$role[3]}') + "); + } + + // Seed permissions + \$permissions = [ + ['devices.view', 'Просмотр устройств', 'Просмотр списка устройств и их статуса', json_encode(['superadmin' => true, 'admin' => true, 'user' => true, 'guest' => true])], + ['devices.scan', 'Сканирование сети', 'Поиск новых устройств в локальной сети', json_encode(['superadmin' => true, 'admin' => true, 'user' => true, 'guest' => false])], + ['devices.control', 'Управление устройствами', 'Включение/выключение, изменение состояния каналов', json_encode(['superadmin' => true, 'admin' => true, 'user' => true, 'guest' => false])], + ['devices.setup', 'Добавление устройств', 'Регистрация новых устройств в системе', json_encode(['superadmin' => true, 'admin' => true, 'user' => true, 'guest' => false])], + ['devices.edit', 'Редактирование устройств', 'Изменение имени, зоны, схемы каналов', json_encode(['superadmin' => true, 'admin' => true, 'user' => false, 'guest' => false])], + ['devices.delete', 'Удаление устройств', 'Удаление устройств из системы', json_encode(['superadmin' => true, 'admin' => true, 'user' => false, 'guest' => false])], + ['areas.view', 'Просмотр зон', 'Просмотр списка зон', json_encode(['superadmin' => true, 'admin' => true, 'user' => true, 'guest' => true])], + ['areas.manage', 'Управление зонами', 'Создание, редактирование, удаление зон', json_encode(['superadmin' => true, 'admin' => true, 'user' => false, 'guest' => false])], + ['scripts.view', 'Просмотр скриптов', 'Просмотр списка скриптов автоматизации', json_encode(['superadmin' => true, 'admin' => true, 'user' => true, 'guest' => false])], + ['scripts.edit', 'Редактирование скриптов', 'Создание и изменение скриптов', json_encode(['superadmin' => true, 'admin' => true, 'user' => false, 'guest' => false])], + ['scripts.run', 'Запуск скриптов', 'Ручной запуск скриптов автоматизации', json_encode(['superadmin' => true, 'admin' => true, 'user' => true, 'guest' => false])], + ['firmware.view', 'Просмотр прошивок', 'Просмотр списка доступных прошивок', json_encode(['superadmin' => true, 'admin' => true, 'user' => false, 'guest' => false])], + ['firmware.upload', 'Загрузка прошивок', 'Загрузка новых версий прошивок', json_encode(['superadmin' => true, 'admin' => true, 'user' => false, 'guest' => false])], + ['admin.users', 'Управление пользователями', 'Просмотр и редактирование пользователей', json_encode(['superadmin' => true, 'admin' => true, 'user' => false, 'guest' => false])], + ['admin.roles', 'Управление ролями', 'Назначение ролей и прав', json_encode(['superadmin' => true, 'admin' => true, 'user' => false, 'guest' => false])], + ['admin.audit', 'Просмотр аудита', 'Просмотр журнала действий', json_encode(['superadmin' => true, 'admin' => true, 'user' => false, 'guest' => false])], + ['settings.edit', 'Настройки системы', 'Изменение глобальных настроек', json_encode(['superadmin' => true, 'admin' => true, 'user' => false, 'guest' => false])], + ]; + foreach (\$permissions as \$perm) { + \$tb->query(" + INSERT IGNORE INTO shserv_permissions (slug, name, description, default_for_roles) + VALUES ('{\$perm[0]}', '{\$perm[1]}', '{\$perm[2]}', '{\$perm[3]}') + "); + } + + return true; +};