Історія входів: 93 записи були в базі й ніде не показувались
core.login_attempts наповнювалась на кожній спробі входу й читалась рівно в одному місці — для стримування підбору. Ні ендпойнта, ні сторінки. На питання «хто заходив у мій моніторинг» продукт мав відповідь у базі й не мав способу її показати. ГОЛОВНЕ БУЛА НЕ ВЕРСТКА. У таблиці немає tenant_id — саме тому ці події й не в аудиті. Показати «як є» означало б віддати одному кабінету спроби входу чужих людей. Прив'язка непряма: спроба -> користувач -> членство, трьома шляхами одночасно (user_id, username, email) в одному JOIN LATERAL з tenant_id усередині. Саме JOIN, а не LEFT JOIN: без збігу рядок ПРИБИРАЄТЬСЯ, а не лишається без імені. На бойових даних це не теорія: у власника немає пошти, тож за email не прив'язується ЖОДНА з 94 спроб. Шлях через username дає 93. Невдала спроба з неіснуючим логіном не належить нікому: рядком не показується, але рахується числом — сигнал «логіни перебирають» лишається, чужа людина в чужий кабінет не потрапляє. Ізоляцію перевірено проти справжньої бази (TestLoginAttemptsTenantIsolation). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
f6df538020
commit
95a19b074d
11 changed files with 2518 additions and 8 deletions
137
server/internal/httpapi/logins.go
Normal file
137
server/internal/httpapi/logins.go
Normal file
|
|
@ -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)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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/meta", s.authenticated(s.handleAuditMeta))
|
||||||
mux.Handle("GET /api/v1/audit/{id}", s.authenticated(s.handleGetAuditEvent))
|
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("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}/ack", s.authenticated(s.handleAckAlert))
|
||||||
mux.Handle("POST /api/v1/alerts/{id}/close", s.authenticated(s.handleCloseAlert))
|
mux.Handle("POST /api/v1/alerts/{id}/close", s.authenticated(s.handleCloseAlert))
|
||||||
|
|
|
||||||
691
server/internal/store/login_attempts.go
Normal file
691
server/internal/store/login_attempts.go
Normal file
|
|
@ -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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
413
server/internal/store/login_attempts_test.go
Normal file
413
server/internal/store/login_attempts_test.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -552,9 +552,64 @@ func (s *Store) CreateUser(ctx context.Context, tenantID, username, email, passw
|
||||||
return &u, nil
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MemberRef — учасник разом із роллю, яку він мав у цю мить.
|
||||||
|
//
|
||||||
|
// Потрібен журналу аудиту, і саме тому в ньому і ключ ролі, і назва.
|
||||||
|
// Ключ — те, за чим шукають і що не змінюється; назва — те, що людина
|
||||||
|
// впізнає, і те, чого через рік може вже не існувати: роль
|
||||||
|
// перейменують або зітруть, і рядок «роль 7f3a…» перестане відповідати
|
||||||
|
// на питання, заради якого журнал відкрили.
|
||||||
|
type MemberRef struct {
|
||||||
|
UserID string
|
||||||
|
Username string
|
||||||
|
RoleID string
|
||||||
|
RoleKey string
|
||||||
|
RoleName string
|
||||||
|
}
|
||||||
|
|
||||||
// SetRole змінює роль учасника команди.
|
// 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, `
|
tag, err := tx.Exec(ctx, `
|
||||||
UPDATE core.memberships SET role_id = $3
|
UPDATE core.memberships SET role_id = $3
|
||||||
WHERE tenant_id = $1 AND user_id = $2
|
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 nil
|
||||||
})
|
})
|
||||||
|
return before, after, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveMember прибирає людину з тенанта.
|
// 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 {
|
func (s *Store) RemoveMember(ctx context.Context, tenantID, userID string) (MemberRef, error) {
|
||||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) 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, `
|
tag, err := tx.Exec(ctx, `
|
||||||
DELETE FROM core.memberships WHERE tenant_id = $1 AND user_id = $2
|
DELETE FROM core.memberships WHERE tenant_id = $1 AND user_id = $2
|
||||||
`, tenantID, userID)
|
`, tenantID, userID)
|
||||||
|
|
@ -592,6 +668,7 @@ func (s *Store) RemoveMember(ctx context.Context, tenantID, userID string) error
|
||||||
`, userID, tenantID)
|
`, userID, tenantID)
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
return gone, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChangePassword міняє пароль і відкликає всі інші сесії.
|
// 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
|
var member bool
|
||||||
if err := tx.QueryRow(ctx, `
|
if err := tx.QueryRow(ctx, `
|
||||||
SELECT EXISTS (SELECT 1 FROM core.memberships
|
SELECT EXISTS (SELECT 1 FROM core.memberships
|
||||||
|
|
@ -681,7 +763,7 @@ func (s *Store) UpdateUserProfile(ctx context.Context, tenantID, userID string,
|
||||||
hash = &h
|
hash = &h
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := tx.Exec(ctx, `
|
if err := tx.QueryRow(ctx, `
|
||||||
UPDATE core.users
|
UPDATE core.users
|
||||||
SET username = COALESCE(NULLIF($2,'')::citext, username),
|
SET username = COALESCE(NULLIF($2,'')::citext, username),
|
||||||
email = CASE WHEN $3 = '' THEN email
|
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),
|
password_hash = COALESCE($5, password_hash),
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $1
|
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)
|
return fmt.Errorf("оновлення профілю: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -708,6 +791,7 @@ func (s *Store) UpdateUserProfile(ctx context.Context, tenantID, userID string,
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
return username, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserByID читає обліковий запис для сторінки профілю та /me.
|
// UserByID читає обліковий запис для сторінки профілю та /me.
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import { QueuesPage } from './pages/QueuesPage'
|
||||||
import { TeamPage } from './pages/TeamPage'
|
import { TeamPage } from './pages/TeamPage'
|
||||||
import { RolesPage } from './pages/RolesPage'
|
import { RolesPage } from './pages/RolesPage'
|
||||||
import { AuditPage } from './pages/AuditPage'
|
import { AuditPage } from './pages/AuditPage'
|
||||||
|
import { LoginsPage } from './pages/LoginsPage'
|
||||||
import { TrapsPage } from './pages/TrapsPage'
|
import { TrapsPage } from './pages/TrapsPage'
|
||||||
import { SLAPage } from './pages/SLAPage'
|
import { SLAPage } from './pages/SLAPage'
|
||||||
import { StoragePage } from './pages/StoragePage'
|
import { StoragePage } from './pages/StoragePage'
|
||||||
|
|
@ -156,6 +157,12 @@ function Workspace() {
|
||||||
сховища й дзеркала. */}
|
сховища й дзеркала. */}
|
||||||
<Route path="/billing" element={<Guard perm="billing:read" element={<BillingPage />} />} />
|
<Route path="/billing" element={<Guard perm="billing:read" element={<BillingPage />} />} />
|
||||||
<Route path="/audit" element={<Guard perm="audit:read" element={<AuditPage />} />} />
|
<Route path="/audit" element={<Guard perm="audit:read" element={<AuditPage />} />} />
|
||||||
|
{/* Історія входів під тим самим правом, що й журнал: обидві
|
||||||
|
сторінки про те, «хто і що робив з нашим доступом», і
|
||||||
|
розслідування — це поєднання їхніх рядків. Роздати їх
|
||||||
|
різним людям означало б, що один бачить «видалив конфіги о
|
||||||
|
14:02», а другий — «увійшов о 14:01 з чужої адреси». */}
|
||||||
|
<Route path="/logins" element={<Guard perm="audit:read" element={<LoginsPage />} />} />
|
||||||
{/* Журнал сервера під settings:write, а НЕ під audit:read — і це
|
{/* Журнал сервера під settings:write, а НЕ під audit:read — і це
|
||||||
протилежне рішення до сховища й дзеркала навмисно. Там ішлося
|
протилежне рішення до сховища й дзеркала навмисно. Там ішлося
|
||||||
про стан мережі, який видно кожному, хто бачить моніторинг;
|
про стан мережі, який видно кожному, хто бачить моніторинг;
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,9 @@ import type {
|
||||||
AuditPage,
|
AuditPage,
|
||||||
AuditEventDetail,
|
AuditEventDetail,
|
||||||
AuditMeta,
|
AuditMeta,
|
||||||
|
LoginsFilter,
|
||||||
|
LoginAttemptsPage,
|
||||||
|
LoginsMeta,
|
||||||
GitMirror,
|
GitMirror,
|
||||||
MirrorTest,
|
MirrorTest,
|
||||||
SelfDeviceRow,
|
SelfDeviceRow,
|
||||||
|
|
@ -1537,6 +1540,34 @@ export const api = {
|
||||||
/** Довідники дій та акторів, межі журналу й перелік його сліпих зон. */
|
/** Довідники дій та акторів, межі журналу й перелік його сліпих зон. */
|
||||||
auditMeta: () => request<AuditMeta>('/api/v1/audit/meta'),
|
auditMeta: () => request<AuditMeta>('/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<LoginAttemptsPage>(`/api/v1/logins?${p.toString()}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Словник причин, перелік людей, межі й перелік того, чого не видно. */
|
||||||
|
loginsMeta: () => request<LoginsMeta>('/api/v1/logins/meta'),
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// SNMP-трапи
|
// SNMP-трапи
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,13 @@ const navGroups: NavGroup[] = [
|
||||||
// пункт не з'явиться — а разом із ним і група, якщо в ній більше
|
// пункт не з'явиться — а разом із ним і група, якщо в ній більше
|
||||||
// нічого не лишилось.
|
// нічого не лишилось.
|
||||||
{ to: '/audit', label: 'Журнал аудиту', icon: '🧾', perm: 'audit:read' },
|
{ to: '/audit', label: 'Журнал аудиту', icon: '🧾', perm: 'audit:read' },
|
||||||
|
// Одразу під журналом і під тим самим правом: це його друга
|
||||||
|
// половина. Журнал відповідає на «хто що зробив УЖЕ ВСЕРЕДИНІ», а
|
||||||
|
// на «хто сюди заходив» він не відповідає взагалі — спроби входу
|
||||||
|
// в нього не пишуться (кабінет на момент перевірки пароля ще
|
||||||
|
// невідомий). Порізно людина шукала б невдалі спроби в журналі й
|
||||||
|
// не знайшла б їх ніколи.
|
||||||
|
{ to: '/logins', label: 'Історія входів', icon: '🔑', perm: 'audit:read' },
|
||||||
// Одразу під журналом аудиту, бо їх плутають, а різниця між ними
|
// Одразу під журналом аудиту, бо їх плутають, а різниця між ними
|
||||||
// вирішальна: аудит відповідає на «хто що зробив», журнал сервера —
|
// вирішальна: аудит відповідає на «хто що зробив», журнал сервера —
|
||||||
// на «на що лається сама система». Поруч цю різницю видно з двох
|
// на «на що лається сама система». Поруч цю різницю видно з двох
|
||||||
|
|
|
||||||
756
web/src/pages/LoginsPage.tsx
Normal file
756
web/src/pages/LoginsPage.tsx
Normal file
|
|
@ -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<Filter> = { 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<LoginsMeta | null>(null)
|
||||||
|
const [page, setPage] = useState<LoginAttemptsPage | null>(null)
|
||||||
|
const [attempts, setAttempts] = useState<LoginAttempt[]>([])
|
||||||
|
const [cursor, setCursor] = useState<string | undefined>()
|
||||||
|
/** Період, у якому сервер зібрав першу сторінку. Дальші беруться в ньому ж. */
|
||||||
|
const [range, setRange] = useState<{ from: string; to: string } | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [more, setMore] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const patch = useCallback(
|
||||||
|
(p: Partial<Filter>) => 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 (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Історія входів"
|
||||||
|
subtitle={
|
||||||
|
meta?.bounds.first
|
||||||
|
? `записи з ${fmtDate(meta.bounds.first)} · час місцевий`
|
||||||
|
: 'час місцевий'
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
active > 0 ? (
|
||||||
|
<Button onClick={() => setSp(writeFilter({ ...filter, ...EMPTY }), { replace: true })}>
|
||||||
|
Скинути фільтри ({active})
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageBody className="space-y-3">
|
||||||
|
<ErrorNote>{error}</ErrorNote>
|
||||||
|
|
||||||
|
<Tally attempts={attempts} loading={loading} filter={filter} onChange={patch} />
|
||||||
|
<Unattributed page={page} />
|
||||||
|
|
||||||
|
<Filters meta={meta} filter={filter} onChange={patch} />
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : attempts.length === 0 ? (
|
||||||
|
<EmptyNote meta={meta} filter={filter} range={range} />
|
||||||
|
) : (
|
||||||
|
<Card className="divide-y divide-slate-800/70">
|
||||||
|
{attempts.map((a, i) => (
|
||||||
|
<AttemptRow
|
||||||
|
key={a.id}
|
||||||
|
attempt={a}
|
||||||
|
showDate={i === 0 || dayKey(attempts[i - 1].ts) !== dayKey(a.ts)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cursor && !loading && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button onClick={loadMore} disabled={more}>
|
||||||
|
{more ? 'Завантаження…' : 'Показати ще'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!cursor && attempts.length > 0 && !loading && (
|
||||||
|
<p className="text-center text-xs text-slate-600">
|
||||||
|
це всі спроби за обраний період — {attempts.length}{' '}
|
||||||
|
{plural(attempts.length, ['спроба', 'спроби', 'спроб'])}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<BlindSpots meta={meta} />
|
||||||
|
</PageBody>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Рахунок угорі
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скільки з показаного — невдалі.
|
||||||
|
*
|
||||||
|
* Стоїть ПЕРЕД фільтрами, бо це і є відповідь, по яку сюди приходять, а
|
||||||
|
* гортати перелік у пошуках червоного — робота, якої можна не робити.
|
||||||
|
* Число клікабельне: воно ж і фільтр «лише невдалі».
|
||||||
|
*
|
||||||
|
* Рахунок ЗАВЖДИ про показане, а не про період: коли натиснули
|
||||||
|
* «показати ще», у переліку більше рядків, і сказати «12 невдалих»,
|
||||||
|
* маючи на увазі перші п'ятдесят, означало б назвати число, яке нічому
|
||||||
|
* на екрані не відповідає.
|
||||||
|
*/
|
||||||
|
function Tally({
|
||||||
|
attempts,
|
||||||
|
loading,
|
||||||
|
filter,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
attempts: LoginAttempt[]
|
||||||
|
loading: boolean
|
||||||
|
filter: Filter
|
||||||
|
onChange: (p: Partial<Filter>) => void
|
||||||
|
}) {
|
||||||
|
if (loading || attempts.length === 0) return null
|
||||||
|
const failed = attempts.filter((a) => !a.success).length
|
||||||
|
const only = filter.outcome === 'failed'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="flex flex-wrap items-baseline gap-x-3 gap-y-1 p-3 text-sm">
|
||||||
|
<span className={failed > 0 ? 'font-medium text-red-200' : 'text-slate-300'}>
|
||||||
|
{failed} {plural(failed, ['невдала спроба', 'невдалі спроби', 'невдалих спроб'])}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
із {attempts.length} показаних {plural(attempts.length, ['спроби', 'спроб', 'спроб'])}
|
||||||
|
</span>
|
||||||
|
{failed > 0 && (
|
||||||
|
<button
|
||||||
|
className="ml-auto rounded border border-slate-700 px-2 py-0.5 text-[11px] text-slate-400 hover:text-slate-200"
|
||||||
|
onClick={() => onChange({ outcome: only ? '' : 'failed' })}
|
||||||
|
>
|
||||||
|
{only ? 'показати всі' : 'лише невдалі'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Межа видимості, яку не можна замовчати.
|
||||||
|
*
|
||||||
|
* Спроба потрапляє на цю сторінку лише тоді, коли її вдалося прив'язати
|
||||||
|
* до людини з вашого кабінету — через логін, пошту або ідентифікатор.
|
||||||
|
* Невдалу спробу з логіном, якого в системі немає, прив'язати нема до
|
||||||
|
* чого: вона не належить жодному кабінету, і показати її комусь
|
||||||
|
* означало б віддати чужі дані. Тому рядків тут немає — є число.
|
||||||
|
*
|
||||||
|
* Без цього блоку сторінка тихо бреше: хвиля перебору неіснуючих
|
||||||
|
* логінів виглядала б на ній як спокійний день.
|
||||||
|
*/
|
||||||
|
function Unattributed({ page }: { page: LoginAttemptsPage | null }) {
|
||||||
|
const u = page?.unattributed
|
||||||
|
if (!u || u.attempts === 0) return null
|
||||||
|
return (
|
||||||
|
<Card className="border-amber-900/60 bg-amber-950/20 p-3">
|
||||||
|
<div className="text-xs font-medium text-amber-200">
|
||||||
|
Ще {u.attempts} {plural(u.attempts, ['невдала спроба', 'невдалі спроби', 'невдалих спроб'])} за
|
||||||
|
цей період не показані нижче
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[11px] leading-relaxed text-amber-100/70">
|
||||||
|
Це спроби входу з логінами, яких у системі немає взагалі — з{' '}
|
||||||
|
{u.ips} {plural(u.ips, ['адреси', 'адрес', 'адрес'])}. Прив'язати їх до кабінету нема до
|
||||||
|
чого, а показати рядками означало б віддати комусь чужі дані, тож видно лише рахунок.
|
||||||
|
Багато таких спроб з кількох адрес — це перебір логінів; одна-дві — найчастіше людина,
|
||||||
|
яка помилилась у власному логіні.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Фільтри
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function Filters({
|
||||||
|
meta,
|
||||||
|
filter,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
meta: LoginsMeta | null
|
||||||
|
filter: Filter
|
||||||
|
onChange: (p: Partial<Filter>) => 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 (
|
||||||
|
<Card className="space-y-2.5 p-3">
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<label className="min-w-[14rem] flex-1">
|
||||||
|
<span className="mb-1 block text-[11px] text-slate-500">Введений логін</span>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={q}
|
||||||
|
placeholder="admin, root, пошта…"
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="w-40">
|
||||||
|
<span className="mb-1 block text-[11px] text-slate-500">Адреса або підмережа</span>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={ip}
|
||||||
|
placeholder="10.20.0.0/24"
|
||||||
|
onChange={(e) => setIP(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<PeriodRow filter={filter} maxDays={meta?.max_range_days} onChange={onChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Результат — один із трьох станів, тому не ChipRow: два обраних
|
||||||
|
«лише невдалі» і «лише вдалі» разом означали б «нічого». */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
<span className="w-20 shrink-0 text-[11px] text-slate-500">Результат</span>
|
||||||
|
{OUTCOMES.map((o) => {
|
||||||
|
const on = filter.outcome === o.value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
className={`rounded border px-1.5 py-0.5 text-[11px] transition-colors ${
|
||||||
|
on
|
||||||
|
? 'border-sky-700 bg-sky-900/50 text-sky-200'
|
||||||
|
: 'border-slate-700 bg-slate-800 text-slate-400 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
onClick={() => onChange({ outcome: on ? '' : o.value })}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChipRow
|
||||||
|
title="Причина"
|
||||||
|
options={reasonOptions}
|
||||||
|
selected={filter.reasons}
|
||||||
|
onChange={(v) => onChange({ reasons: v })}
|
||||||
|
/>
|
||||||
|
<ChipRow
|
||||||
|
title="Хто"
|
||||||
|
options={userOptions}
|
||||||
|
selected={filter.users}
|
||||||
|
onChange={(v) => onChange({ users: v })}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Період. Стеля та сама, що в журналі аудиту, і причина та сама:
|
||||||
|
* глибше дані в базі стиснуто. Практичного значення тут вона майже не
|
||||||
|
* має — спроби входу типово живуть 180 днів.
|
||||||
|
*/
|
||||||
|
function PeriodRow({
|
||||||
|
filter,
|
||||||
|
maxDays,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
filter: Filter
|
||||||
|
maxDays?: number
|
||||||
|
onChange: (p: Partial<Filter>) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<label>
|
||||||
|
<span className="mb-1 block text-[11px] text-slate-500">Період</span>
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
value={filter.period}
|
||||||
|
onChange={(e) => onChange({ period: e.target.value })}
|
||||||
|
title={
|
||||||
|
maxDays
|
||||||
|
? `глибина обмежена ${maxDays} ${plural(maxDays, ['днем', 'днями', 'днями'])}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{PERIODS.map((p) => (
|
||||||
|
<option key={p.key} value={p.key}>
|
||||||
|
останні {p.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="custom">свій…</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{filter.period === 'custom' && (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
<span className="mb-1 block text-[11px] text-slate-500">від</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className={inputClass}
|
||||||
|
value={filter.from}
|
||||||
|
onChange={(e) => onChange({ from: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="mb-1 block text-[11px] text-slate-500">до</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className={inputClass}
|
||||||
|
value={filter.to}
|
||||||
|
onChange={(e) => onChange({ to: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Рядок
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function AttemptRow({ attempt, showDate }: { attempt: LoginAttempt; showDate: boolean }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const a = attempt
|
||||||
|
// Причина показана фразою, але ключ лишається під розкриттям — те
|
||||||
|
// саме рішення, що з діями в журналі аудиту: фразу читають люди,
|
||||||
|
// ключем шукають у логах сервера. Назви немає — показуємо ключ як є:
|
||||||
|
// вигадана з підкреслень фраза виглядала б назвою й брехала б.
|
||||||
|
const reason = a.reason_label || a.reason
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{showDate && (
|
||||||
|
<div className="bg-slate-900/70 px-4 py-1 text-[11px] font-medium text-slate-500">
|
||||||
|
{fmtDate(a.ts)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="flex w-full flex-col gap-1 px-4 py-2.5 text-left hover:bg-slate-800/40"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<div className="flex w-full flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
|
<time className="w-16 shrink-0 tabular-nums text-xs text-slate-500">{fmtTime(a.ts)}</time>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={`rounded border px-1.5 text-[10px] ${
|
||||||
|
a.success
|
||||||
|
? 'border-emerald-900 bg-emerald-950/50 text-emerald-300'
|
||||||
|
: 'border-red-900 bg-red-950/50 text-red-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{a.success ? 'вхід' : 'невдало'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Введений логін — головне в рядку. Саме одруківка в ньому
|
||||||
|
(«adm1n») і є сигналом, і ховати його за іменем людини
|
||||||
|
означало б прибрати те, заради чого дивляться. */}
|
||||||
|
<span
|
||||||
|
className={`font-mono text-sm ${a.success ? 'text-slate-100' : 'text-red-200'}`}
|
||||||
|
title="що ввели в поле логіна"
|
||||||
|
>
|
||||||
|
{a.login}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{!a.success && reason && <span className="text-xs text-red-300/80">{reason}</span>}
|
||||||
|
|
||||||
|
<span className="ml-auto flex items-baseline gap-2 text-xs">
|
||||||
|
{a.ip && <span className="tabular-nums text-slate-400">{a.ip}</span>}
|
||||||
|
<span className="w-3 shrink-0 text-slate-600">{open ? '▾' : '▸'}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-wrap items-baseline gap-x-3 pl-[4.75rem] text-xs text-slate-500">
|
||||||
|
{/* Кому спроба належить — під поточним іменем людини: саме за
|
||||||
|
ним її впізнають сьогодні, навіть якщо тоді вводили інше. */}
|
||||||
|
<span className="text-slate-400">{a.username || '—'}</span>
|
||||||
|
{a.full_name && <span>{a.full_name}</span>}
|
||||||
|
{/* Пристрій — здогадка; повний User-Agent під розкриттям. */}
|
||||||
|
{a.device ? (
|
||||||
|
<span>{a.device}</span>
|
||||||
|
) : (
|
||||||
|
a.user_agent && <span className="min-w-0 truncate">{a.user_agent}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="border-t border-slate-800/70 bg-slate-950/40 px-4 py-3">
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
|
||||||
|
<dt className="text-slate-500">Коли</dt>
|
||||||
|
<dd className="text-slate-300">{fmtFull(a.ts)}</dd>
|
||||||
|
|
||||||
|
<dt className="text-slate-500">Введено</dt>
|
||||||
|
<dd className="font-mono text-slate-300">{a.login}</dd>
|
||||||
|
|
||||||
|
<dt className="text-slate-500">Хто</dt>
|
||||||
|
<dd className="text-slate-300">
|
||||||
|
{a.username || 'невідомо'}
|
||||||
|
{a.email ? ` · ${a.email}` : ''}
|
||||||
|
{a.full_name ? ` · ${a.full_name}` : ''}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt className="text-slate-500">Результат</dt>
|
||||||
|
<dd className={a.success ? 'text-emerald-300' : 'text-red-300'}>
|
||||||
|
{a.success ? 'вхід виконано' : 'відмовлено'}
|
||||||
|
{!a.success && a.reason ? (
|
||||||
|
<>
|
||||||
|
{reason ? ` · ${reason}` : ''}{' '}
|
||||||
|
<code className="text-[11px] text-slate-600">{a.reason}</code>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt className="text-slate-500">Звідки</dt>
|
||||||
|
<dd className="font-mono text-slate-300">{a.ip || '—'}</dd>
|
||||||
|
|
||||||
|
<dt className="text-slate-500">Пристрій</dt>
|
||||||
|
<dd className="text-slate-300">
|
||||||
|
{/* Здогадка й першоджерело поруч, ніколи одне замість
|
||||||
|
іншого: User-Agent задає клієнт, і в розборі інциденту
|
||||||
|
вгадане «Chrome» нічого не доводить. */}
|
||||||
|
{a.device && <span>{a.device}</span>}
|
||||||
|
{a.user_agent ? (
|
||||||
|
<code className="mt-0.5 block break-all text-[11px] text-slate-500">
|
||||||
|
{a.user_agent}
|
||||||
|
</code>
|
||||||
|
) : (
|
||||||
|
!a.device && <span className="text-slate-500">не передано</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt className="text-slate-500">Запис</dt>
|
||||||
|
<dd className="font-mono text-slate-500">{a.id}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Порожній результат і межі
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Порожній перелік — найнебезпечніше місце сторінки.
|
||||||
|
*
|
||||||
|
* «Нічого не знайдено» тут читається як «ніхто не ламався», хоча
|
||||||
|
* означати може чотири різні речі: у цьому кабінеті ще жодного входу;
|
||||||
|
* період закінчився раніше, ніж почалась історія; фільтр надто вузький;
|
||||||
|
* або спроби були, але жодну не вдалося прив'язати до вашого кабінету —
|
||||||
|
* і тоді відповідь стоїть у блоці вище, а не тут.
|
||||||
|
*/
|
||||||
|
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 (
|
||||||
|
<Card className="px-4 py-8">
|
||||||
|
<p className="mx-auto max-w-2xl text-center text-sm leading-relaxed text-slate-400">{text}</p>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Чого ця сторінка не бачить.
|
||||||
|
*
|
||||||
|
* Той самий блок і та сама причина, що в журналі аудиту: найгірша
|
||||||
|
* можлива помилка сторінки про відсутність — мовчазна порожнеча на
|
||||||
|
* місці невиміряного. Тут вона дорожча вдвічі, бо сюди приходять із
|
||||||
|
* питанням «нас ламають?».
|
||||||
|
*
|
||||||
|
* Перелік складає сервер (store.LoginBlindSpots) — там же, де сама
|
||||||
|
* вибірка: кожен рядок у ньому є наслідком того, як влаштоване
|
||||||
|
* зіставлення спроби з кабінетом, і копія в браузері розійшлася б із
|
||||||
|
* ним на першій же правці.
|
||||||
|
*/
|
||||||
|
function BlindSpots({ meta }: { meta: LoginsMeta | null }) {
|
||||||
|
const items = meta?.blind_spots ?? []
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<Card className="p-3">
|
||||||
|
<div className="text-xs font-medium text-slate-400">Чого ця сторінка не бачить</div>
|
||||||
|
<p className="mt-1 text-[11px] leading-relaxed text-slate-600">
|
||||||
|
У таблиці спроб входу немає ознаки кабінету — рядок пишеться тоді, коли кабінет ще
|
||||||
|
невідомий. Тому сюди потрапляє лише те, що вдалося прив'язати до учасника ЦЬОГО кабінету, і
|
||||||
|
порожній результат означає «не прив'язалось», а не «не було».
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2 space-y-1.5 text-xs leading-relaxed text-slate-500">
|
||||||
|
{items.map((s) => (
|
||||||
|
<li key={s} className="flex gap-2">
|
||||||
|
<span className="text-slate-700">•</span>
|
||||||
|
<span>{s}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Дрібниці
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
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)}`
|
||||||
|
}
|
||||||
287
web/src/test/logins.test.tsx
Normal file
287
web/src/test/logins.test.tsx
Normal file
|
|
@ -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<string, unknown> = {}) {
|
||||||
|
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<string, unknown> = {}) {
|
||||||
|
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<string, unknown> = {}) {
|
||||||
|
const server = fetchRouter({
|
||||||
|
'GET /api/v1/logins/meta': meta,
|
||||||
|
'GET /api/v1/logins': page([]),
|
||||||
|
...routes,
|
||||||
|
})
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={[url]}>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/logins"
|
||||||
|
element={
|
||||||
|
<>
|
||||||
|
<Spy />
|
||||||
|
<LoginsPage />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Питання до сервера без часових меж: `from`/`to` рахуються від
|
||||||
|
* Date.now() і в двох прогонах збігтись не можуть. */
|
||||||
|
function question(call: Call): Record<string, string[]> {
|
||||||
|
const sp = new URLSearchParams(call.url.split('?')[1] ?? '')
|
||||||
|
const out: Record<string, string[]> = {}
|
||||||
|
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'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1890,6 +1890,95 @@ export interface AuditFilter {
|
||||||
cursor?: string
|
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 ---
|
// --- дзеркало архіву конфігів на зовнішньому Git ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue