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 // Ім'я входу віддається обов'язково, і це не дрібниця. // // Обліковий запис власника, який заводить установник, входить за // ІМЕНЕМ (admin), а не за поштою — тобто email у нього порожній. // Інтерфейс показував посилання на профіль лише за наявності // імені або пошти, тож у типовій інсталяції воно не малювалось // узагалі, і сторінка профілю ставала недосяжною: ні з меню, ні // з шапки. Разом із нею — прив'язка Telegram, без якої кнопки // під сповіщеннями відповідають «ваш Telegram не прив'язано» і // не дають виходу. resp["username"] = p.Username } 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) }