Files
pipu-music/services/api/internal/auth/password.go
T
mrleo1nidandClaude Opus 5.5 ed4a492dba
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
feat(api): sign-in, refresh rotation and bootstrap admin (M1)
- 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>
2026-10-20 06:12:25 +03:00

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
}