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>
170 lines
5.3 KiB
Go
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[:]
|
|
}
|