From e19f63abe49c289e808ec6ce769158bf301c83be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Fri, 14 Aug 2026 09:43:36 +0400 Subject: [PATCH] =?UTF-8?q?v0.1.28:=20email-=D0=B8=D0=B4=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B8=D1=87=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=E2=80=94=20=D0=B4?= =?UTF-8?q?=D0=B5=D1=82=D0=B5=D1=80=D0=BC=D0=B8=D0=BD=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=BA=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D1=8B=20=D0=B8=D0=B7=20email,=20credentials-=D1=8D=D0=BD=D0=B4?= =?UTF-8?q?=D0=BF=D0=BE=D0=B8=D0=BD=D1=82,=20UI=20=D0=BC=D0=BE=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 2 +- app/admin/admin.go | 36 ++++++++++-- app/auth/jwt.go | 10 ---- app/tenant/tenant_store.go | 111 ++++++++++++++++++++++--------------- app/ui/index.html | 38 +++++++++---- 5 files changed, 125 insertions(+), 72 deletions(-) diff --git a/Makefile b/Makefile index a767e4d..792279d 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # Registry: Docker Hub (naeel/shared-sqs) IMAGE_REPO=naeel/shared-sqs -VERSION=v0.1.27 +VERSION=v0.1.28 LDFLAGS=-X shared-sqs/app/models.Version=$(VERSION) BINARY=shared-sqs diff --git a/app/admin/admin.go b/app/admin/admin.go index b655ee2..9689a92 100644 --- a/app/admin/admin.go +++ b/app/admin/admin.go @@ -155,6 +155,7 @@ func (h *Handler) RegisterPublicRoutes(r *mux.Router) { // jwtMiddleware пропускает /ui/api/auth — единственный публичный endpoint ui.Use(h.jwtMiddleware) ui.HandleFunc("/auth", h.jwtAuth).Methods("POST") + ui.HandleFunc("/credentials", h.uiCredentials).Methods("GET") ui.HandleFunc("/health", h.detailedHealth).Methods("GET") ui.HandleFunc("/tenants", h.listTenants).Methods("GET") ui.HandleFunc("/tenants", h.createTenant).Methods("POST") @@ -221,6 +222,14 @@ func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) { return } + // Email — единственный ключ идентичности. Без него не создаём тенанта + // (иначе hash("") даст один тенант на всех пользователей без email). + if claims.Email == "" { + log.Warnf("jwt auth: token for sub=%s has no email claim", claims.Sub) + jsonErr(w, http.StatusBadRequest, "token has no email claim") + return + } + // Валидируем через nubes API ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() @@ -230,9 +239,9 @@ func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) { return } - // Auto-provisioning: создаём тенанта если не существует - tenantID := auth.TenantIDFromSub(claims.Sub) - t, err := h.store.CreateFromJWT(tenantID, claims.Sub, claims.Email, 10) + // Auto-provisioning: тенант и ключи детерминированы из email. + // Токен — только аутентификация; ключи в ответ не возвращаем (GET /ui/api/credentials). + t, err := h.store.CreateFromJWT(claims.Sub, claims.Email, 10) if err != nil { log.Errorf("jwt auth: failed to create tenant: %v", err) jsonErr(w, http.StatusInternalServerError, "failed to provision tenant") @@ -245,13 +254,28 @@ func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]interface{}{ "email": claims.Email, "tenant_id": t.ID, - "access_key": t.AccessKey, - "secret_key": t.SecretKey, "max_queues": t.MaxQueues, "token": req.Token, // возвращаем для использования в последующих запросах }) } +// uiCredentials — GET /ui/api/credentials: отдаёт AccessKey/SecretKey текущего тенанта. +// Ключи НЕ светятся в других ответах UI API — только по явному запросу под JWT-сессией. +func (h *Handler) uiCredentials(w http.ResponseWriter, r *http.Request) { + t, ok := currentUITenant(r) + if !ok { + jsonErr(w, http.StatusUnauthorized, "unauthorized") + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "tenant_id": t.ID, + "email": t.Email, + "access_key": t.AccessKey, + "secret_key": t.SecretKey, + }) +} + // jwtMiddleware — middleware для /ui/api/* endpoints. // Пропускает /ui/api/auth (публичный endpoint авторизации). // Проверяет Authorization: Bearer заголовок. @@ -306,7 +330,7 @@ func (h *Handler) jwtMiddleware(next http.Handler) http.Handler { } // Проверяем что тенант существует (был создан при /ui/api/auth) - jwtTenant, ok := h.store.GetBySub(claims.Sub) + jwtTenant, ok := h.store.GetByEmail(claims.Email) if !ok { jsonErr(w, http.StatusForbidden, "tenant not found — authenticate first via POST /ui/api/auth") return diff --git a/app/auth/jwt.go b/app/auth/jwt.go index 7089e24..ecd1ad3 100644 --- a/app/auth/jwt.go +++ b/app/auth/jwt.go @@ -7,7 +7,6 @@ package auth import ( "context" - "crypto/sha256" "encoding/base64" "encoding/json" "fmt" @@ -65,15 +64,6 @@ func ParseJWTClaims(token string) (*JWTClaims, error) { return &claims, nil } -// TenantIDFromSub — вычисляет tenant ID из JWT subject (sub claim). -// Алгоритм совместим с sless: SHA256(sub) → первые 8 байт → hex → "sless-{16hex}". -// Длина: 6 + 16 = 22 символа. Детерминирован, необратим. -// Почему "sless-" а не "ssq-": единый namespace для всех сервисов (IoT, funcs, SQS). -func TenantIDFromSub(sub string) string { - hash := sha256.Sum256([]byte(sub)) - return fmt.Sprintf("sless-%x", hash[:8]) -} - // nubesAPIEndpoints — стенды Nubes API Gateway, по которым валидируется токен. // Токен принимается, если ХОТЯ БЫ ОДИН стенд его принял. // Юзер может вводить токен от любого стенда — главное, что токен действительный. diff --git a/app/tenant/tenant_store.go b/app/tenant/tenant_store.go index 96635a2..9fdd05d 100644 --- a/app/tenant/tenant_store.go +++ b/app/tenant/tenant_store.go @@ -4,9 +4,11 @@ package tenant import ( "crypto/rand" + "crypto/sha256" "encoding/hex" "encoding/json" "fmt" + "strings" "sync" "time" @@ -19,7 +21,7 @@ const MaxTenantsGlobal = 1000 // Tenant — модель тенанта shared-sqs. // AccessKey используется как идентификатор в AWS Authorization header. type Tenant struct { - ID string // уникальный идентификатор тенанта (sless- из JWT sub, или t- для legacy) + ID string // уникальный идентификатор тенанта (t-: детерминированный из email для JWT-тенантов, случайный для manual) Name string // человекочитаемое имя AccessKey string // аналог AWS AccessKeyId (SSAK-) SecretKey string // аналог AWS SecretAccessKey (64 hex chars) @@ -31,12 +33,12 @@ type Tenant struct { } // TenantStore — потокобезопасное in-memory хранилище тенантов. -// Три индекса: по ID (admin API), по AccessKey (auth middleware), по NubesSub (JWT auth). +// Три индекса: по ID (admin API), по AccessKey (auth middleware), по Email (JWT auth). type TenantStore struct { mu sync.RWMutex byID map[string]*Tenant byAccessKey map[string]*Tenant - bySub map[string]*Tenant // индекс по NubesSub (JWT sub claim) + byEmail map[string]*Tenant // индекс по Email (JWT claim). Тенанты без email сюда НЕ попадают. } // NewTenantStore — создаёт пустое хранилище тенантов. @@ -44,7 +46,7 @@ func NewTenantStore() *TenantStore { return &TenantStore{ byID: make(map[string]*Tenant), byAccessKey: make(map[string]*Tenant), - bySub: make(map[string]*Tenant), + byEmail: make(map[string]*Tenant), } } @@ -83,8 +85,8 @@ func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) { s.mu.Lock() s.byID[t.ID] = t s.byAccessKey[t.AccessKey] = t - if t.NubesSub != "" { - s.bySub[t.NubesSub] = t + if t.Email != "" { + s.byEmail[t.Email] = t } s.mu.Unlock() @@ -156,8 +158,8 @@ func (s *TenantStore) Delete(id string) bool { } delete(s.byID, t.ID) delete(s.byAccessKey, t.AccessKey) - if t.NubesSub != "" { - delete(s.bySub, t.NubesSub) + if t.Email != "" { + delete(s.byEmail, t.Email) } s.mu.Unlock() @@ -173,60 +175,61 @@ func (s *TenantStore) LoadTenant(t *Tenant) { s.mu.Lock() s.byID[t.ID] = t s.byAccessKey[t.AccessKey] = t - if t.NubesSub != "" { - s.bySub[t.NubesSub] = t + if t.Email != "" { + s.byEmail[t.Email] = t } s.mu.Unlock() } -// GetBySub — поиск тенанта по NubesSub (JWT sub claim). +// GetByEmail — поиск тенанта по Email (JWT claim). // Используется при JWT-авторизации через UI. -func (s *TenantStore) GetBySub(sub string) (*Tenant, bool) { +func (s *TenantStore) GetByEmail(email string) (*Tenant, bool) { s.mu.RLock() - t, ok := s.bySub[sub] + t, ok := s.byEmail[email] s.mu.RUnlock() return t, ok } // CreateFromJWT — auto-provisioning тенанта из JWT claims. -// ID = TenantIDFromSub(sub) — совместим с sless namespace. -// Если тенант с таким sub уже существует — возвращает его (идемпотентно). -func (s *TenantStore) CreateFromJWT(tenantID, sub, email string, maxQueues int) (*Tenant, error) { +// Идентичность — ТОЛЬКО email: ID/AccessKey/SecretKey детерминированы из email. +// Тот же email на любом стенде → тот же тенант и те же креды. sub — информационное поле. +// Легаси-тенант (старые случайные ключи / другой ID) пересчитывается: +// старые индексы и Redis-запись удаляются, создаётся детерминированная запись. +// ВНИМАНИЕ: очереди привязаны к старому AccessKey — при пересчёте они остаются +// в SyncQueues/Redis под старым префиксом (текущий тенант имеет 0 очередей). +func (s *TenantStore) CreateFromJWT(sub, email string, maxQueues int) (*Tenant, error) { + if email == "" { + return nil, fmt.Errorf("JWT email is required for tenant provisioning") + } + tenantID := tenantIDFromEmail(email) + accessKey := accessKeyFromEmail(email) + secretKey := secretKeyFromEmail(email) + s.mu.Lock() - // Идемпотентность: если тенант с таким sub уже есть — возвращаем - if existing, ok := s.bySub[sub]; ok { - // Обновляем email если изменился - if email != "" && existing.Email != email { - existing.Email = email + defer s.mu.Unlock() + + // Идемпотентность: тенант с таким email уже есть + if existing, ok := s.byEmail[email]; ok { + // Полное совпадение — возвращаем как есть + if existing.ID == tenantID && existing.AccessKey == accessKey { + if sub != "" { + existing.NubesSub = sub + } + return existing, nil } - s.mu.Unlock() - return existing, nil + // Легаси: убираем старую запись из всех индексов и из Redis, ниже создаём новую + delete(s.byID, existing.ID) + delete(s.byAccessKey, existing.AccessKey) + delete(s.byEmail, existing.Email) + persistence.DeleteTenant(existing.ID) } if len(s.byID) >= MaxTenantsGlobal { - s.mu.Unlock() return nil, fmt.Errorf("global tenant limit reached (%d)", MaxTenantsGlobal) } - accessKey, err := generateAccessKey() - if err != nil { - s.mu.Unlock() - return nil, fmt.Errorf("generate access key: %w", err) - } - secretKey, err := generateSecretKey() - if err != nil { - s.mu.Unlock() - return nil, fmt.Errorf("generate secret key: %w", err) - } - - // Имя тенанта — email или sub (если email пустой) - name := email - if name == "" { - name = sub - } - t := &Tenant{ ID: tenantID, - Name: name, + Name: email, AccessKey: accessKey, SecretKey: secretKey, MaxQueues: maxQueues, @@ -238,8 +241,7 @@ func (s *TenantStore) CreateFromJWT(tenantID, sub, email string, maxQueues int) s.byID[t.ID] = t s.byAccessKey[t.AccessKey] = t - s.bySub[t.NubesSub] = t - s.mu.Unlock() + s.byEmail[t.Email] = t // Сохраняем в Redis if data, err := json.Marshal(t); err == nil { @@ -249,6 +251,27 @@ func (s *TenantStore) CreateFromJWT(tenantID, sub, email string, maxQueues int) return t, nil } +// tenantIDFromEmail — детерминированный ID тенанта из email: "t-{16 hex}". +func tenantIDFromEmail(email string) string { + hash := sha256.Sum256([]byte(strings.ToLower(email))) + return "t-" + hex.EncodeToString(hash[:8]) +} + +// accessKeyFromEmail — детерминированный AccessKey: "SSAK-{24 hex}". +func accessKeyFromEmail(email string) string { + hash := sha256.Sum256([]byte(strings.ToLower(email))) + return "SSAK-" + hex.EncodeToString(hash[:12]) +} + +// secretKeySalt — соль для детерминированного SecretKey email-тенантов. +const secretKeySalt = "shared-sqs:secret-key:v1" + +// secretKeyFromEmail — детерминированный SecretKey: 64 hex = SHA256(email + соль). +func secretKeyFromEmail(email string) string { + h := sha256.Sum256([]byte(strings.ToLower(email) + ":" + secretKeySalt)) + return hex.EncodeToString(h[:]) +} + // List — список всех тенантов (для admin GET /tenants). func (s *TenantStore) List() []*Tenant { s.mu.RLock() diff --git a/app/ui/index.html b/app/ui/index.html index ceab565..31a874c 100644 --- a/app/ui/index.html +++ b/app/ui/index.html @@ -347,7 +347,7 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
Admin API: POST /admin/tenants (Bearer token)
Realm: iot-naeel · Persistence: Managed Redis
Версия:
-
Образ: naeel/shared-sqs:v0.1.27
+
Образ: naeel/shared-sqs:v0.1.28
Статус: тестирование
@@ -396,10 +396,18 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }

Очереди

⟳ 10с
+
@@ -817,6 +820,19 @@ function closeCredsModal() { document.getElementById('modal-creds').classList.add('hidden'); } +// showCredentials — запрашивает ключи тенанта и показывает их в модалке +function showCredentials() { + api('/credentials') + .then(data => { + document.getElementById('creds-ak').value = data.access_key || ''; + document.getElementById('creds-sk').value = data.secret_key || ''; + document.getElementById('modal-creds').classList.remove('hidden'); + }) + .catch(err => { + alert('Не удалось получить credentials: ' + err.message); + }); +} + // deleteTenant — DELETE /tenants/{id} с подтверждением function deleteTenant(id, name) { if (!confirm('Удалить тенанта "' + name + '"?\nВсе его очереди будут удалены.')) return;