From b9d434bcc5a2a817ed3e494949d074a8a4865865 Mon Sep 17 00:00:00 2001 From: Naeel Date: Fri, 10 Apr 2026 18:35:19 +0300 Subject: [PATCH] v0.1.16: JWT auth via nubes API, auto-provisioning, UI login - app/auth/jwt.go: ParseJWTClaims, TenantIDFromSub (sless-compatible SHA256), PingNubesAPI - app/admin/admin.go: POST /ui/api/auth endpoint, jwtMiddleware for /ui/api/* - app/tenant/tenant_store.go: NubesSub/Email fields, GetBySub, CreateFromJWT - app/ui/index.html: login page, email in navbar, JWT session in localStorage - deployments/k8s/deployment.yaml: v0.1.16, NUBES_ENDPOINT env - doc/decisions/resource-limits-plan.md: 20 vulnerabilities audit - Fix: /ui/api/auth moved to subrouter (gorilla/mux PathPrefix conflict) --- app/admin/admin.go | 120 +++++++++++- app/auth/jwt.go | 99 ++++++++++ app/router/router.go | 4 +- app/tenant/tenant_store.go | 83 +++++++- app/ui/index.html | 133 +++++++++++-- deployments/k8s/deployment.yaml | 6 +- doc/decisions/resource-limits-plan.md | 159 ++++++++++++++++ doc/progress.md | 13 +- doc/thinking/2026-04-10.md | 262 ++++++++++++++++++++++++++ 9 files changed, 854 insertions(+), 25 deletions(-) create mode 100644 app/auth/jwt.go create mode 100644 doc/decisions/resource-limits-plan.md diff --git a/app/admin/admin.go b/app/admin/admin.go index 8e3c1ed..54a5c96 100644 --- a/app/admin/admin.go +++ b/app/admin/admin.go @@ -1,15 +1,18 @@ // app/admin/admin.go // Admin API handlers for shared-sqs management // Created: 2026-04-09 -// Updated: 2026-04-10 — добавлены endpoints для управления очередями и просмотра сообщений +// Updated: 2026-04-10 — JWT auth через nubes API, auto-provisioning тенантов package admin import ( + "context" "encoding/json" "net/http" + "os" "strings" "time" + "shared-sqs/app/auth" "shared-sqs/app/models" "shared-sqs/app/tenant" @@ -33,13 +36,18 @@ func findQueue(tenantAccessKey, queueName string) (string, *models.Queue) { // Handler — admin API handler, holds TenantStore и admin token type Handler struct { - store *tenant.TenantStore - adminToken string + store *tenant.TenantStore + adminToken string + nubesEndpoint string // URL nubes API для валидации JWT (напр. https://deck-api-test.ngcloud.ru/api/v1) } // NewHandler — создаёт admin handler func NewHandler(store *tenant.TenantStore, adminToken string) *Handler { - return &Handler{store: store, adminToken: adminToken} + nubesEndpoint := os.Getenv("NUBES_ENDPOINT") + if nubesEndpoint == "" { + nubesEndpoint = "https://deck-api-test.ngcloud.ru/api/v1" + } + return &Handler{store: store, adminToken: adminToken, nubesEndpoint: nubesEndpoint} } // RegisterRoutes — регистрирует admin маршруты на переданном router (с bearer auth) @@ -59,11 +67,16 @@ func (h *Handler) RegisterRoutes(r *mux.Router) { adminRouter.HandleFunc("/health", h.detailedHealth).Methods("GET") } -// RegisterPublicRoutes — публичные маршруты для UI console (без auth) -// Дублируют admin API, но доступны без bearer token для удобства демо -// TODO: убрать или заменить на session-auth перед production +// RegisterPublicRoutes — маршруты для UI console. +// POST /ui/api/auth — единственный публичный endpoint (принимает JWT, возвращает session). +// Остальные /ui/api/* — защищены JWT middleware (токен в Authorization header). +// ВАЖНО: все UI API routes на ОДНОМ subrouter PathPrefix("/ui/api") — иначе через nginx ingress +// root-router HandleFunc конфликтует с PathPrefix subrouter (404 на POST). func (h *Handler) RegisterPublicRoutes(r *mux.Router) { ui := r.PathPrefix("/ui/api").Subrouter() + // jwtMiddleware пропускает /ui/api/auth — единственный публичный endpoint + ui.Use(h.jwtMiddleware) + ui.HandleFunc("/auth", h.jwtAuth).Methods("POST") ui.HandleFunc("/health", h.detailedHealth).Methods("GET") ui.HandleFunc("/tenants", h.listTenants).Methods("GET") ui.HandleFunc("/tenants", h.createTenant).Methods("POST") @@ -92,6 +105,99 @@ func (h *Handler) bearerAuthMiddleware(next http.Handler) http.Handler { }) } +// jwtAuth — POST /ui/api/auth — принимает JWT токен, валидирует через nubes API, +// auto-provision тенанта если не существует, возвращает email + tenant info. +// Это единственный публичный endpoint UI API. +func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) { + var req struct { + Token string `json:"token"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Token == "" { + jsonErr(w, http.StatusBadRequest, "token is required") + return + } + + // Парсим JWT claims + claims, err := auth.ParseJWTClaims(req.Token) + if err != nil { + log.Warnf("jwt auth: parse error: %v", err) + jsonErr(w, http.StatusUnauthorized, "invalid token: "+err.Error()) + return + } + + // Валидируем через nubes API + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + if err := auth.PingNubesAPI(ctx, h.nubesEndpoint, req.Token); err != nil { + log.Warnf("jwt auth: nubes rejected token for sub=%s: %v", claims.Sub, err) + jsonErr(w, http.StatusForbidden, "token rejected by cloud API: "+err.Error()) + return + } + + // Auto-provisioning: создаём тенанта если не существует + tenantID := auth.TenantIDFromSub(claims.Sub) + t, err := h.store.CreateFromJWT(tenantID, 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") + return + } + + log.Infof("jwt auth: authenticated sub=%s email=%s tenant=%s", claims.Sub, claims.Email, t.ID) + + w.Header().Set("Content-Type", "application/json") + 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, // возвращаем для использования в последующих запросах + }) +} + +// jwtMiddleware — middleware для /ui/api/* endpoints. +// Пропускает /ui/api/auth (публичный endpoint авторизации). +// Проверяет Authorization: Bearer заголовок. +// Парсит JWT, проверяет что sub соответствует существующему тенанту. +// НЕ вызывает PingNubesAPI повторно — токен уже провалидирован при /ui/api/auth. +func (h *Handler) jwtMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // /ui/api/auth — публичный endpoint, пропускаем без проверки JWT + if strings.HasSuffix(r.URL.Path, "/auth") { + next.ServeHTTP(w, r) + return + } + + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + jsonErr(w, http.StatusUnauthorized, "authorization required") + return + } + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") { + jsonErr(w, http.StatusUnauthorized, "invalid authorization format") + return + } + token := parts[1] + + claims, err := auth.ParseJWTClaims(token) + if err != nil { + jsonErr(w, http.StatusUnauthorized, "invalid token: "+err.Error()) + return + } + + // Проверяем что тенант существует (был создан при /ui/api/auth) + _, ok := h.store.GetBySub(claims.Sub) + if !ok { + jsonErr(w, http.StatusForbidden, "tenant not found — authenticate first via POST /ui/api/auth") + return + } + + next.ServeHTTP(w, r) + }) +} + // createTenantRequest — тело запроса POST /admin/tenants type createTenantRequest struct { Name string `json:"name"` diff --git a/app/auth/jwt.go b/app/auth/jwt.go new file mode 100644 index 0000000..60dcd51 --- /dev/null +++ b/app/auth/jwt.go @@ -0,0 +1,99 @@ +// Изменено: 2026-04-10 +// jwt.go — JWT-парсинг и валидация через nubes API (deck-api). +// Извлекает sub (UUID пользователя) и email из JWT claims. +// Валидация: GET к nubes API с Bearer token → если 401/403 → отклонён. +// Подпись JWT НЕ проверяется — аналогично sless (trusted perimeter). +package auth + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +// JWTClaims — минимальный набор claims из nubes JWT. +// Не все поля заполнены у каждого пользователя — email может быть пустым. +type JWTClaims struct { + Sub string `json:"sub"` + Email string `json:"email"` + Exp int64 `json:"exp"` + Iss string `json:"iss"` +} + +// ParseJWTClaims — декодирует JWT payload (base64url) и возвращает claims. +// Проверяет: структуру JWT (3 части), наличие sub, не истёк ли exp. +// Подпись НЕ проверяется — валидация через PingNubesAPI. +func ParseJWTClaims(token string) (*JWTClaims, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid JWT: expected 3 parts, got %d", len(parts)) + } + + // JWT использует base64url без padding — добавляем + payload := parts[1] + switch len(payload) % 4 { + case 2: + payload += "==" + case 3: + payload += "=" + } + + decoded, err := base64.URLEncoding.DecodeString(payload) + if err != nil { + // Пробуем StdEncoding на случай нестандартного токена + decoded, err = base64.StdEncoding.DecodeString(payload) + if err != nil { + return nil, fmt.Errorf("decode JWT payload: %w", err) + } + } + + var claims JWTClaims + if err := json.Unmarshal(decoded, &claims); err != nil { + return nil, fmt.Errorf("parse JWT claims: %w", err) + } + if claims.Sub == "" { + return nil, fmt.Errorf("JWT missing 'sub' claim") + } + if claims.Exp > 0 && claims.Exp < time.Now().Unix() { + return nil, fmt.Errorf("JWT token expired") + } + 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]) +} + +// PingNubesAPI — проверяет валидность токена запросом к nubes API. +// endpoint — базовый URL (например "https://deck-api-test.ngcloud.ru/api/v1"). +// Логика: 401/403 → токен отклонён. Ошибка соединения → API недоступен. +// Любой другой HTTP статус → токен принят. +func PingNubesAPI(ctx context.Context, endpoint, token string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return fmt.Errorf("build nubes ping request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("nubes API unreachable at %s: %w", endpoint, err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("nubes API rejected token (HTTP %d)", resp.StatusCode) + } + return nil +} diff --git a/app/router/router.go b/app/router/router.go index 7c9b2b5..b162cc7 100644 --- a/app/router/router.go +++ b/app/router/router.go @@ -33,10 +33,12 @@ func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler { adminHandler := admin.NewHandler(tenantStore, adminToken) adminHandler.RegisterRoutes(r) - // UI public API — без auth, для встроенной console + // UI public API — JWT auth, для встроенной console + // ВАЖНО: RegisterPublicRoutes ПЕРЕД static handler — иначе PathPrefix("/ui") перехватит /ui/api/* adminHandler.RegisterPublicRoutes(r) // UI console — встроенный SPA, публичный доступ + // НЕ ловит /ui/api/* — mux сначала проверит более специфичные маршруты выше r.PathPrefix("/ui").Handler(http.StripPrefix("/ui", ui.Handler())) // SQS API — tenant auth middleware оборачивает каждый handler отдельно. diff --git a/app/tenant/tenant_store.go b/app/tenant/tenant_store.go index a6e453d..04e5f2b 100644 --- a/app/tenant/tenant_store.go +++ b/app/tenant/tenant_store.go @@ -16,21 +16,24 @@ import ( // Tenant — модель тенанта shared-sqs. // AccessKey используется как идентификатор в AWS Authorization header. type Tenant struct { - ID string // уникальный идентификатор тенанта (t-) + ID string // уникальный идентификатор тенанта (sless- из JWT sub, или t- для legacy) Name string // человекочитаемое имя AccessKey string // аналог AWS AccessKeyId (SSAK-) SecretKey string // аналог AWS SecretAccessKey (64 hex chars) MaxQueues int // лимит очередей (0 = безлимит) CreatedAt time.Time Active bool + NubesSub string `json:"nubes_sub,omitempty"` // JWT sub claim (UUID пользователя nubes). Пусто для legacy тенантов. + Email string `json:"email,omitempty"` // Email из JWT. Для отображения в UI. } // TenantStore — потокобезопасное in-memory хранилище тенантов. -// Два индекса позволяют быстро искать как по ID (admin API), так и по AccessKey (auth middleware). +// Три индекса: по ID (admin API), по AccessKey (auth middleware), по NubesSub (JWT auth). type TenantStore struct { mu sync.RWMutex byID map[string]*Tenant byAccessKey map[string]*Tenant + bySub map[string]*Tenant // индекс по NubesSub (JWT sub claim) } // NewTenantStore — создаёт пустое хранилище тенантов. @@ -38,6 +41,7 @@ func NewTenantStore() *TenantStore { return &TenantStore{ byID: make(map[string]*Tenant), byAccessKey: make(map[string]*Tenant), + bySub: make(map[string]*Tenant), } } @@ -69,6 +73,9 @@ 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 + } s.mu.Unlock() // Сохраняем в Redis асинхронно — сериализуем здесь, вне мьютекса @@ -139,6 +146,9 @@ 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) + } s.mu.Unlock() // Удаляем из Redis асинхронно @@ -153,9 +163,78 @@ 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 + } s.mu.Unlock() } +// GetBySub — поиск тенанта по NubesSub (JWT sub claim). +// Используется при JWT-авторизации через UI. +func (s *TenantStore) GetBySub(sub string) (*Tenant, bool) { + s.mu.RLock() + t, ok := s.bySub[sub] + 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) { + s.mu.Lock() + // Идемпотентность: если тенант с таким sub уже есть — возвращаем + if existing, ok := s.bySub[sub]; ok { + // Обновляем email если изменился + if email != "" && existing.Email != email { + existing.Email = email + } + s.mu.Unlock() + return existing, nil + } + + 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, + AccessKey: accessKey, + SecretKey: secretKey, + MaxQueues: maxQueues, + CreatedAt: time.Now().UTC(), + Active: true, + NubesSub: sub, + Email: email, + } + + s.byID[t.ID] = t + s.byAccessKey[t.AccessKey] = t + s.bySub[t.NubesSub] = t + s.mu.Unlock() + + // Сохраняем в Redis + if data, err := json.Marshal(t); err == nil { + persistence.SaveTenantRaw(t.ID, data) + } + + return t, nil +} + // 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 d45228c..d2c8be9 100644 --- a/app/ui/index.html +++ b/app/ui/index.html @@ -3,9 +3,9 @@ app/ui/index.html SQS Console — веб-интерфейс для shared-sqs (Nubes branding) Created: 2026-04-10 - Updated: 2026-04-10 — queue CRUD (create/delete) + message peek/send/purge + Updated: 2026-04-10 — JWT auth через nubes token, email в navbar, auto-provisioning Vanilla HTML/CSS/JS SPA. Встраивается через go:embed. - Режим: Публичный UI API без авторизации (демо). Все данные in-memory. + Режим: JWT авторизация через nubes API. Пользователь вводит токен → валидация → сессия. --> @@ -324,15 +324,33 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); } - -
+ + + + +