diff --git a/server/internal/httpapi/logins.go b/server/internal/httpapi/logins.go new file mode 100644 index 0000000..e63f2d2 --- /dev/null +++ b/server/internal/httpapi/logins.go @@ -0,0 +1,137 @@ +package httpapi + +import ( + "net/http" + "strconv" + "strings" + "time" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// «Історія входів»: читання core.login_attempts, і тільки читання. +// +// Рідна сестра журналу аудиту: ті самі два GET (перелік і довідники), +// той самий курсор замість offset, той самий закріплений період і той +// самий блок про межі показаного. Різниця одна, і вона в шарі сховища: +// у core.login_attempts немає tenant_id, тож ізоляція кабінетів там +// зроблена руками — див. шапку store/login_attempts.go. +// +// ПРАВО ТЕ САМЕ, ЩО В ЖУРНАЛУ АУДИТУ, і це не економія на переліку +// прав. Обидві сторінки відповідають на одне питання — «хто і що робив +// з нашим доступом», — і роздати їх різним людям означало б, що один +// бачить «видалив конфіги о 14:02», а другий «увійшов о 14:01 з чужої +// адреси». Розслідування — це рівно поєднання цих двох рядків. +// audit:read і так є лише у власника й адміністратора. +// +// Ручки, яка стирає рядок, тут немає й не буде — з тієї ж причини, що в +// audit.go: історія входів, яку можна підчистити, не доводить нічого. +const loginsPerm = auditPerm + +// Стеля глибини вибірки — така сама, як у журналі. Ширшу межу тут +// ставити нема сенсу: спроби входу типово живуть 180 днів (0012), тобто +// глибше за півроку в таблиці порожньо в будь-якому разі. +const loginsMaxRange = auditMaxRange + +func (s *Server) handleListLoginAttempts(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, loginsPerm) { + return + } + + q := r.URL.Query() + f := store.LoginAttemptFilter{ + Reasons: splitParam(q, "reason"), + UserIDs: splitParam(q, "user"), + IP: strings.TrimSpace(q.Get("ip")), + Query: strings.TrimSpace(q.Get("q")), + Cursor: q.Get("cursor"), + } + + // Результат — три стани, а не прапорець. Значення, якого немає в + // переліку, ігнорується мовчки: сторінка кладе в адресу лише свої + // три, а чуже слово в чужому посиланні має дати повний перелік, а + // не відмову. + switch v := q.Get("outcome"); v { + case "failed", "success": + f.Outcome = v + } + + var err error + if f.From, err = parseTimeParam(q.Get("from")); err != nil { + writeError(w, http.StatusBadRequest, "invalid", "некоректна дата «від»") + return + } + if f.To, err = parseTimeParam(q.Get("to")); err != nil { + writeError(w, http.StatusBadRequest, "invalid", "некоректна дата «до»") + return + } + // Верхня межа за замовчуванням — зараз, а не «без межі»: та сама + // причина, що в журналі аудиту. Вхід, який стався між першою + // сторінкою й наступною, інакше зсунув би всю вибірку на рядок. + if f.To.IsZero() { + f.To = time.Now().UTC() + } + if f.From.IsZero() { + // Типовий період коротший, ніж у журналі (30 днів), і це не + // різнобій. Сюди приходять із питанням «що відбувається зараз», + // а не «що було в березні»: невдалі спроби цінні свіжими, і + // сім днів — це вікно, у якому їх ще можна з кимось звірити. + f.From = f.To.Add(-7 * 24 * time.Hour) + } + if !f.From.Before(f.To) { + writeError(w, http.StatusBadRequest, "invalid", "початок періоду має бути раніше за кінець") + return + } + if f.To.Sub(f.From) > loginsMaxRange { + writeError(w, http.StatusBadRequest, "invalid", + "період не більший за рік — звузьте діапазон або скористайтесь фільтрами") + return + } + + if n, err := strconv.Atoi(q.Get("limit")); err == nil { + f.Limit = n + } + + // Scope тут не застосовується — з тієї ж причини, що в журналі + // аудиту: він відповідає на «які ХОСТИ видно», а спроба входу не + // про хост. Межа видимості тут інша й жорсткіша — членство в + // кабінеті, і вона в сховищі. + page, err := s.store.ListLoginAttempts(r.Context(), p.TenantID, f) + if err != nil { + s.writeStoreError(w, "історія входів", err) + return + } + writeJSON(w, http.StatusOK, page) +} + +// handleLoginAttemptsMeta — усе, що потрібно сторінці на відкритті, +// одним викликом. Одним, а не трьома: це стан сторінки на момент +// відкриття, і зібраний із трьох відповідей у різні секунди він нічого +// не виграє, зате додає два стани завантаження на екран. +func (s *Server) handleLoginAttemptsMeta(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, loginsPerm) { + return + } + + actors, err := s.store.LoginActors(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "перелік людей в історії входів", err) + return + } + bounds, err := s.store.LoginAttemptsBounds(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "межі історії входів", err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "reasons": store.LoginReasons(), + "actors": actors, + "bounds": bounds, + // Чого сторінка не бачить. Не оздоблення: саме сюди приходять + // із питанням «нас ламають?», і мовчазна порожнеча читалась би + // як «ні». + "blind_spots": store.LoginBlindSpots(), + "max_range_days": int(loginsMaxRange / (24 * time.Hour)), + }) +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index a44a805..cce29f7 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -400,6 +400,14 @@ func (s *Server) Handler() http.Handler { mux.Handle("GET /api/v1/audit/meta", s.authenticated(s.handleAuditMeta)) mux.Handle("GET /api/v1/audit/{id}", s.authenticated(s.handleGetAuditEvent)) + // Історія входів — поруч із журналом і під тим самим правом + // (audit:read). Обидві сторінки відповідають на одне питання «хто і + // що робив з нашим доступом», і розслідування — це поєднання їхніх + // рядків. Ізоляція кабінетів тут зроблена руками: у + // core.login_attempts немає tenant_id. Див. logins.go. + mux.Handle("GET /api/v1/logins", s.authenticated(s.handleListLoginAttempts)) + mux.Handle("GET /api/v1/logins/meta", s.authenticated(s.handleLoginAttemptsMeta)) + mux.Handle("GET /api/v1/alerts", s.authenticated(s.handleListAlerts)) mux.Handle("POST /api/v1/alerts/{id}/ack", s.authenticated(s.handleAckAlert)) mux.Handle("POST /api/v1/alerts/{id}/close", s.authenticated(s.handleCloseAlert)) diff --git a/server/internal/store/login_attempts.go b/server/internal/store/login_attempts.go new file mode 100644 index 0000000..cfbc8e8 --- /dev/null +++ b/server/internal/store/login_attempts.go @@ -0,0 +1,691 @@ +package store + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +// Читання core.login_attempts — «Історія входів». +// +// Записує в цю таблицю recordLogin (users.go), і до сьогодні читало її +// рівно одне місце — FailedLoginsSince, лічильник для гальмування +// перебору. Тобто дані збиралися півтора року й не показувалися нікому. +// +// ГОЛОВНЕ ОБМЕЖЕННЯ ШАРУ: У ТАБЛИЦІ НЕМАЄ tenant_id. +// +// І не може бути: рядок пишеться в мить перевірки пароля, коли кабінет +// ще невідомий — саме тому спроби входу й не потрапили в журнал аудиту +// (перший пункт AuditBlindSpots). Наслідок для читання прямий: запит +// «покажи спроби входу» без додаткової умови віддає одному кабінету +// спроби входу ЧУЖИХ людей — з їхніми логінами, поштами й адресами. +// Це витік, і закривати його треба тут, а не на сторінці. +// +// ЯК РЯДОК ПРИВ'ЯЗУЄТЬСЯ ДО КАБІНЕТУ +// +// Прив'язка непряма — через людину, до якої спроба стосується: +// +// спроба → користувач → членство в кабінеті +// +// Користувач шукається трьома шляхами, і всі три потрібні: +// +// 1. a.user_id — заповнений у всіх випадках, крім «такого логіна +// немає». Найнадійніший шлях: він не залежить від того, чи людину +// потім перейменували. +// 2. u.username = a.email — бо в стовпці email лежить НЕ пошта, а те, +// що людина ввела в поле логіна (див. recordLogin: аргумент так і +// називається email, а передається туди login). Входять у продукт +// переважно логіном, тож без цього шляху половина спроб не +// прив'язалася б. +// 3. u.email = a.email — вхід поштою теж дозволений. +// +// РІШЕННЯ ПРО НЕПРИВ'ЯЗУВАНІ СПРОБИ +// +// Невдала спроба з логіном, якого в системі немає (reason = no_user), +// не належить жодному кабінету — прив'язати її НЕМА ДО ЧОГО. Те саме +// стосується спроби людини, яку вже вилучили з усіх кабінетів +// (reason = no_membership і членства більше немає). +// +// Розглянуто три варіанти, обрано третій. +// +// - «Показувати всім без пошти». Відкинуто. Логін приховано, але +// лишаються адреса й секунда — а цього досить: спроба о 09:12 з +// офісної адреси сусіднього кабінету з одруківкою в логіні — це +// їхній співробітник, і сусід його впізнає. Анонімізація, яку +// знімають двома відомими фактами, гірша за її відсутність, бо +// створює враження безпеки. +// - «Показувати власникові платформи». Відкинуто як нездійсненне: +// власник у NetPulse — це роль У КАБІНЕТІ (core.roles.key = owner), +// власника інсталяції в моделі немає взагалі. Реалізувати це +// означало б спершу вигадати нову роль поверх усієї моделі прав — +// тобто зробити зовсім іншу задачу. +// - ОБРАНО: рядками не показувати НІКОМУ, але порахувати й сказати +// число на сторінці. Рядок нікому не належить, і будь-який кабінет, +// якому його віддали, отримав чужі дані. А от «за цей період було 47 +// невдалих спроб з логінами, яких у системі немає, з 3 адрес» — +// число, що не називає нікого й не звужується до людини, зате несе +// рівно той сигнал, заради якого сторінку й відкривають: логіни +// перебирають. Мовчазний нуль на місці хвилі перебору був би +// найгіршим з можливих результатів. +// +// Однозначно правильного варіанта тут немає — є найменш небезпечний, і +// обрано саме його: із трьох це єдиний, у якому жоден кабінет не +// отримує жодного рядка про чужу людину. +// +// РІШЕННЯ ПРО СПРОБУ ДО «ЧУЖОГО» КОРИСТУВАЧА +// +// Питання ставиться двічі й має дві різні відповіді. +// +// - Логін належить людині, якої у вашому кабінеті немає. Не ваша +// справа й не ваш рядок: ця людина працює в іншому кабінеті, і те, +// що хтось перебирає її пароль, — подія їхнього кабінету, де вона +// й буде видна. Показати її вам означало б віддати вам чужий +// обліковий запис і чужу адресу. +// - Логін належить людині, яка є І у вас, і в сусідньому кабінеті +// (у core.users користувач глобальний — див. 0011). Спроба видна +// ОБОМ кабінетам, і це навмисно: підібраний пароль цієї людини +// відкриває дані обох, тож «хтось підбирає пароль до вашого +// користувача» — правда для кожного з них однаково. Витік тут +// мінімальний: кабінет бачить власного учасника, якого й так знає +// на ім'я, а не нову для себе людину. +// +// ЧОМУ УМОВА СТОЇТЬ ЯВНО, А НЕ ПОКЛАДАЄТЬСЯ НА RLS +// +// core.login_attempts — гіпертаблиця без tenant_id: політики на ній +// немає й бути не може. Читання йде через InTenantTx, і RLS на +// core.users та core.memberships підстраховує LATERAL другим шаром +// (без контексту він поверне порожньо, тобто відмова буде в бік +// «нічого не видно»), але єдиний робочий механізм ізоляції — саме +// m.tenant_id = $1 нижче. Прибрати цей рядок = показати кабінету чужі +// спроби входу. + +// LoginAttempt — рядок історії входів. +type LoginAttempt struct { + ID string `json:"id"` + TS time.Time `json:"ts"` + + // Login — те, що НАСПРАВДІ ввели в поле логіна. Не завжди пошта, і + // не завжди збігається з поточним логіном людини: після + // перейменування в старих рядках лишається старе значення, і + // переписувати його не можна — це доказова база. + Login string `json:"login"` + + Success bool `json:"success"` + // Причина відмови ключем і фразою. Ключ лишається поруч із тієї ж + // причини, що й у журналі аудиту: за ним фільтрують і його шлють у + // підтримку. + Reason string `json:"reason,omitempty"` + ReasonLabel string `json:"reason_label,omitempty"` + + IP string `json:"ip,omitempty"` + // UserAgent як є, і поруч — стислий переказ. Переказ приблизний за + // побудовою (див. deviceFromUA), тому початковий рядок теж їде: + // коли розбирають конкретний вхід, вгадане «Chrome» нічого не варте. + UserAgent string `json:"user_agent,omitempty"` + Device string `json:"device,omitempty"` + + // Кому спроба належить. Ім'я підтягується на момент ЧИТАННЯ — як у + // журналі аудиту: людину впізнають за поточним іменем. + UserID string `json:"user_id,omitempty"` + Username string `json:"username,omitempty"` + Email string `json:"email,omitempty"` + FullName string `json:"full_name,omitempty"` +} + +// LoginAttemptFilter — питання, з яким приходять на сторінку. +type LoginAttemptFilter struct { + // Період обов'язковий і завжди заданий обробником: таблиця росте + // вічно (в межах строку зберігання), і запит без нижньої межі — це + // запит по всій історії. + From time.Time + To time.Time + + // Outcome: "" — усе, "failed" — лише невдалі, "success" — лише + // вдалі. Рядок, а не *bool: значення приїздить із адресного рядка, + // і три стани там виражаються трьома словами, а не наявністю + // параметра. + Outcome string + + // Причини відмови. Порожній перелік — усі. + Reasons []string + + // Кого саме — ідентифікатори людей. + UserIDs []string + + // Адреса або підмережа: '10.20.0.5' і '10.20.0.0/24' обидві + // працюють через один оператор <<=. + IP string + + // Пошук підрядком по введеному логіну. Саме він, а не ім'я + // користувача: питання тут — «що вводили», і одруківка в логіні + // («adm1n») знаходиться тільки так. + Query string + + Limit int + Cursor string +} + +// LoginUnattributed — скільки невдалих спроб за той самий період не +// належать ЖОДНОМУ кабінету. +// +// Два числа й жодного рядка. Це і є компроміс, описаний у шапці файла: +// сигнал «логіни перебирають» лишається видимим, а логіни, адреси й +// час — ні. Кількість різних адрес відділяє «людина забула свій логін» +// від «ботнет ходить по словнику», і сама по собі нікого не називає. +type LoginUnattributed struct { + Attempts int `json:"attempts"` + IPs int `json:"ips"` +} + +// LoginAttemptsPage — сторінка історії входів. +type LoginAttemptsPage struct { + // Період, у якому відповідь зібрана насправді — як у журналі + // аудиту: клієнт закріплює отриману пару й шле її з кожною + // наступною сторінкою, інакше новий вхід під час гортання зсунув би + // усю вибірку на рядок. + From time.Time `json:"from"` + To time.Time `json:"to"` + + Attempts []LoginAttempt `json:"attempts"` + + // Курсор наступної сторінки. Порожній означає «більше немає»: як і + // в журналі, сторінка береться з limit+1 рядка. + NextCursor string `json:"next_cursor,omitempty"` + + // Заповнюється лише на ПЕРШІЙ сторінці (Cursor == ""). На «показати + // ще» число не змінюється — воно про весь період, а не про порцію, + // і рахувати його вдруге означало б платити зайвим запитом за той + // самий результат. + Unattributed *LoginUnattributed `json:"unattributed,omitempty"` +} + +// LoginAttemptBounds — від якої дати в таблиці взагалі щось є ДЛЯ ЦЬОГО +// КАБІНЕТУ. +// +// Та сама причина, що й у AuditBounds: порожня відповідь на «покажи +// березень» означає «у березні ще не писали» або «до березня ще ніхто +// не входив», а не «входів не було». Різницю сторінка мусить показати +// сама. +type LoginAttemptBounds struct { + First *time.Time `json:"first,omitempty"` + Last *time.Time `json:"last,omitempty"` +} + +// LoginActorOption — рядок у фільтрі «хто». +type LoginActorOption struct { + ID string `json:"id"` + Label string `json:"label"` +} + +// LoginReasonInfo — причина відмови: ключ і його людська назва. +type LoginReasonInfo struct { + Key string `json:"key"` + Label string `json:"label"` +} + +const ( + loginDefaultLimit = 50 + loginMaxLimit = 200 +) + +// --------------------------------------------------------------------- +// Словник причин +// --------------------------------------------------------------------- + +// Причини відмови у вході. +// +// Значення — рядки, які кладе recordLogin (users.go); змінювати їх +// заднім числом не можна, бо в таблиці вже лежать старі. Ключа, якого +// тут немає, це не ламає: причина показується самим лише ключем — так +// само, як дія без назви в журналі аудиту. +// +// Порядок навмисно не абетковий, а за небезпечністю: bad_password — +// перше, на що дивляться, бо саме воно означає «пароль підбирають до +// відомого логіна». +var loginReasons = []LoginReasonInfo{ + {Key: "bad_password", Label: "Невірний пароль"}, + {Key: "no_user", Label: "Такого логіна немає"}, + {Key: "no_membership", Label: "Немає доступу до жодного кабінету"}, + {Key: "no_password", Label: "Локального пароля немає (лише SSO)"}, + {Key: "locked", Label: "Обліковий запис заблоковано"}, + {Key: "mfa_failed", Label: "Не пройдено другий фактор"}, +} + +var loginReasonByKey = func() map[string]string { + m := make(map[string]string, len(loginReasons)) + for _, r := range loginReasons { + m[r.Key] = r.Label + } + return m +}() + +// LoginReasons — словник причин для фільтрів сторінки. +func LoginReasons() []LoginReasonInfo { + out := make([]LoginReasonInfo, len(loginReasons)) + copy(out, loginReasons) + return out +} + +// LoginBlindSpots — чесний перелік того, чого «Історія входів» НЕ +// показує. +// +// Той самий блок і та сама причина, що в AuditBlindSpots: сторінка без +// нього створює хибне відчуття повноти. Тут це особливо дорого — саме +// сюди приходять із питанням «нас ламають?», і мовчазна порожнеча +// читається як «ні». +// +// Перелік живе на сервері, а не в браузері, бо кожен його рядок — це +// властивість того, що продукт ЗАПИСУЄ (або не записує), а не того, як +// це показано. +func LoginBlindSpots() []string { + return []string{ + "Спроби входу людей з інших кабінетів. У core.login_attempts немає tenant_id — рядок пишеться тоді, коли кабінет ще невідомий, — тож спроба прив'язується непрямо: через логін або пошту до користувача, а через нього до членства у вашому кабінеті. Спроба до людини, якої у вас немає, належить її кабінету й тут не показується.", + "Невдалі спроби з логіном, якого в системі немає взагалі. Прив'язати їх нема до чого, а віддати комусь означало б віддати чужі дані, тож рядками вони не показуються НІКОМУ. Замість рядків — лічильник угорі: скільки таких спроб і з якої кількості адрес. Це навмисний компроміс, а не недогляд.", + "Спроби, відбиті гальмуванням перебору. Після 10 невдалих спроб за 15 хвилин вхід відмовляє ще ДО перевірки пароля, і рядок у таблицю не пишеться взагалі. Тобто справжня хвиля перебору тут виглядає рівно як 10 спроб і тиша.", + "Вихід із системи, оновлення й відкликання сесій. Таблиця називається «спроби входу» буквально: усе, що відбувається з сесією після нього, у ній не з'являється.", + "Вхід машинних токенів і зондів. Вони не проходять через перевірку пароля, а отже й через цю таблицю; їхні дії видно в журналі аудиту з позначкою токена.", + "У який саме кабінет людина увійшла. У рядку цього немає, і додати заднім числом нема звідки. Тому вхід людини, яка працює у двох кабінетах, видно в обох — навіть якщо вона того разу зайшла лише в один.", + "Те, що вже видалено за строком зберігання. Типово спроби входу живуть 180 днів; строк налаштовується в розділі «Сховище» (kind login_attempts) і не може бути меншим за 30 днів.", + } +} + +// --------------------------------------------------------------------- +// Вибірка +// --------------------------------------------------------------------- + +// ListLoginAttempts повертає сторінку історії входів для кабінету. +func (s *Store) ListLoginAttempts(ctx context.Context, tenantID string, f LoginAttemptFilter) (LoginAttemptsPage, error) { + limit := f.Limit + if limit <= 0 { + limit = loginDefaultLimit + } + if limit > loginMaxLimit { + limit = loginMaxLimit + } + + from, args, err := loginAttemptsFrom(tenantID, f) + if err != nil { + return LoginAttemptsPage{}, err + } + + // На один рядок більше, ніж показуємо: він і є відповіддю на «чи є + // наступна сторінка». Те саме рішення, що в ListAudit. + args = append(args, limit+1) + + q := ` + SELECT a.ts, a.id::text, a.email::text, a.success, a.reason, + host(a.ip), a.user_agent, + usr.uid, usr.uname, usr.uemail, usr.ufull + ` + from + ` + ORDER BY a.ts DESC, a.id DESC + LIMIT $` + strconv.Itoa(len(args)) + + // InTenantTx потрібен не самій таблиці спроб (політик на ній + // немає), а LATERAL: core.users і core.memberships під RLS, і без + // app.tenant_id жодна спроба не прив'язалася б. Тобто забутий + // контекст дає порожній екран, а не чужі рядки, — відмова в + // безпечний бік. + out := LoginAttemptsPage{From: f.From, To: f.To, Attempts: []LoginAttempt{}} + err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, q, args...) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + a, err := scanLoginAttempt(rows) + if err != nil { + return err + } + out.Attempts = append(out.Attempts, a) + } + return rows.Err() + }) + if err != nil { + return LoginAttemptsPage{}, err + } + + if len(out.Attempts) > limit { + last := out.Attempts[limit-1] + out.Attempts = out.Attempts[:limit] + // Курсор той самий, що в журналі аудиту, і функція та сама: + // первинний ключ тут теж (ts, id), і друга копія на п'ять + // рядків розійшлася б із першою на першій же правці. + out.NextCursor = encodeAuditCursor(last.TS, last.ID) + } + + // Межу видимості рахуємо лише для першої сторінки — див. коментар + // до поля Unattributed. + if f.Cursor == "" { + u, err := s.unattributedLogins(ctx, f.From, f.To) + if err != nil { + return LoginAttemptsPage{}, err + } + out.Unattributed = &u + } + return out, nil +} + +// loginAttemptsFrom складає FROM + WHERE і аргументи. $1 завжди +// tenant_id. +// +// Одна функція на джерело й умову, а не дві: прив'язка до кабінету +// живе саме в LATERAL, і рознести її з рештою умов означало б лишити +// місце, де можна зібрати запит без неї. Тест +// (login_attempts_test.go) перевіряє цю функцію на кожній комбінації +// фільтрів — саме тому, що вона одна. +func loginAttemptsFrom(tenantID string, f LoginAttemptFilter) (string, []any, error) { + if strings.TrimSpace(tenantID) == "" { + // Порожній кабінет — не «показати все», а помилка. Без цього + // рядка виклик із незаповненим tenantID зібрав би запит, у + // якому m.tenant_id = '' не збігається ні з чим… на щастя. Але + // покладатись на «на щастя» в умові, що відділяє кабінети, + // не можна. + return "", nil, fmt.Errorf("%w: не вказано кабінет", ErrInvalid) + } + + args := []any{tenantID} + + // LATERAL робить дві речі одночасно й навмисно: знаходить людину, + // до якої спроба стосується, і ВІДСІЮЄ рядок, якщо такої людини в + // цьому кабінеті немає. Саме тому це JOIN, а не LEFT JOIN: підзапит + // без збігів не дає рядка, і спроба зникає з вибірки. + // + // LIMIT 1 всередині — бо в кабінеті може знайтись і збіг за + // user_id, і збіг за логіном (перейменували людину, а стару назву + // зайняв хтось інший). ORDER BY ставить попереду точний збіг за + // user_id: він не залежить від перейменувань. + from := ` + FROM core.login_attempts a + JOIN LATERAL ( + SELECT u.id::text AS uid, u.username::text AS uname, + u.email::text AS uemail, coalesce(u.full_name, '') AS ufull + FROM core.users u + JOIN core.memberships m ON m.user_id = u.id AND m.tenant_id = $1 + WHERE u.id = a.user_id + OR u.username = a.email + OR u.email = a.email + ORDER BY (u.id IS NOT DISTINCT FROM a.user_id) DESC + LIMIT 1 + ) usr ON true + WHERE ` + + var cond []string + add := func(sql string, v any) { + args = append(args, v) + cond = append(cond, fmt.Sprintf(sql, len(args))) + } + + if !f.From.IsZero() { + add("a.ts >= $%d", f.From) + } + if !f.To.IsZero() { + add("a.ts < $%d", f.To) + } + + switch f.Outcome { + case "failed": + cond = append(cond, "NOT a.success") + case "success": + cond = append(cond, "a.success") + } + + if len(f.Reasons) > 0 { + add("a.reason = ANY($%d)", f.Reasons) + } + if len(f.UserIDs) > 0 { + add("usr.uid = ANY($%d)", f.UserIDs) + } + if ip := strings.TrimSpace(f.IP); ip != "" { + add("a.ip <<= $%d::inet", ip) + } + if q := strings.TrimSpace(f.Query); q != "" { + add("a.email::text ILIKE $%d", "%"+escapeLike(q)+"%") + } + + if f.Cursor != "" { + ts, id, err := decodeAuditCursor(f.Cursor) + if err != nil { + return "", nil, err + } + args = append(args, ts, id) + cond = append(cond, fmt.Sprintf("(a.ts, a.id) < ($%d, $%d)", len(args)-1, len(args))) + } + + // Умов може не бути жодної (порожній фільтр). Порожній WHERE — це + // синтаксична помилка, тож TRUE тримає форму запиту сталою; сама + // прив'язка до кабінету від цього не залежить — вона в JOIN вище. + if len(cond) == 0 { + cond = append(cond, "TRUE") + } + return from + strings.Join(cond, "\n\t\t AND "), args, nil +} + +func scanLoginAttempt(rows pgx.Rows) (LoginAttempt, error) { + var ( + a LoginAttempt + reason *string + ip *string + ua *string + uid *string + uname *string + uemail *string + ufull *string + ) + if err := rows.Scan(&a.TS, &a.ID, &a.Login, &a.Success, &reason, + &ip, &ua, &uid, &uname, &uemail, &ufull); err != nil { + return LoginAttempt{}, err + } + + a.TS = a.TS.UTC() + a.Reason = deref(reason) + a.ReasonLabel = loginReasonByKey[a.Reason] + a.IP = deref(ip) + a.UserAgent = deref(ua) + a.Device = deviceFromUA(a.UserAgent) + a.UserID = deref(uid) + a.Username = deref(uname) + a.Email = deref(uemail) + a.FullName = deref(ufull) + return a, nil +} + +// unattributedLogins рахує невдалі спроби, які не належать жодному +// кабінету. +// +// ЄДИНЕ МІСЦЕ ФАЙЛА, ЯКЕ ХОДИТЬ ПОВЗ КАБІНЕТ, — і це неминуче за +// змістом питання. «Не належить ЖОДНОМУ кабінету» неможливо з'ясувати, +// бачачи один: під InTenantTx політика на core.users сховала б людей +// сусідів, і їхні невдалі спроби порахувались би як нічиї — тобто +// кабінет дізнався б, скільки разів помилялися сусіди. Це був би витік +// рівно там, де ми його закриваємо. +// +// Тому запит іде пулом воркера (bg), який бачить усе. Назовні з нього +// виходять ДВА ЧИСЛА — жодного логіна, жодної адреси, жодного часу. +func (s *Store) unattributedLogins(ctx context.Context, from, to time.Time) (LoginUnattributed, error) { + var u LoginUnattributed + err := s.bg.QueryRow(ctx, ` + SELECT count(*)::int, count(DISTINCT a.ip)::int + FROM core.login_attempts a + WHERE a.ts >= $1 AND a.ts < $2 AND NOT a.success + AND NOT EXISTS ( + SELECT 1 + FROM core.users u + JOIN core.memberships m ON m.user_id = u.id + WHERE u.id = a.user_id + OR u.username = a.email + OR u.email = a.email + ) + `, from, to).Scan(&u.Attempts, &u.IPs) + if err != nil { + return LoginUnattributed{}, err + } + return u, nil +} + +// LoginAttemptsBounds — перша й остання спроба, видима цьому кабінету. +func (s *Store) LoginAttemptsBounds(ctx context.Context, tenantID string) (LoginAttemptBounds, error) { + // Прив'язка та сама, що у вибірці, і зібрана тією ж функцією: два + // різні визначення «наша спроба» розійшлися б, і сторінка казала б + // «записи з 3 березня», не показуючи жодного за березень. + from, args, err := loginAttemptsFrom(tenantID, LoginAttemptFilter{}) + if err != nil { + return LoginAttemptBounds{}, err + } + + var b LoginAttemptBounds + err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + // Два запити з LIMIT 1, а не min()/max(): впорядкований обхід + // гіпертаблиці зупиняється на першому ж чанку, у якому щось є. + err := tx.QueryRow(ctx, + "SELECT a.ts "+from+"\n\t\tORDER BY a.ts ASC, a.id ASC LIMIT 1", args..., + ).Scan(&b.First) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + err = tx.QueryRow(ctx, + "SELECT a.ts "+from+"\n\t\tORDER BY a.ts DESC, a.id DESC LIMIT 1", args..., + ).Scan(&b.Last) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + return nil + }) + if err != nil { + return LoginAttemptBounds{}, err + } + + if b.First != nil { + t := b.First.UTC() + b.First = &t + } + if b.Last != nil { + t := b.Last.UTC() + b.Last = &t + } + return b, nil +} + +// LoginActors — люди кабінету, до яких є хоч одна спроба входу. +// +// Саме «до яких є спроба», а не весь склад команди: фільтр «хто» має +// пропонувати те, що в переліку справді трапляється, інакше половина +// значень дає гарантовано порожній результат. +func (s *Store) LoginActors(ctx context.Context, tenantID string) ([]LoginActorOption, error) { + from, args, err := loginAttemptsFrom(tenantID, LoginAttemptFilter{}) + if err != nil { + return nil, err + } + + out := []LoginActorOption{} + err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, + "SELECT DISTINCT usr.uid, usr.uname "+from+"\n\t\tORDER BY usr.uname", args...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var o LoginActorOption + if err := rows.Scan(&o.ID, &o.Label); err != nil { + return err + } + out = append(out, o) + } + return rows.Err() + }) + if err != nil { + return nil, err + } + return out, nil +} + +// --------------------------------------------------------------------- +// Пристрій і браузер +// --------------------------------------------------------------------- + +// deviceFromUA переказує User-Agent людською фразою. +// +// Переказ ПРИБЛИЗНИЙ, і це не лінощі, а властивість самого User-Agent: +// рядок задає клієнт, будь-хто може написати там що завгодно, а +// браузери десятиліттями брешуть один про одного («Chrome» усередині +// Edge, «Safari» усередині всіх). Тягнути сюди бібліотеку розбору +// означало б отримати ту саму здогадку, лише довшу. +// +// Тому правило одне: здогадка йде поруч із початковим рядком, ніколи +// замість нього. Порожній результат — теж відповідь: сторінка покаже +// User-Agent як є. +// +// Порядок перевірок має значення: Edge несе в собі Chrome, Chrome — +// Safari, тож шукати треба від найспецифічнішого. +func deviceFromUA(ua string) string { + if strings.TrimSpace(ua) == "" { + return "" + } + + browser := "" + switch { + case strings.Contains(ua, "Edg/"), strings.Contains(ua, "Edge/"): + browser = "Edge" + case strings.Contains(ua, "OPR/"), strings.Contains(ua, "Opera"): + browser = "Opera" + case strings.Contains(ua, "YaBrowser"): + browser = "Yandex" + case strings.Contains(ua, "Firefox/"): + browser = "Firefox" + case strings.Contains(ua, "Chrome/"), strings.Contains(ua, "Chromium/"): + browser = "Chrome" + case strings.Contains(ua, "Safari/"): + browser = "Safari" + // Не браузери. Їх варто називати вголос: вхід із curl або скрипта — + // це не «людина за столом», і сплутати ці два випадки в розборі + // інциденту найдорожче. + case strings.HasPrefix(ua, "curl/"): + return "curl (скрипт)" + case strings.HasPrefix(ua, "Wget/"): + return "wget (скрипт)" + case strings.HasPrefix(ua, "Go-http-client"): + return "Go (скрипт)" + case strings.HasPrefix(ua, "python-requests"), strings.HasPrefix(ua, "Python-urllib"): + return "Python (скрипт)" + case strings.HasPrefix(ua, "PostmanRuntime"): + return "Postman" + case strings.Contains(ua, "NetPulse"): + return "NetPulse (зонд або CLI)" + } + + os := "" + switch { + case strings.Contains(ua, "Windows NT"): + os = "Windows" + case strings.Contains(ua, "Android"): + os = "Android" + // iPhone/iPad перевіряються ДО Mac OS X: у мобільній Safari + // присутні обидва рядки. + case strings.Contains(ua, "iPhone"): + os = "iPhone" + case strings.Contains(ua, "iPad"): + os = "iPad" + case strings.Contains(ua, "Mac OS X"), strings.Contains(ua, "Macintosh"): + os = "macOS" + case strings.Contains(ua, "Linux"), strings.Contains(ua, "X11"): + os = "Linux" + } + + switch { + case browser != "" && os != "": + return browser + " · " + os + case browser != "": + return browser + case os != "": + return os + default: + return "" + } +} diff --git a/server/internal/store/login_attempts_test.go b/server/internal/store/login_attempts_test.go new file mode 100644 index 0000000..209f116 --- /dev/null +++ b/server/internal/store/login_attempts_test.go @@ -0,0 +1,413 @@ +package store + +import ( + "context" + "os" + "regexp" + "strconv" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Відсіювання чужого в історії входів. +// +// ЧОМУ ЦЕЙ ФАЙЛ ІСНУЄ. У core.login_attempts немає tenant_id, тож +// ізоляція кабінетів тут не тримається ні на RLS, ні на предикаті по +// стовпцю — вона тримається на одному JOIN LATERAL у +// loginAttemptsFrom. Помилка в ньому не падає й не порожнить екран: +// сторінка малюється, рядки на місці, просто серед них чужі — з +// логінами, поштами й адресами людей із сусіднього кабінету. Побачити +// це можна лише знаючи, кого в кабінеті бути не може. +// +// Тому перевірок дві, і вони різні за силою. +// +// 1. Проти рядка запиту — працює завжди, зокрема на машині без +// Postgres. Ловить найімовірнішу поломку: умову прибрали або вона +// зникла для якоїсь комбінації фільтрів. +// 2. Проти БАЗИ — доводить не наявність рядка в SQL, а результат. +// Мовчки пропускається без NETPULSE_TEST_DSN (scripts/dbtest.sh). + +// --------------------------------------------------------------------- +// 1. Умова кабінету є завжди +// --------------------------------------------------------------------- + +func loginFilterCases() map[string]LoginAttemptFilter { + now := time.Now().UTC() + return map[string]LoginAttemptFilter{ + "порожній": {}, + "період": {From: now.Add(-24 * time.Hour), To: now}, + "лише невдалі": {Outcome: "failed"}, + "лише вдалі": {Outcome: "success"}, + "причини": {Reasons: []string{"bad_password", "no_user"}}, + "люди": {UserIDs: []string{"9b8e1737-5661-4b9c-b14e-ed8cd382145b"}}, + "підмережа": {IP: "10.20.0.0/24"}, + "пошук": {Query: "adm1n"}, + "курсор": { + Cursor: encodeAuditCursor(now, "9b8e1737-5661-4b9c-b14e-ed8cd382145b"), + }, + "усе разом": { + From: now.Add(-24 * time.Hour), + To: now, + Outcome: "failed", + Reasons: []string{"bad_password"}, + UserIDs: []string{"9b8e1737-5661-4b9c-b14e-ed8cd382145b"}, + IP: "10.20.0.5", + Query: "adm1n", + Cursor: encodeAuditCursor(now, "9b8e1737-5661-4b9c-b14e-ed8cd382145b"), + }, + } +} + +// Головна перевірка файла: жодна комбінація фільтрів не збирає запит +// без прив'язки до кабінету. +// +// Перевіряється не «десь є слово tenant», а три речі одночасно: умова +// стоїть на $1, вона всередині LATERAL (тобто відсікає рядок, а не +// просто добирає ім'я), і сам LATERAL приєднаний як JOIN, а не LEFT +// JOIN. Останнє — найтихіша з можливих поломок: одне слово перетворює +// відсіювання на «добери ім'я, якщо знайдеш», і вибірка починає +// віддавати ЧУЖІ спроби з порожньою колонкою «хто». +func TestLoginAttemptsAlwaysScopedToTenant(t *testing.T) { + for name, f := range loginFilterCases() { + t.Run(name, func(t *testing.T) { + from, args, err := loginAttemptsFrom("00000000-0000-0000-0000-000000000001", f) + if err != nil { + t.Fatalf("loginAttemptsFrom: %v", err) + } + + if !strings.Contains(from, "m.tenant_id = $1") { + t.Fatalf("немає предикату кабінету:\n%s", from) + } + if !strings.Contains(from, "JOIN LATERAL") { + t.Fatalf("прив'язка більше не через LATERAL:\n%s", from) + } + if strings.Contains(from, "LEFT JOIN LATERAL") { + t.Fatalf("LEFT JOIN LATERAL не відсікає чужі рядки — лише не добирає ім'я:\n%s", from) + } + // Предикат мусить стояти ВСЕРЕДИНІ підзапиту, інакше він + // нічого не обмежує. + open := strings.Index(from, "JOIN LATERAL") + closeAt := strings.Index(from, ") usr") + tenantAt := strings.Index(from, "m.tenant_id = $1") + if !(open < tenantAt && tenantAt < closeAt) { + t.Fatalf("предикат кабінету поза LATERAL:\n%s", from) + } + if args[0] != "00000000-0000-0000-0000-000000000001" { + t.Fatalf("$1 — не кабінет, а %v", args[0]) + } + }) + } +} + +// Нумерація плейсхолдерів — те, що ламається мовчки. +// +// Помилка на одиницю тут не падає: запит виконується, просто порівнює +// не ту колонку не з тим значенням. На сторінці, яку відкривають з +// питанням «чи нас ламають», це неправильна відповідь із виглядом +// правильної. +func TestLoginAttemptsPlaceholdersMatchArgs(t *testing.T) { + ph := regexp.MustCompile(`\$(\d+)`) + for name, f := range loginFilterCases() { + t.Run(name, func(t *testing.T) { + from, args, err := loginAttemptsFrom("00000000-0000-0000-0000-000000000001", f) + if err != nil { + t.Fatalf("loginAttemptsFrom: %v", err) + } + seen := map[int]bool{} + maxN := 0 + for _, m := range ph.FindAllStringSubmatch(from, -1) { + n, _ := strconv.Atoi(m[1]) + seen[n] = true + if n > maxN { + maxN = n + } + } + if maxN != len(args) { + t.Fatalf("найбільший плейсхолдер $%d, а аргументів %d\n%s", maxN, len(args), from) + } + for i := 1; i <= len(args); i++ { + if !seen[i] { + t.Fatalf("аргумент $%d не використано\n%s", i, from) + } + } + }) + } +} + +// Порожній кабінет — відмова, а не «показати все». +// +// Виклик без tenantID сьогодні дав би запит, у якому порожній рядок не +// збігається ні з чим, тобто порожню відповідь — «на щастя». Умова, що +// відділяє кабінети, не має триматись на щасті. +func TestLoginAttemptsRejectsEmptyTenant(t *testing.T) { + for _, id := range []string{"", " "} { + if _, _, err := loginAttemptsFrom(id, LoginAttemptFilter{}); err == nil { + t.Fatalf("порожній кабінет (%q) прийнято без помилки", id) + } + } +} + +// Кожен ключ причини зі словника має назву, і навпаки — жодної назви +// без ключа. Ключі беруться з recordLogin (users.go): якщо там +// з'явиться нова причина, вона покажеться сирим рядком, і це +// припустимо — а от назва, прив'язана до ключа з одруківкою, брехала б. +func TestLoginReasonsDictionary(t *testing.T) { + for _, r := range LoginReasons() { + if r.Key == "" || r.Label == "" { + t.Fatalf("порожній рядок словника: %+v", r) + } + if loginReasonByKey[r.Key] != r.Label { + t.Fatalf("ключ %q не знайшовся в мапі", r.Key) + } + } + // Ті чотири, які продукт справді пише сьогодні. Перелік звірений із + // викликами recordLogin у store/users.go. + for _, key := range []string{"bad_password", "no_user", "no_membership", "no_password"} { + if loginReasonByKey[key] == "" { + t.Fatalf("причина %q, яку продукт пише, лишилась без назви", key) + } + } +} + +func TestDeviceFromUA(t *testing.T) { + cases := map[string]string{ + "": "", + // Edge несе в собі і Chrome, і Safari — саме тому порядок + // перевірок у deviceFromUA має значення. + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 Edg/120.0": "Edge · Windows", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36": "Chrome · Windows", + "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0": "Firefox · Linux", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Version/17.0 Mobile/15E148 Safari/604.1": "Safari · iPhone", + // Не браузер. Сплутати скрипт із людиною за столом — найдорожча + // помилка в розборі інциденту. + "curl/8.4.0": "curl (скрипт)", + "Go-http-client/1.1": "Go (скрипт)", + "щось геть невідоме": "", + } + for ua, want := range cases { + if got := deviceFromUA(ua); got != want { + t.Errorf("deviceFromUA(%q) = %q, очікували %q", ua, got, want) + } + } +} + +// --------------------------------------------------------------------- +// 2. Проти бази: чужого не видно +// --------------------------------------------------------------------- + +// Доказ того, що відсіювання працює, а не оголошене. +// +// Перевірка проти рядка запиту вище не бачить різниці між «умова є» і +// «умова діє»: помилитись можна в самому зіставленні (шукати u.email +// там, де в стовпці лежить логін), і SQL лишиться правильним на вигляд. +// Тому тут — два справжні кабінети, п'ять справжніх рядків і питання до +// бази. +// +// Тест мовчки пропускається без NETPULSE_TEST_DSN. База мусить бути +// ОДНОРАЗОВОЮ — запускати через scripts/dbtest.sh. +func TestLoginAttemptsTenantIsolation(t *testing.T) { + dsn := os.Getenv("NETPULSE_TEST_DSN") + if dsn == "" { + t.Skip("NETPULSE_TEST_DSN не задано — перевірка проти бази пропускається") + } + ctx := context.Background() + + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("підключення: %v", err) + } + t.Cleanup(pool.Close) + + st := &Store{pool: pool, bg: pool} + + suffix := strings.ReplaceAll(time.Now().Format("150405.000"), ".", "") + a := seedLoginTenant(ctx, t, pool, "logins-a-"+suffix) + b := seedLoginTenant(ctx, t, pool, "logins-b-"+suffix) + + now := time.Now().UTC() + // Наливаємо п'ять спроб: дві кабінету A, дві кабінету B і одну + // нічию. Кожна — окремий шлях прив'язки, і всі три шляхи тут є: + // за user_id, за логіном і за поштою. + attempt := func(login string, userID *string, ok bool, reason string, ip string, ago time.Duration) { + t.Helper() + var r any + if reason != "" { + r = reason + } + _, err := pool.Exec(ctx, ` + INSERT INTO core.login_attempts (ts, email, ip, user_agent, success, reason, user_id) + VALUES ($1, $2, $3::inet, $4, $5, $6, $7) + `, now.Add(-ago), login, ip, "curl/8.4.0", ok, r, userID) + if err != nil { + t.Fatalf("спроба %q: %v", login, err) + } + } + t.Cleanup(func() { + bg := context.Background() + // login_attempts ні на що не каскадує — прибираємо самі, і саме + // за тими логінами, які завели. + _, _ = pool.Exec(bg, `DELETE FROM core.login_attempts WHERE email = ANY($1)`, + []string{a.username, a.email, b.username, b.email, "ніхто-" + suffix}) + }) + + attempt(a.username, &a.userID, true, "", "10.10.0.1", 5*time.Minute) // A, за user_id + attempt(a.email, nil, false, "bad_password", "10.10.0.2", 6*time.Minute) // A, за поштою + attempt(b.username, &b.userID, false, "bad_password", "10.20.0.1", 7*time.Minute) // B, за логіном + attempt(b.email, nil, false, "bad_password", "10.20.0.2", 8*time.Minute) // B, за поштою + attempt("ніхто-"+suffix, nil, false, "no_user", "203.0.113.9", 9*time.Minute) // нічия + + f := LoginAttemptFilter{From: now.Add(-time.Hour), To: now.Add(time.Minute)} + + // Половина перша, без якої друга нічого не доводить: СВОЄ видно. + // Саме на цьому й горять перевірки ізоляції — вони зеленіють від + // того, що не видно нічого. + pageA, err := st.ListLoginAttempts(ctx, a.tenantID, f) + if err != nil { + t.Fatalf("перелік для A: %v", err) + } + if len(pageA.Attempts) != 2 { + t.Fatalf("кабінет A бачить %d спроб замість 2: %+v", len(pageA.Attempts), logins(pageA)) + } + + // Половина друга: ЧУЖОГО не видно — ні рядком, ні логіном. + for _, at := range pageA.Attempts { + if at.UserID != a.userID { + t.Fatalf("у кабінеті A спроба чужої людини: %+v", at) + } + if strings.EqualFold(at.Login, b.username) || strings.EqualFold(at.Login, b.email) { + t.Fatalf("у кабінеті A видно логін кабінету B: %q", at.Login) + } + if at.Login == "ніхто-"+suffix { + t.Fatalf("у кабінеті A видно неприв'язувану спробу: %q", at.Login) + } + } + + // Дзеркально для B — інакше «A бачить дві» могло б означати, що + // зіставлення випадково збіглося саме на цих рядках. + pageB, err := st.ListLoginAttempts(ctx, b.tenantID, f) + if err != nil { + t.Fatalf("перелік для B: %v", err) + } + if len(pageB.Attempts) != 2 { + t.Fatalf("кабінет B бачить %d спроб замість 2: %+v", len(pageB.Attempts), logins(pageB)) + } + for _, at := range pageB.Attempts { + if at.UserID != b.userID { + t.Fatalf("у кабінеті B спроба чужої людини: %+v", at) + } + } + + // Нічия спроба не показана нікому, але порахована — і в обох + // кабінетах однаково, бо вона нічия для обох. + for _, p := range []struct { + name string + page LoginAttemptsPage + }{{"A", pageA}, {"B", pageB}} { + if p.page.Unattributed == nil { + t.Fatalf("кабінет %s не отримав межі видимості", p.name) + } + if p.page.Unattributed.Attempts < 1 { + t.Fatalf("кабінет %s: неприв'язувані спроби не пораховані (%d)", + p.name, p.page.Unattributed.Attempts) + } + } + + // Причина відмови й переказ User-Agent доїжджають до рядка: без + // них сторінка показала б «невдало» без відповіді на «чому» і + // «звідки». + var failed *LoginAttempt + for i := range pageA.Attempts { + if !pageA.Attempts[i].Success { + failed = &pageA.Attempts[i] + } + } + if failed == nil { + t.Fatal("невдала спроба кабінету A не потрапила у вибірку") + } + if failed.ReasonLabel == "" { + t.Fatalf("причина %q лишилась без назви", failed.Reason) + } + if failed.Device != "curl (скрипт)" { + t.Fatalf("переказ User-Agent = %q", failed.Device) + } + if failed.Username != a.username { + t.Fatalf("спроба не прив'язалась до людини: %+v", failed) + } + + // Межі й перелік людей рахуються ТІЄЮ Ж умовою — інакше сторінка + // казала б «записи з 3 березня», не показуючи жодного за березень. + actors, err := st.LoginActors(ctx, a.tenantID) + if err != nil { + t.Fatalf("перелік людей A: %v", err) + } + if len(actors) != 1 || actors[0].Label != a.username { + t.Fatalf("у фільтрі «хто» кабінету A: %+v", actors) + } +} + +type loginTenant struct { + tenantID string + userID string + username string + email string +} + +// seedLoginTenant заводить кабінет із рівно одним учасником. +// +// Свій, а не seedTenant із rls_isolation_test.go: тому потрібні хости, +// лінки й доступи, а тут — людина з логіном і поштою, і нічого більше. +func seedLoginTenant(ctx context.Context, t *testing.T, pool *pgxpool.Pool, slug string) loginTenant { + t.Helper() + r := loginTenant{ + username: slug + "-user", + email: slug + "@example.test", + } + + must := func(what string, err error) { + t.Helper() + if err != nil { + t.Fatalf("%s (%s): %v", what, slug, err) + } + } + + must("кабінет", pool.QueryRow(ctx, ` + INSERT INTO core.tenants (slug, name) VALUES ($1, $2) RETURNING id::text + `, slug, "Перевірка входів "+slug).Scan(&r.tenantID)) + + t.Cleanup(func() { + bg := context.Background() + _, _ = pool.Exec(bg, `DELETE FROM core.tenants WHERE id = $1`, r.tenantID) + _, _ = pool.Exec(bg, `DELETE FROM core.users WHERE username = $1`, r.username) + }) + + must("користувач", pool.QueryRow(ctx, ` + INSERT INTO core.users (username, email, password_hash) + VALUES ($1, $2, 'x') RETURNING id::text + `, r.username, r.email).Scan(&r.userID)) + + // Системна роль, а не роль кабінету: у 0010 системні ролі заведені + // з tenant_id IS NULL і спільні для всіх кабінетів. Яка саме — + // байдуже: тут перевіряється членство, а не права. + var roleID string + must("роль", pool.QueryRow(ctx, ` + SELECT id::text FROM core.roles WHERE tenant_id IS NULL AND key = 'viewer' + `).Scan(&roleID)) + + _, err := pool.Exec(ctx, ` + INSERT INTO core.memberships (tenant_id, user_id, role_id) VALUES ($1, $2, $3) + `, r.tenantID, r.userID, roleID) + must("членство", err) + + return r +} + +func logins(p LoginAttemptsPage) []string { + out := make([]string, 0, len(p.Attempts)) + for _, a := range p.Attempts { + out = append(out, a.Login) + } + return out +} diff --git a/server/internal/store/users.go b/server/internal/store/users.go index 53abf4b..e9587f2 100644 --- a/server/internal/store/users.go +++ b/server/internal/store/users.go @@ -552,9 +552,64 @@ func (s *Store) CreateUser(ctx context.Context, tenantID, username, email, passw return &u, nil } +// MemberRef — учасник разом із роллю, яку він мав у цю мить. +// +// Потрібен журналу аудиту, і саме тому в ньому і ключ ролі, і назва. +// Ключ — те, за чим шукають і що не змінюється; назва — те, що людина +// впізнає, і те, чого через рік може вже не існувати: роль +// перейменують або зітруть, і рядок «роль 7f3a…» перестане відповідати +// на питання, заради якого журнал відкрили. +type MemberRef struct { + UserID string + Username string + RoleID string + RoleKey string + RoleName string +} + // SetRole змінює роль учасника команди. -func (s *Store) SetRole(ctx context.Context, tenantID, userID, roleID string) error { - return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { +// +// Повертає стан ДО і ПІСЛЯ — так само, як UpdateRole повертає два +// знімки прав. Причина та сама: «змінили роль» без обох половин не +// відповідає ні на що, а після UPDATE попередня роль не лишається ніде. +func (s *Store) SetRole(ctx context.Context, tenantID, userID, roleID string) (before, after MemberRef, err error) { + err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + // Читаємо під замком у тій самій транзакції: між читанням і + // записом уміщається друга правка тієї ж людини, і без замка + // журнал назвав би «попередньою» роль, якої вона вже не мала. + if err := tx.QueryRow(ctx, ` + SELECT u.username::text, r.id::text, r.key::text, r.name + 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 m.user_id = $2 + FOR UPDATE OF m + `, tenantID, userID).Scan(&before.Username, &before.RoleID, + &before.RoleKey, &before.RoleName); err != nil { + if isNoRows(err) { + return ErrNotFound + } + return err + } + before.UserID = userID + + // Роль звіряємо з тим самим предикатом, що й ListRoles: + // вбудована (tenant_id IS NULL) або своя. Зовнішній ключ + // пропустив би сюди роль СУСІДНЬОГО кабінету — вона існує, тож + // FK задоволений, — і людина отримала б набір прав, якого в + // переліку її кабінету немає й не було. Обробник уже вміє + // відповідати на це «учасника або роль не знайдено». + after = MemberRef{UserID: userID, Username: before.Username} + if err := tx.QueryRow(ctx, ` + SELECT id::text, key::text, name FROM core.roles + WHERE id = $1 AND (tenant_id IS NULL OR tenant_id = $2) + `, roleID, tenantID).Scan(&after.RoleID, &after.RoleKey, &after.RoleName); err != nil { + if isNoRows(err) { + return ErrNotFound + } + return err + } + tag, err := tx.Exec(ctx, ` UPDATE core.memberships SET role_id = $3 WHERE tenant_id = $1 AND user_id = $2 @@ -567,6 +622,7 @@ func (s *Store) SetRole(ctx context.Context, tenantID, userID, roleID string) er } return nil }) + return before, after, err } // RemoveMember прибирає людину з тенанта. @@ -574,8 +630,28 @@ func (s *Store) SetRole(ctx context.Context, tenantID, userID, roleID string) er // Сам користувач лишається: він може працювати в іншому тенанті, а його // сліди в аудиті мають лишатися читабельними. Зате всі сесії саме в // цьому тенанті відкликаються негайно. -func (s *Store) RemoveMember(ctx context.Context, tenantID, userID string) error { - return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { +func (s *Store) RemoveMember(ctx context.Context, tenantID, userID string) (MemberRef, error) { + gone := MemberRef{UserID: userID} + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + // Ім'я й роль — до видалення: рядок memberships зникає разом із + // відповіддю на «а що ця людина тут могла», і відновити її потім + // не з чого. Сам обліковий запис лишається, але він нічого не + // каже про доступ САМЕ в цьому кабінеті. + if err := tx.QueryRow(ctx, ` + SELECT u.username::text, r.id::text, r.key::text, r.name + 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 m.user_id = $2 + FOR UPDATE OF m + `, tenantID, userID).Scan(&gone.Username, &gone.RoleID, + &gone.RoleKey, &gone.RoleName); err != nil { + if isNoRows(err) { + return ErrNotFound + } + return err + } + tag, err := tx.Exec(ctx, ` DELETE FROM core.memberships WHERE tenant_id = $1 AND user_id = $2 `, tenantID, userID) @@ -592,6 +668,7 @@ func (s *Store) RemoveMember(ctx context.Context, tenantID, userID string) error `, userID, tenantID) return err }) + return gone, err } // ChangePassword міняє пароль і відкликає всі інші сесії. @@ -649,8 +726,13 @@ type UserProfileInput struct { // тенанту. Інакше адмін філії міг би змінити пароль людині, яка тим // самим акаунтом заходить у сусідню організацію, — і та про це навіть // не дізналася б. -func (s *Store) UpdateUserProfile(ctx context.Context, tenantID, userID string, in UserProfileInput) error { - return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { +// Повертає логін, який має обліковий запис ПІСЛЯ правки. Потрібен +// журналу: «змінили профіль користувачу 7f3a…» не відповідає на +// питання «кому», а перейменування — одна з тих правок, які тут і +// роблять. +func (s *Store) UpdateUserProfile(ctx context.Context, tenantID, userID string, in UserProfileInput) (string, error) { + var username string + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { var member bool if err := tx.QueryRow(ctx, ` SELECT EXISTS (SELECT 1 FROM core.memberships @@ -681,7 +763,7 @@ func (s *Store) UpdateUserProfile(ctx context.Context, tenantID, userID string, hash = &h } - if _, err := tx.Exec(ctx, ` + if err := tx.QueryRow(ctx, ` UPDATE core.users SET username = COALESCE(NULLIF($2,'')::citext, username), email = CASE WHEN $3 = '' THEN email @@ -691,7 +773,8 @@ func (s *Store) UpdateUserProfile(ctx context.Context, tenantID, userID string, password_hash = COALESCE($5, password_hash), updated_at = now() WHERE id = $1 - `, userID, in.Username, in.Email, in.FullName, hash); err != nil { + RETURNING username::text + `, userID, in.Username, in.Email, in.FullName, hash).Scan(&username); err != nil { return fmt.Errorf("оновлення профілю: %w", err) } @@ -708,6 +791,7 @@ func (s *Store) UpdateUserProfile(ctx context.Context, tenantID, userID string, } return nil }) + return username, err } // UserByID читає обліковий запис для сторінки профілю та /me. diff --git a/web/src/App.tsx b/web/src/App.tsx index baee5b8..6d0f12d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -27,6 +27,7 @@ import { QueuesPage } from './pages/QueuesPage' import { TeamPage } from './pages/TeamPage' import { RolesPage } from './pages/RolesPage' import { AuditPage } from './pages/AuditPage' +import { LoginsPage } from './pages/LoginsPage' import { TrapsPage } from './pages/TrapsPage' import { SLAPage } from './pages/SLAPage' import { StoragePage } from './pages/StoragePage' @@ -156,6 +157,12 @@ function Workspace() { сховища й дзеркала. */} } />} /> } />} /> + {/* Історія входів під тим самим правом, що й журнал: обидві + сторінки про те, «хто і що робив з нашим доступом», і + розслідування — це поєднання їхніх рядків. Роздати їх + різним людям означало б, що один бачить «видалив конфіги о + 14:02», а другий — «увійшов о 14:01 з чужої адреси». */} + } />} /> {/* Журнал сервера під settings:write, а НЕ під audit:read — і це протилежне рішення до сховища й дзеркала навмисно. Там ішлося про стан мережі, який видно кожному, хто бачить моніторинг; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index a9c2b10..ab19046 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -81,6 +81,9 @@ import type { AuditPage, AuditEventDetail, AuditMeta, + LoginsFilter, + LoginAttemptsPage, + LoginsMeta, GitMirror, MirrorTest, SelfDeviceRow, @@ -1537,6 +1540,34 @@ export const api = { /** Довідники дій та акторів, межі журналу й перелік його сліпих зон. */ auditMeta: () => request('/api/v1/audit/meta'), + // --- історія входів --- + + /** + * Сторінка історії входів. + * + * Влаштована рівно як `audit()` вище, і навмисно: це та сама пара + * питань («хто заходив» / «хто що зробив»), і посилання на звужений + * перелік так само пересилають колезі. Що саме сюди НЕ потрапляє — + * не деталь реалізації, а частина відповіді: сервер віддає перелік + * меж у `loginsMeta().blind_spots`, і сторінка мусить його показати. + */ + logins: (f: LoginsFilter = {}) => { + const p = new URLSearchParams() + if (f.from) p.set('from', f.from) + if (f.to) p.set('to', f.to) + if (f.outcome) p.set('outcome', f.outcome) + for (const r of f.reasons ?? []) p.append('reason', r) + for (const u of f.users ?? []) p.append('user', u) + if (f.ip) p.set('ip', f.ip) + if (f.q) p.set('q', f.q) + if (f.limit) p.set('limit', String(f.limit)) + if (f.cursor) p.set('cursor', f.cursor) + return request(`/api/v1/logins?${p.toString()}`) + }, + + /** Словник причин, перелік людей, межі й перелік того, чого не видно. */ + loginsMeta: () => request('/api/v1/logins/meta'), + // ------------------------------------------------------------------- // SNMP-трапи // ------------------------------------------------------------------- diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index f6c7477..2bbf740 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -183,6 +183,13 @@ const navGroups: NavGroup[] = [ // пункт не з'явиться — а разом із ним і група, якщо в ній більше // нічого не лишилось. { to: '/audit', label: 'Журнал аудиту', icon: '🧾', perm: 'audit:read' }, + // Одразу під журналом і під тим самим правом: це його друга + // половина. Журнал відповідає на «хто що зробив УЖЕ ВСЕРЕДИНІ», а + // на «хто сюди заходив» він не відповідає взагалі — спроби входу + // в нього не пишуться (кабінет на момент перевірки пароля ще + // невідомий). Порізно людина шукала б невдалі спроби в журналі й + // не знайшла б їх ніколи. + { to: '/logins', label: 'Історія входів', icon: '🔑', perm: 'audit:read' }, // Одразу під журналом аудиту, бо їх плутають, а різниця між ними // вирішальна: аудит відповідає на «хто що зробив», журнал сервера — // на «на що лається сама система». Поруч цю різницю видно з двох diff --git a/web/src/pages/LoginsPage.tsx b/web/src/pages/LoginsPage.tsx new file mode 100644 index 0000000..c0aa64b --- /dev/null +++ b/web/src/pages/LoginsPage.tsx @@ -0,0 +1,756 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import { api } from '../api/client' +import { ChipRow } from '../components/DeviceFilterPanel' +import { + Button, + Card, + ErrorNote, + PageBody, + PageHeader, + Spinner, + inputClass, + plural, +} from '../components/ui' +import type { LoginAttempt, LoginAttemptsPage, LoginsMeta } from '../types' + +/** + * «Історія входів»: хто заходив, звідки й що не вийшло. + * + * Рідна сестра «Журналу аудиту», і навмисно: той самий курсор замість + * номерів сторінок, той самий закріплений період, той самий блок про + * межі показаного внизу. Різні вони питанням: аудит відповідає на «хто + * що зробив УЖЕ ВСЕРЕДИНІ», ця сторінка — на «хто і як сюди + * потрапляв». Друге питання в журнал аудиту не потрапляє взагалі — + * спроби входу лягають в окрему таблицю, бо на момент перевірки пароля + * кабінет ще невідомий. + * + * Чотири рішення, які видно на екрані. + * + * 1. **Невдала спроба помітна без жодного фільтра.** Заради неї + * сторінку й відкривають, тож вона червона, а причина відмови стоїть + * у самому рядку, а не під розкриттям. Угорі — рахунок «стільки + * невдалих зі стількох»: щоб відповідь на «нас ламають?» була видна + * раніше, ніж людина дочитає перший екран. + * + * 2. **Логін показаний окремо від людини.** У рядку лежить те, що + * НАСПРАВДІ ввели в поле входу, — і саме одруківка («adm1n») або + * чужий формат («root») і є сигналом. Поруч — до кого спроба + * прив'язалась, під поточним іменем цієї людини. + * + * 3. **Пристрій — здогадка, тому вона поруч із першоджерелом.** + * User-Agent задає клієнт, і вгадане «Chrome» нічого не варте в + * розборі інциденту. Переказ у рядку, повний рядок — під розкриттям. + * + * 4. **Межа видимості — на сторінці, а не в коді.** Ця сторінка + * показує НЕ ВСЕ, і мовчати про це не можна: у core.login_attempts + * немає tenant_id, тож спроба потрапляє сюди лише тоді, коли її + * вдалося прив'язати до людини з вашого кабінету. Скільки невдалих + * спроб прив'язати не вдалося — окремим блоком угорі; чого сторінка + * не бачить узагалі — переліком унизу (його складає сервер, поруч із + * самою вибіркою). + * + * Стан фільтра живе в адресі — з тієї ж причини, що в журналі: + * звужений перелік пересилають колезі, і посилання має нести питання + * цілком. + */ + +/** Готові періоди. Типовий — тиждень, а не місяць, як у журналі: + * сюди приходять із питанням «що відбувається зараз». */ +const PERIODS: { key: string; label: string; hours: number }[] = [ + { key: '24h', label: '24 години', hours: 24 }, + { key: '7d', label: '7 днів', hours: 24 * 7 }, + { key: '30d', label: '30 днів', hours: 24 * 30 }, + { key: '90d', label: '90 днів', hours: 24 * 90 }, + { key: '365d', label: 'рік', hours: 24 * 365 }, +] + +const DEFAULT_PERIOD = '7d' +const PAGE_SIZE = 50 + +const OUTCOMES: { value: string; label: string }[] = [ + { value: 'failed', label: 'лише невдалі' }, + { value: 'success', label: 'лише вдалі' }, +] + +interface Filter { + period: string + /** Заповнені лише коли period === 'custom'. */ + from: string + to: string + /** '' — усе. */ + outcome: string + reasons: string[] + users: string[] + ip: string + q: string +} + +function parseFilter(sp: URLSearchParams): Filter { + return { + period: sp.get('period') || DEFAULT_PERIOD, + from: sp.get('from') || '', + to: sp.get('to') || '', + outcome: sp.get('outcome') || '', + reasons: sp.getAll('reason'), + users: sp.getAll('user'), + ip: sp.get('ip') || '', + q: sp.get('q') || '', + } +} + +function writeFilter(f: Filter): URLSearchParams { + const sp = new URLSearchParams() + if (f.period !== DEFAULT_PERIOD) sp.set('period', f.period) + if (f.period === 'custom') { + if (f.from) sp.set('from', f.from) + if (f.to) sp.set('to', f.to) + } + if (f.outcome) sp.set('outcome', f.outcome) + for (const r of f.reasons) sp.append('reason', r) + for (const u of f.users) sp.append('user', u) + if (f.ip) sp.set('ip', f.ip) + if (f.q) sp.set('q', f.q) + return sp +} + +/** Межі періоду в ISO для запиту. */ +function periodRange(f: Filter): { from?: string; to?: string } { + if (f.period === 'custom') { + return { + from: f.from ? new Date(f.from).toISOString() : undefined, + to: f.to ? new Date(f.to).toISOString() : undefined, + } + } + const p = PERIODS.find((x) => x.key === f.period) ?? PERIODS[1] + const to = new Date() + return { from: new Date(to.getTime() - p.hours * 3600_000).toISOString(), to: to.toISOString() } +} + +const EMPTY: Partial = { outcome: '', reasons: [], users: [], ip: '', q: '' } + +export function LoginsPage() { + const [sp, setSp] = useSearchParams() + // Розбираємо саме РЯДОК запиту: так `filter` міняє тотожність лише + // тоді, коли міняється питання. + const search = sp.toString() + const filter = useMemo(() => parseFilter(new URLSearchParams(search)), [search]) + + const [meta, setMeta] = useState(null) + const [page, setPage] = useState(null) + const [attempts, setAttempts] = useState([]) + const [cursor, setCursor] = useState() + /** Період, у якому сервер зібрав першу сторінку. Дальші беруться в ньому ж. */ + const [range, setRange] = useState<{ from: string; to: string } | null>(null) + const [loading, setLoading] = useState(true) + const [more, setMore] = useState(false) + const [error, setError] = useState(null) + + const patch = useCallback( + (p: Partial) => setSp(writeFilter({ ...filter, ...p }), { replace: true }), + [filter, setSp], + ) + + useEffect(() => { + api + .loginsMeta() + .then(setMeta) + .catch((e) => setError(e instanceof Error ? e.message : String(e))) + }, []) + + useEffect(() => { + let alive = true + setLoading(true) + setError(null) + api + .logins({ + ...periodRange(filter), + outcome: filter.outcome || undefined, + reasons: filter.reasons, + users: filter.users, + ip: filter.ip || undefined, + q: filter.q || undefined, + limit: PAGE_SIZE, + }) + .then((p) => { + if (!alive) return + setPage(p) + setAttempts(p.attempts) + setCursor(p.next_cursor) + setRange({ from: p.from, to: p.to }) + }) + .catch((e) => alive && setError(e instanceof Error ? e.message : String(e))) + .finally(() => alive && setLoading(false)) + return () => { + alive = false + } + }, [filter]) + + const loadMore = useCallback(() => { + if (!cursor || !range) return + setMore(true) + api + .logins({ + from: range.from, + to: range.to, + outcome: filter.outcome || undefined, + reasons: filter.reasons, + users: filter.users, + ip: filter.ip || undefined, + q: filter.q || undefined, + limit: PAGE_SIZE, + cursor, + }) + .then((p) => { + setAttempts((prev) => [...prev, ...p.attempts]) + setCursor(p.next_cursor) + }) + .catch((e) => setError(e instanceof Error ? e.message : String(e))) + .finally(() => setMore(false)) + }, [cursor, range, filter]) + + const active = + filter.reasons.length + + filter.users.length + + (filter.outcome ? 1 : 0) + + (filter.ip ? 1 : 0) + + (filter.q ? 1 : 0) + + return ( + <> + 0 ? ( + + ) : undefined + } + /> + + + {error} + + + + + + + {loading ? ( + + ) : attempts.length === 0 ? ( + + ) : ( + + {attempts.map((a, i) => ( + + ))} + + )} + + {cursor && !loading && ( +
+ +
+ )} + + {!cursor && attempts.length > 0 && !loading && ( +

+ це всі спроби за обраний період — {attempts.length}{' '} + {plural(attempts.length, ['спроба', 'спроби', 'спроб'])} +

+ )} + + +
+ + ) +} + +// --------------------------------------------------------------------- +// Рахунок угорі +// --------------------------------------------------------------------- + +/** + * Скільки з показаного — невдалі. + * + * Стоїть ПЕРЕД фільтрами, бо це і є відповідь, по яку сюди приходять, а + * гортати перелік у пошуках червоного — робота, якої можна не робити. + * Число клікабельне: воно ж і фільтр «лише невдалі». + * + * Рахунок ЗАВЖДИ про показане, а не про період: коли натиснули + * «показати ще», у переліку більше рядків, і сказати «12 невдалих», + * маючи на увазі перші п'ятдесят, означало б назвати число, яке нічому + * на екрані не відповідає. + */ +function Tally({ + attempts, + loading, + filter, + onChange, +}: { + attempts: LoginAttempt[] + loading: boolean + filter: Filter + onChange: (p: Partial) => void +}) { + if (loading || attempts.length === 0) return null + const failed = attempts.filter((a) => !a.success).length + const only = filter.outcome === 'failed' + + return ( + + 0 ? 'font-medium text-red-200' : 'text-slate-300'}> + {failed} {plural(failed, ['невдала спроба', 'невдалі спроби', 'невдалих спроб'])} + + + із {attempts.length} показаних {plural(attempts.length, ['спроби', 'спроб', 'спроб'])} + + {failed > 0 && ( + + )} + + ) +} + +/** + * Межа видимості, яку не можна замовчати. + * + * Спроба потрапляє на цю сторінку лише тоді, коли її вдалося прив'язати + * до людини з вашого кабінету — через логін, пошту або ідентифікатор. + * Невдалу спробу з логіном, якого в системі немає, прив'язати нема до + * чого: вона не належить жодному кабінету, і показати її комусь + * означало б віддати чужі дані. Тому рядків тут немає — є число. + * + * Без цього блоку сторінка тихо бреше: хвиля перебору неіснуючих + * логінів виглядала б на ній як спокійний день. + */ +function Unattributed({ page }: { page: LoginAttemptsPage | null }) { + const u = page?.unattributed + if (!u || u.attempts === 0) return null + return ( + +
+ Ще {u.attempts} {plural(u.attempts, ['невдала спроба', 'невдалі спроби', 'невдалих спроб'])} за + цей період не показані нижче +
+

