Netpulse_SasS/server/internal/httpapi/auth.go
byrsapty 9f901f8b8e
All checks were successful
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 1m7s
CI / server (push) Successful in 1m36s
CI / agent (push) Successful in 57s
/me віддає ім'я з бази: Principal.Username ніколи не заповнювався
Перша спроба віддавала p.Username — прогін на стенді показав порожнє.
Поле оголошене в структурі й не присвоюється ніде, а в токені лежить
лише пошта. Нова UserByID читає ім'я з core.users; класти його в токен
означало б, що після перейменування людина чверть години бачить старе.

Спіймано лише тому, що перевірка йшла наживо, а не на збірці.

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

305 lines
12 KiB
Go
Raw Permalink 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 не прив'язано» і
// не дають виходу.
// Ім'я читається з бази, а не з токена: Principal.Username
// оголошене, але ніде не заповнюється, а в самому токені лежить
// лише пошта. Класти туди ще й ім'я означало б, що після
// перейменування людина чверть години бачить у шапці старе
// ім'я й не має способу це виправити.
//
// Збій запиту не валить /me: без імені інтерфейс покаже
// «Профіль», а без відповіді взагалі — викине людину на вхід.
resp["username"] = p.Username
if u, err := s.store.UserByID(r.Context(), p.UserID); err == nil {
resp["username"] = u.Username
if u.Email != "" {
resp["email"] = u.Email
}
resp["full_name"] = u.FullName
} else {
s.log.Warn("читання облікового запису для /me", "err", err)
}
} 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)
}