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