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

133 lines
4.4 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 — паролі, токени доступу й перевірка прав.
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
}