From 0f24658fc7bc3bf13f54c0895ffdb8813e0e3b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Fri, 14 Aug 2026 14:09:49 +0400 Subject: [PATCH] =?UTF-8?q?v0.1.34:=20=D0=BA=D1=8D=D1=88=20=D0=B2=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D0=B8=20JWT=20(TTL=205=20?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD=20+=20grace=202=20=D0=BC=D0=B8=D0=BD)=20?= =?UTF-8?q?=E2=80=94=20=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D1=8C=20=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B9=D1=87=D0=B8=D0=B2=D0=B0=20=D0=BA=20?= =?UTF-8?q?=D1=84=D0=BB=D0=B0=D0=BA=D1=83=20=D1=88=D0=BB=D1=8E=D0=B7=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 2 +- app/admin/admin.go | 11 +++-- app/auth/cache.go | 113 +++++++++++++++++++++++++++++++++++++++++++++ app/auth/jwt.go | 7 ++- app/ui/index.html | 2 +- 5 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 app/auth/cache.go diff --git a/Makefile b/Makefile index 72f6397..d306b77 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # Registry: Docker Hub (naeel/shared-sqs) IMAGE_REPO=naeel/shared-sqs -VERSION=v0.1.33 +VERSION=v0.1.34 LDFLAGS=-X shared-sqs/app/models.Version=$(VERSION) BINARY=shared-sqs diff --git a/app/admin/admin.go b/app/admin/admin.go index 9689a92..372c157 100644 --- a/app/admin/admin.go +++ b/app/admin/admin.go @@ -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 } diff --git a/app/auth/cache.go b/app/auth/cache.go new file mode 100644 index 0000000..ae6bd1e --- /dev/null +++ b/app/auth/cache.go @@ -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() + } + }() + }) +} diff --git a/app/auth/jwt.go b/app/auth/jwt.go index ecd1ad3..168af0e 100644 --- a/app/auth/jwt.go +++ b/app/auth/jwt.go @@ -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 diff --git a/app/ui/index.html b/app/ui/index.html index 596a39e..0932152 100644 --- a/app/ui/index.html +++ b/app/ui/index.html @@ -384,7 +384,7 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
Админ-консоль: /admin (вход по admin-токену)
Realm: iot-naeel · Persistence: Managed Redis
Версия:
-
Образ: naeel/shared-sqs:v0.1.33
+
Образ: naeel/shared-sqs:v0.1.34
Статус: тестирование