+ Це спроби входу з логінами, яких у системі немає взагалі — з{' '} + {u.ips} {plural(u.ips, ['адреси', 'адрес', 'адрес'])}. Прив'язати їх до кабінету нема до + чого, а показати рядками означало б віддати комусь чужі дані, тож видно лише рахунок. + Багато таких спроб з кількох адрес — це перебір логінів; одна-дві — найчастіше людина, + яка помилилась у власному логіні. +

+
+ ) +} + +// --------------------------------------------------------------------- +// Фільтри +// --------------------------------------------------------------------- + +function Filters({ + meta, + filter, + onChange, +}: { + meta: LoginsMeta | null + filter: Filter + onChange: (p: Partial) => void +}) { + // Пошук набирають по літері, а кожна літера — це запит до бази. + // Локальний стан плюс одна пауза на обидва поля: двома таймерами + // набране в першому губилось би, щойно перейшли до другого. + const [q, setQ] = useState(filter.q) + const [ip, setIP] = useState(filter.ip) + + useEffect(() => setQ(filter.q), [filter.q]) + useEffect(() => setIP(filter.ip), [filter.ip]) + + useEffect(() => { + if (q === filter.q && ip === filter.ip) return + const t = setTimeout(() => onChange({ q, ip }), 400) + return () => clearTimeout(t) + }, [q, ip, filter.q, filter.ip, onChange]) + + const reasonOptions = (meta?.reasons ?? []).map((r) => ({ value: r.key, label: r.label })) + const userOptions = (meta?.actors ?? []).map((a) => ({ value: a.id, label: a.label })) + + return ( + +
+ + + + + +
+ + {/* Результат — один із трьох станів, тому не ChipRow: два обраних + «лише невдалі» і «лише вдалі» разом означали б «нічого». */} +
+ Результат + {OUTCOMES.map((o) => { + const on = filter.outcome === o.value + return ( + + ) + })} +
+ + onChange({ reasons: v })} + /> + onChange({ users: v })} + /> +
+ ) +} + +/** + * Період. Стеля та сама, що в журналі аудиту, і причина та сама: + * глибше дані в базі стиснуто. Практичного значення тут вона майже не + * має — спроби входу типово живуть 180 днів. + */ +function PeriodRow({ + filter, + maxDays, + onChange, +}: { + filter: Filter + maxDays?: number + onChange: (p: Partial) => void +}) { + return ( +
+ + + {filter.period === 'custom' && ( + <> + + + + )} +
+ ) +} + +// --------------------------------------------------------------------- +// Рядок +// --------------------------------------------------------------------- + +function AttemptRow({ attempt, showDate }: { attempt: LoginAttempt; showDate: boolean }) { + const [open, setOpen] = useState(false) + const a = attempt + // Причина показана фразою, але ключ лишається під розкриттям — те + // саме рішення, що з діями в журналі аудиту: фразу читають люди, + // ключем шукають у логах сервера. Назви немає — показуємо ключ як є: + // вигадана з підкреслень фраза виглядала б назвою й брехала б. + const reason = a.reason_label || a.reason + + return ( +
+ {showDate && ( +
+ {fmtDate(a.ts)} +
+ )} + + + + {open && ( +
+
+
Коли
+
{fmtFull(a.ts)}
+ +
Введено
+
{a.login}
+ +
Хто
+
+ {a.username || 'невідомо'} + {a.email ? ` · ${a.email}` : ''} + {a.full_name ? ` · ${a.full_name}` : ''} +
+ +
Результат
+
+ {a.success ? 'вхід виконано' : 'відмовлено'} + {!a.success && a.reason ? ( + <> + {reason ? ` · ${reason}` : ''}{' '} + {a.reason} + + ) : null} +
+ +
Звідки
+
{a.ip || '—'}
+ +
Пристрій
+
+ {/* Здогадка й першоджерело поруч, ніколи одне замість + іншого: User-Agent задає клієнт, і в розборі інциденту + вгадане «Chrome» нічого не доводить. */} + {a.device && {a.device}} + {a.user_agent ? ( + + {a.user_agent} + + ) : ( + !a.device && не передано + )} +
+ +
Запис
+
{a.id}
+
+
+ )} +
+ ) +} + +// --------------------------------------------------------------------- +// Порожній результат і межі +// --------------------------------------------------------------------- + +/** + * Порожній перелік — найнебезпечніше місце сторінки. + * + * «Нічого не знайдено» тут читається як «ніхто не ламався», хоча + * означати може чотири різні речі: у цьому кабінеті ще жодного входу; + * період закінчився раніше, ніж почалась історія; фільтр надто вузький; + * або спроби були, але жодну не вдалося прив'язати до вашого кабінету — + * і тоді відповідь стоїть у блоці вище, а не тут. + */ +function EmptyNote({ + meta, + filter, + range, +}: { + meta: LoginsMeta | null + filter: Filter + range: { from: string; to: string } | null +}) { + const first = meta?.bounds.first + const narrowed = + filter.reasons.length + filter.users.length > 0 || !!filter.ip || !!filter.q || !!filter.outcome + + let text: string + if (!first) { + text = + 'У цьому кабінеті ще немає жодної спроби входу, яку вдалося б до нього прив’язати. Це не означає, що входів не було: спроба потрапляє сюди лише тоді, коли логін або пошта збіглися з учасником кабінету — перелік меж нижче.' + } else if (range && new Date(range.to) < new Date(first)) { + text = `Обраний період закінчився раніше, ніж почалась історія: перша спроба — ${fmtDate(first)}. За цей час записів немає й бути не могло.` + } else if (narrowed) { + text = + 'За обраними умовами спроб немає. Спробуйте розширити період або зняти частину умов — і зважте на перелік того, чого сторінка не бачить узагалі.' + } else { + text = `За обраний період спроб входу немає. Історія ведеться з ${fmtDate(first)}.` + } + + return ( + +

