diff --git a/docs/planning/auth-fix-plan.md b/docs/planning/auth-fix-plan.md new file mode 100644 index 0000000..ec0cc07 --- /dev/null +++ b/docs/planning/auth-fix-plan.md @@ -0,0 +1,170 @@ +# План устранения проблем авторизации + +> Статус: **Актуальный** +> Создан: 2026-06-06 +> Последнее обновление: 2026-06-06 + +--- + +## Контекст + +После аудита авторизации (OAuth + SPA same-domain) выявлено 17 проблем разной критичности. +Этот документ — единственный источник правды по порядку исправлений. Каждая фаза должна быть завершена полностью перед переходом к следующей. + +--- + +## Phase 1 — Security Hotfixes (P0) + +**Цель:** закрыть дыры, которые позволяют злоумышленнику или багу получить/сохранить несанкционированный доступ. + +### 1.1. Убрать `access_token` из URL query string +- **Проблема:** токен утекает в browser history, access logs, Referer. +- **Подход:** перейти на **cookie-based session**. PHP ставит `Set-Cookie: shserv_session=` (HttpOnly, Secure, SameSite=Lax), фронтенд хранит только факт аутентификации в памяти (Pinia), а API-клиент отправляет cookie автоматически (`credentials: "include"`). +- **Файлы:** + - `server/SHServ/Controllers/AuthController.php` — убрать `?access_token=` из redirect, установить cookie в `callback()`. + - `server/SHServ/Integrations/GAuth/Store/DbTokenStore.php` — генерация session_token уже есть, расширить колонки. + - `webclient/src/app/main.js` — удалить извлечение `access_token` из `window.location.search`. + - `webclient/src/api/auth.js` — оставить только `localStorage` как **fallback**, но основной auth — cookie. +- **Acceptance criteria:** + - После OAuth callback URL не содержит `access_token`. + - В DevTools → Network → Cookies виден `shserv_session`. + - `api/http.js` не добавляет `Authorization: Bearer` если нет token в localStorage (cookie работает сама). + +### 1.2. Проверять `expires_at` и `status='active'` при Bearer resolution +- **Проблема:** старый/истёкший токен и заблокированный пользователь продолжают иметь доступ. +- **Файлы:** + - `server/SHServ/Integrations/GAuth/AuthControllerTrait.php`: + - `resolve_user_by_bearer()` — добавить `['expires_at', '>', date('Y-m-d H:i:s')]` в where. + - `load_user_by_id()` — добавить `['status', '=', 'active']`. +- **Acceptance criteria:** + - Запрос с истёкшим токеном возвращает 401. + - Запрос токеном заблокированного (`status != 'active'`) пользователя возвращает 401. + +### 1.3. Исправить race condition router guard / `authStore.init()` +- **Проблема:** открытие защищённого URL напрямую выкидывает на login до завершения `init()`. +- **Файлы:** + - `webclient/src/app/main.js` — инициализировать auth **до** `app.use(router)`. + - `webclient/src/router/index.js` — guard должен доверять `isAuthenticated`, не редиректить если `isLoading`. +- **Acceptance criteria:** + - Прямой переход на `/#/devices` не показывает login при валидной сессии. + - При отсутствии сессии редирект на login происходит ровно один раз. + +### 1.4. Очистка `return_to` от `access_token` при 401-редиректе +- **Проблема:** цикл OAuth, если `return_to` содержит старый `access_token`. +- **Файлы:** + - `webclient/src/api/client.js` — guard при 401 формирует `return_to` через `URLSearchParams.delete('access_token')`. +- **Acceptance criteria:** + - URL после неудачного refresh не содержит `access_token`. + +--- + +## Phase 2 — Session Stability (P1) + +**Цель:** пользователь остаётся залогиненым дни и недели без ручных повторных входов. + +### 2.1. Автоматический refresh токена при 401 +- **Проблема:** истечение access_token мгновенно разлогинивает. +- **Файлы:** + - `webclient/src/stores/auth.js` — в `init()` при 401 сначала вызвать `refreshToken()`, потом повторить `apiGet("/auth/me")`. + - `webclient/src/api/client.js` — при 401 ответа делать **один** вызов `/auth/refresh` (с queue, чтобы не делать N параллельных), затем retry исходного запроса. +- **Acceptance criteria:** + - Токен истёк → frontend автоматически обновляет и retry запроса без видимого разлогина. + - Если refresh тоже 401 → только тогда редирект на login. + +### 2.2. Поддержать Bearer-token fallback в `refresh()` и `logout()` +- **Проблема:** эти endpoint'ы читают только `$_SESSION`, но SPA может работать без session cookie. +- **Файлы:** + - `server/SHServ/Controllers/AuthController.php` — `refresh()` и `logout()` должны извлекать access_token из `Authorization` header, искать session по нему в `shserv_sessions`, и работать от найденной записи. +- **Acceptance criteria:** + - Вызов `POST /auth/refresh` с `Authorization: Bearer ` возвращает новый токен даже без PHP session cookie. + - `POST /auth/logout` с валидным Bearer отзывает сессию. + +### 2.3. Использовать `expires_in` на фронтенде (proactive refresh) +- **Проблема:** фронтенд не знает, когда токен истечёт, и ждёт 401. +- **Файлы:** + - `webclient/src/api/auth.js` — сохранять `expires_at` (timestamp) рядом с token в localStorage. + - `webclient/src/stores/auth.js` — `setInterval` или `setTimeout` на обновление за 60 секунд до expiration. +- **Acceptance criteria:** + - Токен обновляется **до** истечения, 401 от API не происходит при нормальной работе. + +### 2.4. Убрать дублирование извлечения токена из URL +- **Проблема:** `main.js` и `LoginPage.vue` оба пытаются обработать callback → лишний `me`. +- **Файлы:** + - `webclient/src/features/auth/pages/LoginPage.vue` — удалить `onMounted` логику с `route.query.access_token`. + - `webclient/src/app/main.js` — оставить единственную точку обработки. +- **Acceptance criteria:** + - `LoginPage.vue` не содержит логики OAuth callback. + +--- + +## Phase 3 — Infrastructure Hardening (P2) + +**Цель:** защитить от stolen tokens, несовместимостей окружения и abuse. + +### 3.1. Записывать IP/UA в сессии и проверять при авторизации +- **Проблема:** stolen token полностью работает с любого устройства. +- **Файлы:** + - `server/SHServ/Integrations/GAuth/Store/DbTokenStore.php` — `put()` добавляет `ip_address`, `user_agent`. + - `server/SHServ/Integrations/GAuth/AuthControllerTrait.php` — `resolve_user_by_bearer()` проверяет совпадение. +- **Acceptance criteria:** + - Запрос с валидным токеном, но другого IP/UA → 401 с кодом `session_suspicious`. + +### 3.2. Fix `getallheaders()` для nginx +- **Проблема:** функция может отсутствовать в nginx + php-fpm. +- **Файлы:** + - `server/SHServ/Controllers/WebhookController.php` — заменить `getallheaders()` на fallback из `$_SERVER`. +- **Acceptance criteria:** + - Webhook успешно принимается на проде (nginx). + +### 3.3. Rate limiting на auth endpoints +- **Проблема:** brute force state, спам OAuth flow. +- **Файлы:** + - `/etc/nginx/sites-enabled/default` (prod) — `limit_req_zone` + `limit_req` для `/auth/`. + - Либо PHP middleware для fallback. +- **Acceptance criteria:** + - 10+ запросов `/auth/login` или `/auth/callback` в минуту с одного IP → 429. + +### 3.4. Cache permissions +- **Проблема:** `PermissionResolver` делает 3 запроса к БД на каждый API call. +- **Файлы:** + - `server/SHServ/Integrations/GAuth/PermissionResolver.php` — кешировать результат в `$_SESSION` на время запроса или с TTL. +- **Acceptance criteria:** + - Повторные `require_permission()` в рамках одного запроса не бьют в БД. + +--- + +## Phase 4 — Cleanup & Polish (P3) + +**Цель:** убрать технический долг, привести код к стандартам проекта. + +### 4.1. Исправить миграцию — убрать raw string interpolation +- **Проблема:** seed-данные вставляются через строковую интерполяция в SQL (потенциальная SQLi). +- **Файлы:** + - `server/database/migrations/2026_06_06_000001_gauth_integration.php` — заменить raw `INSERT IGNORE` на `ThinBuilder::insert()` или параметризованные запросы. +- **Acceptance criteria:** + - Нет конкатенации переменных в SQL строках миграции. + +### 4.2. LoginPage.vue стили — перейти на gnexus-ui-kit +- **Проблема:** используются CSS custom properties вместо utility-классов kit'а. +- **Файлы:** + - `webclient/src/features/auth/pages/LoginPage.vue` — заменить `var(--color-*)` на `.text-primary`, `.text-muted`, `.border-subtle` и т.д. +- **Acceptance criteria:** + - В `