Netpulse_SasS/server/internal/httpapi/auth.go
byrsapty a4faf31fcd Етап 5: користувачі, вхід і права
Досі доступ давав машинний токен зі змінної збірки — одні права на всіх
і жодного способу відрізнити, хто що зробив. Тепер продукт уміє впустити
людину.

Сервер:
- argon2id для паролів, власний HS256 JWT (15 хв) + refresh-сесія в
  httpOnly-кукі на 30 днів з ротацією при кожному обміні
- Principal зводить людину й машинний токен до одного набору прав;
  права читаються з БД на кожному запиті, а не з claims, щоб відкликана
  роль не жила до кінця TTL
- 9 ендпоїнтів: auth/login|refresh|logout|password, me, team CRUD, roles
- netpulse-user — CLI для першого власника: публічна реєстрація в B2B
  це дірка, а «перший через веб, поки нікого немає» — нечесна гонка
- міграція 0012: три RLS-політики винятку для шляху входу (без них
  вхід неможливий за побудовою — щоб знайти користувача за email,
  треба знати тенант, який відомий лише після пошуку) і login_attempts
  для тротлінгу

Фронтенд:
- сторінка входу з вибором організації, access-токен у замиканні
  модуля замість localStorage, тихе відновлення сесії по кукі
- один refresh на всі паралельні запити: інакше ротація зробила б усі,
  крім першого, недійсними й викинула б людину на вхід
- дії без права не показуються; полотно нередаговане для глядача
- мобільний адаптив: висувна бічна панель, інспектор нижнім аркушем

11 нових тестів (37 у httpapi), go vet і tsc чисто, живий прогін з
8 кроків проти netpulse_it.

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

269 lines
9 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 хвилин вхід для цього
// email тимчасово закритий. Рахуємо за email, а не за 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 {
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
}
if in.Email == "" || in.Password == "" {
writeError(w, http.StatusBadRequest, "invalid", "потрібні email і пароль")
return
}
ctx := r.Context()
ip := clientIP(r)
ua := r.UserAgent()
if n, err := s.store.FailedLoginsSince(ctx, in.Email, failWindow); err == nil && n >= maxFailedLogins {
s.log.Warn("вхід заблоковано через перебір", "email", in.Email, "спроб", n)
writeError(w, http.StatusTooManyRequests, "too_many_attempts",
"забагато невдалих спроб, спробуйте за 15 хвилин")
return
}
user, memberships, err := s.store.AuthenticateUser(ctx, in.Email, in.Password, ip, ua)
switch {
case errors.Is(err, store.ErrBadCredentials):
// Однакова відповідь для «немає такого email» і «невірний
// пароль»: різні тексти перетворюють форму входу на довідник
// зареєстрованих адрес.
writeError(w, http.StatusUnauthorized, "bad_credentials", "невірний email або пароль")
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)
}