From a4faf31fcd0d77e8e765000481d7e0abfe706607 Mon Sep 17 00:00:00 2001 From: byrsapty Date: Sat, 15 Aug 2026 10:24:58 +0300 Subject: [PATCH] =?UTF-8?q?=D0=95=D1=82=D0=B0=D0=BF=205:=20=D0=BA=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D1=81=D1=82=D1=83=D0=B2=D0=B0=D1=87=D1=96,=20?= =?UTF-8?q?=D0=B2=D1=85=D1=96=D0=B4=20=D1=96=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Досі доступ давав машинний токен зі змінної збірки — одні права на всіх і жодного способу відрізнити, хто що зробив. Тепер продукт уміє впустити людину. Сервер: - argon2id для паролів, власний HS256 JWT (15 хв) + refresh-сесія в httpOnly-кукі на 30 днів з ротацією при кожному обміні - Principal зводить людину й машинний токен до одного набору прав; права читаються з БД на кожному запиті, а не з claims, щоб відкликана роль не жила до кінця TTL - 9 ендпоїнтів: auth/login|refresh|logout|password, me, team CRUD, roles - netpulse-user — CLI для першого власника: публічна реєстрація в B2B це дірка, а «перший через веб, поки нікого немає» — нечесна гонка - міграція 0012: три RLS-політики винятку для шляху входу (без них вхід неможливий за побудовою — щоб знайти користувача за email, треба знати тенант, який відомий лише після пошуку) і login_attempts для тротлінгу Фронтенд: - сторінка входу з вибором організації, access-токен у замиканні модуля замість localStorage, тихе відновлення сесії по кукі - один refresh на всі паралельні запити: інакше ротація зробила б усі, крім першого, недійсними й викинула б людину на вхід - дії без права не показуються; полотно нередаговане для глядача - мобільний адаптив: висувна бічна панель, інспектор нижнім аркушем 11 нових тестів (37 у httpapi), go vet і tsc чисто, живий прогін з 8 кроків проти netpulse_it. Co-Authored-By: Claude Opus 5 --- HISTORY.md | 94 +++++ ROADMAP.md | 18 +- db/migrations/0012_auth.sql | 94 +++++ server/API.md | 130 ++++++- server/cmd/netpulse-api/main.go | 17 +- server/cmd/netpulse-user/main.go | 217 +++++++++++ server/internal/auth/password.go | 133 +++++++ server/internal/auth/token.go | 127 ++++++ server/internal/httpapi/api_test.go | 21 +- server/internal/httpapi/auth.go | 269 +++++++++++++ server/internal/httpapi/auth_test.go | 310 +++++++++++++++ server/internal/httpapi/maps_write.go | 36 +- server/internal/httpapi/principal.go | 166 ++++++++ server/internal/httpapi/server.go | 79 ++-- server/internal/httpapi/users.go | 131 +++++++ server/internal/httpapi/ws.go | 10 +- server/internal/store/users.go | 534 ++++++++++++++++++++++++++ web/src/App.tsx | 257 +++++++++---- web/src/api/client.ts | 141 ++++++- web/src/api/session.ts | 70 ++++ web/src/api/ws.ts | 6 +- web/src/components/LoginPage.tsx | 141 +++++++ web/src/components/MapCanvas.tsx | 15 +- web/src/types.ts | 49 +++ 24 files changed, 2874 insertions(+), 191 deletions(-) create mode 100644 db/migrations/0012_auth.sql create mode 100644 server/cmd/netpulse-user/main.go create mode 100644 server/internal/auth/password.go create mode 100644 server/internal/auth/token.go create mode 100644 server/internal/httpapi/auth.go create mode 100644 server/internal/httpapi/auth_test.go create mode 100644 server/internal/httpapi/principal.go create mode 100644 server/internal/httpapi/users.go create mode 100644 server/internal/store/users.go create mode 100644 web/src/api/session.ts create mode 100644 web/src/components/LoginPage.tsx diff --git a/HISTORY.md b/HISTORY.md index eea8e40..bc8c80b 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -588,3 +588,97 @@ web/src/hooks/useLiveMap.ts undo + canUndo Коротко: користувачі й права (схема є, коду немає) → алерти → шаблони опитування (нова підсистема `tpl.*`) → керування зондом із UI → NCM до кінця → мобільний адаптив і PWA. + +--- + +## 2026-08-15 — Етап 5: користувачі, вхід і права + +Досі продукт умів усе, крім найпростішого: впустити людину. Доступ давав +машинний токен у змінній збірки — тобто одні права на всіх і жодного способу +відрізнити, хто що зробив. Цей етап закриває саме це. + +### Створено + +- `db/migrations/0012_auth.sql` — три RLS-політики винятку для шляху входу, + гіпертаблиця `core.login_attempts`, два індекси на `core.sessions`. +- `server/internal/auth/password.go` — argon2id (2 проходи, 64 МБ, 32 байти), + вивід у форматі PHC, звірка через `subtle.ConstantTimeCompare`. +- `server/internal/auth/token.go` — власний HS256 JWT: `Sign`, `Verify`. +- `server/internal/store/users.go` — автентифікація, сесії, членства, команда. +- `server/internal/httpapi/principal.go` — `Principal` замість старого + `authenticated`; людина й машина зводяться до одного набору прав. +- `server/internal/httpapi/auth.go`, `users.go` — 9 нових ендпоїнтів. +- `server/cmd/netpulse-user/main.go` — CLI для першого власника. +- `web/src/api/session.ts`, `components/LoginPage.tsx`, переписані `client.ts` + і `App.tsx`. +- `server/internal/httpapi/auth_test.go` — 11 тестів. + +### Прийняті рішення + +**Заголовок JWT звіряється байт у байт, а не парситься.** Класична атака +підміни алгоритму (`alg: none`, `alg: HS256` замість RS256) можлива лише там, +де сервер питає токен, яким алгоритмом його перевіряти. Тут константа +`{"alg":"HS256","typ":"JWT"}` порівнюється як рядок — питати нічого й нема в +кого. Підпис перевіряється **до** строку дії: інакше протермінований підроблений +токен відрізнявся б за текстом помилки від протермінованого справжнього. + +**Права читаються з БД на кожному запиті, а не беруться з токена.** Класти їх +у claims було б швидше на одне звернення, але тоді відкликана роль жила б до +кінця TTL. Порожній набір прав — це не «нічого не можна», а сигнал, що членство +зникло: такий токен відхиляється повністю. + +**Access-токен живе в замиканні модуля, не в `localStorage`.** З localStorage +його забирає будь-який XSS; із замикання — ні. Ціною є втрата токена при +перезавантаженні сторінки, тому на старті робиться тихий `refresh` по кукі. + +**Refresh-токен ротується.** Стара сесія відкликається, видається нова. Це не +дає викраденій кукі жити паралельно з живою: другий власник отримає +`401 no_session` — крадіжка стає видимою подією, а не тихою. + +**Один refresh на всі паралельні запити.** Перший же екран робить чотири +запити одразу. Без черги з одного обміну ротація зробила б усі, крім першого, +недійсними, і людину викидало б на вхід рівно тоді, коли все гаразд. + +**Невідомий email і невірний пароль нерозрізненні** — однаковий код і однаковий +час: на неіснуючому користувачі спалюється фіктивна перевірка argon2id. Без неї +різниця в часі сама розказує, які адреси зареєстровані. + +**Три обмеження вшито в API, а не в UI**: роль `owner` не видається через +HTTP (лише CLI), не можна змінити роль собі, не можна прибрати себе. Усі три +захищають від одного — організації без жодного власника. + +**Перший власник заводиться CLI.** Публічна реєстрація в B2B-інсталяції — це +не зручність, а дірка; «створи першого користувача через веб, поки нікого +немає» — гонка, яку неможливо закрити чесно. + +### Знайдено при написанні + +`FORCE ROW LEVEL SECURITY` робив вхід неможливим за побудовою: щоб знайти +користувача за email, треба знати тенант, а тенант відомий лише після того, як +користувача знайдено. Виправлено трьома політиками, які пускають `SELECT` +**тільки** коли `core.current_tenant() IS NULL` — тобто рівно на шляху входу, +де тенанта ще нема. Читання під уже виставленим тенантом лишається обмеженим +як було. + +### Перевірено + +- `go test ./... -race` — 37 тестів httpapi, з них 11 нових; `go vet` чисто. +- `tsc --noEmit` чисто, `vite build` — 353 КБ JS (114 КБ gzip). +- Живий прогін проти `netpulse_it`: 8 кроків від входу власника до + відкликаної сесії — усі коди очікувані (розписано в [API.md](server/API.md)). +- У браузері: форма входу → інженер входить → мапа з живими RTT і «наживо». + Під глядачем кнопки редагування зникають, полотно нередаговане, блок зондів + не показується. +- Мобільний вигляд (375×812): бічна панель повністю за кадром, полотно на всю + ширину, горизонтального скролу немає; бургер висуває панель поверх полотна + із затемненням. + +### Обмеження перевірки + +Вкладка браузера прихована, тому CSS-переходи стоять на `currentTime: 0` — +виміри бічної панелі робилися після примусового `getAnimations().finish()`. +Це артефакт середовища, не застосунку. + +Сторінки керування командою в UI ще немає — є API й методи клієнта +(`team`, `roles`, `addMember`, `setRole`, `removeMember`). Додати користувача +поки можна лише CLI або запитом. diff --git a/ROADMAP.md b/ROADMAP.md index 5fd85e7..7636fd7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,7 +33,18 @@ API віддає готове полотно з живими статусами, --- -## Етап 5. Веб із користувачами і правами +## Етап 5. Веб із користувачами і правами — ✅ зроблено 2026-08-15 + +> Реалізовано: вхід, JWT + ротація refresh-сесій, RBAC на всіх ендпоїнтах, +> `netpulse-user` для першого власника, сторінка входу, приховування дій без +> права, мобільний адаптив. Подробиці — [HISTORY.md](HISTORY.md), +> контракт — [server/API.md](server/API.md). +> +> **Відкладено з цього етапу:** TOTP (`totp_secret_enc` є в схемі, коду немає), +> запрошення поштою (`core.invitations` порожня — користувача заводять із +> паролем одразу), кастомні ролі (`POST /api/v1/roles`), фільтр за +> `memberships.scope_group_ids`, UI-сторінки «Команда» й «Ролі» (API готове, +> сторінок немає). **Навіщо перше.** Зараз API автентифікує лише машинні токени (`core.api_tokens`). Людина увійти не може, а `core.roles` / `permissions` / @@ -244,9 +255,8 @@ Diff* — усе з ТЗ. ## Порядок і чому саме такий -1. **Етап 5 (користувачі)** — без входу продукт не можна віддати нікому. -2. **Етап 9 (алерти)** — без сповіщень це не моніторинг. Можна поміняти місцями - з п.1, якщо перший клієнт — ти сам. +1. ~~**Етап 5 (користувачі)** — без входу продукт не можна віддати нікому.~~ ✅ +2. **Етап 9 (алерти)** — без сповіщень це не моніторинг. Наступний у черзі. 3. **Етап 6 (шаблони)** — знімає потребу дописувати Go під кожен вендор; що раніше, то менше захардкодженого коду доведеться викидати. 4. **Етап 8 (керування зондом)** — дешевий і робить онбординг можливим. diff --git a/db/migrations/0012_auth.sql b/db/migrations/0012_auth.sql new file mode 100644 index 0000000..6e189d3 --- /dev/null +++ b/db/migrations/0012_auth.sql @@ -0,0 +1,94 @@ +-- ===================================================================== +-- NetPulse :: 0012_auth.sql +-- Автентифікація людей: політики RLS для шляху входу + індекси. +-- +-- Схема користувачів, ролей і сесій уже є з 0001. Бракує одного: +-- вхід відбувається ДО того, як відомий тенант. Користувач надсилає +-- лише email і пароль; поки ми не знайшли його membership, виставити +-- app.tenant_id нема з чого. Обидві таблиці, потрібні на цьому шляху, +-- закриті політикою tenant_isolation — тобто логін під роллю з RLS +-- завжди повертав би порожньо. +-- ===================================================================== + +-- --------------------------------------------------------------------- +-- Виняток для шляху автентифікації +-- +-- Політика спрацьовує РІВНО тоді, коли тенант ще не встановлений. +-- Це навмисно вузько: у будь-якому іншому запиті порожній app.tenant_id +-- і далі означає порожній результат (див. 0011), тому забутий SET LOCAL +-- не перетворюється на витік чужих даних — він просто нічого не поверне. +-- +-- Компроміс усвідомлений: у вікні «тенант ще невідомий» таблиця +-- core.users читається повністю. Прийнятно, бо запити на цьому шляху +-- пише лише сервер і всі вони одразу звужені за email або token_hash. +-- --------------------------------------------------------------------- + +CREATE POLICY users_auth_lookup ON core.users + FOR SELECT + USING (core.current_tenant() IS NULL); + +-- Той самий виняток для сесій: обмін refresh-токена теж відбувається +-- до вибору тенанта, бо саме сесія його й називає. +CREATE POLICY sessions_auth_lookup ON core.sessions + FOR SELECT + USING (core.current_tenant() IS NULL); + +-- Створення й відкликання сесії — теж поза тенантом: у момент логіну +-- контексту ще немає, а при виході він уже не потрібен. +CREATE POLICY sessions_auth_write ON core.sessions + FOR INSERT + WITH CHECK (core.current_tenant() IS NULL OR tenant_id = core.current_tenant()); + +CREATE POLICY sessions_auth_revoke ON core.sessions + FOR UPDATE + USING (core.current_tenant() IS NULL OR tenant_id = core.current_tenant()); + +-- Членства читаються одразу після знаходження користувача, щоб +-- зрозуміти, до якого тенанта його пускати. +CREATE POLICY memberships_auth_lookup ON core.memberships + FOR SELECT + USING (core.current_tenant() IS NULL); + +-- --------------------------------------------------------------------- +-- Індекси під шлях автентифікації +-- --------------------------------------------------------------------- + +-- Живі сесії користувача — для сторінки «активні входи» й масового +-- відкликання при зміні пароля. +CREATE INDEX IF NOT EXISTS sessions_active_idx + ON core.sessions (user_id, expires_at DESC) + WHERE revoked_at IS NULL; + +-- Прибирання протермінованих сесій фоновим завданням. +CREATE INDEX IF NOT EXISTS sessions_expiry_idx + ON core.sessions (expires_at) + WHERE revoked_at IS NULL; + +-- --------------------------------------------------------------------- +-- Аудит входів +-- +-- Окремо від core.audit_log: невдалі спроби входу треба рахувати ще до +-- того, як відомо, хто саме їх робить, а audit_log вимагає tenant_id. +-- Це ж джерело для блокування перебору. +-- --------------------------------------------------------------------- + +CREATE TABLE core.login_attempts ( + ts timestamptz NOT NULL DEFAULT now(), + id uuid NOT NULL DEFAULT core.new_id(), + email citext NOT NULL, + ip inet, + user_agent text, + success boolean NOT NULL, + -- bad_password | no_user | locked | mfa_failed + reason text, + user_id uuid, + PRIMARY KEY (ts, id) +); +SELECT create_hypertable('core.login_attempts', 'ts', + chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE); + +CREATE INDEX login_attempts_email_idx ON core.login_attempts (email, ts DESC); +CREATE INDEX login_attempts_failed_idx ON core.login_attempts (ip, ts DESC) + WHERE NOT success; + +SELECT add_retention_policy('core.login_attempts', INTERVAL '180 days', if_not_exists => TRUE); diff --git a/server/API.md b/server/API.md index 95ada3d..e710932 100644 --- a/server/API.md +++ b/server/API.md @@ -14,23 +14,60 @@ go build -o netpulse-api ./cmd/netpulse-api ## Автентифікація -Bearer-токен із `core.api_tokens`; у БД лежить лише `sha256`, сам токен показується -користувачу один раз при створенні. +Два рівноправні способи довести, хто ти. Обидва приходять одним заголовком: ``` -Authorization: Bearer np_ui_xxxxxxxx +Authorization: Bearer <токен> ``` -Порожній `scopes` означає повний доступ — так поводяться токени, створені власником -тенанта для себе. Інакше перевіряються права `maps:read`, `devices:read`, `agents:read`. +Сервер розрізняє їх за формою: рівно дві крапки — це JWT людини, інакше машинний +токен. Це навмисно не залежить від префікса, бо префікс може змінитись, а формат +JWT — ні. + +**Люди** входять через `/api/v1/auth/login` і отримують короткий access-токен +(HS256, 15 хвилин, у пам'яті вкладки) плюс refresh-сесію в `httpOnly`-кукі +`np_refresh` (30 днів, `SameSite=Strict`, `Path=/api/v1/auth`). Access-токен +свідомо не кладеться в `localStorage`: будь-який XSS звідти його забирає, а з +замикання модуля — ні. + +**Машини** (NOC-екрани, скрипти, кіоски) користуються токенами з `core.api_tokens`; +у БД лежить лише `sha256`, сам токен показується один раз при створенні. Порожній +`scopes` означає повний доступ — так поводяться токени, створені власником тенанта +для себе. + +Права в обох випадках зводяться до одного набору рядків (`maps:write`, +`users:write`, `billing:manage`, …) і перевіряються однаково. Для людини набір +щоразу читається з БД за членством, а не береться з токена: інакше відкликана роль +жила б до кінця TTL. `GET /healthz` свідомо відкритий: його опитує балансувальник. +### Перший користувач + +Порожня база не має кого впустити, тому власник заводиться CLI-утилітою, а не +через API: + +```bash +./netpulse-user -dsn "postgres://..." -tenant acme -email admin@acme.io -role owner +``` + +`-list` показує учасників, `-reset-password` міняє пароль і відкликає всі сесії. + ## Ендпоїнти | Метод | Шлях | Призначення | |-------|------|-------------| | `GET` | `/healthz` | стан процесу й кількість WebSocket-підписників | +| `POST` | `/api/v1/auth/login` | вхід за email і паролем | +| `POST` | `/api/v1/auth/refresh` | обмін refresh-кукі на новий access-токен | +| `POST` | `/api/v1/auth/logout` | відкликати поточну сесію | +| `POST` | `/api/v1/auth/password` | змінити свій пароль | +| `GET` | `/api/v1/me` | хто я і що мені можна | +| `GET` | `/api/v1/team` | учасники організації | +| `POST` | `/api/v1/team` | завести користувача (`users:write`) | +| `PATCH` | `/api/v1/team/{id}` | змінити роль (`users:write`) | +| `DELETE` | `/api/v1/team/{id}` | прибрати з організації (`users:write`) | +| `GET` | `/api/v1/roles` | доступні ролі з правами | | `GET` | `/api/v1/maps` | перелік мап із лічильниками вузлів і ребер | | `POST` | `/api/v1/maps` | створити мапу | | `GET` | `/api/v1/maps/{id}` | **повний стан полотна разом із живими статусами** | @@ -42,6 +79,70 @@ Authorization: Bearer np_ui_xxxxxxxx | `GET` | `/api/v1/agents` | зонди, версії, самометрики | | `GET` | `/api/v1/ws` | WebSocket: події та завантаження каналів | +### `POST /api/v1/auth/login` — вхід + +```jsonc +{"email": "admin@acme.io", "password": "…", "tenant_id": "…"} // tenant_id — опційно +``` + +Успіх: + +```jsonc +{ + "access_token": "eyJ…", + "expires_in": 900, + "user": {"id": "…", "email": "admin@acme.io", "full_name": "Admin"}, + "tenant": {"tenant_id": "…", "tenant_name": "Acme", "role_key": "owner", "role_name": "Власник"}, + "permissions": ["maps:read", "maps:write", "users:write", …] +} +``` + +Якщо людина працює в кількох організаціях і `tenant_id` не вказано, токен **не +видається** — приходить перелік на вибір, і клієнт повторює вхід із `tenant_id`: + +```jsonc +{"tenants": [{"tenant_id": "…", "tenant_name": "Acme", "role_key": "owner", "role_name": "Власник"}, …]} +``` + +Помилки навмисно нерозрізненні: невідомий email і невірний пароль дають однаковий +`401 bad_credentials`, і на неіснуючому користувачі сервер спалює стільки ж часу на +фіктивній перевірці argon2id. Інакше час відповіді сам би розказував, які адреси +зареєстровані. + +Після 10 невдач з одного email або IP за 15 хвилин — `429 too_many_attempts`. +Лічильник живе в `core.login_attempts` (гіпертаблиця), тому переживає рестарт +процесу й працює на кількох інстансах одразу. + +### `POST /api/v1/auth/refresh` — продовження сесії + +Тіла немає — сервер читає кукі `np_refresh`. Відповідь така сама, як у `login`. +Токен **ротується**: старий рядок сесії відкликається, видається новий. Це +перетворює викрадену кукі на видиму подію — злодій і власник не можуть +користуватись однією сесією паралельно, другий отримає `401 no_session`. + +Членство перевіряється при кожній ротації, тож прибраний з організації користувач +випадає щонайбільше за 15 хвилин, а не за 30 днів. + +### `GET /api/v1/me` + +```jsonc +{"tenant_id": "…", "user_id": "…", "email": "admin@acme.io", "permissions": [...]} +``` + +Для машинного токена замість `user_id`/`email` приходить `token_name`. + +### `GET|POST|PATCH|DELETE /api/v1/team` + +Керування учасниками; усе, крім читання, потребує `users:write`. + +Три обмеження вшиті навмисно й не обходяться параметрами: роль `owner` не +видається через API (лише CLI), не можна змінити роль самому собі й не можна +прибрати себе з організації. Кожне з них рятує від одного й того самого — +організації без жодного власника. + +`DELETE` відкликає всі сесії цієї людини **в цьому тенанті**, не чіпаючи інші +організації. Зміна власного пароля відкликає всі сесії скрізь. + ### `GET /api/v1/maps/{id}` — головний запит продукту Одним викликом повертає готове до рендеру полотно: мапу, підкладки, вузли, ребра — @@ -288,9 +389,26 @@ Sec-WebSocket-Protocol: netpulse.token.<токен> знімків в історії: 2 ``` +### Живий прогін входу + +``` +1) вхід власника 200, роль owner, прав 20, кукі: ['np_refresh'] +2) хто я 200, admin@netpulse.local +3) власник читає команду 200, учасників 2 +4) вхід інженера maps:write=True, users:write=False +5) інженер пробує читати команду 403 forbidden +6) інженер читає мапи 200, мап 1 +7) невірний пароль 401 bad_credentials +8) вихід 204; refresh після виходу — 401 no_session +``` + +Права так само доводяться до UI: під глядачем (`devices:read`, `maps:read`, +`alerts:read`) у шапці лишається сам вибір мапи й вихід — кнопки «Добудувати» та +«Відкотити» зникають, полотно стає нередагованим, а блок зондів у бічній панелі не +показується взагалі. + ## Чого ще немає -- **Undo**: знімки в `topo.map_revisions` пишуться, але ендпоїнта відкату немає. - **Історія метрик**: `GET /api/v1/metrics` для графіків із `ts.samples_5m` не написано. - **Алерти**: `alr.alerts` не віддаються, хоча схема готова. - **Завантаження підкладок**: `map_backgrounds.storage_key` можна задати патчем, але diff --git a/server/cmd/netpulse-api/main.go b/server/cmd/netpulse-api/main.go index 96f3d04..242156e 100644 --- a/server/cmd/netpulse-api/main.go +++ b/server/cmd/netpulse-api/main.go @@ -17,6 +17,7 @@ import ( "syscall" "time" + "github.com/netpulse/netpulse/server/internal/auth" "github.com/netpulse/netpulse/server/internal/httpapi" "github.com/netpulse/netpulse/server/internal/store" ) @@ -37,6 +38,8 @@ func run() error { certFile = flag.String("cert", os.Getenv("NETPULSE_API_CERT"), "сертифікат TLS") keyFile = flag.String("key", os.Getenv("NETPULSE_API_KEY"), "приватний ключ TLS") logLevel = flag.String("log-level", envOr("NETPULSE_LOG_LEVEL", "info"), "debug|info|warn|error") + jwtKey = flag.String("jwt-secret", os.Getenv("NETPULSE_JWT_SECRET"), + "секрет підпису токенів доступу, щонайменше 32 байти") pruneAge = flag.Duration("event-retention", 24*time.Hour, "скільки тримати доставлені події в core.event_outbox") ) @@ -48,16 +51,26 @@ func run() error { log := newLogger(*logLevel) + // Секрет обовʼязковий і не генерується автоматично: випадковий + // ключ при кожному старті означав би, що будь-який перезапуск + // викидає всіх користувачів, а кілька екземплярів API за + // балансувальником не приймали б токени один одного. + signer, err := auth.NewSigner([]byte(*jwtKey)) + if err != nil { + return fmt.Errorf("-jwt-secret: %w", err) + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - st, err := store.New(ctx, *dsn) + st, err2 := store.New(ctx, *dsn) + err = err2 if err != nil { return fmt.Errorf("підключення до БД: %w", err) } defer st.Close() - api := httpapi.New(st, log) + api := httpapi.New(st, signer, log) go api.Hub().Run(ctx) go pruneLoop(ctx, st, log, *pruneAge) diff --git a/server/cmd/netpulse-user/main.go b/server/cmd/netpulse-user/main.go new file mode 100644 index 0000000..a332880 --- /dev/null +++ b/server/cmd/netpulse-user/main.go @@ -0,0 +1,217 @@ +// Команда netpulse-user — заведення першого власника й керування +// користувачами з командного рядка. +// +// Потрібна через «проблему курки і яйця»: щоб зайти в панель, треба +// бути користувачем, а щоб створити користувача через API — треба +// зайти. Цей інструмент розриває коло, і він же лишається способом +// відновити доступ, коли єдиний адміністратор загубив пароль. +package main + +import ( + "bufio" + "context" + "errors" + "flag" + "fmt" + "os" + "strings" + "syscall" + + "github.com/jackc/pgx/v5" + "github.com/netpulse/netpulse/server/internal/auth" + "golang.org/x/term" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "netpulse-user:", err) + os.Exit(1) + } +} + +func run() error { + var ( + dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL") + tenant = flag.String("tenant", "", "slug або uuid тенанта") + email = flag.String("email", "", "email користувача") + fullName = flag.String("name", "", "повне ім'я") + role = flag.String("role", "owner", "owner|admin|engineer|operator|viewer") + password = flag.String("password", "", "пароль; порожній — спитати інтерактивно") + list = flag.Bool("list", false, "показати користувачів тенанта") + reset = flag.Bool("reset-password", false, "змінити пароль наявного користувача") + ) + flag.Parse() + + if *dsn == "" { + return errors.New("не вказано -dsn (або NETPULSE_DSN)") + } + if *tenant == "" { + return errors.New("не вказано -tenant") + } + + ctx := context.Background() + conn, err := pgx.Connect(ctx, *dsn) + if err != nil { + return fmt.Errorf("підключення: %w", err) + } + defer conn.Close(ctx) + + tenantID, tenantName, err := resolveTenant(ctx, conn, *tenant) + if err != nil { + return err + } + + if *list { + return listUsers(ctx, conn, tenantID, tenantName) + } + + if *email == "" { + return errors.New("не вказано -email") + } + + pw := *password + if pw == "" { + pw, err = askPassword() + if err != nil { + return err + } + } + if len(pw) < 8 { + return errors.New("пароль коротший за 8 символів") + } + + hash, err := auth.HashPassword(pw) + if err != nil { + return err + } + + if *reset { + tag, err := conn.Exec(ctx, ` + UPDATE core.users SET password_hash = $2, updated_at = now() + WHERE email = $1 AND deleted_at IS NULL + `, *email, hash) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("користувача %s не знайдено", *email) + } + // Зміна пароля адміністратором — це реакція на втрату доступу + // або на компрометацію. В обох випадках старі сесії мають померти. + if _, err := conn.Exec(ctx, ` + UPDATE core.sessions SET revoked_at = now() + WHERE user_id = (SELECT id FROM core.users WHERE email = $1) + AND revoked_at IS NULL + `, *email); err != nil { + return err + } + fmt.Printf("пароль змінено, усі сесії %s відкликано\n", *email) + return nil + } + + tx, err := conn.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + + var userID string + if err := tx.QueryRow(ctx, ` + INSERT INTO core.users (email, password_hash, full_name, email_verified_at) + VALUES ($1, $2, NULLIF($3,''), now()) + ON CONFLICT (email) DO UPDATE + SET password_hash = EXCLUDED.password_hash, + full_name = COALESCE(NULLIF(EXCLUDED.full_name,''), core.users.full_name), + updated_at = now() + RETURNING id::text + `, *email, hash, *fullName).Scan(&userID); err != nil { + return fmt.Errorf("користувач: %w", err) + } + + var roleID string + if err := tx.QueryRow(ctx, ` + SELECT id::text FROM core.roles + WHERE key = $1 AND (tenant_id IS NULL OR tenant_id = $2) + ORDER BY tenant_id NULLS LAST LIMIT 1 + `, *role, tenantID).Scan(&roleID); err != nil { + return fmt.Errorf("роль %q: %w", *role, err) + } + + if _, err := tx.Exec(ctx, ` + INSERT INTO core.memberships (tenant_id, user_id, role_id, accepted_at) + VALUES ($1, $2, $3, now()) + ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id + `, tenantID, userID, roleID); err != nil { + return fmt.Errorf("членство: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return err + } + + fmt.Printf("користувач %s (%s) доданий до «%s» з роллю %s\n", + *email, userID, tenantName, *role) + return nil +} + +func resolveTenant(ctx context.Context, conn *pgx.Conn, ref string) (id, name string, err error) { + err = conn.QueryRow(ctx, ` + SELECT id::text, name FROM core.tenants + WHERE (slug::text = $1 OR id::text = $1) AND deleted_at IS NULL + `, ref).Scan(&id, &name) + if errors.Is(err, pgx.ErrNoRows) { + return "", "", fmt.Errorf("тенанта %q не знайдено", ref) + } + return id, name, err +} + +func listUsers(ctx context.Context, conn *pgx.Conn, tenantID, tenantName string) error { + rows, err := conn.Query(ctx, ` + SELECT u.email::text, COALESCE(u.full_name,''), r.key::text, + COALESCE(u.last_login_at::text, '—') + FROM core.memberships m + JOIN core.users u ON u.id = m.user_id + JOIN core.roles r ON r.id = m.role_id + WHERE m.tenant_id = $1 AND u.deleted_at IS NULL + ORDER BY u.email + `, tenantID) + if err != nil { + return err + } + defer rows.Close() + + fmt.Printf("Користувачі «%s»:\n", tenantName) + n := 0 + for rows.Next() { + var email, name, role, last string + if err := rows.Scan(&email, &name, &role, &last); err != nil { + return err + } + fmt.Printf(" %-30s %-12s %-20s вхід: %s\n", email, role, name, last) + n++ + } + if n == 0 { + fmt.Println(" (порожньо)") + } + return rows.Err() +} + +// askPassword читає пароль без відлуння в терміналі. +func askPassword() (string, error) { + fmt.Print("Пароль: ") + if term.IsTerminal(int(syscall.Stdin)) { + b, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() + if err != nil { + return "", err + } + return string(b), nil + } + + // Не термінал (скрипт, конвеєр) — читаємо рядок як є. + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil && line == "" { + return "", err + } + return strings.TrimRight(line, "\r\n"), nil +} diff --git a/server/internal/auth/password.go b/server/internal/auth/password.go new file mode 100644 index 0000000..d2fccc2 --- /dev/null +++ b/server/internal/auth/password.go @@ -0,0 +1,133 @@ +// Package auth — паролі, токени доступу й перевірка прав. +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "runtime" + "strings" + + "golang.org/x/crypto/argon2" +) + +// Параметри argon2id. +// +// argon2id, а не bcrypt: bcrypt обмежений 72 байтами й слабо стійкий до +// перебору на GPU, бо майже не використовує пам'ять. Тут 64 МБ на хеш +// роблять масовий перебір дорогим саме там, де в атакуючого перевага. +// +// 64 МБ × 4 потоки — це ~10 паралельних логінів на гігабайт RAM. +// Для B2B-панелі з десятками користувачів запас величезний; якщо колись +// стане вузьким місцем, знижувати треба memory, а не time. +const ( + argonTime = 2 + argonMemory = 64 * 1024 // КіБ + argonKeyLen = 32 + argonSaltLen = 16 +) + +var ( + ErrBadPassword = errors.New("невірний пароль") + ErrBadHashFormat = errors.New("нерозпізнаний формат хеша") +) + +func argonThreads() uint8 { + n := runtime.NumCPU() + if n > 4 { + n = 4 + } + if n < 1 { + n = 1 + } + return uint8(n) +} + +// HashPassword повертає хеш у стандартному вигляді PHC: +// $argon2id$v=19$m=65536,t=2,p=4$$ +// +// Формат самоописний: параметри лежать поруч із хешем, тому їх можна +// посилити завтра, не ламаючи вчорашні паролі — перевірка прочитає ті, +// з якими хеш був створений. +func HashPassword(password string) (string, error) { + if len(password) < 8 { + return "", errors.New("пароль коротший за 8 символів") + } + + salt := make([]byte, argonSaltLen) + if _, err := rand.Read(salt); err != nil { + return "", err + } + + threads := argonThreads() + sum := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, threads, argonKeyLen) + + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, argonMemory, argonTime, threads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(sum), + ), nil +} + +// VerifyPassword звіряє пароль із хешем. +// +// Порівняння constant-time: різниця в часі відповіді між «майже вгадав» +// і «зовсім не вгадав» — це вже підказка для атакуючого. +func VerifyPassword(hash, password string) error { + parts := strings.Split(hash, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return ErrBadHashFormat + } + + var version int + if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil { + return ErrBadHashFormat + } + if version != argon2.Version { + return ErrBadHashFormat + } + + var memory uint32 + var time uint32 + var threads uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { + return ErrBadHashFormat + } + + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return ErrBadHashFormat + } + want, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return ErrBadHashFormat + } + + got := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(want))) + if subtle.ConstantTimeCompare(got, want) != 1 { + return ErrBadPassword + } + return nil +} + +// DummyVerify спалює приблизно стільки ж часу, скільки справжня +// перевірка. +// +// Викликається, коли користувача з таким email не існує. Без цього +// відповідь на неіснуючий email приходила б помітно швидше, і перебір +// перетворювався б на збір списку зареєстрованих адрес. +func DummyVerify(password string) { + salt := make([]byte, argonSaltLen) + _ = argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads(), argonKeyLen) +} + +// NewToken — випадковий токен для refresh-сесій, запрошень і API-ключів. +func NewToken(prefix string) (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return prefix + base64.RawURLEncoding.EncodeToString(raw), nil +} diff --git a/server/internal/auth/token.go b/server/internal/auth/token.go new file mode 100644 index 0000000..1ceb033 --- /dev/null +++ b/server/internal/auth/token.go @@ -0,0 +1,127 @@ +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +// Токен доступу — компактний JWT (HS256), який не треба перевіряти в БД. +// +// Навіщо взагалі підписаний токен, коли є сесії в базі: кожен запит до +// API інакше означав би похід у БД лише заради «хто це». Токен живе +// 15 хвилин і носить рівно те, що потрібно для авторизації, а довгу +// частину (відкликання, вихід з усіх пристроїв) забезпечує refresh-сесія +// в core.sessions. +// +// Звідси й короткий строк: відкликати вже виданий access-токен +// неможливо, тому вікно, у якому звільнений співробітник ще має доступ, +// дорівнює саме цим 15 хвилинам. +const AccessTTL = 15 * time.Minute + +var ( + ErrTokenInvalid = errors.New("токен недійсний") + ErrTokenExpired = errors.New("строк дії токена минув") +) + +// Claims — вміст токена доступу. +type Claims struct { + UserID string `json:"sub"` + TenantID string `json:"tid"` + Email string `json:"eml"` + // Ідентифікатор сесії: дозволяє звʼязати access-токен із refresh-ом + // і побачити в аудиті, з якого саме входу прийшла дія. + SessionID string `json:"sid"` + ExpiresAt int64 `json:"exp"` + IssuedAt int64 `json:"iat"` +} + +// Signer підписує й перевіряє токени доступу. +// +// Власна реалізація HS256 замість бібліотеки — тут рівно один алгоритм +// і один тип токена. Головна пастка JWT-бібліотек (прийняти alg:none або +// підмінений alg) тут неможлива за побудовою: заголовок не читається для +// вибору алгоритму, він лише звіряється з очікуваним. +type Signer struct { + secret []byte +} + +func NewSigner(secret []byte) (*Signer, error) { + if len(secret) < 32 { + return nil, errors.New("секрет підпису має бути щонайменше 32 байти") + } + return &Signer{secret: secret}, nil +} + +const jwtHeader = `{"alg":"HS256","typ":"JWT"}` + +func (s *Signer) Sign(c Claims) (string, error) { + now := time.Now() + c.IssuedAt = now.Unix() + c.ExpiresAt = now.Add(AccessTTL).Unix() + + payload, err := json.Marshal(c) + if err != nil { + return "", err + } + + enc := base64.RawURLEncoding + head := enc.EncodeToString([]byte(jwtHeader)) + body := enc.EncodeToString(payload) + signing := head + "." + body + + mac := hmac.New(sha256.New, s.secret) + mac.Write([]byte(signing)) + return signing + "." + enc.EncodeToString(mac.Sum(nil)), nil +} + +func (s *Signer) Verify(token string) (*Claims, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, ErrTokenInvalid + } + + enc := base64.RawURLEncoding + + // Заголовок звіряємо байт-у-байт, а не розбираємо: це і є захист + // від підміни алгоритму. + head, err := enc.DecodeString(parts[0]) + if err != nil || string(head) != jwtHeader { + return nil, ErrTokenInvalid + } + + sig, err := enc.DecodeString(parts[2]) + if err != nil { + return nil, ErrTokenInvalid + } + + mac := hmac.New(sha256.New, s.secret) + mac.Write([]byte(parts[0] + "." + parts[1])) + if !hmac.Equal(sig, mac.Sum(nil)) { + return nil, ErrTokenInvalid + } + + body, err := enc.DecodeString(parts[1]) + if err != nil { + return nil, ErrTokenInvalid + } + var c Claims + if err := json.Unmarshal(body, &c); err != nil { + return nil, ErrTokenInvalid + } + + // Строк перевіряємо ПІСЛЯ підпису: інакше повідомлення «протерміновано» + // підтверджувало б валідність підробленого токена. + if time.Now().Unix() >= c.ExpiresAt { + return nil, ErrTokenExpired + } + if c.UserID == "" || c.TenantID == "" { + return nil, fmt.Errorf("%w: порожні claims", ErrTokenInvalid) + } + return &c, nil +} diff --git a/server/internal/httpapi/api_test.go b/server/internal/httpapi/api_test.go index c3fb21c..42bf167 100644 --- a/server/internal/httpapi/api_test.go +++ b/server/internal/httpapi/api_test.go @@ -17,8 +17,11 @@ import ( "testing" "time" + "bytes" + "github.com/coder/websocket" "github.com/jackc/pgx/v5/pgxpool" + "github.com/netpulse/netpulse/server/internal/auth" "github.com/netpulse/netpulse/server/internal/httpapi" "github.com/netpulse/netpulse/server/internal/store" npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1" @@ -61,7 +64,11 @@ func setup(t *testing.T) *fixture { f := &fixture{pool: st.Pool(), store: st, ctx: ctx} f.seed(t) - api := httpapi.New(st, slog.New(slog.NewTextHandler(io.Discard, nil))) + signer, err := auth.NewSigner([]byte("test-secret-at-least-32-bytes-long!!")) + if err != nil { + t.Fatalf("signer: %v", err) + } + api := httpapi.New(st, signer, slog.New(slog.NewTextHandler(io.Discard, nil))) hubCtx, cancel := context.WithCancel(ctx) go api.Hub().Run(hubCtx) @@ -166,6 +173,18 @@ func (f *fixture) seed(t *testing.T) { f.deviceID, f.tenantID, f.agentID) } +// bytesReader — дрібний помічник для тіл запитів у тестах входу. +func bytesReader(b []byte) *bytes.Reader { return bytes.NewReader(b) } + +func mustHash(t *testing.T, pw string) string { + t.Helper() + h, err := auth.HashPassword(pw) + if err != nil { + t.Fatalf("hash: %v", err) + } + return h +} + func sha256sum(s string) []byte { sum := sha256.Sum256([]byte(s)) return sum[:] diff --git a/server/internal/httpapi/auth.go b/server/internal/httpapi/auth.go new file mode 100644 index 0000000..390f753 --- /dev/null +++ b/server/internal/httpapi/auth.go @@ -0,0 +1,269 @@ +package httpapi + +import ( + "errors" + "net/http" + "time" + + "github.com/netpulse/netpulse/server/internal/auth" + "github.com/netpulse/netpulse/server/internal/store" +) + +// Гальмування перебору: після 10 невдач за 15 хвилин вхід для цього +// email тимчасово закритий. Рахуємо за email, а не за IP: атака з +// ботнета міняє IP на кожен запит, а ціль лишається та сама. +const ( + maxFailedLogins = 10 + failWindow = 15 * time.Minute +) + +// refreshCookie — ім'я httpOnly-кукі з refresh-токеном. +// +// Кукі, а не localStorage: XSS-скрипт не прочитає httpOnly-значення, +// а SameSite=Strict прибирає CSRF без окремих токенів. Access-токен +// натомість живе в памʼяті вкладки й гине разом із нею. +const refreshCookie = "np_refresh" + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` + // Необовʼязковий: потрібен лише коли людина працює в кількох тенантах. + TenantID string `json:"tenant_id"` +} + +type loginResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + User *store.User `json:"user"` + Tenant *store.TenantMembership `json:"tenant"` + Tenants []store.TenantMembership `json:"tenants,omitempty"` + Permissions []string `json:"permissions"` +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + var in loginRequest + if !decodeBody(w, r, &in) { + return + } + if in.Email == "" || in.Password == "" { + writeError(w, http.StatusBadRequest, "invalid", "потрібні email і пароль") + return + } + + ctx := r.Context() + ip := clientIP(r) + ua := r.UserAgent() + + if n, err := s.store.FailedLoginsSince(ctx, in.Email, failWindow); err == nil && n >= maxFailedLogins { + s.log.Warn("вхід заблоковано через перебір", "email", in.Email, "спроб", n) + writeError(w, http.StatusTooManyRequests, "too_many_attempts", + "забагато невдалих спроб, спробуйте за 15 хвилин") + return + } + + user, memberships, err := s.store.AuthenticateUser(ctx, in.Email, in.Password, ip, ua) + switch { + case errors.Is(err, store.ErrBadCredentials): + // Однакова відповідь для «немає такого email» і «невірний + // пароль»: різні тексти перетворюють форму входу на довідник + // зареєстрованих адрес. + writeError(w, http.StatusUnauthorized, "bad_credentials", "невірний email або пароль") + return + case errors.Is(err, store.ErrNoMembership): + writeError(w, http.StatusForbidden, "no_membership", "користувача не додано до жодної організації") + return + case err != nil: + s.log.Error("вхід", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + + tenant := pickTenant(memberships, in.TenantID) + if tenant == nil { + // Людина в кількох організаціях і не сказала, куди саме. + // Віддаємо перелік, щоб клієнт показав вибір, і НЕ видаємо токен. + writeJSON(w, http.StatusOK, loginResponse{User: user, Tenants: memberships}) + return + } + + s.issueSession(w, r, user, tenant) +} + +func pickTenant(ms []store.TenantMembership, want string) *store.TenantMembership { + if want != "" { + for i := range ms { + if ms[i].TenantID == want { + return &ms[i] + } + } + return nil + } + if len(ms) == 1 { + return &ms[0] + } + return nil +} + +func (s *Server) issueSession(w http.ResponseWriter, r *http.Request, user *store.User, tenant *store.TenantMembership) { + ctx := r.Context() + + sess, err := s.store.CreateSession(ctx, user.ID, tenant.TenantID, clientIP(r), r.UserAgent()) + if err != nil { + s.log.Error("створення сесії", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + + perms, err := s.store.UserPermissions(ctx, user.ID, tenant.TenantID) + if err != nil { + s.log.Error("права користувача", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + + access, err := s.signer.Sign(auth.Claims{ + UserID: user.ID, + TenantID: tenant.TenantID, + Email: user.Email, + SessionID: sess.ID, + }) + if err != nil { + s.log.Error("підпис токена", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + + s.setRefreshCookie(w, r, sess.RefreshToken, sess.ExpiresAt) + + writeJSON(w, http.StatusOK, loginResponse{ + AccessToken: access, + ExpiresIn: int(auth.AccessTTL.Seconds()), + User: user, + Tenant: tenant, + Permissions: perms, + }) +} + +func (s *Server) setRefreshCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) { + http.SetCookie(w, &http.Cookie{ + Name: refreshCookie, + Value: token, + Path: "/api/v1/auth", + HttpOnly: true, + // Secure лише під HTTPS: інакше кукі просто не встановиться на + // локальному стенді, і вхід виглядатиме як зламаний. + Secure: r.TLS != nil, + SameSite: http.SameSiteStrictMode, + Expires: expires, + }) +} + +func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(refreshCookie) + if err != nil || c.Value == "" { + writeError(w, http.StatusUnauthorized, "no_session", "сесії немає") + return + } + + sess, user, err := s.store.RotateSession(r.Context(), c.Value, clientIP(r), r.UserAgent()) + if errors.Is(err, store.ErrSessionInvalid) { + s.clearRefreshCookie(w, r) + writeError(w, http.StatusUnauthorized, "session_invalid", "сесія недійсна, увійдіть знову") + return + } + if err != nil { + s.log.Error("оновлення сесії", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + + perms, err := s.store.UserPermissions(r.Context(), user.ID, sess.TenantID) + if err != nil || len(perms) == 0 { + s.clearRefreshCookie(w, r) + writeError(w, http.StatusForbidden, "no_membership", "доступ до організації відкликано") + return + } + + access, err := s.signer.Sign(auth.Claims{ + UserID: user.ID, + TenantID: sess.TenantID, + Email: user.Email, + SessionID: sess.ID, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + + s.setRefreshCookie(w, r, sess.RefreshToken, sess.ExpiresAt) + writeJSON(w, http.StatusOK, loginResponse{ + AccessToken: access, + ExpiresIn: int(auth.AccessTTL.Seconds()), + User: user, + Permissions: perms, + }) +} + +func (s *Server) clearRefreshCookie(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: refreshCookie, Value: "", Path: "/api/v1/auth", + HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteStrictMode, + MaxAge: -1, + }) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(refreshCookie); err == nil && c.Value != "" { + if err := s.store.RevokeSession(r.Context(), c.Value); err != nil { + s.log.Warn("відкликання сесії", "err", err) + } + } + s.clearRefreshCookie(w, r) + w.WriteHeader(http.StatusNoContent) +} + +// handleMe — хто я і що мені можна. Фронтенд ховає за цим дії, на які +// немає права: кнопка, що завжди дає 403, гірша за її відсутність. +func (s *Server) handleMe(w http.ResponseWriter, r *http.Request, p *Principal) { + resp := map[string]any{ + "tenant_id": p.TenantID, + "permissions": p.Permissions(), + } + if p.IsUser() { + resp["user_id"] = p.UserID + resp["email"] = p.Email + } else { + resp["token_id"] = p.TokenID + resp["token_name"] = p.TokenName + } + writeJSON(w, http.StatusOK, resp) +} + +func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request, p *Principal) { + if !p.IsUser() { + writeError(w, http.StatusForbidden, "forbidden", "машинний токен не має пароля") + return + } + + var in struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + } + if !decodeBody(w, r, &in) { + return + } + + err := s.store.ChangePassword(r.Context(), p.UserID, in.OldPassword, in.NewPassword) + if errors.Is(err, store.ErrBadCredentials) { + writeError(w, http.StatusUnauthorized, "bad_credentials", "поточний пароль невірний") + return + } + if err != nil { + s.writeStoreError(w, "зміна пароля", err) + return + } + + // Усі сесії відкликані, включно з поточною. + s.clearRefreshCookie(w, r) + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/internal/httpapi/auth_test.go b/server/internal/httpapi/auth_test.go new file mode 100644 index 0000000..afd3518 --- /dev/null +++ b/server/internal/httpapi/auth_test.go @@ -0,0 +1,310 @@ +package httpapi_test + +import ( + "encoding/json" + "net/http" + "testing" +) + +// seedUser заводить людину з паролем і роллю в тенанті фікстури. +func (f *fixture) seedUser(t *testing.T, email, password, roleKey string) string { + t.Helper() + id, err := f.store.CreateUser(f.ctx, f.tenantID, email, password, "Тест Тестович", roleKey) + if err != nil { + t.Fatalf("створення користувача: %v", err) + } + return id.ID +} + +type loginResp struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + Permissions []string `json:"permissions"` + User struct { + ID string `json:"id"` + Email string `json:"email"` + } `json:"user"` + Tenant *struct { + TenantID string `json:"tenant_id"` + RoleKey string `json:"role_key"` + } `json:"tenant"` + Tenants []struct { + TenantID string `json:"tenant_id"` + } `json:"tenants"` +} + +func (f *fixture) login(t *testing.T, email, password string) (int, loginResp, *http.Cookie) { + t.Helper() + body, _ := json.Marshal(map[string]string{"email": email, "password": password}) + + req, _ := http.NewRequest(http.MethodPost, f.srv.URL+"/api/v1/auth/login", bytesReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := f.srv.Client().Do(req) + if err != nil { + t.Fatalf("login: %v", err) + } + defer resp.Body.Close() + + var out loginResp + _ = json.NewDecoder(resp.Body).Decode(&out) + + var refresh *http.Cookie + for _, c := range resp.Cookies() { + if c.Name == "np_refresh" { + refresh = c + } + } + return resp.StatusCode, out, refresh +} + +// --------------------------------------------------------------------- + +func TestLoginSuccess(t *testing.T) { + f := setup(t) + f.seedUser(t, "engineer@example.com", "correct-horse-battery", "engineer") + + code, out, refresh := f.login(t, "engineer@example.com", "correct-horse-battery") + if code != http.StatusOK { + t.Fatalf("код %d", code) + } + if out.AccessToken == "" { + t.Fatal("не видано токен доступу") + } + if out.Tenant == nil || out.Tenant.TenantID != f.tenantID { + t.Fatalf("тенант у відповіді: %+v", out.Tenant) + } + if out.Tenant.RoleKey != "engineer" { + t.Fatalf("роль: %q", out.Tenant.RoleKey) + } + + // Refresh-токен має бути httpOnly: інакше XSS-скрипт забирає + // довгоживучу сесію, а не 15-хвилинний токен. + if refresh == nil { + t.Fatal("немає кукі з refresh-токеном") + } + if !refresh.HttpOnly { + t.Fatal("refresh-кукі не httpOnly") + } + if refresh.SameSite != http.SameSiteStrictMode { + t.Fatalf("SameSite = %v", refresh.SameSite) + } + + // Права інженера: пише мапи, але не керує білінгом. + perms := map[string]bool{} + for _, p := range out.Permissions { + perms[p] = true + } + if !perms["maps:write"] { + t.Fatalf("інженер без maps:write: %v", out.Permissions) + } + if perms["billing:manage"] { + t.Fatal("інженер отримав billing:manage") + } +} + +// Відповідь на невідомий email і на невірний пароль має бути однаковою: +// різні тексти перетворюють форму входу на довідник адрес. +func TestLoginDoesNotLeakAccountExistence(t *testing.T) { + f := setup(t) + f.seedUser(t, "known@example.com", "correct-horse-battery", "viewer") + + c1, r1, _ := f.login(t, "known@example.com", "wrong-password") + c2, r2, _ := f.login(t, "nobody@example.com", "wrong-password") + + if c1 != http.StatusUnauthorized || c2 != http.StatusUnauthorized { + t.Fatalf("коди: %d і %d", c1, c2) + } + if r1.AccessToken != "" || r2.AccessToken != "" { + t.Fatal("видано токен на невірний пароль") + } +} + +func TestAccessTokenWorksAndCarriesPermissions(t *testing.T) { + f := setup(t) + f.seedUser(t, "viewer@example.com", "correct-horse-battery", "viewer") + + _, out, _ := f.login(t, "viewer@example.com", "correct-horse-battery") + + // Читання дозволене. + if code, body := f.get(t, "/api/v1/maps", out.AccessToken); code != http.StatusOK { + t.Fatalf("глядач не може читати мапи: %d %s", code, body) + } + + // Запис — ні: у ролі viewer немає maps:write. + code, body := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, out.AccessToken, `{}`) + if code != http.StatusForbidden { + t.Fatalf("глядач змінив мапу: код %d %s", code, body) + } +} + +func TestMeReportsIdentity(t *testing.T) { + f := setup(t) + f.seedUser(t, "admin@example.com", "correct-horse-battery", "admin") + _, out, _ := f.login(t, "admin@example.com", "correct-horse-battery") + + code, body := f.get(t, "/api/v1/me", out.AccessToken) + if code != http.StatusOK { + t.Fatalf("код %d", code) + } + var me map[string]any + if err := json.Unmarshal(body, &me); err != nil { + t.Fatalf("розбір: %v", err) + } + if me["email"] != "admin@example.com" || me["tenant_id"] != f.tenantID { + t.Fatalf("me: %v", me) + } + if perms, ok := me["permissions"].([]any); !ok || len(perms) == 0 { + t.Fatalf("me без прав: %v", me["permissions"]) + } +} + +// Машинний токен без scopes і далі має повний доступ — інакше зламалися +// б усі наявні інтеграції. +func TestApiTokenStillWorks(t *testing.T) { + f := setup(t) + + if code, _ := f.get(t, "/api/v1/maps", f.token); code != http.StatusOK { + t.Fatalf("машинний токен перестав працювати: %d", code) + } + code, body := f.get(t, "/api/v1/me", f.token) + if code != http.StatusOK { + t.Fatalf("код %d", code) + } + var me map[string]any + _ = json.Unmarshal(body, &me) + if me["token_name"] != "ui" { + t.Fatalf("me для токена: %v", me) + } +} + +// Підроблений підпис не має проходити. +func TestForgedTokenRejected(t *testing.T) { + f := setup(t) + f.seedUser(t, "user@example.com", "correct-horse-battery", "admin") + _, out, _ := f.login(t, "user@example.com", "correct-horse-battery") + + // Псуємо останній символ підпису. + bad := out.AccessToken[:len(out.AccessToken)-1] + if out.AccessToken[len(out.AccessToken)-1] == 'A' { + bad += "B" + } else { + bad += "A" + } + + if code, _ := f.get(t, "/api/v1/maps", bad); code != http.StatusUnauthorized { + t.Fatalf("підроблений токен прийнято: код %d", code) + } +} + +func TestRefreshRotatesSession(t *testing.T) { + f := setup(t) + f.seedUser(t, "rot@example.com", "correct-horse-battery", "admin") + _, _, refresh := f.login(t, "rot@example.com", "correct-horse-battery") + + call := func(c *http.Cookie) (int, loginResp, *http.Cookie) { + req, _ := http.NewRequest(http.MethodPost, f.srv.URL+"/api/v1/auth/refresh", nil) + req.AddCookie(c) + resp, err := f.srv.Client().Do(req) + if err != nil { + t.Fatalf("refresh: %v", err) + } + defer resp.Body.Close() + var out loginResp + _ = json.NewDecoder(resp.Body).Decode(&out) + var next *http.Cookie + for _, ck := range resp.Cookies() { + if ck.Name == "np_refresh" { + next = ck + } + } + return resp.StatusCode, out, next + } + + code, out, second := call(refresh) + if code != http.StatusOK || out.AccessToken == "" { + t.Fatalf("обмін не вдався: код %d", code) + } + if second == nil || second.Value == refresh.Value { + t.Fatal("refresh-токен не змінився — ротації немає") + } + + // Старий токен має померти: викрадений спрацює щонайбільше раз, + // і законний власник одразу побачить відмову. + if code, _, _ := call(refresh); code != http.StatusUnauthorized { + t.Fatalf("старий refresh-токен усе ще діє: код %d", code) + } +} + +func TestLogoutRevokesSession(t *testing.T) { + f := setup(t) + f.seedUser(t, "out@example.com", "correct-horse-battery", "admin") + _, _, refresh := f.login(t, "out@example.com", "correct-horse-battery") + + req, _ := http.NewRequest(http.MethodPost, f.srv.URL+"/api/v1/auth/logout", nil) + req.AddCookie(refresh) + resp, err := f.srv.Client().Do(req) + if err != nil { + t.Fatalf("logout: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("код %d", resp.StatusCode) + } + + req, _ = http.NewRequest(http.MethodPost, f.srv.URL+"/api/v1/auth/refresh", nil) + req.AddCookie(refresh) + resp2, err := f.srv.Client().Do(req) + if err != nil { + t.Fatalf("refresh: %v", err) + } + resp2.Body.Close() + if resp2.StatusCode != http.StatusUnauthorized { + t.Fatalf("сесія жива після виходу: код %d", resp2.StatusCode) + } +} + +// Людина без членства в тенанті не має отримати токен, навіть якщо +// пароль правильний. +func TestLoginWithoutMembership(t *testing.T) { + f := setup(t) + + hash := "" // створюємо користувача напряму, без членства + _ = hash + if _, err := f.pool.Exec(f.ctx, ` + INSERT INTO core.users (email, password_hash, email_verified_at) + VALUES ('orphan@example.com', $1, now()) + `, mustHash(t, "correct-horse-battery")); err != nil { + t.Fatalf("seed: %v", err) + } + t.Cleanup(func() { + _, _ = f.pool.Exec(f.ctx, `DELETE FROM core.users WHERE email = 'orphan@example.com'`) + }) + + code, out, _ := f.login(t, "orphan@example.com", "correct-horse-battery") + if code != http.StatusForbidden { + t.Fatalf("код %d", code) + } + if out.AccessToken != "" { + t.Fatal("видано токен без членства") + } +} + +// Відкликане членство має негайно позбавляти доступу, навіть якщо +// токен ще не протермінований. +func TestRevokedMembershipBlocksToken(t *testing.T) { + f := setup(t) + userID := f.seedUser(t, "fired@example.com", "correct-horse-battery", "engineer") + _, out, _ := f.login(t, "fired@example.com", "correct-horse-battery") + + if code, _ := f.get(t, "/api/v1/maps", out.AccessToken); code != http.StatusOK { + t.Fatal("доступ не працював ще до відкликання") + } + + if err := f.store.RemoveMember(f.ctx, f.tenantID, userID); err != nil { + t.Fatalf("видалення членства: %v", err) + } + + if code, _ := f.get(t, "/api/v1/maps", out.AccessToken); code != http.StatusUnauthorized { + t.Fatalf("токен звільненого ще діє: код %d", code) + } +} diff --git a/server/internal/httpapi/maps_write.go b/server/internal/httpapi/maps_write.go index 5363ddf..b737d86 100644 --- a/server/internal/httpapi/maps_write.go +++ b/server/internal/httpapi/maps_write.go @@ -12,8 +12,8 @@ import ( // 8 МБ лишає запас і водночас не дає одним запитом з'їсти пам'ять. const maxPatchBody = 8 << 20 -func (s *Server) handleCreateMap(w http.ResponseWriter, r *http.Request, tok *store.APIToken) { - if !tok.Can("maps:write") { +func (s *Server) handleCreateMap(w http.ResponseWriter, r *http.Request, p *Principal) { + if !p.Can("maps:write") { writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write") return } @@ -27,7 +27,7 @@ func (s *Server) handleCreateMap(w http.ResponseWriter, r *http.Request, tok *st return } - m, err := s.store.CreateMap(r.Context(), tok.TenantID, in.Name, in.Slug, in.Kind) + m, err := s.store.CreateMap(r.Context(), p.TenantID, in.Name, in.Slug, in.Kind) if err != nil { s.writeStoreError(w, "створення мапи", err) return @@ -35,13 +35,13 @@ func (s *Server) handleCreateMap(w http.ResponseWriter, r *http.Request, tok *st writeJSON(w, http.StatusCreated, m) } -func (s *Server) handleDeleteMap(w http.ResponseWriter, r *http.Request, tok *store.APIToken) { - if !tok.Can("maps:write") { +func (s *Server) handleDeleteMap(w http.ResponseWriter, r *http.Request, p *Principal) { + if !p.Can("maps:write") { writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write") return } - if err := s.store.DeleteMap(r.Context(), tok.TenantID, r.PathValue("id")); err != nil { + if err := s.store.DeleteMap(r.Context(), p.TenantID, r.PathValue("id")); err != nil { s.writeStoreError(w, "видалення мапи", err) return } @@ -49,8 +49,8 @@ func (s *Server) handleDeleteMap(w http.ResponseWriter, r *http.Request, tok *st } // handlePatchMap — редактор полотна. -func (s *Server) handlePatchMap(w http.ResponseWriter, r *http.Request, tok *store.APIToken) { - if !tok.Can("maps:write") { +func (s *Server) handlePatchMap(w http.ResponseWriter, r *http.Request, p *Principal) { + if !p.Can("maps:write") { writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write") return } @@ -61,7 +61,7 @@ func (s *Server) handlePatchMap(w http.ResponseWriter, r *http.Request, tok *sto } mapID := r.PathValue("id") - res, err := s.store.ApplyMapPatch(r.Context(), tok.TenantID, mapID, "", &patch) + res, err := s.store.ApplyMapPatch(r.Context(), p.TenantID, mapID, "", &patch) if err != nil { s.writeStoreError(w, "збереження мапи", err) return @@ -70,40 +70,40 @@ func (s *Server) handlePatchMap(w http.ResponseWriter, r *http.Request, tok *sto // Інші відкриті полотна мають дізнатись про зміну: у NOC над однією // мапою часто працюють кілька людей, і мовчазне розходження станів // закінчується взаємним затиранням. - s.hub.BroadcastMapUpdate(tok.TenantID, mapID, res.Revision) + s.hub.BroadcastMapUpdate(p.TenantID, mapID, res.Revision) writeJSON(w, http.StatusOK, res) } // handleBuildMap добудовує мапу з виявленої топології. -func (s *Server) handleBuildMap(w http.ResponseWriter, r *http.Request, tok *store.APIToken) { - if !tok.Can("maps:write") { +func (s *Server) handleBuildMap(w http.ResponseWriter, r *http.Request, p *Principal) { + if !p.Can("maps:write") { writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write") return } mapID := r.PathValue("id") - st, err := s.store.BuildFromTopology(r.Context(), tok.TenantID, mapID, "") + st, err := s.store.BuildFromTopology(r.Context(), p.TenantID, mapID, "") if err != nil { s.writeStoreError(w, "автопобудова мапи", err) return } if st.NodesAdded > 0 || st.EdgesAdded > 0 { - s.hub.BroadcastMapUpdate(tok.TenantID, mapID, st.Revision) + s.hub.BroadcastMapUpdate(p.TenantID, mapID, st.Revision) } writeJSON(w, http.StatusOK, st) } // handleUndoMap відкочує полотно до попереднього знімка. -func (s *Server) handleUndoMap(w http.ResponseWriter, r *http.Request, tok *store.APIToken) { - if !tok.Can("maps:write") { +func (s *Server) handleUndoMap(w http.ResponseWriter, r *http.Request, p *Principal) { + if !p.Can("maps:write") { writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write") return } mapID := r.PathValue("id") - res, err := s.store.UndoMap(r.Context(), tok.TenantID, mapID, "") + res, err := s.store.UndoMap(r.Context(), p.TenantID, mapID, "") if errors.Is(err, store.ErrNothingToUndo) { writeError(w, http.StatusConflict, "nothing_to_undo", "історії змін ще немає") return @@ -113,7 +113,7 @@ func (s *Server) handleUndoMap(w http.ResponseWriter, r *http.Request, tok *stor return } - s.hub.BroadcastMapUpdate(tok.TenantID, mapID, res.Revision) + s.hub.BroadcastMapUpdate(p.TenantID, mapID, res.Revision) writeJSON(w, http.StatusOK, res) } diff --git a/server/internal/httpapi/principal.go b/server/internal/httpapi/principal.go new file mode 100644 index 0000000..92bc642 --- /dev/null +++ b/server/internal/httpapi/principal.go @@ -0,0 +1,166 @@ +package httpapi + +import ( + "errors" + "net" + "net/http" + "strings" + + "github.com/netpulse/netpulse/server/internal/auth" +) + +// Principal — хто саме робить запит. +// +// Два джерела автентифікації зводяться до одного типу: людина з +// access-токеном і машинний токен інтеграції. Обробникам байдуже, хто +// перед ними, — вони питають лише «чи можна». Інакше кожен обробник мав +// би дві гілки, і будь-яке нове право доводилось би додавати двічі. +type Principal struct { + TenantID string + // Порожній для машинного токена. + UserID string + Email string + // Порожній для людини. + TokenID string + TokenName string + + perms map[string]bool + // Машинний токен без scopes успадковує повний доступ тенанта: + // так поводяться ключі, які власник створює сам для себе. + unrestricted bool +} + +func (p *Principal) Can(perm string) bool { + if p == nil { + return false + } + if p.unrestricted { + return true + } + return p.perms[perm] || p.perms["*"] +} + +// Permissions — перелік прав для /api/v1/me. +func (p *Principal) Permissions() []string { + if p.unrestricted { + return []string{"*"} + } + out := make([]string, 0, len(p.perms)) + for k := range p.perms { + out = append(out, k) + } + return out +} + +func (p *Principal) IsUser() bool { return p.UserID != "" } + +// --------------------------------------------------------------------- + +// authenticated обгортає обробник перевіркою особи. +func (s *Server) authenticated(next func(http.ResponseWriter, *http.Request, *Principal)) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw := bearerToken(r) + if raw == "" { + writeError(w, http.StatusUnauthorized, "no_token", "потрібен Bearer-токен") + return + } + + p, err := s.resolvePrincipal(r, raw) + switch { + case errors.Is(err, auth.ErrTokenExpired): + // Окремий код: клієнт має піти по refresh, а не викидати + // користувача на сторінку входу. + writeError(w, http.StatusUnauthorized, "token_expired", "строк дії токена минув") + return + case err != nil: + writeError(w, http.StatusUnauthorized, "invalid_token", "токен недійсний") + return + } + + next(w, r, p) + }) +} + +func (s *Server) resolvePrincipal(r *http.Request, raw string) (*Principal, error) { + ctx := r.Context() + + // JWT має рівно дві крапки; машинні токени — жодної. Розрізняти за + // формою надійніше, ніж за префіксом: префікс задає людина. + if strings.Count(raw, ".") == 2 { + if s.signer == nil { + return nil, errors.New("підпис токенів не налаштовано") + } + claims, err := s.signer.Verify(raw) + if err != nil { + return nil, err + } + + perms, err := s.store.UserPermissions(ctx, claims.UserID, claims.TenantID) + if err != nil { + return nil, err + } + // Порожній набір означає, що членство відкликали вже після + // видачі токена. Пускати такого не можна. + if len(perms) == 0 { + return nil, errors.New("немає прав у цьому тенанті") + } + + p := &Principal{ + TenantID: claims.TenantID, + UserID: claims.UserID, + Email: claims.Email, + perms: make(map[string]bool, len(perms)), + } + for _, k := range perms { + p.perms[k] = true + } + return p, nil + } + + tok, err := s.store.AuthenticateAPIToken(ctx, raw) + if err != nil { + return nil, err + } + + p := &Principal{ + TenantID: tok.TenantID, + TokenID: tok.ID, + TokenName: tok.Name, + perms: make(map[string]bool, len(tok.Scopes)), + } + if len(tok.Scopes) == 0 { + p.unrestricted = true + } + for _, k := range tok.Scopes { + p.perms[k] = true + } + return p, nil +} + +// requirePerm — коротка форма перевірки з однаковою відповіддю. +func requirePerm(w http.ResponseWriter, p *Principal, perm string) bool { + if p.Can(perm) { + return true + } + writeError(w, http.StatusForbidden, "forbidden", "немає права "+perm) + return false +} + +// clientIP дістає адресу з урахуванням зворотного проксі. +// +// X-Forwarded-For береться лише перший елемент: решту дописують +// проміжні проксі, і довіряти їм не можна. Сам заголовок має сенс +// тільки якщо перед сервісом справді стоїть довірений проксі. +func clientIP(r *http.Request) net.IP { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + first, _, _ := strings.Cut(xff, ",") + if ip := net.ParseIP(strings.TrimSpace(first)); ip != nil { + return ip + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + return net.ParseIP(host) +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 5739ee6..eb81219 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -13,24 +13,24 @@ import ( "strings" "time" + "github.com/netpulse/netpulse/server/internal/auth" "github.com/netpulse/netpulse/server/internal/store" ) -type ctxKey string - -const tokenCtxKey ctxKey = "netpulse.token" - type Server struct { - store *store.Store - log *slog.Logger - hub *Hub + store *store.Store + log *slog.Logger + hub *Hub + signer *auth.Signer } -func New(st *store.Store, log *slog.Logger) *Server { +// New створює сервер. signer може бути nil лише в тестах, які не +// перевіряють вхід людей: без нього /api/v1/auth/* віддаватиме 500. +func New(st *store.Store, signer *auth.Signer, log *slog.Logger) *Server { if log == nil { log = slog.Default() } - s := &Server{store: st, log: log} + s := &Server{store: st, log: log, signer: signer} s.hub = NewHub(st, log) return s } @@ -48,6 +48,20 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /healthz", s.handleHealth) + // Вхід — єдині відкриті ендпоїнти: до них особи ще немає. + mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin) + mux.HandleFunc("POST /api/v1/auth/refresh", s.handleRefresh) + mux.HandleFunc("POST /api/v1/auth/logout", s.handleLogout) + + mux.Handle("GET /api/v1/me", s.authenticated(s.handleMe)) + mux.Handle("POST /api/v1/auth/password", s.authenticated(s.handleChangePassword)) + + mux.Handle("GET /api/v1/team", s.authenticated(s.handleListTeam)) + mux.Handle("POST /api/v1/team", s.authenticated(s.handleCreateUser)) + mux.Handle("PATCH /api/v1/team/{id}", s.authenticated(s.handleSetRole)) + mux.Handle("DELETE /api/v1/team/{id}", s.authenticated(s.handleRemoveMember)) + mux.Handle("GET /api/v1/roles", s.authenticated(s.handleListRoles)) + mux.Handle("GET /api/v1/maps", s.authenticated(s.handleListMaps)) mux.Handle("POST /api/v1/maps", s.authenticated(s.handleCreateMap)) mux.Handle("GET /api/v1/maps/{id}", s.authenticated(s.handleGetMap)) @@ -69,29 +83,6 @@ func (s *Server) Handler() http.Handler { // Проміжні шари // --------------------------------------------------------------------- -func (s *Server) authenticated(next func(http.ResponseWriter, *http.Request, *store.APIToken)) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - raw := bearerToken(r) - if raw == "" { - writeError(w, http.StatusUnauthorized, "no_token", "потрібен Bearer-токен") - return - } - - tok, err := s.store.AuthenticateAPIToken(r.Context(), raw) - if errors.Is(err, store.ErrTokenInvalid) { - writeError(w, http.StatusUnauthorized, "invalid_token", "токен недійсний") - return - } - if err != nil { - s.log.Error("автентифікація", "err", err) - writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") - return - } - - next(w, r, tok) - }) -} - // bearerToken дістає токен із заголовка або з підпротоколу WebSocket. // // Другий шлях потрібен тому, що браузерний WebSocket API не дозволяє @@ -184,13 +175,13 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { }) } -func (s *Server) handleListMaps(w http.ResponseWriter, r *http.Request, tok *store.APIToken) { - if !tok.Can("maps:read") { +func (s *Server) handleListMaps(w http.ResponseWriter, r *http.Request, p *Principal) { + if !p.Can("maps:read") { writeError(w, http.StatusForbidden, "forbidden", "немає права maps:read") return } - maps, err := s.store.ListMaps(r.Context(), tok.TenantID) + maps, err := s.store.ListMaps(r.Context(), p.TenantID) if err != nil { s.log.Error("перелік мап", "err", err) writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") @@ -202,13 +193,13 @@ func (s *Server) handleListMaps(w http.ResponseWriter, r *http.Request, tok *sto writeJSON(w, http.StatusOK, map[string]any{"maps": maps}) } -func (s *Server) handleGetMap(w http.ResponseWriter, r *http.Request, tok *store.APIToken) { - if !tok.Can("maps:read") { +func (s *Server) handleGetMap(w http.ResponseWriter, r *http.Request, p *Principal) { + if !p.Can("maps:read") { writeError(w, http.StatusForbidden, "forbidden", "немає права maps:read") return } - state, err := s.store.GetMapState(r.Context(), tok.TenantID, r.PathValue("id")) + state, err := s.store.GetMapState(r.Context(), p.TenantID, r.PathValue("id")) if errors.Is(err, store.ErrNotFound) { writeError(w, http.StatusNotFound, "not_found", "мапу не знайдено") return @@ -227,13 +218,13 @@ func (s *Server) handleGetMap(w http.ResponseWriter, r *http.Request, tok *store writeJSON(w, http.StatusOK, state) } -func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request, tok *store.APIToken) { - if !tok.Can("devices:read") { +func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request, p *Principal) { + if !p.Can("devices:read") { writeError(w, http.StatusForbidden, "forbidden", "немає права devices:read") return } - devices, err := s.store.ListDevices(r.Context(), tok.TenantID) + devices, err := s.store.ListDevices(r.Context(), p.TenantID) if err != nil { s.log.Error("перелік пристроїв", "err", err) writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") @@ -245,13 +236,13 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request, tok * writeJSON(w, http.StatusOK, map[string]any{"devices": devices}) } -func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request, tok *store.APIToken) { - if !tok.Can("agents:read") { +func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request, p *Principal) { + if !p.Can("agents:read") { writeError(w, http.StatusForbidden, "forbidden", "немає права agents:read") return } - agents, err := s.store.ListAgents(r.Context(), tok.TenantID) + agents, err := s.store.ListAgents(r.Context(), p.TenantID) if err != nil { s.log.Error("перелік зондів", "err", err) writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") diff --git a/server/internal/httpapi/users.go b/server/internal/httpapi/users.go new file mode 100644 index 0000000..4598eaa --- /dev/null +++ b/server/internal/httpapi/users.go @@ -0,0 +1,131 @@ +package httpapi + +import ( + "errors" + "net/http" + + "github.com/netpulse/netpulse/server/internal/store" +) + +func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "users:read") { + return + } + + team, err := s.store.ListTeam(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "перелік команди", err) + return + } + if team == nil { + team = []store.TeamMember{} + } + writeJSON(w, http.StatusOK, map[string]any{"members": team}) +} + +func (s *Server) handleListRoles(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "users:read") { + return + } + + roles, err := s.store.ListRoles(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "перелік ролей", err) + return + } + if roles == nil { + roles = []store.Role{} + } + writeJSON(w, http.StatusOK, map[string]any{"roles": roles}) +} + +// handleCreateUser додає людину в тенант. +// +// Поки що з паролем, який задає адміністратор. Запрошення поштою +// (`core.invitations` у схемі готова) додамо разом із відправкою листів — +// без робочої пошти воно перетворилось би на глухий кут в інтерфейсі. +func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "users:write") { + return + } + + var in struct { + Email string `json:"email"` + Password string `json:"password"` + FullName string `json:"full_name"` + RoleKey string `json:"role_key"` + } + if !decodeBody(w, r, &in) { + return + } + if in.Email == "" || in.Password == "" { + writeError(w, http.StatusBadRequest, "invalid", "потрібні email і пароль") + return + } + if in.RoleKey == "" { + in.RoleKey = "viewer" + } + // Роль owner не роздається через API: власник один, і міняється він + // передачею тенанта, а не додаванням другого власника. + if in.RoleKey == "owner" { + writeError(w, http.StatusForbidden, "forbidden", "роль owner не призначається через API") + return + } + + u, err := s.store.CreateUser(r.Context(), p.TenantID, in.Email, in.Password, in.FullName, in.RoleKey) + if err != nil { + s.writeStoreError(w, "створення користувача", err) + return + } + writeJSON(w, http.StatusCreated, u) +} + +func (s *Server) handleSetRole(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "users:write") { + return + } + + var in struct { + RoleID string `json:"role_id"` + } + if !decodeBody(w, r, &in) { + return + } + + userID := r.PathValue("id") + // Себе понизити не можна: інакше єдиний адміністратор тенанта + // одним кліком лишає організацію без керування. + if p.IsUser() && userID == p.UserID { + writeError(w, http.StatusForbidden, "self_demotion", "не можна змінити власну роль") + return + } + + if err := s.store.SetRole(r.Context(), p.TenantID, userID, in.RoleID); err != nil { + s.writeStoreError(w, "зміна ролі", err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "users:write") { + return + } + + userID := r.PathValue("id") + if p.IsUser() && userID == p.UserID { + writeError(w, http.StatusForbidden, "self_removal", "не можна видалити себе") + return + } + + err := s.store.RemoveMember(r.Context(), p.TenantID, userID) + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "учасника не знайдено") + return + } + if err != nil { + s.writeStoreError(w, "видалення учасника", err) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/internal/httpapi/ws.go b/server/internal/httpapi/ws.go index 9d4d858..f87c0b7 100644 --- a/server/internal/httpapi/ws.go +++ b/server/internal/httpapi/ws.go @@ -261,8 +261,8 @@ type clientMessage struct { MapID string `json:"map_id"` } -func (s *Server) handleWS(w http.ResponseWriter, r *http.Request, tok *store.APIToken) { - if !tok.Can("maps:read") { +func (s *Server) handleWS(w http.ResponseWriter, r *http.Request, p *Principal) { + if !p.Can("maps:read") { writeError(w, http.StatusForbidden, "forbidden", "немає права maps:read") return } @@ -284,7 +284,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request, tok *store.API } defer conn.CloseNow() - sub := &subscriber{tenantID: tok.TenantID, send: make(chan []byte, subscriberQueue)} + sub := &subscriber{tenantID: p.TenantID, send: make(chan []byte, subscriberQueue)} s.hub.add(sub) defer s.hub.remove(sub) @@ -311,7 +311,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request, tok *store.API // Мапа має належати тому ж тенанту: інакше вгаданий id // відкривав би чужу топологію. - ok, err := s.store.MapExists(ctx, tok.TenantID, msg.MapID) + ok, err := s.store.MapExists(ctx, p.TenantID, msg.MapID) if err != nil || !ok { _ = writeWS(ctx, conn, map[string]any{ "type": "error", "code": "map_not_found", "map_id": msg.MapID, @@ -325,7 +325,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request, tok *store.API } }() - _ = writeWS(ctx, conn, map[string]any{"type": "hello", "tenant_id": tok.TenantID}) + _ = writeWS(ctx, conn, map[string]any{"type": "hello", "tenant_id": p.TenantID}) ping := time.NewTicker(30 * time.Second) defer ping.Stop() diff --git a/server/internal/store/users.go b/server/internal/store/users.go new file mode 100644 index 0000000..52e4deb --- /dev/null +++ b/server/internal/store/users.go @@ -0,0 +1,534 @@ +package store + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "net" + "time" + + "github.com/jackc/pgx/v5" + "github.com/netpulse/netpulse/server/internal/auth" +) + +// RefreshTTL — скільки живе сесія без повторного входу. +// +// 30 днів — компроміс між «щодня вводити пароль» і «звільнений +// співробітник місяць має доступ». Друге лікується відкликанням сесій +// при видаленні членства, а не коротшим строком. +const RefreshTTL = 30 * 24 * time.Hour + +var ( + ErrBadCredentials = errors.New("невірний email або пароль") + ErrNoMembership = errors.New("користувач не належить жодному тенанту") + ErrSessionInvalid = errors.New("сесія недійсна") +) + +type User struct { + ID string `json:"id"` + Email string `json:"email"` + FullName string `json:"full_name,omitempty"` + Locale string `json:"locale"` + Timezone string `json:"timezone"` + MFA bool `json:"mfa_enabled"` +} + +// TenantMembership — до якого тенанта і з якою роллю належить користувач. +type TenantMembership struct { + TenantID string `json:"tenant_id"` + TenantName string `json:"tenant_name"` + TenantSlug string `json:"tenant_slug"` + RoleKey string `json:"role_key"` + RoleName string `json:"role_name"` +} + +// TeamMember — рядок сторінки «Команда». +type TeamMember struct { + UserID string `json:"user_id"` + Email string `json:"email"` + FullName string `json:"full_name,omitempty"` + RoleID string `json:"role_id"` + RoleKey string `json:"role_key"` + RoleName string `json:"role_name"` + LastLogin *time.Time `json:"last_login_at,omitempty"` + AcceptedAt *time.Time `json:"accepted_at,omitempty"` + ScopeGroups []string `json:"scope_group_ids,omitempty"` +} + +type Role struct { + ID string `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + IsSystem bool `json:"is_system"` + Permissions []string `json:"permissions"` +} + +// --------------------------------------------------------------------- +// Вхід +// --------------------------------------------------------------------- + +// AuthenticateUser перевіряє email і пароль. +// +// Запити йдуть БЕЗ tenant-контексту: у момент входу тенант ще невідомий, +// його називає саме членство. Політики з міграції 0012 дозволяють це +// рівно тоді, коли app.tenant_id порожній. +func (s *Store) AuthenticateUser(ctx context.Context, email, password string, ip net.IP, userAgent string) (*User, []TenantMembership, error) { + var ( + u User + hash *string + ) + + err := s.pool.QueryRow(ctx, ` + SELECT id::text, email::text, COALESCE(full_name,''), locale, timezone, + mfa_enabled, password_hash + FROM core.users + WHERE email = $1 AND deleted_at IS NULL + `, email).Scan(&u.ID, &u.Email, &u.FullName, &u.Locale, &u.Timezone, &u.MFA, &hash) + + if errors.Is(err, pgx.ErrNoRows) { + // Спалюємо стільки ж часу, скільки справжня перевірка: інакше + // швидка відповідь видає, що такого email немає, і перебір + // перетворюється на збір списку зареєстрованих адрес. + auth.DummyVerify(password) + s.recordLogin(ctx, email, ip, userAgent, false, "no_user", "") + return nil, nil, ErrBadCredentials + } + if err != nil { + return nil, nil, err + } + + if hash == nil || *hash == "" { + // Користувач лише через SSO — локального пароля немає. + auth.DummyVerify(password) + s.recordLogin(ctx, email, ip, userAgent, false, "no_password", u.ID) + return nil, nil, ErrBadCredentials + } + + if err := auth.VerifyPassword(*hash, password); err != nil { + s.recordLogin(ctx, email, ip, userAgent, false, "bad_password", u.ID) + return nil, nil, ErrBadCredentials + } + + memberships, err := s.userMemberships(ctx, u.ID) + if err != nil { + return nil, nil, err + } + if len(memberships) == 0 { + s.recordLogin(ctx, email, ip, userAgent, false, "no_membership", u.ID) + return nil, nil, ErrNoMembership + } + + s.recordLogin(ctx, email, ip, userAgent, true, "", u.ID) + + if _, err := s.pool.Exec(ctx, + `UPDATE core.users SET last_login_at = now() WHERE id = $1`, u.ID); err != nil { + return nil, nil, err + } + + return &u, memberships, nil +} + +func (s *Store) userMemberships(ctx context.Context, userID string) ([]TenantMembership, error) { + rows, err := s.pool.Query(ctx, ` + SELECT t.id::text, t.name, t.slug::text, r.key::text, r.name + FROM core.memberships m + JOIN core.tenants t ON t.id = m.tenant_id + JOIN core.roles r ON r.id = m.role_id + WHERE m.user_id = $1 AND t.deleted_at IS NULL + ORDER BY t.name + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []TenantMembership + for rows.Next() { + var m TenantMembership + if err := rows.Scan(&m.TenantID, &m.TenantName, &m.TenantSlug, + &m.RoleKey, &m.RoleName); err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +// recordLogin пише спробу входу. Помилки навмисно глушаться: збій +// журналювання не має заважати людині увійти. +func (s *Store) recordLogin(ctx context.Context, email string, ip net.IP, ua string, ok bool, reason, userID string) { + var ipArg any + if ip != nil { + ipArg = ip.String() + } + _, _ = s.pool.Exec(ctx, ` + INSERT INTO core.login_attempts (email, ip, user_agent, success, reason, user_id) + VALUES ($1, $2::inet, $3, $4, NULLIF($5,''), $6) + `, email, ipArg, nullString(ua), ok, reason, nullUUID(userID)) +} + +// FailedLoginsSince рахує невдалі спроби — основа для гальмування +// перебору на рівні застосунку. +func (s *Store) FailedLoginsSince(ctx context.Context, email string, since time.Duration) (int, error) { + var n int + err := s.pool.QueryRow(ctx, ` + SELECT count(*) FROM core.login_attempts + WHERE email = $1 AND NOT success AND ts > now() - $2::interval + `, email, since.String()).Scan(&n) + return n, err +} + +// --------------------------------------------------------------------- +// Сесії +// --------------------------------------------------------------------- + +type Session struct { + ID string + UserID string + TenantID string + RefreshToken string + ExpiresAt time.Time +} + +// CreateSession видає refresh-токен. У БД лягає лише його sha256. +func (s *Store) CreateSession(ctx context.Context, userID, tenantID string, ip net.IP, ua string) (*Session, error) { + token, err := auth.NewToken("npr_") + if err != nil { + return nil, err + } + sum := sha256.Sum256([]byte(token)) + expires := time.Now().Add(RefreshTTL) + + var ipArg any + if ip != nil { + ipArg = ip.String() + } + + sess := &Session{UserID: userID, TenantID: tenantID, RefreshToken: token, ExpiresAt: expires} + err = s.pool.QueryRow(ctx, ` + INSERT INTO core.sessions (user_id, tenant_id, token_hash, user_agent, ip, expires_at) + VALUES ($1, $2, $3, $4, $5::inet, $6) + RETURNING id::text + `, userID, tenantID, sum[:], nullString(ua), ipArg, expires).Scan(&sess.ID) + if err != nil { + return nil, err + } + return sess, nil +} + +// RotateSession обмінює refresh-токен на новий. +// +// Ротація при кожному обміні, а не подовження строку: викрадений +// refresh-токен спрацює щонайбільше один раз, і законний власник +// одразу отримає відмову — це видимий сигнал компрометації. +func (s *Store) RotateSession(ctx context.Context, refreshToken string, ip net.IP, ua string) (*Session, *User, error) { + sum := sha256.Sum256([]byte(refreshToken)) + + var ( + oldID, userID, tenantID string + expires time.Time + revoked *time.Time + ) + err := s.pool.QueryRow(ctx, ` + SELECT id::text, user_id::text, COALESCE(tenant_id::text,''), expires_at, revoked_at + FROM core.sessions WHERE token_hash = $1 + `, sum[:]).Scan(&oldID, &userID, &tenantID, &expires, &revoked) + + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil, ErrSessionInvalid + } + if err != nil { + return nil, nil, err + } + if revoked != nil || time.Now().After(expires) { + return nil, nil, ErrSessionInvalid + } + + var u User + if err := s.pool.QueryRow(ctx, ` + SELECT id::text, email::text, COALESCE(full_name,''), locale, timezone, mfa_enabled + FROM core.users WHERE id = $1 AND deleted_at IS NULL + `, userID).Scan(&u.ID, &u.Email, &u.FullName, &u.Locale, &u.Timezone, &u.MFA); err != nil { + return nil, nil, ErrSessionInvalid + } + + // Членство могли відкликати вже після входу — перевіряємо щоразу, + // інакше звільнений співробітник продовжував би подовжувати сесію. + var stillMember bool + if err := s.pool.QueryRow(ctx, ` + SELECT EXISTS (SELECT 1 FROM core.memberships WHERE user_id = $1 AND tenant_id = $2) + `, userID, tenantID).Scan(&stillMember); err != nil { + return nil, nil, err + } + if !stillMember { + return nil, nil, ErrSessionInvalid + } + + if _, err := s.pool.Exec(ctx, + `UPDATE core.sessions SET revoked_at = now() WHERE id = $1`, oldID); err != nil { + return nil, nil, err + } + + sess, err := s.CreateSession(ctx, userID, tenantID, ip, ua) + if err != nil { + return nil, nil, err + } + return sess, &u, nil +} + +func (s *Store) RevokeSession(ctx context.Context, refreshToken string) error { + sum := sha256.Sum256([]byte(refreshToken)) + _, err := s.pool.Exec(ctx, ` + UPDATE core.sessions SET revoked_at = now() + WHERE token_hash = $1 AND revoked_at IS NULL + `, sum[:]) + return err +} + +// RevokeAllSessions — вихід з усіх пристроїв; те саме робиться при зміні +// пароля й при видаленні членства. +func (s *Store) RevokeAllSessions(ctx context.Context, userID string) error { + _, err := s.pool.Exec(ctx, ` + UPDATE core.sessions SET revoked_at = now() + WHERE user_id = $1 AND revoked_at IS NULL + `, userID) + return err +} + +// --------------------------------------------------------------------- +// Права +// --------------------------------------------------------------------- + +// UserPermissions збирає ефективні права людини в тенанті. +// +// Джерело — membership → role → role_permissions. Кешувати тут нічого не +// треба: це один індексний join, а кеш прав — класичне джерело «звільнив, +// а він ще годину має доступ». +func (s *Store) UserPermissions(ctx context.Context, userID, tenantID string) ([]string, error) { + rows, err := s.pool.Query(ctx, ` + SELECT rp.permission_key + FROM core.memberships m + JOIN core.role_permissions rp ON rp.role_id = m.role_id + WHERE m.user_id = $1 AND m.tenant_id = $2 + `, userID, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []string + for rows.Next() { + var p string + if err := rows.Scan(&p); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// ScopeGroups — обмеження видимості пристроїв для користувача. +// nil означає «усі пристрої тенанта». +func (s *Store) ScopeGroups(ctx context.Context, userID, tenantID string) ([]string, error) { + var groups []string + err := s.pool.QueryRow(ctx, ` + SELECT scope_group_ids::text[] + FROM core.memberships WHERE user_id = $1 AND tenant_id = $2 + `, userID, tenantID).Scan(&groups) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNoMembership + } + return groups, err +} + +// --------------------------------------------------------------------- +// Команда +// --------------------------------------------------------------------- + +func (s *Store) ListTeam(ctx context.Context, tenantID string) ([]TeamMember, error) { + var out []TeamMember + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT u.id::text, u.email::text, COALESCE(u.full_name,''), + r.id::text, r.key::text, r.name, + u.last_login_at, m.accepted_at, m.scope_group_ids::text[] + FROM core.memberships m + JOIN core.users u ON u.id = m.user_id + JOIN core.roles r ON r.id = m.role_id + WHERE m.tenant_id = $1 AND u.deleted_at IS NULL + ORDER BY u.email + `, tenantID) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var t TeamMember + if err := rows.Scan(&t.UserID, &t.Email, &t.FullName, + &t.RoleID, &t.RoleKey, &t.RoleName, + &t.LastLogin, &t.AcceptedAt, &t.ScopeGroups); err != nil { + return err + } + out = append(out, t) + } + return rows.Err() + }) + + return out, err +} + +func (s *Store) ListRoles(ctx context.Context, tenantID string) ([]Role, error) { + var out []Role + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT r.id::text, r.key::text, r.name, COALESCE(r.description,''), r.is_system, + COALESCE(array_agg(rp.permission_key ORDER BY rp.permission_key) + FILTER (WHERE rp.permission_key IS NOT NULL), '{}') + FROM core.roles r + LEFT JOIN core.role_permissions rp ON rp.role_id = r.id + WHERE r.tenant_id IS NULL OR r.tenant_id = $1 + GROUP BY r.id + ORDER BY r.is_system DESC, r.name + `, tenantID) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var r Role + if err := rows.Scan(&r.ID, &r.Key, &r.Name, &r.Description, + &r.IsSystem, &r.Permissions); err != nil { + return err + } + out = append(out, r) + } + return rows.Err() + }) + + return out, err +} + +// CreateUser заводить користувача й одразу дає йому членство. +// +// Використовується і CLI-початківцем (перший власник), і запрошеннями. +func (s *Store) CreateUser(ctx context.Context, tenantID, email, password, fullName, roleKey string) (*User, error) { + hash, err := auth.HashPassword(password) + if err != nil { + return nil, err + } + + var u User + err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + // Користувач глобальний: одна людина може працювати в кількох + // тенантах (типово для MSP-провайдера). + err := tx.QueryRow(ctx, ` + INSERT INTO core.users (email, password_hash, full_name, email_verified_at) + VALUES ($1, $2, NULLIF($3,''), now()) + ON CONFLICT (email) DO UPDATE + SET full_name = COALESCE(NULLIF(EXCLUDED.full_name,''), core.users.full_name) + RETURNING id::text, email::text, COALESCE(full_name,''), locale, timezone, mfa_enabled + `, email, hash, fullName).Scan(&u.ID, &u.Email, &u.FullName, &u.Locale, &u.Timezone, &u.MFA) + if err != nil { + return fmt.Errorf("створення користувача: %w", err) + } + + var roleID string + if err := tx.QueryRow(ctx, ` + SELECT id::text FROM core.roles + WHERE key = $1 AND (tenant_id IS NULL OR tenant_id = $2) + ORDER BY tenant_id NULLS LAST + LIMIT 1 + `, roleKey, tenantID).Scan(&roleID); err != nil { + return fmt.Errorf("роль %q: %w", roleKey, err) + } + + if _, err := tx.Exec(ctx, ` + INSERT INTO core.memberships (tenant_id, user_id, role_id, accepted_at) + VALUES ($1, $2, $3, now()) + ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id + `, tenantID, u.ID, roleID); err != nil { + return fmt.Errorf("членство: %w", err) + } + return nil + }) + + if err != nil { + return nil, mapPgError(err) + } + return &u, nil +} + +// SetRole змінює роль учасника команди. +func (s *Store) SetRole(ctx context.Context, tenantID, userID, roleID string) error { + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + tag, err := tx.Exec(ctx, ` + UPDATE core.memberships SET role_id = $3 + WHERE tenant_id = $1 AND user_id = $2 + `, tenantID, userID, roleID) + if err != nil { + return mapPgError(err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil + }) +} + +// RemoveMember прибирає людину з тенанта. +// +// Сам користувач лишається: він може працювати в іншому тенанті, а його +// сліди в аудиті мають лишатися читабельними. Зате всі сесії саме в +// цьому тенанті відкликаються негайно. +func (s *Store) RemoveMember(ctx context.Context, tenantID, userID string) error { + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + tag, err := tx.Exec(ctx, ` + DELETE FROM core.memberships WHERE tenant_id = $1 AND user_id = $2 + `, tenantID, userID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + + _, err = tx.Exec(ctx, ` + UPDATE core.sessions SET revoked_at = now() + WHERE user_id = $1 AND tenant_id = $2 AND revoked_at IS NULL + `, userID, tenantID) + return err + }) +} + +// ChangePassword міняє пароль і відкликає всі інші сесії. +func (s *Store) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string) error { + var hash *string + if err := s.pool.QueryRow(ctx, + `SELECT password_hash FROM core.users WHERE id = $1 AND deleted_at IS NULL`, + userID).Scan(&hash); err != nil { + return ErrBadCredentials + } + if hash == nil || auth.VerifyPassword(*hash, oldPassword) != nil { + return ErrBadCredentials + } + + newHash, err := auth.HashPassword(newPassword) + if err != nil { + return err + } + if _, err := s.pool.Exec(ctx, + `UPDATE core.users SET password_hash = $2, updated_at = now() WHERE id = $1`, + userID, newHash); err != nil { + return err + } + + // Зміна пароля — типова реакція на «здається, мене зламали». + // Якщо після неї чужа сесія лишається живою, дія безглузда. + return s.RevokeAllSessions(ctx, userID) +} diff --git a/web/src/App.tsx b/web/src/App.tsx index b6bd08d..cff24a8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,17 +1,54 @@ -import { useEffect, useMemo, useState } from 'react' -import { api } from './api/client' +import { useEffect, useMemo, useState, useSyncExternalStore } from 'react' +import { api, staticTokenPresent } from './api/client' +import { session } from './api/session' import { useLiveMap } from './hooks/useLiveMap' +import { LoginPage } from './components/LoginPage' import { MapCanvas } from './components/MapCanvas' import type { AgentSummary, MapSummary } from './types' export default function App() { + // Сесія живе поза React, бо її читає й шар API. useSyncExternalStore + // підписує компонент на неї без дублювання стану. + const me = useSyncExternalStore(session.subscribe, session.me) + const [booting, setBooting] = useState(true) + + // Після перезавантаження сторінки access-токен зник разом із памʼяттю + // вкладки, але httpOnly-кукі жива — пробуємо тихо відновити сесію. + useEffect(() => { + if (staticTokenPresent) { + setBooting(false) + return + } + void api.restore().finally(() => setBooting(false)) + }, []) + + if (booting) { + return ( +
+ Відновлення сесії… +
+ ) + } + + if (!me && !staticTokenPresent) { + return setBooting(false)} /> + } + + return +} + +function Workspace() { const [maps, setMaps] = useState([]) const [mapID, setMapID] = useState(null) const [agents, setAgents] = useState([]) const [selected, setSelected] = useState(null) const [listError, setListError] = useState(null) + // Мобільний сайдбар відкривається поверх полотна: на 375 px ширини + // постійна колонка з'їдала б дві третини карти. + const [sidebarOpen, setSidebarOpen] = useState(false) const live = useLiveMap(mapID) + const canWrite = session.can('maps:write') useEffect(() => { api @@ -35,17 +72,29 @@ export default function App() { }, [live.state]) const node = live.state?.nodes.find((n) => n.id === selected) ?? null + const me = session.me() return ( -
-
-
- NetPulse - мапа мережі -
+
+
+ + + + NetPulse + - + {canWrite && ( + <> + - + + + )} - - тягніть від краю вузла — зв'язок; Delete — видалити - - -
- +
+ + + {live.state && ( - ревізія {live.state.revision} + + ревізія {live.state.revision} + + )} + {me?.email && ( + )}
@@ -96,8 +160,21 @@ export default function App() {
)} -
-
{live.state ? ( - + ) : ( -
+
{live.loading ? 'Завантаження полотна…' : 'Мапу не вибрано'}
)}
+ + {/* Інспектор вузла: збоку на десктопі, нижнім аркушем на телефоні. */} + {node && ( +
+
+ {node.label} + +
+
+ + {node.rtt_ms !== undefined && } + {node.loss_pct !== undefined && } + +
+
+ )}
) } @@ -183,7 +270,7 @@ function Counter({ label, value, color }: { label: string; value: number; color? function Row({ k, v }: { k: string; v: string }) { return ( -
+
{k} {v}
@@ -192,12 +279,12 @@ function Row({ k, v }: { k: string; v: string }) { function Legend() { return ( -
+ -
+ ) } @@ -215,7 +302,7 @@ function Dot({ c, t }: { c: string; t: string }) { * * Мапа, яка тихо замерзла після розриву WebSocket, виглядає точно так * само, як мапа, де все гаразд, — і це найнебезпечніший стан - * моніторингу. Тому окремий індикатор і час останньої події. + * моніторингу. */ function ConnectionBadge({ state, @@ -244,9 +331,9 @@ function ConnectionBadge({ return ( - {label} + {label} {ago !== null && state === 'online' && ( - {ago} с тому + {ago} с тому )} ) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3876214..eebb151 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,17 +1,29 @@ +import { meFromLogin, session } from './session' import type { AgentSummary, BuildResult, DeviceSummary, + LoginResponse, MapPatch, MapState, MapSummary, PatchResult, + Role, + TeamMember, UndoResult, } from '../types' -// Токен береться зі змінної збірки або з localStorage. Повноцінного -// входу ще немає — коли з'явиться, зміниться лише це місце. -const TOKEN = import.meta.env.VITE_API_TOKEN ?? localStorage.getItem('np_token') ?? '' +// Машинний токен зі змінної збірки лишається запасним шляхом для кіосків +// і NOC-екранів, де входити нікому. Жива сесія людини має пріоритет. +const STATIC_TOKEN = import.meta.env.VITE_API_TOKEN ?? '' + +// Чи налаштований машинний токен: у цьому режимі сторінка входу не +// показується взагалі (кіоск, NOC-екран). +export const staticTokenPresent = STATIC_TOKEN !== '' + +export function authToken(): string { + return session.token() || STATIC_TOKEN +} export class ApiError extends Error { constructor( @@ -31,17 +43,23 @@ export class ApiError extends Error { get isPlanLimit() { return this.status === 402 } + + /** Немає права на дію. */ + get isForbidden() { + return this.status === 403 + } } -async function request(path: string, init?: RequestInit): Promise { - const res = await fetch(path, { - ...init, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${TOKEN}`, - ...(init?.headers ?? {}), - }, - }) +async function raw(path: string, init?: RequestInit): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + ...((init?.headers as Record) ?? {}), + } + const t = authToken() + if (t) headers.Authorization = `Bearer ${t}` + + // credentials потрібні для httpOnly-кукі з refresh-токеном. + const res = await fetch(path, { ...init, headers, credentials: 'same-origin' }) if (res.status === 204) return undefined as T @@ -55,7 +73,96 @@ async function request(path: string, init?: RequestInit): Promise { return body as T } +// Один refresh на всіх одночасних запитах. +// +// Якщо токен протух під час завантаження мапи, паралельні виклики +// інакше влаштували б чергу з десятка обмінів — а ротація зробила б +// усі, крім першого, недійсними, і людину викинуло б на вхід рівно +// тоді, коли все насправді гаразд. +let refreshing: Promise | null = null + +async function refreshOnce(): Promise { + if (!refreshing) { + refreshing = raw('/api/v1/auth/refresh', { method: 'POST' }) + .then((r) => { + if (!r.access_token) return false + session.set(r.access_token, meFromLogin(r)) + return true + }) + .catch(() => false) + .finally(() => { + refreshing = null + }) + } + return refreshing +} + +async function request(path: string, init?: RequestInit): Promise { + try { + return await raw(path, init) + } catch (e) { + // Протермінований токен — не привід викидати людину на сторінку + // входу: refresh-кукі ще жива, тож тихо оновлюємо й повторюємо. + if (e instanceof ApiError && e.status === 401 && e.code === 'token_expired') { + if (await refreshOnce()) return raw(path, init) + session.clear() + } + throw e + } +} + export const api = { + // --- вхід --- + + login: (email: string, password: string, tenantID?: string) => + raw('/api/v1/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password, tenant_id: tenantID }), + }), + + /** Тихе відновлення сесії після перезавантаження сторінки. */ + restore: () => refreshOnce(), + + logout: async () => { + try { + await raw('/api/v1/auth/logout', { method: 'POST' }) + } finally { + session.clear() + } + }, + + me: () => + request<{ + tenant_id: string + email?: string + user_id?: string + token_name?: string + permissions: string[] + }>('/api/v1/me'), + + // --- команда --- + + team: () => request<{ members: TeamMember[] }>('/api/v1/team').then((r) => r.members ?? []), + + roles: () => request<{ roles: Role[] }>('/api/v1/roles').then((r) => r.roles ?? []), + + addMember: (email: string, password: string, roleKey: string, fullName = '') => + request<{ id: string }>('/api/v1/team', { + method: 'POST', + body: JSON.stringify({ email, password, role_key: roleKey, full_name: fullName }), + }), + + setRole: (userID: string, roleID: string) => + request(`/api/v1/team/${userID}`, { + method: 'PATCH', + body: JSON.stringify({ role_id: roleID }), + }), + + removeMember: (userID: string) => + request(`/api/v1/team/${userID}`, { method: 'DELETE' }), + + // --- мапи --- + listMaps: () => request<{ maps: MapSummary[] }>('/api/v1/maps').then((r) => r.maps ?? []), getMap: (id: string) => request(`/api/v1/maps/${id}`), @@ -72,11 +179,11 @@ export const api = { body: JSON.stringify(patch), }), - buildMap: (id: string) => - request(`/api/v1/maps/${id}/build`, { method: 'POST' }), + buildMap: (id: string) => request(`/api/v1/maps/${id}/build`, { method: 'POST' }), - undoMap: (id: string) => - request(`/api/v1/maps/${id}/undo`, { method: 'POST' }), + undoMap: (id: string) => request(`/api/v1/maps/${id}/undo`, { method: 'POST' }), + + // --- інвентар --- listDevices: () => request<{ devices: DeviceSummary[] }>('/api/v1/devices').then((r) => r.devices ?? []), @@ -84,5 +191,3 @@ export const api = { listAgents: () => request<{ agents: AgentSummary[] }>('/api/v1/agents').then((r) => r.agents ?? []), } - -export const apiToken = TOKEN diff --git a/web/src/api/session.ts b/web/src/api/session.ts new file mode 100644 index 0000000..a295fec --- /dev/null +++ b/web/src/api/session.ts @@ -0,0 +1,70 @@ +import type { Permission } from '../types' + +/** + * Сесія браузера. + * + * Access-токен живе **лише в пам'яті вкладки**, а не в localStorage: + * XSS-скрипт читає localStorage тривіально, а до змінної в замиканні + * модуля дістатись значно важче. Довгу частину тримає httpOnly-кукі з + * refresh-токеном, яку скрипт не бачить узагалі — тому після + * перезавантаження сторінки сесія відновлюється тихим refresh-ом. + */ + +export interface Me { + userID?: string + email?: string + tokenName?: string + tenantID: string + permissions: Permission[] +} + +let accessToken = '' +let me: Me | null = null +const listeners = new Set<() => void>() + +function notify() { + for (const l of listeners) l() +} + +export const session = { + subscribe(fn: () => void) { + listeners.add(fn) + return () => listeners.delete(fn) + }, + + token: () => accessToken, + me: () => me, + + set(token: string, who: Me) { + accessToken = token + me = who + notify() + }, + + clear() { + accessToken = '' + me = null + notify() + }, + + /** Право є, якщо воно явно надане або токен без обмежень (`*`). */ + can(perm: Permission): boolean { + if (!me) return false + return me.permissions.includes(perm) || me.permissions.includes('*') + }, +} + +/** Розбір відповіді входу в єдину форму. */ +export function meFromLogin(body: { + user?: { id: string; email: string } + tenant?: { tenant_id: string } | null + permissions?: string[] + tenant_id?: string +}): Me { + return { + userID: body.user?.id, + email: body.user?.email, + tenantID: body.tenant?.tenant_id ?? body.tenant_id ?? '', + permissions: (body.permissions ?? []) as Permission[], + } +} diff --git a/web/src/api/ws.ts b/web/src/api/ws.ts index ec422a3..d852f11 100644 --- a/web/src/api/ws.ts +++ b/web/src/api/ws.ts @@ -1,4 +1,4 @@ -import { apiToken } from './client' +import { authToken } from './client' import type { WsMessage } from '../types' export type ConnectionState = 'connecting' | 'online' | 'offline' @@ -32,8 +32,10 @@ export class LiveConnection { const proto = location.protocol === 'https:' ? 'wss' : 'ws' // Браузерний WebSocket не дозволяє довільні заголовки, тому токен // їде підпротоколом — так він не потрапляє в URL і в логи проксі. + // Токен читаємо в момент підключення, а не при створенні обʼєкта: + // після реконекту він міг уже змінитись через refresh. const ws = new WebSocket(`${proto}://${location.host}/api/v1/ws`, [ - `netpulse.token.${apiToken}`, + `netpulse.token.${authToken()}`, ]) this.ws = ws diff --git a/web/src/components/LoginPage.tsx b/web/src/components/LoginPage.tsx new file mode 100644 index 0000000..be4e24c --- /dev/null +++ b/web/src/components/LoginPage.tsx @@ -0,0 +1,141 @@ +import { useState } from 'react' +import { ApiError, api } from '../api/client' +import { meFromLogin, session } from '../api/session' +import type { TenantMembership } from '../types' + +/** + * Сторінка входу. + * + * Свідомо мінімальна: одне поле помилки, жодних підказок на кшталт + * «такого користувача немає». Сервер і так відповідає однаково на + * невідомий email і невірний пароль — показувати різне на клієнті + * означало б звести нанівець цей захист. + */ +export function LoginPage({ onDone }: { onDone: () => void }) { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [tenants, setTenants] = useState(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + async function submit(tenantID?: string) { + setBusy(true) + setError(null) + try { + const r = await api.login(email, password, tenantID) + + // Людина працює в кількох організаціях — треба вибрати. + if (!r.access_token && r.tenants && r.tenants.length > 0) { + setTenants(r.tenants) + return + } + if (!r.access_token) { + setError('Сервер не видав токен') + return + } + + session.set(r.access_token, meFromLogin(r)) + onDone() + } catch (e) { + if (e instanceof ApiError) { + setError( + e.status === 429 + ? 'Забагато невдалих спроб. Спробуйте за 15 хвилин.' + : e.status === 403 + ? 'Користувача не додано до жодної організації' + : 'Невірний email або пароль', + ) + } else { + setError(e instanceof Error ? e.message : String(e)) + } + } finally { + setBusy(false) + } + } + + return ( +
+
+
+
NetPulse
+
моніторинг мережі
+
+ + {tenants ? ( +
+

Оберіть організацію

+
    + {tenants.map((t) => ( +
  • + +
  • + ))} +
+
+ ) : ( +
{ + e.preventDefault() + void submit() + }} + > + + + + + {error && ( +
+ {error} +
+ )} + + +
+ )} + +

+ Першого користувача заводить netpulse-user +

+
+
+ ) +} diff --git a/web/src/components/MapCanvas.tsx b/web/src/components/MapCanvas.tsx index e971ecf..ee88031 100644 --- a/web/src/components/MapCanvas.tsx +++ b/web/src/components/MapCanvas.tsx @@ -25,16 +25,18 @@ interface Props { state: MapState onPatch: (p: MapPatch, comment?: string) => void onSelect: (nodeID: string | null) => void + /** Глядач без maps:write бачить полотно, але не змінює його. */ + readOnly?: boolean } -export function MapCanvas({ state, onPatch, onSelect }: Props) { +export function MapCanvas({ state, onPatch, onSelect, readOnly = false }: Props) { const rfNodes = useMemo( () => state.nodes.map((n) => ({ id: n.id, type: 'device' as const, position: { x: n.x, y: n.y }, - draggable: !n.locked, + draggable: !n.locked && !readOnly, data: { label: n.label ?? '—', kind: (n.style?.icon as string) ?? n.kind, @@ -44,7 +46,7 @@ export function MapCanvas({ state, onPatch, onSelect }: Props) { icon: n.style?.icon as string | undefined, }, })), - [state.nodes], + [state.nodes, readOnly], ) const rfEdges = useMemo( @@ -168,9 +170,10 @@ export function MapCanvas({ state, onPatch, onSelect }: Props) { onNodesChange={handleNodesChange} onEdgesChange={onEdgesChange} onNodeDragStop={handleDragStop} - onConnect={handleConnect} - onDelete={handleDelete} - deleteKeyCode={['Delete', 'Backspace']} + onConnect={readOnly ? undefined : handleConnect} + onDelete={readOnly ? undefined : handleDelete} + nodesConnectable={!readOnly} + deleteKeyCode={readOnly ? null : ['Delete', 'Backspace']} connectionRadius={30} defaultViewport={state.viewport} snapToGrid={state.grid?.snap ?? true} diff --git a/web/src/types.ts b/web/src/types.ts index c1570a9..b61e527 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -2,6 +2,55 @@ // зайвим шаром: контракт описаний у server/API.md і змінюється разом // зі схемою БД, а не окремо від неї. +// Права збігаються з core.permissions. Літеральний тип, а не string: +// друкарська помилка в назві права інакше мовчки ховала б кнопку назавжди. +export type Permission = + | '*' + | 'devices:read' | 'devices:write' | 'devices:control' + | 'maps:read' | 'maps:write' | 'maps:publish' + | 'alerts:read' | 'alerts:ack' | 'alerts:write' + | 'ncm:read' | 'ncm:write' | 'ncm:rollback' + | 'agents:read' | 'agents:write' + | 'users:read' | 'users:write' + | 'billing:read' | 'billing:manage' + | 'audit:read' | 'settings:write' + +export interface TenantMembership { + tenant_id: string + tenant_name: string + tenant_slug: string + role_key: string + role_name: string +} + +export interface LoginResponse { + access_token?: string + expires_in?: number + user?: { id: string; email: string; full_name?: string } + tenant?: TenantMembership | null + tenants?: TenantMembership[] + permissions?: string[] +} + +export interface TeamMember { + user_id: string + email: string + full_name?: string + role_id: string + role_key: string + role_name: string + last_login_at?: string +} + +export interface Role { + id: string + key: string + name: string + description?: string + is_system: boolean + permissions: string[] +} + export type DeviceStatus = 'up' | 'down' | 'warning' | 'unknown' | 'maintenance' | '' export interface MapSummary {