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