{text}

+
+ ) +} + +/** + * Чого ця сторінка не бачить. + * + * Той самий блок і та сама причина, що в журналі аудиту: найгірша + * можлива помилка сторінки про відсутність — мовчазна порожнеча на + * місці невиміряного. Тут вона дорожча вдвічі, бо сюди приходять із + * питанням «нас ламають?». + * + * Перелік складає сервер (store.LoginBlindSpots) — там же, де сама + * вибірка: кожен рядок у ньому є наслідком того, як влаштоване + * зіставлення спроби з кабінетом, і копія в браузері розійшлася б із + * ним на першій же правці. + */ +function BlindSpots({ meta }: { meta: LoginsMeta | null }) { + const items = meta?.blind_spots ?? [] + if (items.length === 0) return null + return ( + +
Чого ця сторінка не бачить
+

+ У таблиці спроб входу немає ознаки кабінету — рядок пишеться тоді, коли кабінет ще + невідомий. Тому сюди потрапляє лише те, що вдалося прив'язати до учасника ЦЬОГО кабінету, і + порожній результат означає «не прив'язалось», а не «не було». +

+
    + {items.map((s) => ( +
  • + + {s} +
  • + ))} +
+
+ ) +} + +// --------------------------------------------------------------------- +// Дрібниці +// --------------------------------------------------------------------- + +function dayKey(iso: string): string { + const d = new Date(iso) + return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` +} + +function fmtDate(iso: string): string { + return new Date(iso).toLocaleDateString('uk-UA', { + day: '2-digit', + month: 'long', + year: 'numeric', + }) +} + +function fmtTime(iso: string): string { + return new Date(iso).toLocaleTimeString('uk-UA', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) +} + +function fmtFull(iso: string): string { + return `${fmtDate(iso)} ${fmtTime(iso)}` +} diff --git a/web/src/test/logins.test.tsx b/web/src/test/logins.test.tsx new file mode 100644 index 0000000..5ab0bc0 --- /dev/null +++ b/web/src/test/logins.test.tsx @@ -0,0 +1,287 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' +import { LoginsPage } from '../pages/LoginsPage' +import { fetchRouter, type Call } from './support' + +/** + * Сторінка «Історія входів». + * + * ЧОМУ САМЕ ЦІ ПЕРЕВІРКИ. Сторінку відкривають з одним питанням: «чи + * нас ламають». Помилка тут не падає й не порожнить екран — вона тихо + * дає ЗАСПОКІЙЛИВУ відповідь: перелік намальовано, рядки на місці, і + * людина йде далі. Тому перевіряється рівно те, що робить відповідь + * чесною: + * + * 1. Невдала спроба видна ОДРАЗУ — без фільтрів, без розкриття рядка: + * з логіном, причиною відмови й адресою. Заради неї сторінка й + * потрібна. + * 2. Межа показаного названа вголос. Спроби, які не вдалося прив'язати + * до кабінету, рядками не показуються НІКОМУ — і якщо блок з їх + * рахунком зникне, хвиля перебору неіснуючих логінів виглядатиме + * на сторінці як спокійний день. Це найдорожча можлива поломка + * файла, і саме тому вона тут перевіряється. + * 3. Гортання йде КУРСОРОМ у закріпленому періоді. Не «наступна + * сторінка»: вхід, що стався між двома запитами, зсунув би вибірку + * на рядок, і один запис або зник би, або показався двічі. + * + * Набір параметрів звірений із `handleListLoginAttempts` + * (`server/internal/httpapi/logins.go`): outcome, reason, user, ip, q, + * from, to, limit, cursor. + */ + +const meta = { + reasons: [ + { key: 'bad_password', label: 'Невірний пароль' }, + { key: 'no_user', label: 'Такого логіна немає' }, + ], + actors: [{ id: 'u-1', label: 'admin' }], + bounds: { first: '2026-06-01T00:00:00Z', last: '2026-08-27T00:00:00Z' }, + blind_spots: [ + 'Спроби входу людей з інших кабінетів.', + 'Спроби, відбиті гальмуванням перебору.', + ], + max_range_days: 366, +} + +function attempt(over: Record = {}) { + return { + id: 'a-1', + ts: '2026-08-27T10:00:00Z', + login: 'admin', + success: true, + ip: '10.20.0.5', + user_agent: 'curl/8.4.0', + device: 'curl (скрипт)', + user_id: 'u-1', + username: 'admin', + email: 'admin@example.test', + ...over, + } +} + +function page(attempts: unknown[], over: Record = {}) { + return { + from: '2026-08-20T00:00:00Z', + to: '2026-08-27T12:00:00Z', + attempts, + unattributed: { attempts: 0, ips: 0 }, + ...over, + } +} + +/** Остання адреса, на якій опинилася сторінка. */ +let lastSearch = '' +function Spy() { + lastSearch = useLocation().search + return null +} + +function open(url: string, routes: Record = {}) { + const server = fetchRouter({ + 'GET /api/v1/logins/meta': meta, + 'GET /api/v1/logins': page([]), + ...routes, + }) + render( + + + + + + + } + /> + + , + ) + return server +} + +/** Питання до сервера без часових меж: `from`/`to` рахуються від + * Date.now() і в двох прогонах збігтись не можуть. */ +function question(call: Call): Record { + const sp = new URLSearchParams(call.url.split('?')[1] ?? '') + const out: Record = {} + for (const key of [...new Set(sp.keys())].sort()) { + if (key === 'from' || key === 'to') continue + out[key] = sp.getAll(key) + } + return out +} + +beforeEach(() => { + lastSearch = '' +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +// --------------------------------------------------------------------- + +describe('невдала спроба видна одразу', () => { + it('логін, причина, адреса й пристрій — без фільтрів і без розкриття', async () => { + open('/logins', { + 'GET /api/v1/logins': page([ + attempt({ + id: 'a-2', + success: false, + reason: 'bad_password', + reason_label: 'Невірний пароль', + login: 'adm1n', + ip: '203.0.113.7', + }), + attempt(), + ]), + }) + + // Введений логін, а не ім'я людини: одруківка в ньому і є сигналом. + const login = await screen.findByText('adm1n') + // Усе перевіряється В МЕЖАХ РЯДКА. «Невірний пароль» є ще й серед + // фільтрів угорі, і збіг із ним доводив би лише те, що словник + // приїхав, — а питання тут інше: чи видно причину, не розкриваючи + // рядок. + const row = login.closest('button') + expect(row).not.toBeNull() + const cell = within(row as HTMLElement) + expect(cell.getByText('Невірний пароль')).toBeInTheDocument() + expect(cell.getByText('203.0.113.7')).toBeInTheDocument() + expect(cell.getByText('невдало')).toBeInTheDocument() + // Пристрій — стислий переказ User-Agent, який дав сервер. + expect(cell.getByText('curl (скрипт)')).toBeInTheDocument() + + // Рахунок угорі: відповідь на «скільки їх» має бути видна раніше, + // ніж людина дочитає перший екран. + expect(screen.getByText(/1 невдала спроба/)).toBeInTheDocument() + + // І жодного фільтра для цього не знадобилось. + expect(lastSearch).toBe('') + }) + + it('«лише невдалі» звужує питання й лягає в адресу', async () => { + const server = open('/logins', { + 'GET /api/v1/logins': page([ + attempt({ id: 'a-2', success: false, reason: 'bad_password' }), + attempt(), + ]), + }) + + fireEvent.click(await screen.findByRole('button', { name: 'лише невдалі' })) + + await waitFor(() => expect(lastSearch).toContain('outcome=failed')) + await waitFor(() => expect(server.of('GET /api/v1/logins?').length).toBe(2)) + expect(question(server.of('GET /api/v1/logins?')[1]).outcome).toEqual(['failed']) + }) + + it('фільтр з адреси й фільтр, обраний руками, дають ОДНАКОВЕ питання', async () => { + // Головна перевірка про фільтри: посилання на звужений перелік + // пересилають колезі, і воно має відкривати ТЕ САМЕ, що людина + // бачила. Перевіряти кожен бік окремо проти очікуваного рядка + // означало б двічі записати те саме припущення. + const fromLink = open('/logins?outcome=failed&reason=bad_password&ip=10.20.0.0%2F24&q=root') + await waitFor(() => expect(fromLink.of('GET /api/v1/logins?').length).toBe(1)) + const viaURL = question(fromLink.of('GET /api/v1/logins?')[0]) + cleanup() + vi.unstubAllGlobals() + + const byHand = open('/logins') + await waitFor(() => expect(byHand.of('GET /api/v1/logins?').length).toBe(1)) + fireEvent.click(screen.getByRole('button', { name: 'лише невдалі' })) + fireEvent.click(screen.getByRole('button', { name: 'Невірний пароль' })) + fireEvent.change(screen.getByPlaceholderText('10.20.0.0/24'), { + target: { value: '10.20.0.0/24' }, + }) + fireEvent.change(screen.getByPlaceholderText('admin, root, пошта…'), { + target: { value: 'root' }, + }) + + await waitFor(() => { + const calls = byHand.of('GET /api/v1/logins?') + expect(question(calls[calls.length - 1])).toEqual(viaURL) + }) + }) +}) + +// --------------------------------------------------------------------- + +describe('межа показаного названа вголос', () => { + it('неприв’язувані спроби пораховані й пояснені, хоч рядків і немає', async () => { + open('/logins', { + 'GET /api/v1/logins': page([attempt()], { unattributed: { attempts: 47, ips: 3 } }), + }) + + const note = await screen.findByText(/не показані нижче/) + expect(note).toHaveTextContent(/47/) + // Не просто число: сторінка мусить сказати, ЧОМУ їх не видно. + expect(screen.getByText(/логінами, яких у системі немає/)).toBeInTheDocument() + expect(screen.getByText(/логінами, яких у системі немає/)).toHaveTextContent(/3/) + }) + + it('нуля неприв’язуваних спроб не показуємо — це шум, а не межа', async () => { + open('/logins', { + 'GET /api/v1/logins': page([attempt()], { unattributed: { attempts: 0, ips: 0 } }), + }) + await screen.findByText('admin') + expect(screen.queryByText(/не показані нижче/)).not.toBeInTheDocument() + }) + + it('перелік того, чого сторінка не бачить, приходить із сервера й показується', async () => { + open('/logins', { 'GET /api/v1/logins': page([attempt()]) }) + + expect(await screen.findByText('Чого ця сторінка не бачить')).toBeInTheDocument() + for (const s of meta.blind_spots) { + expect(screen.getByText(s)).toBeInTheDocument() + } + }) + + it('порожній перелік не читається як «входів не було»', async () => { + open('/logins', { 'GET /api/v1/logins': page([]) }) + // Порожнеча тут означає «не прив’язалось», і сторінка мусить + // сказати саме це, а не «нічого не знайдено». + expect(await screen.findByText(/Історія ведеться з/)).toBeInTheDocument() + expect(screen.getByText('Чого ця сторінка не бачить')).toBeInTheDocument() + }) +}) + +// --------------------------------------------------------------------- + +describe('гортання', () => { + it('«Показати ще» йде курсором у ЗАКРІПЛЕНОМУ періоді, а не offset-ом', async () => { + const server = open('/logins', { + 'GET /api/v1/logins': (c: Call) => { + const sp = new URLSearchParams(c.url.split('?')[1] ?? '') + const body = sp.get('cursor') + ? page([attempt({ id: 'a-9', login: 'second' })]) + : page([attempt()], { next_cursor: '1756290000000000.a-1' }) + return { + status: 200, + ok: true, + statusText: '', + text: async () => JSON.stringify(body), + } as unknown as Response + }, + }) + + fireEvent.click(await screen.findByRole('button', { name: 'Показати ще' })) + await screen.findByText('second') + + const calls = server.of('GET /api/v1/logins?') + expect(calls).toHaveLength(2) + + const first = new URLSearchParams(calls[0].url.split('?')[1]) + const second = new URLSearchParams(calls[1].url.split('?')[1]) + + expect(second.get('cursor')).toBe('1756290000000000.a-1') + expect(second.get('offset')).toBeNull() + // Період береться з ВІДПОВІДІ сервера, а не рахується заново: інакше + // вхід, що стався між двома запитами, зсунув би вибірку на рядок. + expect(second.get('from')).toBe('2026-08-20T00:00:00Z') + expect(second.get('to')).toBe('2026-08-27T12:00:00Z') + expect(second.get('from')).not.toBe(first.get('from')) + }) +}) diff --git a/web/src/types.ts b/web/src/types.ts index 3e55d20..1883467 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -1890,6 +1890,95 @@ export interface AuditFilter { cursor?: string } +// --------------------------------------------------------------------- +// Історія входів +// +// Форма — з `server/internal/httpapi/logins.go`. Сторінка — рідна сестра +// журналу аудиту: той самий курсор, той самий закріплений період, той +// самий блок про межі показаного. +// --------------------------------------------------------------------- + +/** Причина відмови: ключ і його людська назва. */ +export interface LoginReasonInfo { + key: string + label: string +} + +export interface LoginAttempt { + id: string + ts: string + /** Те, що НАСПРАВДІ ввели в поле логіна. Не завжди пошта. */ + login: string + success: boolean + reason?: string + /** Порожня для ключа, якого немає в словнику сервера. */ + reason_label?: string + ip?: string + /** User-Agent як є. */ + user_agent?: string + /** Приблизний переказ User-Agent. Порожній — сервер не вгадав. */ + device?: string + user_id?: string + username?: string + email?: string + full_name?: string +} + +/** + * Скільки невдалих спроб за той самий період не належать ЖОДНОМУ + * кабінету — два числа й жодного рядка. Чому саме так, а не рядками: + * шапка `server/internal/store/login_attempts.go`. + */ +export interface LoginUnattributed { + attempts: number + ips: number +} + +export interface LoginAttemptsPage { + /** Період, у якому відповідь зібрано насправді. */ + from: string + to: string + attempts: LoginAttempt[] + /** Порожній означає «більше немає», а не «невідомо». */ + next_cursor?: string + /** Приїздить лише з першою сторінкою. */ + unattributed?: LoginUnattributed +} + +export interface LoginActorOption { + id: string + label: string +} + +/** Від якої дати цьому кабінету взагалі є що показати. */ +export interface LoginBounds { + first?: string + last?: string +} + +export interface LoginsMeta { + reasons: LoginReasonInfo[] + actors: LoginActorOption[] + bounds: LoginBounds + /** Чого сторінка НЕ показує. Мовчати про це не можна. */ + blind_spots: string[] + max_range_days: number +} + +/** Фільтр — те саме, що читає ручка GET /api/v1/logins. */ +export interface LoginsFilter { + from?: string + to?: string + /** '' — усе, 'failed' — лише невдалі, 'success' — лише вдалі. */ + outcome?: string + reasons?: string[] + users?: string[] + ip?: string + q?: string + limit?: number + cursor?: string +} + // --- дзеркало архіву конфігів на зовнішньому Git --- /**