Досі доступ давав машинний токен зі змінної збірки — одні права на всіх і жодного способу відрізнити, хто що зробив. Тепер продукт уміє впустити людину. Сервер: - 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>
269 lines
9 KiB
Go
269 lines
9 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 хвилин вхід для цього
|
||
// 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)
|
||
}
|