ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m15s
ci / api / api (push) Successful in 1m31s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 47s
ci / ts / ts (packages + web) (push) Successful in 39s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 26s
- users, refresh_tokens and audit_log schema (migration 00002) - argon2id passwords with rehash on login, password policy, lockout with exponential back-off and a per-IP rate limit in Redis - Ed25519 JWT access tokens with kid-based key rotation; rotating refresh tokens with reuse detection (cookie for web, body for Android) - bootstrap admin on first start; must_change_credentials and must_change_password gate every other endpoint - /auth/login, /auth/refresh, /auth/logout(-all), /me, /me/password, /me/bootstrap-credentials, /me/sessions; server-info reports bootstrap_required - role policy from docs/auth-and-roles.md with table tests - problem+json errors with field errors; ru/en translations of the codes - install.sh and update.sh generate JWT_PRIVATE_KEY Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
112 lines
4.1 KiB
Go
112 lines
4.1 KiB
Go
package auth
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"golang.org/x/crypto/argon2"
|
|
)
|
|
|
|
// Argon2Params — параметры argon2id. Хранятся в самом хэше (формат PHC), поэтому их можно менять:
|
|
// старые хэши проверяются со своими параметрами и пересчитываются при следующем входе.
|
|
type Argon2Params struct {
|
|
MemoryKiB uint32
|
|
Time uint32
|
|
Threads uint8
|
|
SaltLen int
|
|
KeyLen uint32
|
|
}
|
|
|
|
// DefaultArgon2Params — m=64 MiB, t=3, p=2 (docs/auth-and-roles.md).
|
|
var DefaultArgon2Params = Argon2Params{MemoryKiB: 64 * 1024, Time: 3, Threads: 2, SaltLen: 16, KeyLen: 32}
|
|
|
|
// Hasher хэширует и проверяет пароли.
|
|
type Hasher struct{ p Argon2Params }
|
|
|
|
// NewHasher создаёт Hasher с заданными параметрами.
|
|
func NewHasher(p Argon2Params) *Hasher { return &Hasher{p: p} }
|
|
|
|
// Hash возвращает PHC-строку `$argon2id$v=19$m=…,t=…,p=…$salt$key`.
|
|
func (h *Hasher) Hash(password string) (string, error) {
|
|
salt := make([]byte, h.p.SaltLen)
|
|
if _, err := rand.Read(salt); err != nil {
|
|
return "", fmt.Errorf("auth: salt: %w", err)
|
|
}
|
|
key := argon2.IDKey([]byte(password), salt, h.p.Time, h.p.MemoryKiB, h.p.Threads, h.p.KeyLen)
|
|
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, h.p.MemoryKiB, h.p.Time, h.p.Threads,
|
|
b64.EncodeToString(salt), b64.EncodeToString(key)), nil
|
|
}
|
|
|
|
// Verify проверяет пароль. needsRehash = true, если хэш посчитан с устаревшими параметрами.
|
|
func (h *Hasher) Verify(password, encoded string) (ok, needsRehash bool, err error) {
|
|
p, salt, key, err := decodePHC(encoded)
|
|
if err != nil {
|
|
return false, false, err
|
|
}
|
|
got := argon2.IDKey([]byte(password), salt, p.Time, p.MemoryKiB, p.Threads, uint32(len(key)))
|
|
if subtle.ConstantTimeCompare(got, key) != 1 {
|
|
return false, false, nil
|
|
}
|
|
needsRehash = p.MemoryKiB != h.p.MemoryKiB || p.Time != h.p.Time || p.Threads != h.p.Threads ||
|
|
len(salt) != h.p.SaltLen || uint32(len(key)) != h.p.KeyLen
|
|
return true, needsRehash, nil
|
|
}
|
|
|
|
var b64 = base64.RawStdEncoding
|
|
|
|
var errBadHash = errors.New("auth: malformed password hash")
|
|
|
|
func decodePHC(s string) (Argon2Params, []byte, []byte, error) {
|
|
var p Argon2Params
|
|
parts := strings.Split(s, "$")
|
|
if len(parts) != 6 || parts[1] != "argon2id" {
|
|
return p, nil, nil, errBadHash
|
|
}
|
|
var v int
|
|
if _, err := fmt.Sscanf(parts[2], "v=%d", &v); err != nil || v != argon2.Version {
|
|
return p, nil, nil, errBadHash
|
|
}
|
|
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.MemoryKiB, &p.Time, &p.Threads); err != nil {
|
|
return p, nil, nil, errBadHash
|
|
}
|
|
salt, err := b64.DecodeString(parts[4])
|
|
if err != nil {
|
|
return p, nil, nil, errBadHash
|
|
}
|
|
key, err := b64.DecodeString(parts[5])
|
|
if err != nil || len(key) == 0 {
|
|
return p, nil, nil, errBadHash
|
|
}
|
|
p.SaltLen, p.KeyLen = len(salt), uint32(len(key))
|
|
return p, salt, key, nil
|
|
}
|
|
|
|
// Алфавит временных паролей: без похожих символов (0/O, 1/l/I), чтобы пароль легко продиктовать.
|
|
const tempPasswordAlphabet = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
|
|
// GeneratePassword возвращает случайный пароль длины n из tempPasswordAlphabet.
|
|
func GeneratePassword(n int) (string, error) {
|
|
buf := make([]byte, n)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", fmt.Errorf("auth: random password: %w", err)
|
|
}
|
|
// 256 % 56 != 0: отбрасываем байты из «хвоста», чтобы распределение было равномерным.
|
|
const limit = 256 - 256%len(tempPasswordAlphabet)
|
|
out := make([]byte, 0, n)
|
|
for len(out) < n {
|
|
for _, b := range buf {
|
|
if int(b) < limit && len(out) < n {
|
|
out = append(out, tempPasswordAlphabet[int(b)%len(tempPasswordAlphabet)])
|
|
}
|
|
}
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", fmt.Errorf("auth: random password: %w", err)
|
|
}
|
|
}
|
|
return string(out), nil
|
|
}
|