Netpulse_SasS/server/internal/httpapi/logins.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

137 lines
6.6 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 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)),
})
}