Netpulse_SasS/server/internal/store/login_attempts.go
byrsapty 95a19b074d
Some checks failed
CI / agent (push) Waiting to run
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 1m11s
CI / server (push) Successful in 1m35s
CI / dbtest (push) Has been cancelled
Історія входів: 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>
2026-08-29 01:06:42 +03:00

691 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 ""
}
}