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[:] }