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>
691 lines
34 KiB
Go
691 lines
34 KiB
Go
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 ""
|
||
}
|
||
}
|