Етап 5: користувачі, вхід і права
Досі доступ давав машинний токен зі змінної збірки — одні права на всіх і жодного способу відрізнити, хто що зробив. Тепер продукт уміє впустити людину. Сервер: - 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 <noreply@anthropic.com>
This commit is contained in:
parent
ccaa9afcc4
commit
a4faf31fcd
24 changed files with 2874 additions and 191 deletions
94
HISTORY.md
94
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 або запитом.
|
||||
|
|
|
|||
18
ROADMAP.md
18
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 (керування зондом)** — дешевий і робить онбординг можливим.
|
||||
|
|
|
|||
94
db/migrations/0012_auth.sql
Normal file
94
db/migrations/0012_auth.sql
Normal file
|
|
@ -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);
|
||||
130
server/API.md
130
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` можна задати патчем, але
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
217
server/cmd/netpulse-user/main.go
Normal file
217
server/cmd/netpulse-user/main.go
Normal file
|
|
@ -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
|
||||
}
|
||||
133
server/internal/auth/password.go
Normal file
133
server/internal/auth/password.go
Normal file
|
|
@ -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$<salt>$<hash>
|
||||
//
|
||||
// Формат самоописний: параметри лежать поруч із хешем, тому їх можна
|
||||
// посилити завтра, не ламаючи вчорашні паролі — перевірка прочитає ті,
|
||||
// з якими хеш був створений.
|
||||
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
|
||||
}
|
||||
127
server/internal/auth/token.go
Normal file
127
server/internal/auth/token.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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[:]
|
||||
|
|
|
|||
269
server/internal/httpapi/auth.go
Normal file
269
server/internal/httpapi/auth.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
310
server/internal/httpapi/auth_test.go
Normal file
310
server/internal/httpapi/auth_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
166
server/internal/httpapi/principal.go
Normal file
166
server/internal/httpapi/principal.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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", "внутрішня помилка")
|
||||
|
|
|
|||
131
server/internal/httpapi/users.go
Normal file
131
server/internal/httpapi/users.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
534
server/internal/store/users.go
Normal file
534
server/internal/store/users.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
257
web/src/App.tsx
257
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 (
|
||||
<div className="flex h-screen items-center justify-center bg-slate-950 text-sm text-slate-600">
|
||||
Відновлення сесії…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!me && !staticTokenPresent) {
|
||||
return <LoginPage onDone={() => setBooting(false)} />
|
||||
}
|
||||
|
||||
return <Workspace />
|
||||
}
|
||||
|
||||
function Workspace() {
|
||||
const [maps, setMaps] = useState<MapSummary[]>([])
|
||||
const [mapID, setMapID] = useState<string | null>(null)
|
||||
const [agents, setAgents] = useState<AgentSummary[]>([])
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [listError, setListError] = useState<string | null>(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 (
|
||||
<div className="flex h-screen w-screen flex-col bg-slate-950 text-slate-200">
|
||||
<header className="flex shrink-0 items-center gap-4 border-b border-slate-800 bg-slate-900 px-4 py-2">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-lg font-semibold tracking-tight text-slate-100">NetPulse</span>
|
||||
<span className="text-xs text-slate-500">мапа мережі</span>
|
||||
</div>
|
||||
<div className="flex h-[100dvh] w-screen flex-col bg-slate-950 text-slate-200">
|
||||
<header
|
||||
className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b border-slate-800
|
||||
bg-slate-900 px-3 py-2 md:px-4"
|
||||
>
|
||||
<button
|
||||
className="rounded border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm md:hidden"
|
||||
onClick={() => setSidebarOpen((v) => !v)}
|
||||
aria-label="Меню"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
|
||||
<span className="text-base font-semibold tracking-tight text-slate-100 md:text-lg">
|
||||
NetPulse
|
||||
</span>
|
||||
|
||||
<select
|
||||
className="rounded border border-slate-700 bg-slate-800 px-2 py-1 text-sm"
|
||||
className="min-w-0 flex-1 rounded border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm
|
||||
md:flex-none"
|
||||
value={mapID ?? ''}
|
||||
onChange={(e) => setMapID(e.target.value || null)}
|
||||
>
|
||||
|
|
@ -57,35 +106,50 @@ export default function App() {
|
|||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
className="rounded border border-slate-700 bg-slate-800 px-3 py-1 text-sm hover:bg-slate-700
|
||||
disabled:opacity-40"
|
||||
disabled={!mapID}
|
||||
onClick={() => void live.build()}
|
||||
title="Додати на мапу пристрої та зв'язки, знайдені автовиявленням"
|
||||
>
|
||||
Добудувати з топології
|
||||
</button>
|
||||
{canWrite && (
|
||||
<>
|
||||
<button
|
||||
className="rounded border border-slate-700 bg-slate-800 px-3 py-1.5 text-sm
|
||||
hover:bg-slate-700 disabled:opacity-40"
|
||||
disabled={!mapID}
|
||||
onClick={() => void live.build()}
|
||||
title="Додати на мапу пристрої та зв'язки, знайдені автовиявленням"
|
||||
>
|
||||
<span className="hidden lg:inline">Добудувати з топології</span>
|
||||
<span className="lg:hidden">Добудувати</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="rounded border border-slate-700 bg-slate-800 px-3 py-1 text-sm hover:bg-slate-700
|
||||
disabled:opacity-40"
|
||||
disabled={!live.canUndo}
|
||||
onClick={() => void live.undo()}
|
||||
title="Відкотити останню зміну полотна"
|
||||
>
|
||||
↶ Відкотити
|
||||
</button>
|
||||
<button
|
||||
className="rounded border border-slate-700 bg-slate-800 px-3 py-1.5 text-sm
|
||||
hover:bg-slate-700 disabled:opacity-40"
|
||||
disabled={!live.canUndo}
|
||||
onClick={() => void live.undo()}
|
||||
title="Відкотити останню зміну полотна"
|
||||
>
|
||||
↶<span className="ml-1 hidden lg:inline">Відкотити</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-slate-600">
|
||||
тягніть від краю вузла — зв'язок; Delete — видалити
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-4 text-xs">
|
||||
<Legend />
|
||||
<div className="ml-auto flex items-center gap-3 text-xs">
|
||||
<span className="hidden xl:block">
|
||||
<Legend />
|
||||
</span>
|
||||
<ConnectionBadge state={live.connection} lastEventAt={live.lastEventAt} />
|
||||
{live.state && (
|
||||
<span className="tabular-nums text-slate-500">ревізія {live.state.revision}</span>
|
||||
<span className="hidden tabular-nums text-slate-500 sm:inline">
|
||||
ревізія {live.state.revision}
|
||||
</span>
|
||||
)}
|
||||
{me?.email && (
|
||||
<button
|
||||
className="max-w-[10rem] truncate rounded border border-slate-700 px-2 py-1
|
||||
text-slate-400 hover:bg-slate-800"
|
||||
title={`${me.email} — вийти`}
|
||||
onClick={() => void api.logout()}
|
||||
>
|
||||
{me.email}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -96,8 +160,21 @@ export default function App() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<aside className="flex w-60 shrink-0 flex-col gap-4 overflow-y-auto border-r border-slate-800 bg-slate-900/60 p-3">
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
{/* Затемнення під висувним сайдбаром — лише на вузьких екранах. */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="absolute inset-0 z-10 bg-slate-950/60 md:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={`absolute inset-y-0 left-0 z-20 flex w-64 flex-col gap-4 overflow-y-auto
|
||||
border-r border-slate-800 bg-slate-900 p-3 transition-transform
|
||||
md:static md:z-0 md:w-60 md:translate-x-0 md:bg-slate-900/60
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}
|
||||
>
|
||||
<section>
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Стан вузлів
|
||||
|
|
@ -110,64 +187,74 @@ export default function App() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Зонди
|
||||
</h2>
|
||||
{agents.length === 0 && <p className="text-xs text-slate-600">немає</p>}
|
||||
<ul className="space-y-2">
|
||||
{agents.map((a) => (
|
||||
<li key={a.id} className="rounded border border-slate-800 bg-slate-900 p-2 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${
|
||||
a.status === 'online' ? 'bg-emerald-400' : 'bg-slate-600'
|
||||
}`}
|
||||
/>
|
||||
<span className="truncate font-medium text-slate-200">{a.name}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-slate-500">
|
||||
{a.os}/{a.arch} · {a.device_count} пристроїв
|
||||
</div>
|
||||
{typeof a.health?.rss_bytes === 'number' && (
|
||||
<div className="tabular-nums text-slate-500">
|
||||
RSS {((a.health.rss_bytes as number) / 1048576).toFixed(1)} МБ
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{node && (
|
||||
{session.can('agents:read') && (
|
||||
<section>
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Вузол
|
||||
Зонди
|
||||
</h2>
|
||||
<div className="space-y-1 rounded border border-slate-800 bg-slate-900 p-2 text-xs">
|
||||
<div className="font-medium text-slate-100">{node.label}</div>
|
||||
<Row k="статус" v={node.status || 'unknown'} />
|
||||
{node.rtt_ms !== undefined && <Row k="RTT" v={`${node.rtt_ms.toFixed(3)} мс`} />}
|
||||
{node.loss_pct !== undefined && <Row k="втрати" v={`${node.loss_pct}%`} />}
|
||||
<Row k="координати" v={`${Math.round(node.x)}, ${Math.round(node.y)}`} />
|
||||
{node.last_seen_at && (
|
||||
<Row k="бачили" v={new Date(node.last_seen_at).toLocaleTimeString('uk')} />
|
||||
)}
|
||||
</div>
|
||||
{agents.length === 0 && <p className="text-xs text-slate-600">немає</p>}
|
||||
<ul className="space-y-2">
|
||||
{agents.map((a) => (
|
||||
<li key={a.id} className="rounded border border-slate-800 bg-slate-900 p-2 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${
|
||||
a.status === 'online' ? 'bg-emerald-400' : 'bg-slate-600'
|
||||
}`}
|
||||
/>
|
||||
<span className="truncate font-medium text-slate-200">{a.name}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-slate-500">
|
||||
{a.os}/{a.arch} · {a.device_count} пристроїв
|
||||
</div>
|
||||
{typeof a.health?.rss_bytes === 'number' && (
|
||||
<div className="tabular-nums text-slate-500">
|
||||
RSS {((a.health.rss_bytes as number) / 1048576).toFixed(1)} МБ
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0 flex-1">
|
||||
{live.state ? (
|
||||
<MapCanvas state={live.state} onPatch={live.patch} onSelect={setSelected} />
|
||||
<MapCanvas
|
||||
state={live.state}
|
||||
onPatch={live.patch}
|
||||
onSelect={setSelected}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-slate-600">
|
||||
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-slate-600">
|
||||
{live.loading ? 'Завантаження полотна…' : 'Мапу не вибрано'}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Інспектор вузла: збоку на десктопі, нижнім аркушем на телефоні. */}
|
||||
{node && (
|
||||
<div
|
||||
className="shrink-0 border-t border-slate-800 bg-slate-900 px-3 py-2 text-xs
|
||||
md:absolute md:bottom-3 md:right-3 md:w-64 md:rounded-lg md:border"
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="font-medium text-slate-100">{node.label}</span>
|
||||
<button className="px-2 text-slate-500 hover:text-slate-300" onClick={() => setSelected(null)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-0.5 md:block">
|
||||
<Row k="статус" v={node.status || 'unknown'} />
|
||||
{node.rtt_ms !== undefined && <Row k="RTT" v={`${node.rtt_ms.toFixed(3)} мс`} />}
|
||||
{node.loss_pct !== undefined && <Row k="втрати" v={`${node.loss_pct}%`} />}
|
||||
<Row k="координати" v={`${Math.round(node.x)}, ${Math.round(node.y)}`} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -183,7 +270,7 @@ function Counter({ label, value, color }: { label: string; value: number; color?
|
|||
|
||||
function Row({ k, v }: { k: string; v: string }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-2">
|
||||
<div className="flex justify-between gap-2 md:w-full">
|
||||
<span className="text-slate-500">{k}</span>
|
||||
<span className="tabular-nums text-slate-300">{v}</span>
|
||||
</div>
|
||||
|
|
@ -192,12 +279,12 @@ function Row({ k, v }: { k: string; v: string }) {
|
|||
|
||||
function Legend() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-slate-500">
|
||||
<span className="flex items-center gap-2 text-slate-500">
|
||||
<Dot c="bg-emerald-400" t="норма" />
|
||||
<Dot c="bg-amber-400" t="навантажено" />
|
||||
<Dot c="bg-red-500" t="обрив" />
|
||||
<Dot c="bg-slate-500" t="невідомо" />
|
||||
</div>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -215,7 +302,7 @@ function Dot({ c, t }: { c: string; t: string }) {
|
|||
*
|
||||
* Мапа, яка тихо замерзла після розриву WebSocket, виглядає точно так
|
||||
* само, як мапа, де все гаразд, — і це найнебезпечніший стан
|
||||
* моніторингу. Тому окремий індикатор і час останньої події.
|
||||
* моніторингу.
|
||||
*/
|
||||
function ConnectionBadge({
|
||||
state,
|
||||
|
|
@ -244,9 +331,9 @@ function ConnectionBadge({
|
|||
return (
|
||||
<span className="flex items-center gap-1.5 text-slate-400">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${color}`} />
|
||||
{label}
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
{ago !== null && state === 'online' && (
|
||||
<span className="tabular-nums text-slate-600">{ago} с тому</span>
|
||||
<span className="hidden tabular-nums text-slate-600 lg:inline">{ago} с тому</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
})
|
||||
async function raw<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...((init?.headers as Record<string, string>) ?? {}),
|
||||
}
|
||||
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<T>(path: string, init?: RequestInit): Promise<T> {
|
|||
return body as T
|
||||
}
|
||||
|
||||
// Один refresh на всіх одночасних запитах.
|
||||
//
|
||||
// Якщо токен протух під час завантаження мапи, паралельні виклики
|
||||
// інакше влаштували б чергу з десятка обмінів — а ротація зробила б
|
||||
// усі, крім першого, недійсними, і людину викинуло б на вхід рівно
|
||||
// тоді, коли все насправді гаразд.
|
||||
let refreshing: Promise<boolean> | null = null
|
||||
|
||||
async function refreshOnce(): Promise<boolean> {
|
||||
if (!refreshing) {
|
||||
refreshing = raw<LoginResponse>('/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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
try {
|
||||
return await raw<T>(path, init)
|
||||
} catch (e) {
|
||||
// Протермінований токен — не привід викидати людину на сторінку
|
||||
// входу: refresh-кукі ще жива, тож тихо оновлюємо й повторюємо.
|
||||
if (e instanceof ApiError && e.status === 401 && e.code === 'token_expired') {
|
||||
if (await refreshOnce()) return raw<T>(path, init)
|
||||
session.clear()
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// --- вхід ---
|
||||
|
||||
login: (email: string, password: string, tenantID?: string) =>
|
||||
raw<LoginResponse>('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, tenant_id: tenantID }),
|
||||
}),
|
||||
|
||||
/** Тихе відновлення сесії після перезавантаження сторінки. */
|
||||
restore: () => refreshOnce(),
|
||||
|
||||
logout: async () => {
|
||||
try {
|
||||
await raw<void>('/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<void>(`/api/v1/team/${userID}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ role_id: roleID }),
|
||||
}),
|
||||
|
||||
removeMember: (userID: string) =>
|
||||
request<void>(`/api/v1/team/${userID}`, { method: 'DELETE' }),
|
||||
|
||||
// --- мапи ---
|
||||
|
||||
listMaps: () => request<{ maps: MapSummary[] }>('/api/v1/maps').then((r) => r.maps ?? []),
|
||||
|
||||
getMap: (id: string) => request<MapState>(`/api/v1/maps/${id}`),
|
||||
|
|
@ -72,11 +179,11 @@ export const api = {
|
|||
body: JSON.stringify(patch),
|
||||
}),
|
||||
|
||||
buildMap: (id: string) =>
|
||||
request<BuildResult>(`/api/v1/maps/${id}/build`, { method: 'POST' }),
|
||||
buildMap: (id: string) => request<BuildResult>(`/api/v1/maps/${id}/build`, { method: 'POST' }),
|
||||
|
||||
undoMap: (id: string) =>
|
||||
request<UndoResult>(`/api/v1/maps/${id}/undo`, { method: 'POST' }),
|
||||
undoMap: (id: string) => request<UndoResult>(`/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
|
||||
|
|
|
|||
70
web/src/api/session.ts
Normal file
70
web/src/api/session.ts
Normal file
|
|
@ -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[],
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
141
web/src/components/LoginPage.tsx
Normal file
141
web/src/components/LoginPage.tsx
Normal file
|
|
@ -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<TenantMembership[] | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4 py-8">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-6 text-center">
|
||||
<div className="text-2xl font-semibold tracking-tight text-slate-100">NetPulse</div>
|
||||
<div className="mt-1 text-sm text-slate-500">моніторинг мережі</div>
|
||||
</div>
|
||||
|
||||
{tenants ? (
|
||||
<div className="rounded-lg border border-slate-800 bg-slate-900 p-4">
|
||||
<h2 className="mb-3 text-sm text-slate-300">Оберіть організацію</h2>
|
||||
<ul className="space-y-2">
|
||||
{tenants.map((t) => (
|
||||
<li key={t.tenant_id}>
|
||||
<button
|
||||
className="w-full rounded border border-slate-700 bg-slate-800 px-3 py-2.5 text-left
|
||||
text-sm hover:bg-slate-700 disabled:opacity-40"
|
||||
disabled={busy}
|
||||
onClick={() => void submit(t.tenant_id)}
|
||||
>
|
||||
<span className="block text-slate-100">{t.tenant_name}</span>
|
||||
<span className="block text-xs text-slate-500">{t.role_name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
className="space-y-3 rounded-lg border border-slate-800 bg-slate-900 p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void submit()
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-slate-400">Email</span>
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
autoFocus
|
||||
className="w-full rounded border border-slate-700 bg-slate-800 px-3 py-2.5 text-sm
|
||||
text-slate-100 outline-none focus:border-sky-600"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-slate-400">Пароль</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="w-full rounded border border-slate-700 bg-slate-800 px-3 py-2.5 text-sm
|
||||
text-slate-100 outline-none focus:border-sky-600"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-red-900/60 bg-red-950/50 px-3 py-2 text-xs text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="w-full rounded bg-sky-700 px-3 py-2.5 text-sm font-medium text-white
|
||||
hover:bg-sky-600 disabled:opacity-40"
|
||||
>
|
||||
{busy ? 'Вхід…' : 'Увійти'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-center text-xs text-slate-600">
|
||||
Першого користувача заводить <code className="text-slate-500">netpulse-user</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<DeviceNodeType[]>(
|
||||
() =>
|
||||
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<TrafficEdgeType[]>(
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue