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
+8 -3
View File
@@ -65,6 +65,7 @@ type Handler struct {
func NewHandler(store *tenant.TenantStore, adminToken string) *Handler {
nubesEndpoint := os.Getenv("NUBES_ENDPOINT")
// Если не задан — пустой: PingNubesAPI переберёт все стенды сам.
auth.StartTokenCacheCleanup() // фоновая очистка кэша валидации (идемпотентно)
return &Handler{store: store, adminToken: adminToken, nubesEndpoint: nubesEndpoint}
}
@@ -238,6 +239,9 @@ func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) {
jsonErr(w, http.StatusForbidden, "token rejected by cloud API: "+err.Error())
return
}
// Логин — всегда свежая проверка; после успеха прогреваем кэш,
// чтобы дальнейшие запросы консоли не дёргали нестабильный шлюз.
auth.WarmTokenCache(req.Token)
// Auto-provisioning: тенант и ключи детерминированы из email.
// Токен — только аутентификация; ключи в ответ не возвращаем (GET /ui/api/credentials).
@@ -320,11 +324,12 @@ func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
return
}
// Повторно валидируем токен в nubes API на каждый UI API запрос,
// чтобы отозванные токены не оставались валидными до повторного логина.
// Повторная валидация через кэш: в течение TTL шлюз не вызывается
// (шлюз нестабилен с IP платформы), отозванный токен умрёт через TTL,
// явный 401/403 от шлюза сбрасывает кэш.
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if err := auth.PingNubesAPI(ctx, h.nubesEndpoint, token); err != nil {
if err := auth.PingNubesAPICached(ctx, h.nubesEndpoint, token); err != nil {
jsonErr(w, http.StatusForbidden, "token rejected by cloud API: "+err.Error())
return
}
+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
+1 -1
View File
@@ -384,7 +384,7 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
<div>Админ-консоль: <a href="/admin">/admin</a> (вход по admin-токену)</div>
<div>Realm: <span style="font-family:monospace">iot-naeel</span> · Persistence: Managed Redis</div>
<div>Версия: <span id="svc-version" style="font-family:monospace"></span></div>
<div>Образ: <span style="font-family:monospace">naeel/shared-sqs:v0.1.33</span></div>
<div>Образ: <span style="font-family:monospace">naeel/shared-sqs:v0.1.34</span></div>
<div style="margin-top:8px;color:var(--warning)">Статус: тестирование</div>
</div>
</div>