Перша спроба віддавала p.Username — прогін на стенді показав порожнє. Поле оголошене в структурі й не присвоюється ніде, а в токені лежить лише пошта. Нова UserByID читає ім'я з core.users; класти його в токен означало б, що після перейменування людина чверть години бачить старе. Спіймано лише тому, що перевірка йшла наживо, а не на збірці. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
305 lines
12 KiB
Go
305 lines
12 KiB
Go
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)
|
||
}
|