Netpulse_SasS/server/internal/auth/token.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

127 lines
4.3 KiB
Go
Raw Permalink 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 auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
// Токен доступу — компактний JWT (HS256), який не треба перевіряти в БД.
//
// Навіщо взагалі підписаний токен, коли є сесії в базі: кожен запит до
// API інакше означав би похід у БД лише заради «хто це». Токен живе
// 15 хвилин і носить рівно те, що потрібно для авторизації, а довгу
// частину (відкликання, вихід з усіх пристроїв) забезпечує refresh-сесія
// в core.sessions.
//
// Звідси й короткий строк: відкликати вже виданий access-токен
// неможливо, тому вікно, у якому звільнений співробітник ще має доступ,
// дорівнює саме цим 15 хвилинам.
const AccessTTL = 15 * time.Minute
var (
ErrTokenInvalid = errors.New("токен недійсний")
ErrTokenExpired = errors.New("строк дії токена минув")
)
// Claims — вміст токена доступу.
type Claims struct {
UserID string `json:"sub"`
TenantID string `json:"tid"`
Email string `json:"eml"`
// Ідентифікатор сесії: дозволяє звʼязати access-токен із refresh-ом
// і побачити в аудиті, з якого саме входу прийшла дія.
SessionID string `json:"sid"`
ExpiresAt int64 `json:"exp"`
IssuedAt int64 `json:"iat"`
}
// Signer підписує й перевіряє токени доступу.
//
// Власна реалізація HS256 замість бібліотеки — тут рівно один алгоритм
// і один тип токена. Головна пастка JWT-бібліотек (прийняти alg:none або
// підмінений alg) тут неможлива за побудовою: заголовок не читається для
// вибору алгоритму, він лише звіряється з очікуваним.
type Signer struct {
secret []byte
}
func NewSigner(secret []byte) (*Signer, error) {
if len(secret) < 32 {
return nil, errors.New("секрет підпису має бути щонайменше 32 байти")
}
return &Signer{secret: secret}, nil
}
const jwtHeader = `{"alg":"HS256","typ":"JWT"}`
func (s *Signer) Sign(c Claims) (string, error) {
now := time.Now()
c.IssuedAt = now.Unix()
c.ExpiresAt = now.Add(AccessTTL).Unix()
payload, err := json.Marshal(c)
if err != nil {
return "", err
}
enc := base64.RawURLEncoding
head := enc.EncodeToString([]byte(jwtHeader))
body := enc.EncodeToString(payload)
signing := head + "." + body
mac := hmac.New(sha256.New, s.secret)
mac.Write([]byte(signing))
return signing + "." + enc.EncodeToString(mac.Sum(nil)), nil
}
func (s *Signer) Verify(token string) (*Claims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, ErrTokenInvalid
}
enc := base64.RawURLEncoding
// Заголовок звіряємо байт-у-байт, а не розбираємо: це і є захист
// від підміни алгоритму.
head, err := enc.DecodeString(parts[0])
if err != nil || string(head) != jwtHeader {
return nil, ErrTokenInvalid
}
sig, err := enc.DecodeString(parts[2])
if err != nil {
return nil, ErrTokenInvalid
}
mac := hmac.New(sha256.New, s.secret)
mac.Write([]byte(parts[0] + "." + parts[1]))
if !hmac.Equal(sig, mac.Sum(nil)) {
return nil, ErrTokenInvalid
}
body, err := enc.DecodeString(parts[1])
if err != nil {
return nil, ErrTokenInvalid
}
var c Claims
if err := json.Unmarshal(body, &c); err != nil {
return nil, ErrTokenInvalid
}
// Строк перевіряємо ПІСЛЯ підпису: інакше повідомлення «протерміновано»
// підтверджувало б валідність підробленого токена.
if time.Now().Unix() >= c.ExpiresAt {
return nil, ErrTokenExpired
}
if c.UserID == "" || c.TenantID == "" {
return nil, fmt.Errorf("%w: порожні claims", ErrTokenInvalid)
}
return &c, nil
}