Досі доступ давав машинний токен зі змінної збірки — одні права на всіх і жодного способу відрізнити, хто що зробив. Тепер продукт уміє впустити людину. Сервер: - 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>
94 lines
5 KiB
SQL
94 lines
5 KiB
SQL
-- =====================================================================
|
||
-- 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);
|