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