Netpulse_SasS/server/internal/httpapi/principal.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

166 lines
5.2 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"
"net/http"
"strings"
"github.com/netpulse/netpulse/server/internal/auth"
)
// Principal — хто саме робить запит.
//
// Два джерела автентифікації зводяться до одного типу: людина з
// access-токеном і машинний токен інтеграції. Обробникам байдуже, хто
// перед ними, — вони питають лише «чи можна». Інакше кожен обробник мав
// би дві гілки, і будь-яке нове право доводилось би додавати двічі.
type Principal struct {
TenantID string
// Порожній для машинного токена.
UserID string
Email string
// Порожній для людини.
TokenID string
TokenName string
perms map[string]bool
// Машинний токен без scopes успадковує повний доступ тенанта:
// так поводяться ключі, які власник створює сам для себе.
unrestricted bool
}
func (p *Principal) Can(perm string) bool {
if p == nil {
return false
}
if p.unrestricted {
return true
}
return p.perms[perm] || p.perms["*"]
}
// Permissions — перелік прав для /api/v1/me.
func (p *Principal) Permissions() []string {
if p.unrestricted {
return []string{"*"}
}
out := make([]string, 0, len(p.perms))
for k := range p.perms {
out = append(out, k)
}
return out
}
func (p *Principal) IsUser() bool { return p.UserID != "" }
// ---------------------------------------------------------------------
// authenticated обгортає обробник перевіркою особи.
func (s *Server) authenticated(next func(http.ResponseWriter, *http.Request, *Principal)) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw := bearerToken(r)
if raw == "" {
writeError(w, http.StatusUnauthorized, "no_token", "потрібен Bearer-токен")
return
}
p, err := s.resolvePrincipal(r, raw)
switch {
case errors.Is(err, auth.ErrTokenExpired):
// Окремий код: клієнт має піти по refresh, а не викидати
// користувача на сторінку входу.
writeError(w, http.StatusUnauthorized, "token_expired", "строк дії токена минув")
return
case err != nil:
writeError(w, http.StatusUnauthorized, "invalid_token", "токен недійсний")
return
}
next(w, r, p)
})
}
func (s *Server) resolvePrincipal(r *http.Request, raw string) (*Principal, error) {
ctx := r.Context()
// JWT має рівно дві крапки; машинні токени — жодної. Розрізняти за
// формою надійніше, ніж за префіксом: префікс задає людина.
if strings.Count(raw, ".") == 2 {
if s.signer == nil {
return nil, errors.New("підпис токенів не налаштовано")
}
claims, err := s.signer.Verify(raw)
if err != nil {
return nil, err
}
perms, err := s.store.UserPermissions(ctx, claims.UserID, claims.TenantID)
if err != nil {
return nil, err
}
// Порожній набір означає, що членство відкликали вже після
// видачі токена. Пускати такого не можна.
if len(perms) == 0 {
return nil, errors.New("немає прав у цьому тенанті")
}
p := &Principal{
TenantID: claims.TenantID,
UserID: claims.UserID,
Email: claims.Email,
perms: make(map[string]bool, len(perms)),
}
for _, k := range perms {
p.perms[k] = true
}
return p, nil
}
tok, err := s.store.AuthenticateAPIToken(ctx, raw)
if err != nil {
return nil, err
}
p := &Principal{
TenantID: tok.TenantID,
TokenID: tok.ID,
TokenName: tok.Name,
perms: make(map[string]bool, len(tok.Scopes)),
}
if len(tok.Scopes) == 0 {
p.unrestricted = true
}
for _, k := range tok.Scopes {
p.perms[k] = true
}
return p, nil
}
// requirePerm — коротка форма перевірки з однаковою відповіддю.
func requirePerm(w http.ResponseWriter, p *Principal, perm string) bool {
if p.Can(perm) {
return true
}
writeError(w, http.StatusForbidden, "forbidden", "немає права "+perm)
return false
}
// clientIP дістає адресу з урахуванням зворотного проксі.
//
// X-Forwarded-For береться лише перший елемент: решту дописують
// проміжні проксі, і довіряти їм не можна. Сам заголовок має сенс
// тільки якщо перед сервісом справді стоїть довірений проксі.
func clientIP(r *http.Request) net.IP {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
first, _, _ := strings.Cut(xff, ",")
if ip := net.ParseIP(strings.TrimSpace(first)); ip != nil {
return ip
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
return net.ParseIP(host)
}