P1.5: убрать демо-бэкдор (публичный bypass-токен UI)
Удалены authenticateUIDemoToken, uiDemoToken/uiDemoTenantID/uiDemoEmail и их константы (demo-ui-shared-sqs-ngcloud-2026) — публичный путь входа в UI мимо JWT-валидации. seed.go теперь создаёт demo-тенанта со СЛУЧАЙНЫМИ кредами (tenant.Create), без захардкоженного SecretKey в исходнике. Тест TestJWTAuth_AllowsDemoToken заменён на TestJWTAuth_RejectsMissingToken.
This commit is contained in:
@@ -6,9 +6,7 @@ package admin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/subtle"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -30,17 +28,6 @@ const (
|
|||||||
uiTenantContextKey uiContextKey = "ui-tenant"
|
uiTenantContextKey uiContextKey = "ui-tenant"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ВАЖНО: значения ниже относятся только к временному demo/showcase режиму.
|
|
||||||
// Перед production rollout без публичного demo-режима этот блок должен быть удалён
|
|
||||||
// вместе с веткой authenticateUIDemoToken и связанными UI-подсказками.
|
|
||||||
const (
|
|
||||||
defaultUIDemoToken = "demo-ui-shared-sqs-ngcloud-2026"
|
|
||||||
defaultUIDemoTenantID = "t-demo-shared-sqs-ngcloud"
|
|
||||||
defaultUIDemoEmail = "demo@shared-sqs.ngcloud"
|
|
||||||
)
|
|
||||||
|
|
||||||
var errUIDemoTokenMismatch = errors.New("demo token mismatch")
|
|
||||||
|
|
||||||
// ─── вспомогательная функция: найти очередь тенанта по имени ───────────────
|
// ─── вспомогательная функция: найти очередь тенанта по имени ───────────────
|
||||||
// findQueue — возвращает ключ и очередь тенанта по имени, или "",nil если не найдено.
|
// findQueue — возвращает ключ и очередь тенанта по имени, или "",nil если не найдено.
|
||||||
func findQueue(tenantAccessKey, queueName string) (string, *models.Queue) {
|
func findQueue(tenantAccessKey, queueName string) (string, *models.Queue) {
|
||||||
@@ -69,48 +56,6 @@ func NewHandler(store *tenant.TenantStore, adminToken string) *Handler {
|
|||||||
return &Handler{store: store, adminToken: adminToken, nubesEndpoint: nubesEndpoint}
|
return &Handler{store: store, adminToken: adminToken, nubesEndpoint: nubesEndpoint}
|
||||||
}
|
}
|
||||||
|
|
||||||
// uiDemoToken — возвращает публичный demo token для UI.
|
|
||||||
// Временный showcase-only путь: в production без demo user этот метод нужно удалить.
|
|
||||||
func (h *Handler) uiDemoToken() string {
|
|
||||||
if token := os.Getenv("SHARED_SQS_UI_DEMO_TOKEN"); token != "" {
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
return defaultUIDemoToken
|
|
||||||
}
|
|
||||||
|
|
||||||
// uiDemoTenantID — возвращает tenant ID, к которому привязан demo token.
|
|
||||||
// Существует только для showcase-режима, не для постоянной production auth-модели.
|
|
||||||
func (h *Handler) uiDemoTenantID() string {
|
|
||||||
if tenantID := os.Getenv("SHARED_SQS_UI_DEMO_TENANT_ID"); tenantID != "" {
|
|
||||||
return tenantID
|
|
||||||
}
|
|
||||||
return defaultUIDemoTenantID
|
|
||||||
}
|
|
||||||
|
|
||||||
// uiDemoEmail — возвращает отображаемый email для demo UI session.
|
|
||||||
// Нужен только для публичного демо-логина и должен уйти вместе с demo path.
|
|
||||||
func (h *Handler) uiDemoEmail() string {
|
|
||||||
if email := os.Getenv("SHARED_SQS_UI_DEMO_EMAIL"); email != "" {
|
|
||||||
return email
|
|
||||||
}
|
|
||||||
return defaultUIDemoEmail
|
|
||||||
}
|
|
||||||
|
|
||||||
// authenticateUIDemoToken — маппит публичный demo token на заранее сидированный demo tenant.
|
|
||||||
// Почему так: это быстрый showcase-вход для заказчика. Для production без demo user
|
|
||||||
// функция должна быть удалена, чтобы в коде не осталось публичного bypass-пути.
|
|
||||||
func (h *Handler) authenticateUIDemoToken(token string) (*tenant.Tenant, string, error) {
|
|
||||||
demoToken := h.uiDemoToken()
|
|
||||||
if demoToken == "" || subtle.ConstantTimeCompare([]byte(token), []byte(demoToken)) != 1 {
|
|
||||||
return nil, "", errUIDemoTokenMismatch
|
|
||||||
}
|
|
||||||
demoTenant, ok := h.store.GetByID(h.uiDemoTenantID())
|
|
||||||
if !ok {
|
|
||||||
return nil, "", errors.New("demo tenant unavailable — enable SHARED_SQS_SEED_DEMO=true")
|
|
||||||
}
|
|
||||||
return demoTenant, h.uiDemoEmail(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// currentUITenant — возвращает tenant, авторизованный через UI middleware.
|
// currentUITenant — возвращает tenant, авторизованный через UI middleware.
|
||||||
func currentUITenant(r *http.Request) (*tenant.Tenant, bool) {
|
func currentUITenant(r *http.Request) (*tenant.Tenant, bool) {
|
||||||
t, ok := r.Context().Value(uiTenantContextKey).(*tenant.Tenant)
|
t, ok := r.Context().Value(uiTenantContextKey).(*tenant.Tenant)
|
||||||
@@ -197,24 +142,6 @@ func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if demoTenant, demoEmail, err := h.authenticateUIDemoToken(req.Token); err == nil {
|
|
||||||
log.Infof("ui auth: authenticated demo tenant=%s", demoTenant.ID)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"email": demoEmail,
|
|
||||||
"tenant_id": demoTenant.ID,
|
|
||||||
"access_key": demoTenant.AccessKey,
|
|
||||||
"secret_key": demoTenant.SecretKey,
|
|
||||||
"max_queues": demoTenant.MaxQueues,
|
|
||||||
"token": req.Token,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
} else if !errors.Is(err, errUIDemoTokenMismatch) {
|
|
||||||
log.Warnf("ui auth: demo login unavailable: %v", err)
|
|
||||||
jsonErr(w, http.StatusForbidden, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Парсим JWT claims
|
// Парсим JWT claims
|
||||||
claims, err := auth.ParseJWTClaims(req.Token)
|
claims, err := auth.ParseJWTClaims(req.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -305,19 +232,6 @@ func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
token := parts[1]
|
token := parts[1]
|
||||||
|
|
||||||
if demoTenant, _, err := h.authenticateUIDemoToken(token); err == nil {
|
|
||||||
if pathTenantID, exists := mux.Vars(r)["id"]; exists && pathTenantID != "" && pathTenantID != demoTenant.ID {
|
|
||||||
jsonErr(w, http.StatusForbidden, "forbidden tenant access")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx := context.WithValue(r.Context(), uiTenantContextKey, demoTenant)
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
|
||||||
return
|
|
||||||
} else if !errors.Is(err, errUIDemoTokenMismatch) {
|
|
||||||
jsonErr(w, http.StatusForbidden, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
claims, err := auth.ParseJWTClaims(token)
|
claims, err := auth.ParseJWTClaims(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonErr(w, http.StatusUnauthorized, "invalid token: "+err.Error())
|
jsonErr(w, http.StatusUnauthorized, "invalid token: "+err.Error())
|
||||||
|
|||||||
+5
-26
@@ -1,7 +1,5 @@
|
|||||||
// app/admin/admin_test.go
|
// app/admin/admin_test.go
|
||||||
// Focused tests for UI auth and scoping
|
// Focused tests for UI auth and scoping
|
||||||
// Created: 2026-04-12 09:28 MSK
|
|
||||||
// Updated: 2026-04-12 09:28 MSK — demo UI token and tenant-scoped UI responses
|
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -15,37 +13,18 @@ import (
|
|||||||
"shared-sqs/app/tenant"
|
"shared-sqs/app/tenant"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestJWTAuth_AllowsDemoToken verifies that the public demo token authenticates into the seeded demo tenant.
|
// TestJWTAuth_RejectsMissingToken verifies jwtAuth rejects a request without a token.
|
||||||
func TestJWTAuth_AllowsDemoToken(t *testing.T) {
|
func TestJWTAuth_RejectsMissingToken(t *testing.T) {
|
||||||
t.Setenv("SHARED_SQS_UI_DEMO_TOKEN", "demo-token-for-test")
|
|
||||||
t.Setenv("SHARED_SQS_UI_DEMO_TENANT_ID", "t-demo-test")
|
|
||||||
|
|
||||||
store := tenant.NewTenantStore()
|
store := tenant.NewTenantStore()
|
||||||
demoTenant, err := store.CreateFixed("demo-service", 10, "t-demo-test", "SSAK-demo-test", "demo-secret")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateFixed(): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
h := NewHandler(store, "admin-token")
|
h := NewHandler(store, "admin-token")
|
||||||
req := httptest.NewRequest(http.MethodPost, "/ui/api/auth", strings.NewReader(`{"token":"demo-token-for-test"}`))
|
req := httptest.NewRequest(http.MethodPost, "/ui/api/auth", strings.NewReader(`{}`))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
h.jwtAuth(w, req)
|
h.jwtAuth(w, req)
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("jwtAuth() status = %d, body = %s", w.Code, w.Body.String())
|
t.Fatalf("jwtAuth() status = %d, want 400", w.Code)
|
||||||
}
|
|
||||||
|
|
||||||
var resp map[string]any
|
|
||||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
||||||
t.Fatalf("decode response: %v", err)
|
|
||||||
}
|
|
||||||
if got := resp["tenant_id"]; got != demoTenant.ID {
|
|
||||||
t.Fatalf("tenant_id = %v, want %s", got, demoTenant.ID)
|
|
||||||
}
|
|
||||||
if got := resp["access_key"]; got != demoTenant.AccessKey {
|
|
||||||
t.Fatalf("access_key = %v, want %s", got, demoTenant.AccessKey)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-13
@@ -1,12 +1,11 @@
|
|||||||
// app/cmd/seed.go
|
// app/cmd/seed.go
|
||||||
// Created: 2026-04-09
|
|
||||||
// Updated: 2026-04-09 — фиксированные credentials для demo-tenant (BYOC)
|
|
||||||
// Автосид демо-данных при старте через SHARED_SQS_SEED_DEMO=true.
|
// Автосид демо-данных при старте через SHARED_SQS_SEED_DEMO=true.
|
||||||
// Создаёт тенанта demo-service с 5 очередями и демо-сообщениями.
|
// Создаёт тенанта demo-service со случайными кредами (генерируются через
|
||||||
|
// tenant.Create → crypto/rand) с 5 очередями и демо-сообщениями.
|
||||||
//
|
//
|
||||||
// DEMO CREDENTIALS — только для тестового стенда.
|
|
||||||
// Тенант demo-service изолирован: видит только свои очереди, не имеет доступа к
|
// Тенант demo-service изолирован: видит только свои очереди, не имеет доступа к
|
||||||
// admin API и к очередям других тенантов. Credentials открыты намеренно — стенд публичный.
|
// admin API и к очередям других тенантов. Креды генерируются случайно и печатаются
|
||||||
|
// в лог при старте (только при явном включении SHARED_SQS_SEED_DEMO).
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -21,26 +20,22 @@ import (
|
|||||||
"shared-sqs/app/tenant"
|
"shared-sqs/app/tenant"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Фиксированные credentials демо-тенанта.
|
|
||||||
// Открыты намеренно — тестовый стенд.
|
|
||||||
// Тенант ограничен 10 очередями и не имеет прав admin API.
|
|
||||||
const (
|
const (
|
||||||
demoTenantID = "t-demo-shared-sqs-ngcloud"
|
|
||||||
demoAccessKey = "SSAK-demo-shared-sqs"
|
|
||||||
demoSecretKey = "demo-secret-key-shared-sqs-ngcloud-2026"
|
|
||||||
demoTenantName = "demo-service"
|
demoTenantName = "demo-service"
|
||||||
demoMaxQueues = 10
|
demoMaxQueues = 10
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedDemoData создаёт тенанта demo-service с очередями и сообщениями.
|
// seedDemoData создаёт тенанта demo-service с очередями и сообщениями.
|
||||||
// Вызывается при SHARED_SQS_SEED_DEMO=true при старте сервера.
|
// Вызывается при SHARED_SQS_SEED_DEMO=true при старте сервера.
|
||||||
|
// Креды генерируются случайно (tenant.Create) — никаких захардкоженных
|
||||||
|
// AccessKey/SecretKey в исходнике.
|
||||||
func seedDemoData(store *tenant.TenantStore) {
|
func seedDemoData(store *tenant.TenantStore) {
|
||||||
t, err := store.CreateFixed(demoTenantName, demoMaxQueues, demoTenantID, demoAccessKey, demoSecretKey)
|
t, err := store.Create(demoTenantName, demoMaxQueues)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("seed: не удалось создать demo-tenant: %v", err)
|
log.Warnf("seed: не удалось создать demo-tenant: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Infof("seed: создан тенант %s (AccessKey=%s)", t.ID, t.AccessKey)
|
log.Infof("seed: создан тенант %s (AccessKey=%s, SecretKey=%s)", t.ID, t.AccessKey, t.SecretKey)
|
||||||
|
|
||||||
// Демо-очереди с набором сообщений
|
// Демо-очереди с набором сообщений
|
||||||
queues := []struct {
|
queues := []struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user