Netpulse_SasS/server/internal/httpapi/auth.go
byrsapty 96692ec9df Вхід за логіном, групи хостів і права доступу
Логін:
- core.users.username замість пошти як ідентифікатор: у мережевій
  інсталяції половина акаунтів технічні (noc, monitoring, oncall) і
  скриньки не мають узагалі. Пошта лишилась необов'язковим полем
- наявним користувачам логін виведено з пошти, збіги розведено
  суфіксом: мовчки злити admin@a.com і admin@b.com в один логін —
  це втрата акаунта, а не міграція
- сервер шукає за логіном і за поштою, тому звичка вводити email
  нікого не відхиляє

Групи (модель Zabbix):
- групи хостів і групи доступу; права read/write/deny на групу хостів
- роль каже, що вільно робити; група — над якими хостами. Інженер над
  філією та інженер над усією мережею мають однакову роль і різний
  доступ
- хто не входить у жодну групу, групами не обмежений — свідомо не
  по-заббіксівськи: там кожна нова інсталяція починається з питання
  «чому порожньо»
- заборона перемагає дозвіл, інакше її обійти додаванням у сусідню групу
- фільтр накладається в самому запиті, а не після вибірки

Хости: додавання, редагування, м'яке видалення, прив'язка до груп,
фільтр за групою в таблиці.

Мапа: створення мапи з транслітерацією slug, інспектор вузла — підпис,
значок, розмір, ширина, колір рамки, закріплення.

Виправлено за скаргами:
- перемикач вилазив на 14px за трек: у ручки не заданий left, а
  статичну позицію зсуває типове text-align: center у <button>
- сторінка правил не оновлювала «активних»: після повернення правила
  алерти піднімаються наступним тіком движка, тобто ПІСЛЯ нашого
  перечитування — бракувало підписки на живі події
- канал із секретом показувався як «секрету немає»

Живий прогін: eng у групі з read на «Доступ» бачить 2 хости замість 3,
обидва без запису, алертів 1 замість 3, редагування чужого хоста 403.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 02:00:04 +03:00

