Досі доступ давав машинний токен зі змінної збірки — одні права на всіх і жодного способу відрізнити, хто що зробив. Тепер продукт уміє впустити людину. Сервер: - 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>
133 lines
4.4 KiB
Go
133 lines
4.4 KiB
Go
// Package auth — паролі, токени доступу й перевірка прав.
|
||
package auth
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"crypto/subtle"
|
||
"encoding/base64"
|
||
"errors"
|
||
"fmt"
|
||
"runtime"
|
||
"strings"
|
||
|
||
"golang.org/x/crypto/argon2"
|
||
)
|
||
|
||
// Параметри argon2id.
|
||
//
|
||
// argon2id, а не bcrypt: bcrypt обмежений 72 байтами й слабо стійкий до
|
||
// перебору на GPU, бо майже не використовує пам'ять. Тут 64 МБ на хеш
|
||
// роблять масовий перебір дорогим саме там, де в атакуючого перевага.
|
||
//
|
||
// 64 МБ × 4 потоки — це ~10 паралельних логінів на гігабайт RAM.
|
||
// Для B2B-панелі з десятками користувачів запас величезний; якщо колись
|
||
// стане вузьким місцем, знижувати треба memory, а не time.
|
||
const (
|
||
argonTime = 2
|
||
argonMemory = 64 * 1024 // КіБ
|
||
argonKeyLen = 32
|
||
argonSaltLen = 16
|
||
)
|
||
|
||
var (
|
||
ErrBadPassword = errors.New("невірний пароль")
|
||
ErrBadHashFormat = errors.New("нерозпізнаний формат хеша")
|
||
)
|
||
|
||
func argonThreads() uint8 {
|
||
n := runtime.NumCPU()
|
||
if n > 4 {
|
||
n = 4
|
||
}
|
||
if n < 1 {
|
||
n = 1
|
||
}
|
||
return uint8(n)
|
||
}
|
||
|
||
// HashPassword повертає хеш у стандартному вигляді PHC:
|
||
// $argon2id$v=19$m=65536,t=2,p=4$<salt>$<hash>
|
||
//
|
||
// Формат самоописний: параметри лежать поруч із хешем, тому їх можна
|
||
// посилити завтра, не ламаючи вчорашні паролі — перевірка прочитає ті,
|
||
// з якими хеш був створений.
|
||
func HashPassword(password string) (string, error) {
|
||
if len(password) < 8 {
|
||
return "", errors.New("пароль коротший за 8 символів")
|
||
}
|
||
|
||
salt := make([]byte, argonSaltLen)
|
||
if _, err := rand.Read(salt); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
threads := argonThreads()
|
||
sum := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, threads, argonKeyLen)
|
||
|
||
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||
argon2.Version, argonMemory, argonTime, threads,
|
||
base64.RawStdEncoding.EncodeToString(salt),
|
||
base64.RawStdEncoding.EncodeToString(sum),
|
||
), nil
|
||
}
|
||
|
||
// VerifyPassword звіряє пароль із хешем.
|
||
//
|
||
// Порівняння constant-time: різниця в часі відповіді між «майже вгадав»
|
||
// і «зовсім не вгадав» — це вже підказка для атакуючого.
|
||
func VerifyPassword(hash, password string) error {
|
||
parts := strings.Split(hash, "$")
|
||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||
return ErrBadHashFormat
|
||
}
|
||
|
||
var version int
|
||
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
|
||
return ErrBadHashFormat
|
||
}
|
||
if version != argon2.Version {
|
||
return ErrBadHashFormat
|
||
}
|
||
|
||
var memory uint32
|
||
var time uint32
|
||
var threads uint8
|
||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
|
||
return ErrBadHashFormat
|
||
}
|
||
|
||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||
if err != nil {
|
||
return ErrBadHashFormat
|
||
}
|
||
want, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||
if err != nil {
|
||
return ErrBadHashFormat
|
||
}
|
||
|
||
got := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(want)))
|
||
if subtle.ConstantTimeCompare(got, want) != 1 {
|
||
return ErrBadPassword
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DummyVerify спалює приблизно стільки ж часу, скільки справжня
|
||
// перевірка.
|
||
//
|
||
// Викликається, коли користувача з таким email не існує. Без цього
|
||
// відповідь на неіснуючий email приходила б помітно швидше, і перебір
|
||
// перетворювався б на збір списку зареєстрованих адрес.
|
||
func DummyVerify(password string) {
|
||
salt := make([]byte, argonSaltLen)
|
||
_ = argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads(), argonKeyLen)
|
||
}
|
||
|
||
// NewToken — випадковий токен для refresh-сесій, запрошень і API-ключів.
|
||
func NewToken(prefix string) (string, error) {
|
||
raw := make([]byte, 32)
|
||
if _, err := rand.Read(raw); err != nil {
|
||
return "", err
|
||
}
|
||
return prefix + base64.RawURLEncoding.EncodeToString(raw), nil
|
||
}
|