Netpulse_SasS/server/internal/httpapi/auth.go
byrsapty ae07bd2a79
All checks were successful
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 1m21s
CI / server (push) Successful in 1m48s
CI / agent (push) Successful in 2m59s
Прив'язка Telegram: сторінка була, дороги до неї не було
Кнопка під сповіщенням відповідала «ваш Telegram не прив'язано» і не
давала виходу. У базі нуль прив'язок і нуль кодів за весь час.

Сторінка профілю існує, але пункту меню не мала, а єдиний вхід — ім'я
користувача в шапці — малювався за умовою «є ім'я або пошта», тоді як
/me віддавало лише пошту. В облікового запису власника, який заводить
установник і який входить ІМЕНЕМ, вона порожня. Тобто в типовій
інсталяції входу в профіль не було взагалі.

* /me віддає username (тип Me на фронтенді його вже вимагав);
* вхід у профіль малюється завжди для людини;
* пункт меню «Обліковий запис → Мій профіль», perm став необов'язковим;
* текст бота називає те, що видно на екрані;
* сторінка каналів показує стан прив'язки біля telegram-каналу.

Плюс 0073: оренда сходинки ескалації отримала lease_token. Партія
переростає 2-хвилинну оренду, і другий інстанс доставляв ту саму
сходинку паралельно з першим. Тепер запис проходить лише за збігу
токена; при розбіжності не відбувається нічого, сходинка лишається
належною.

65 міграцій, усе зелене проти справжньої бази.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 19:57:09 +03:00

288 lines
10 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
// Ім'я входу віддається обов'язково, і це не дрібниця.
//
// Обліковий запис власника, який заводить установник, входить за
// ІМЕНЕМ (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)
}