277 lines
9.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 (
"errors"
"net/http"
"time"
"github.com/netpulse/netpulse/server/internal/auth"
"github.com/netpulse/netpulse/server/internal/store"
)
// Гальмування перебору: після 10 невдач за 15 хвилин вхід для цього
// логін тимчасово закритий. Рахуємо за логіном, а не за IP: атака з
// ботнета міняє IP на кожен запит, а ціль лишається та сама.
const (
maxFailedLogins = 10
failWindow = 15 * time.Minute
)
// refreshCookie — ім'я httpOnly-кукі з refresh-токеном.
//
// Кукі, а не localStorage: XSS-скрипт не прочитає httpOnly-значення,
// а SameSite=Strict прибирає CSRF без окремих токенів. Access-токен
// натомість живе в памʼяті вкладки й гине разом із нею.
const refreshCookie = "np_refresh"
type loginRequest struct {
// Login — те, що людина ввела в поле «Логін». Приймається і пошта:
// відмовляти тому, хто за звичкою ввів email, було б причепливістю.
Login string `json:"login"`
Email string `json:"email"`
Password string `json:"password"`
// Необовʼязковий: потрібен лише коли людина працює в кількох тенантах.
TenantID string `json:"tenant_id"`
}
type loginResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
User *store.User `json:"user"`
Tenant *store.TenantMembership `json:"tenant"`
Tenants []store.TenantMembership `json:"tenants,omitempty"`
Permissions []string `json:"permissions"`
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var in loginRequest
if !decodeBody(w, r, &in) {
return
}
// Старе поле лишається прийнятним: інтеграції, написані до
// перейменування, не мають ламатися через назву ключа в JSON.
if in.Login == "" {
in.Login = in.Email
}
if in.Login == "" || in.Password == "" {
writeError(w, http.StatusBadRequest, "invalid", "потрібні логін і пароль")
return
}
ctx := r.Context()
ip := clientIP(r)
ua := r.UserAgent()
if n, err := s.store.FailedLoginsSince(ctx, in.Login, failWindow); err == nil && n >= maxFailedLogins {
s.log.Warn("вхід заблоковано через перебір", "login", in.Login, "спроб", n)
writeError(w, http.StatusTooManyRequests, "too_many_attempts",
"забагато невдалих спроб, спробуйте за 15 хвилин")
return
}
user, memberships, err := s.store.AuthenticateUser(ctx, in.Login, in.Password, ip, ua)
switch {
case errors.Is(err, store.ErrBadCredentials):
// Однакова відповідь для «немає такого логіна» і «невірний
// пароль»: різні тексти перетворюють форму входу на довідник
// зареєстрованих адрес.
writeError(w, http.StatusUnauthorized, "bad_credentials", "невірний логін або пароль")
return
case errors.Is(err, store.ErrNoMembership):
writeError(w, http.StatusForbidden, "no_membership", "користувача не додано до жодної організації")
return
case err != nil:
s.log.Error("вхід", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
tenant := pickTenant(memberships, in.TenantID)
if tenant == nil {
// Людина в кількох організаціях і не сказала, куди саме.
// Віддаємо перелік, щоб клієнт показав вибір, і НЕ видаємо токен.
writeJSON(w, http.StatusOK, loginResponse{User: user, Tenants: memberships})
return
}
s.issueSession(w, r, user, tenant)
}
func pickTenant(ms []store.TenantMembership, want string) *store.TenantMembership {
if want != "" {
for i := range ms {
if ms[i].TenantID == want {
return &ms[i]
}
}
return nil
}
if len(ms) == 1 {
return &ms[0]
}
return nil
}
func (s *Server) issueSession(w http.ResponseWriter, r *http.Request, user *store.User, tenant *store.TenantMembership) {
ctx := r.Context()
sess, err := s.store.CreateSession(ctx, user.ID, tenant.TenantID, clientIP(r), r.UserAgent())
if err != nil {
s.log.Error("створення сесії", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
perms, err := s.store.UserPermissions(ctx, user.ID, tenant.TenantID)
if err != nil {
s.log.Error("права користувача", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
access, err := s.signer.Sign(auth.Claims{
UserID: user.ID,
TenantID: tenant.TenantID,
Email: user.Email,
SessionID: sess.ID,
})
if err != nil {
s.log.Error("підпис токена", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
s.setRefreshCookie(w, r, sess.RefreshToken, sess.ExpiresAt)
writeJSON(w, http.StatusOK, loginResponse{
AccessToken: access,
ExpiresIn: int(auth.AccessTTL.Seconds()),
User: user,
Tenant: tenant,
Permissions: perms,
})
}
func (s *Server) setRefreshCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: refreshCookie,
Value: token,
Path: "/api/v1/auth",
HttpOnly: true,
// Secure лише під HTTPS: інакше кукі просто не встановиться на
// локальному стенді, і вхід виглядатиме як зламаний.
Secure: r.TLS != nil,
SameSite: http.SameSiteStrictMode,
Expires: expires,
})
}
func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(refreshCookie)
if err != nil || c.Value == "" {
writeError(w, http.StatusUnauthorized, "no_session", "сесії немає")
return
}
sess, user, err := s.store.RotateSession(r.Context(), c.Value, clientIP(r), r.UserAgent())
if errors.Is(err, store.ErrSessionInvalid) {
s.clearRefreshCookie(w, r)
writeError(w, http.StatusUnauthorized, "session_invalid", "сесія недійсна, увійдіть знову")
return
}
if err != nil {
s.log.Error("оновлення сесії", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
perms, err := s.store.UserPermissions(r.Context(), user.ID, sess.TenantID)
if err != nil || len(perms) == 0 {
s.clearRefreshCookie(w, r)
writeError(w, http.StatusForbidden, "no_membership", "доступ до організації відкликано")
return
}
access, err := s.signer.Sign(auth.Claims{
UserID: user.ID,
TenantID: sess.TenantID,
Email: user.Email,
SessionID: sess.ID,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
s.setRefreshCookie(w, r, sess.RefreshToken, sess.ExpiresAt)
writeJSON(w, http.StatusOK, loginResponse{
AccessToken: access,
ExpiresIn: int(auth.AccessTTL.Seconds()),
User: user,
Permissions: perms,
})
}
func (s *Server) clearRefreshCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: refreshCookie, Value: "", Path: "/api/v1/auth",
HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteStrictMode,
MaxAge: -1,
})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(refreshCookie); err == nil && c.Value != "" {
if err := s.store.RevokeSession(r.Context(), c.Value); err != nil {
s.log.Warn("відкликання сесії", "err", err)
}
}
s.clearRefreshCookie(w, r)
w.WriteHeader(http.StatusNoContent)
}
// handleMe — хто я і що мені можна. Фронтенд ховає за цим дії, на які
// немає права: кнопка, що завжди дає 403, гірша за її відсутність.
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request, p *Principal) {
resp := map[string]any{
"tenant_id": p.TenantID,
"permissions": p.Permissions(),
}
if p.IsUser() {
resp["user_id"] = p.UserID
resp["email"] = p.Email
} else {
resp["token_id"] = p.TokenID
resp["token_name"] = p.TokenName
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.IsUser() {
writeError(w, http.StatusForbidden, "forbidden", "машинний токен не має пароля")
return
}
var in struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
if !decodeBody(w, r, &in) {
return
}
err := s.store.ChangePassword(r.Context(), p.UserID, in.OldPassword, in.NewPassword)
if errors.Is(err, store.ErrBadCredentials) {
writeError(w, http.StatusUnauthorized, "bad_credentials", "поточний пароль невірний")
return
}
if err != nil {
s.writeStoreError(w, "зміна пароля", err)
return
}
// Усі сесії відкликані, включно з поточною.
s.clearRefreshCookie(w, r)
w.WriteHeader(http.StatusNoContent)
}