Files
pipu-music/services/api/internal/auth/token.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

170 lines
5.3 KiB
Go

package auth
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// Ошибки проверки access-токена.
var (
ErrTokenExpired = errors.New("auth: token expired")
ErrTokenInvalid = errors.New("auth: token invalid")
)
// AccessClaims — содержимое access-токена.
type AccessClaims struct {
UserID uuid.UUID
Role Role
SessionID uuid.UUID
TokenVersion int
IssuedAt time.Time
ExpiresAt time.Time
}
type jwtClaims struct {
Role string `json:"role"`
SID string `json:"sid"`
Ver int `json:"ver"`
jwt.RegisteredClaims
}
// TokenIssuer подписывает и проверяет access-токены (JWT, EdDSA). Подписывает активным ключом,
// проверяет активным и предыдущими (по kid) — так ключ можно сменить, не разлогинив всех.
type TokenIssuer struct {
kid string
key ed25519.PrivateKey
verify map[string]ed25519.PublicKey
ttl time.Duration
now func() time.Time
}
// NewTokenIssuer создаёт issuer. previous — ключи, которыми подписаны ещё живые токены.
func NewTokenIssuer(active ed25519.PrivateKey, previous []ed25519.PrivateKey, ttl time.Duration, now func() time.Time) *TokenIssuer {
t := &TokenIssuer{key: active, ttl: ttl, now: now, verify: map[string]ed25519.PublicKey{}}
t.kid = KeyID(active.Public().(ed25519.PublicKey))
t.verify[t.kid] = active.Public().(ed25519.PublicKey)
for _, k := range previous {
pub := k.Public().(ed25519.PublicKey)
t.verify[KeyID(pub)] = pub
}
return t
}
// TTL — срок жизни access-токена.
func (t *TokenIssuer) TTL() time.Duration { return t.ttl }
// KeyID — идентификатор ключа: первые 8 байт SHA-256 публичного ключа.
func KeyID(pub ed25519.PublicKey) string {
sum := sha256.Sum256(pub)
return hex.EncodeToString(sum[:8])
}
// Issue выпускает access-токен.
func (t *TokenIssuer) Issue(userID uuid.UUID, role Role, sessionID uuid.UUID, tokenVersion int) (string, error) {
now := t.now()
tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwtClaims{
Role: string(role),
SID: sessionID.String(),
Ver: tokenVersion,
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID.String(),
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(t.ttl)),
},
})
tok.Header["kid"] = t.kid
s, err := tok.SignedString(t.key)
if err != nil {
return "", fmt.Errorf("auth: sign token: %w", err)
}
return s, nil
}
// Parse проверяет подпись и срок токена.
func (t *TokenIssuer) Parse(s string) (AccessClaims, error) {
var c jwtClaims
_, err := jwt.ParseWithClaims(s, &c, func(tok *jwt.Token) (any, error) {
kid, _ := tok.Header["kid"].(string)
pub, ok := t.verify[kid]
if !ok {
return nil, ErrTokenInvalid
}
return pub, nil
}, jwt.WithValidMethods([]string{jwt.SigningMethodEdDSA.Alg()}), jwt.WithTimeFunc(t.now),
jwt.WithExpirationRequired(), jwt.WithIssuedAt())
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return AccessClaims{}, ErrTokenExpired
}
return AccessClaims{}, ErrTokenInvalid
}
uid, err1 := uuid.Parse(c.Subject)
sid, err2 := uuid.Parse(c.SID)
if err1 != nil || err2 != nil || !Role(c.Role).Valid() {
return AccessClaims{}, ErrTokenInvalid
}
return AccessClaims{
UserID: uid, Role: Role(c.Role), SessionID: sid, TokenVersion: c.Ver,
IssuedAt: c.IssuedAt.Time, ExpiresAt: c.ExpiresAt.Time,
}, nil
}
// ParsePrivateKey читает ключ Ed25519 из base64 (стандартный или url-алфавит): 32-байтовый seed
// или 64-байтовый приватный ключ.
func ParsePrivateKey(s string) (ed25519.PrivateKey, error) {
s = strings.TrimSpace(s)
var raw []byte
var err error
for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
if raw, err = enc.DecodeString(s); err == nil {
break
}
}
if err != nil {
return nil, fmt.Errorf("auth: jwt key is not base64: %w", err)
}
switch len(raw) {
case ed25519.SeedSize:
return ed25519.NewKeyFromSeed(raw), nil
case ed25519.PrivateKeySize:
return ed25519.PrivateKey(raw), nil
default:
return nil, fmt.Errorf("auth: jwt key must be %d or %d bytes, got %d", ed25519.SeedSize, ed25519.PrivateKeySize, len(raw))
}
}
// GenerateKey создаёт новый ключ Ed25519.
func GenerateKey() (ed25519.PrivateKey, error) {
_, k, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("auth: generate key: %w", err)
}
return k, nil
}
// NewRefreshToken возвращает случайный refresh-токен (32 байта, base64url) и его SHA-256 для БД.
func NewRefreshToken() (token string, hash []byte, err error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", nil, fmt.Errorf("auth: refresh token: %w", err)
}
token = base64.RawURLEncoding.EncodeToString(buf)
return token, HashRefreshToken(token), nil
}
// HashRefreshToken — SHA-256 от строки токена.
func HashRefreshToken(token string) []byte {
sum := sha256.Sum256([]byte(token))
return sum[:]
}