v0.1.34: кэш валидации JWT (TTL 5 мин + grace 2 мин) — консоль устойчива к флаку шлюза

This commit is contained in:
“Naeel”
2026-08-14 14:09:49 +04:00
parent 0d5dbaba4f
commit 0f24658fc7
5 changed files with 129 additions and 6 deletions
+113
View File
@@ -0,0 +1,113 @@
// app/auth/cache.go
// Кэш успешной валидации JWT-токенов (в памяти процесса).
// Мотивация: Nubes-шлюз нестабильно отвечает на запросы с IP платформы
// (то 200, то 401/403), из-за чего повторная валидация на каждый запрос
// консоли роняла UI. Кэш: одна успешная проверка → TTL без обращений к шлюзу.
// Безопасность: отозванный токен перестаёт работать максимум через TTL;
// явный 401/403 сбрасывает запись немедленно.
// Created: 2026-08-14
package auth
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"sync"
"time"
)
// cacheTTL — срок жизни записи успешной валидации.
const cacheTTL = 5 * time.Minute
// cacheGraceTTL — продление при сетевой ошибке шлюза для ранее валидного токена.
const cacheGraceTTL = 2 * time.Minute
// cacheCleanupInterval — период фоновой очистки протухших записей.
const cacheCleanupInterval = 5 * time.Minute
type tokenCacheEntry struct {
validUntil time.Time
}
var (
tokenCacheMu sync.RWMutex
tokenCache = map[string]tokenCacheEntry{}
)
// tokenCacheKey — компактный ключ кэша: hex(SHA256(token)).
func tokenCacheKey(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}
// PingNubesAPICached — валидация токена через шлюз с кэшем.
// HIT (запись валидна) → nil без обращения к шлюзу.
// MISS → свежая проверка: успех → запись с TTL; 401/403 → удаление записи и ошибка.
// Сетевая ошибка: если запись была (пусть и протухшая) → grace-продление и nil,
// иначе — ошибка (шлюз недоступен, кэша нет — справедливый отказ).
func PingNubesAPICached(ctx context.Context, endpoint, token string) error {
key := tokenCacheKey(token)
now := time.Now()
tokenCacheMu.RLock()
entry, ok := tokenCache[key]
tokenCacheMu.RUnlock()
if ok && now.Before(entry.validUntil) {
return nil // cache HIT
}
err := PingNubesAPI(ctx, endpoint, token)
if errors.Is(err, ErrTokenRejected) {
tokenCacheMu.Lock()
delete(tokenCache, key)
tokenCacheMu.Unlock()
return err
}
if err != nil {
// Сетевая ошибка шлюза: если токен ранее был валиден — даём grace.
if ok {
tokenCacheMu.Lock()
tokenCache[key] = tokenCacheEntry{validUntil: time.Now().Add(cacheGraceTTL)}
tokenCacheMu.Unlock()
return nil
}
return err
}
tokenCacheMu.Lock()
tokenCache[key] = tokenCacheEntry{validUntil: time.Now().Add(cacheTTL)}
tokenCacheMu.Unlock()
return nil
}
// WarmTokenCache — записывает токен в кэш как валидный без вызова шлюза.
// Используется после успешной свежей проверки при логине (jwtAuth).
func WarmTokenCache(token string) {
tokenCacheMu.Lock()
tokenCache[tokenCacheKey(token)] = tokenCacheEntry{validUntil: time.Now().Add(cacheTTL)}
tokenCacheMu.Unlock()
}
var tokenCacheCleanupOnce sync.Once
// StartTokenCacheCleanup — фоновая очистка протухших записей. Идемпотентна (sync.Once).
func StartTokenCacheCleanup() {
tokenCacheCleanupOnce.Do(func() {
go func() {
ticker := time.NewTicker(cacheCleanupInterval)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
tokenCacheMu.Lock()
for k, e := range tokenCache {
if now.After(e.validUntil) {
delete(tokenCache, k)
}
}
tokenCacheMu.Unlock()
}
}()
})
}
+6 -1
View File
@@ -9,6 +9,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
@@ -64,6 +65,10 @@ func ParseJWTClaims(token string) (*JWTClaims, error) {
return &claims, nil
}
// ErrTokenRejected — шлюз явно отклонил токен (401/403).
// Sentinel, чтобы отличать отказ от сетевых ошибок (см. cache.go).
var ErrTokenRejected = errors.New("token rejected by nubes API")
// nubesAPIEndpoints — стенды Nubes API Gateway, по которым валидируется токен.
// Токен принимается, если ХОТЯ БЫ ОДИН стенд его принял.
// Юзер может вводить токен от любого стенда — главное, что токен действительный.
@@ -107,7 +112,7 @@ func PingNubesAPI(ctx context.Context, endpoint, token string) error {
resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
lastErr = fmt.Errorf("nubes API rejected token (HTTP %d)", resp.StatusCode)
lastErr = fmt.Errorf("%w (HTTP %d)", ErrTokenRejected, resp.StatusCode)
continue
}
return nil