diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 17e89bc..e3370c2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,6 +5,14 @@ - Ничего лишнего. - Код — только по запросу. +## ⛔⛔⛔ ВОПРОС = СТОП + +**Если в сообщении есть вопрос в ЛЮБОЙ форме** ("так ?", "верно ?", "почему ?", "как ?", "так же ?" и т.д.): +1. ТОЛЬКО ответить на вопрос +2. ОСТАНОВИТЬСЯ +3. ЖДАТЬ следующей команды +**ЗАПРЕЩЕНО** начинать работу, писать код, запускать команды — без явного "делай". + 1. Не трогать рабочий код без явного указания. 2. Файлы редактируются локально: diff --git a/console/cmd/server/main.go b/console/cmd/server/main.go index 5a3c0ba..e995ce1 100644 --- a/console/cmd/server/main.go +++ b/console/cmd/server/main.go @@ -11,6 +11,7 @@ import ( "time" "fission-console/internal/api" + "fission-console/internal/auth" "k8s.io/client-go/dynamic" "k8s.io/client-go/rest" @@ -36,6 +37,15 @@ func main() { log.Fatalf("create dynamic client: %v", err) } + testMode := os.Getenv("FISSION_TEST_MODE") == "true" + + var authenticator auth.Authenticator + if testMode { + authenticator = &auth.TestAuthenticator{} + } else { + authenticator = auth.NewDeckAuthenticator(auth.DefaultDeckAPIs, nil) + } + srv := api.NewServer(api.Config{ Dyn: dyn, Namespace: namespace, @@ -45,7 +55,8 @@ func main() { SATokenPath: envDefault("SA_TOKEN_PATH", "/var/run/secrets/kubernetes.io/serviceaccount/token"), AuthUser: envDefault("FISSION_AUTH_USERNAME", ""), AuthPass: envDefault("FISSION_AUTH_PASSWORD", ""), - TestMode: os.Getenv("FISSION_TEST_MODE") == "true", + TestMode: testMode, + Authenticator: authenticator, // --- ai/ask feature --- LLMUrl: envDefault("FISSION_LLM_URL", "https://api.aillm.ru"), LLMKey: os.Getenv("FISSION_LLM_KEY"), diff --git a/console/deploy/console.yaml b/console/deploy/console.yaml index 9686a97..c7e7c2b 100644 --- a/console/deploy/console.yaml +++ b/console/deploy/console.yaml @@ -52,7 +52,7 @@ spec: serviceAccountName: fission-console containers: - name: console - image: naeel/fission-console:v1.3.23 + image: naeel/fission-console:v1.3.24 imagePullPolicy: Always ports: - containerPort: 8090 diff --git a/console/internal/api/auth.go b/console/internal/api/auth.go index 6cb07c6..3c51415 100644 --- a/console/internal/api/auth.go +++ b/console/internal/api/auth.go @@ -2,19 +2,20 @@ package api import ( "context" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" "fmt" "net/http" "strings" + + "fission-console/internal/auth" ) -// ctxKeyNS — ключ для хранения namespace пользователя в context.Context. -// Использует приватный тип чтобы избежать коллизий с ключами из других пакетов. +// ctxKeyNS — ключ для K8s namespace пользователя в context.Context. type ctxKeyNS struct{} +// ctxKeyIdentity — ключ для UserIdentity пользователя в context.Context. +type ctxKeyIdentity struct{} + +// authTokenFromRequest извлекает Bearer токен из X-Auth-Token или Authorization заголовка. func authTokenFromRequest(r *http.Request) string { token := strings.TrimSpace(r.Header.Get("X-Auth-Token")) if token != "" { @@ -32,61 +33,57 @@ func authTokenFromRequest(r *http.Request) string { } // userNS возвращает namespace пользователя из контекста запроса. -// Устанавливается в authMiddleware после успешной аутентификации. func (s *Server) userNS(r *http.Request) string { if ns, ok := r.Context().Value(ctxKeyNS{}).(string); ok && ns != "" { return ns } - // Fallback: использовать системный namespace (не должно происходить в prod) return s.ns } -// authMiddleware оборачивает handler, добавляя аутентификацию и инициализацию namespace. +// normalizeEnv приводит название стенда к допустимому значению. +func normalizeEnv(env string) string { + switch strings.TrimSpace(strings.ToLower(env)) { + case "prod", "dev", "test": + return strings.TrimSpace(strings.ToLower(env)) + } + return "test" +} + +// authMiddleware оборачивает handler аутентификацией. // -// В testMode (FISSION_TEST_MODE=true): -// - Deck API не вызывается -// - X-Test-Sub или X-Auth-Token задают sub → разные namespace-ы для тестирования -// -// В production: -// - X-Auth-Token валидируется через Deck API -// - Namespace вычисляется из JWT claim "sub" +// В testMode: заголовок X-Test-Sub позволяет задать sub напрямую (без токена). +// Иначе: токен передаётся в s.authenticator.Authenticate — детали скрыты за интерфейсом. func (s *Server) authMiddleware(h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var ns string - env := strings.TrimSpace(strings.ToLower(r.Header.Get("X-Auth-Env"))) - if _, ok := deckAPIs[env]; !ok { - env = "test" - } + var identity auth.UserIdentity + + env := normalizeEnv(r.Header.Get("X-Auth-Env")) if s.testMode { - sub := strings.TrimSpace(r.Header.Get("X-Test-Sub")) - if sub != "" { - ns = namespaceFromSub(sub) - } else { - token := authTokenFromRequest(r) - resolvedNS, err := s.resolveNamespaceForToken(token, env, true) - if err != nil { - writeJSONError(w, http.StatusUnauthorized, "unauthorized") + if sub := strings.TrimSpace(r.Header.Get("X-Test-Sub")); sub != "" { + identity = auth.UserIdentity{Sub: sub} + ctx := s.contextWithIdentity(r.Context(), identity) + if err := s.nsManager.EnsureUserNS(ctx, auth.NamespaceForSub(sub)); err != nil { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure namespace: %v", err)) return } - ns = resolvedNS - } - } else { - token := authTokenFromRequest(r) - resolvedNS, err := s.resolveNamespaceForToken(token, env, false) - if err != nil { - writeJSONError(w, http.StatusUnauthorized, "unauthorized") + h(w, r.WithContext(ctx)) return } - ns = resolvedNS } - ctx := context.WithValue(r.Context(), ctxKeyNS{}, ns) + token := authTokenFromRequest(r) + var err error + identity, err = s.authenticator.Authenticate(r.Context(), token, env) + if err != nil { + writeJSONError(w, http.StatusUnauthorized, "unauthorized") + return + } - // Гарантируем что namespace + RBAC + quota + netpol существуют. - // EnsureUserNS реализует singleflight + кэш + семафор параллелизма. - if ensureErr := s.nsManager.EnsureUserNS(ctx, ns); ensureErr != nil { - writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure namespace: %v", ensureErr)) + ns := auth.NamespaceForSub(identity.Sub) + ctx := s.contextWithIdentity(r.Context(), identity) + if err := s.nsManager.EnsureUserNS(ctx, ns); err != nil { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure namespace: %v", err)) return } @@ -94,72 +91,10 @@ func (s *Server) authMiddleware(h http.HandlerFunc) http.HandlerFunc { } } -func namespaceFromSub(sub string) string { - h32 := sha256.Sum256([]byte(sub)) - return "fission-" + hex.EncodeToString(h32[:8]) -} - -func (s *Server) resolveNamespaceForToken(token, env string, allowTestSub bool) (string, error) { - token = strings.TrimSpace(token) - if token == "" { - if allowTestSub { - return "", fmt.Errorf("test mode: X-Test-Sub required") - } - return "", fmt.Errorf("unauthorized") - } - - if err := s.validateDeckToken(token, env); err == nil { - ns, nsErr := namespaceFromJWT(token) - if nsErr != nil { - return s.ns, nil - } - return ns, nil - } - - if allowTestSub && strings.Contains(token, "@") { - return namespaceFromSub(token), nil - } - - return "", fmt.Errorf("invalid token") -} - -// namespaceFromJWT декодирует JWT payload (без верификации подписи), -// извлекает claim "sub" и вычисляет namespace: "fission-" + hex(SHA256(sub)[:8]). -// -// Подпись не проверяется — токен уже валидирован через Deck API (validateDeckToken). -// Здесь нам нужен только deterministic namespace name из sub claim. -func namespaceFromJWT(token string) (string, error) { - parts := strings.SplitN(token, ".", 3) - if len(parts) != 3 { - return "", fmt.Errorf("invalid JWT format") - } - payload := parts[1] - - // JWT использует base64url без padding — добавляем если нужно - switch len(payload) % 4 { - case 2: - payload += "==" - case 3: - payload += "=" - } - - // base64url без стандартного padding — пробуем оба варианта - decoded, err := base64.URLEncoding.DecodeString(payload) - if err != nil { - decoded, err = base64.StdEncoding.DecodeString(payload) - if err != nil { - return "", fmt.Errorf("decode JWT payload: %w", err) - } - } - - var claims map[string]any - if err := json.Unmarshal(decoded, &claims); err != nil { - return "", fmt.Errorf("unmarshal JWT claims: %w", err) - } - sub, _ := claims["sub"].(string) - if sub == "" { - return "", fmt.Errorf("JWT missing sub claim") - } - h := sha256.Sum256([]byte(sub)) - return "fission-" + hex.EncodeToString(h[:8]), nil +// contextWithIdentity кладёт UserIdentity и вычисленный namespace в контекст. +func (s *Server) contextWithIdentity(ctx context.Context, identity auth.UserIdentity) context.Context { + ns := auth.NamespaceForSub(identity.Sub) + ctx = context.WithValue(ctx, ctxKeyNS{}, ns) + ctx = context.WithValue(ctx, ctxKeyIdentity{}, identity) + return ctx } diff --git a/console/internal/api/auth_test.go b/console/internal/api/auth_test.go deleted file mode 100644 index 5d40e4f..0000000 --- a/console/internal/api/auth_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package api - -import ( - "net/http" - "net/http/httptest" - "testing" - "time" -) - -const validJWTForTests = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXVzZXItMTIzIn0.signature" - -func TestResolveNamespaceForTokenRejectsInvalidTokenOutsideTestMode(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/index.cfm/instances" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - w.WriteHeader(http.StatusUnauthorized) - })) - defer server.Close() - - oldDeckAPIs := deckAPIs - deckAPIs = map[string]string{"test": server.URL} - defer func() { deckAPIs = oldDeckAPIs }() - - s := &Server{http: server.Client()} - if _, err := s.resolveNamespaceForToken("user@example.com", "test", false); err == nil { - t.Fatal("expected invalid token to be rejected outside test mode") - } -} - -func TestResolveNamespaceForTokenAllowsValidatedJWTInTestMode(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("Authorization"); got == "" { - t.Fatal("expected Authorization header") - } - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"results":[]}`)) - })) - defer server.Close() - - oldDeckAPIs := deckAPIs - deckAPIs = map[string]string{"test": server.URL} - defer func() { deckAPIs = oldDeckAPIs }() - - s := &Server{http: server.Client()} - ns, err := s.resolveNamespaceForToken(validJWTForTests, "test", true) - if err != nil { - t.Fatalf("expected JWT to pass in test mode: %v", err) - } - - expected, err := namespaceFromJWT(validJWTForTests) - if err != nil { - t.Fatalf("namespaceFromJWT: %v", err) - } - if ns != expected { - t.Fatalf("expected namespace %q, got %q", expected, ns) - } -} - -func TestResolveNamespaceForTokenAllowsEmailFallbackOnlyInTestMode(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - })) - defer server.Close() - - oldDeckAPIs := deckAPIs - deckAPIs = map[string]string{"test": server.URL} - defer func() { deckAPIs = oldDeckAPIs }() - - s := &Server{http: server.Client()} - ns, err := s.resolveNamespaceForToken("user@example.com", "test", true) - if err != nil { - t.Fatalf("expected email fallback in test mode: %v", err) - } - if ns != namespaceFromSub("user@example.com") { - t.Fatalf("unexpected namespace: %q", ns) - } - - _, err = s.resolveNamespaceForToken("user@example.com", "test", false) - if err == nil { - t.Fatal("expected email fallback to be rejected outside test mode") - } -} - -func TestValidateDeckTokenCachesSuccessfulValidation(t *testing.T) { - requests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests++ - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"results":[]}`)) - })) - defer server.Close() - - oldDeckAPIs := deckAPIs - deckAPIs = map[string]string{"test": server.URL} - defer func() { deckAPIs = oldDeckAPIs }() - - s := &Server{http: server.Client()} - if err := s.validateDeckToken(validJWTForTests, "test"); err != nil { - t.Fatalf("first validate failed: %v", err) - } - if err := s.validateDeckToken(validJWTForTests, "test"); err != nil { - t.Fatalf("second validate failed: %v", err) - } - if requests != 1 { - t.Fatalf("expected 1 upstream request because of cache, got %d", requests) - } - - if v, ok := s.tokenCache.Load("test:" + validJWTForTests); !ok || time.Now().After(v.(time.Time)) { - t.Fatal("expected token to be cached") - } -} \ No newline at end of file diff --git a/console/internal/api/handlers.go b/console/internal/api/handlers.go index acacacb..57159d1 100644 --- a/console/internal/api/handlers.go +++ b/console/internal/api/handlers.go @@ -16,6 +16,7 @@ import ( "strings" "time" + "fission-console/internal/auth" "fission-console/internal/fission" "fission-console/internal/model" "fission-console/internal/runtime" @@ -1018,7 +1019,7 @@ func (s *Server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, na } // handleAuth обрабатывает POST /console/api/auth. -// Валидирует токен, создаёт namespace, возвращает namespace пользователя. +// Валидирует токен через authenticator, создаёт namespace, возвращает namespace + email. func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") @@ -1034,17 +1035,16 @@ func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) { return } - env := strings.TrimSpace(strings.ToLower(body.Env)) - if _, ok := deckAPIs[env]; !ok { - env = "test" - } + env := normalizeEnv(body.Env) - ns, err := s.resolveNamespaceForToken(body.Token, env, s.testMode) + identity, err := s.authenticator.Authenticate(r.Context(), body.Token, env) if err != nil { writeJSONError(w, http.StatusUnauthorized, "invalid token") return } + ns := auth.NamespaceForSub(identity.Sub) + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) defer cancel() if ensureErr := s.nsManager.EnsureUserNS(ctx, ns); ensureErr != nil { @@ -1052,7 +1052,7 @@ func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json; charset=utf-8") - _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "env": env, "namespace": ns}) + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "env": env, "namespace": ns, "email": identity.Email}) } // parseTTL парсит строку TTL и возвращает время истечения. diff --git a/console/internal/api/server.go b/console/internal/api/server.go index 16c2a46..c05e8f8 100644 --- a/console/internal/api/server.go +++ b/console/internal/api/server.go @@ -5,13 +5,13 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" "os" "strings" "sync" "time" + "fission-console/internal/auth" "fission-console/internal/cloud" "fission-console/internal/fission" "fission-console/ui" @@ -22,20 +22,12 @@ import ( "k8s.io/client-go/dynamic" ) -// deckAPIs — карта окружений Deck API. -// Ключ используется в X-Auth-Env заголовке для выбора нужного сервера. -var deckAPIs = map[string]string{ - "prod": "https://deck-api.ngcloud.ru/api/v1", - "dev": "https://deck-api-dev.ngcloud.ru/api/v1", - "test": "https://deck-api-test.ngcloud.ru/api/v1", -} - // defaultSATokenPath — путь к service account токену внутри pod-а. // Используется для авторизации запросов от console к Fission router. const defaultSATokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" // Server — основная структура HTTP сервера. -// Содержит все зависимости: kubernetes client, конфиги, кэши токенов. +// Содержит все зависимости: kubernetes client, конфиги, кэши. type Server struct { dyn dynamic.Interface ns string // системный namespace (fallback, обычно "fission") @@ -44,7 +36,7 @@ type Server struct { saTokenPath string invokeTimeout time.Duration - testMode bool // FISSION_TEST_MODE=true — пропускает deck auth + testMode bool // FISSION_TEST_MODE=true — разрешает X-Test-Sub shortcut authUser string authPass string @@ -59,9 +51,8 @@ type Server struct { cachedJWT string tokenExpAt time.Time - // tokenCache кэширует результаты валидации Deck токенов. - // Ключ: "env:token", значение: time.Time — когда кэш истекает. - tokenCache sync.Map + // authenticator — слой аутентификации. Сервер не знает деталей реализации. + authenticator auth.Authenticator // nsManager управляет жизненным циклом пользовательских namespace-ов. nsManager *cloud.NSManager @@ -78,6 +69,7 @@ type Config struct { AuthUser string AuthPass string TestMode bool + Authenticator auth.Authenticator // слой аутентификации LLMUrl string LLMKey string } @@ -94,6 +86,7 @@ func NewServer(cfg Config) *Server { authUser: cfg.AuthUser, authPass: cfg.AuthPass, testMode: cfg.TestMode, + authenticator: cfg.Authenticator, llmURL: cfg.LLMUrl, llmKey: cfg.LLMKey, nsManager: cloud.NewNSManager(cfg.Dyn), @@ -274,43 +267,3 @@ func (s *Server) getRouterToken() string { s.tokenExpAt = time.Now().Add(100 * time.Second) return s.cachedJWT } - -// validateDeckToken проверяет токен через Deck API с кэшированием результата на 5 минут. -func (s *Server) validateDeckToken(token, env string) error { - cacheKey := env + ":" + token - if v, ok := s.tokenCache.Load(cacheKey); ok { - if time.Now().Before(v.(time.Time)) { - return nil // кэш актуален — токен валиден - } - s.tokenCache.Delete(cacheKey) - } - - apiBase, ok := deckAPIs[env] - if !ok { - return fmt.Errorf("unknown deck env: %s", env) - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBase+"/index.cfm/instances", nil) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+token) - - resp, err := s.http.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - _, _ = io.ReadAll(resp.Body) - - if resp.StatusCode == http.StatusUnauthorized { - return fmt.Errorf("invalid token") - } - - // Кэшируем успешный результат - s.tokenCache.Store(cacheKey, time.Now().Add(5*time.Minute)) - return nil -} diff --git a/console/internal/auth/auth.go b/console/internal/auth/auth.go new file mode 100644 index 0000000..889814a --- /dev/null +++ b/console/internal/auth/auth.go @@ -0,0 +1,174 @@ +// Package auth реализует слой аутентификации консоли. +// +// Сервер получает UserIdentity (Sub + Email) и больше не знает +// ни про Deck API, ни про JWT подписи, ни про тест/прод режимы. +package auth + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" +) + +// UserIdentity — идентификатор пользователя, полученный после аутентификации. +type UserIdentity struct { + Sub string // ID пользователя (claim "sub" из JWT) — обязательно + Email string // email — опционально, для отображения в UI +} + +// NamespaceForSub вычисляет детерминированный K8s namespace из Sub: +// "fission-" + hex(SHA256(sub)[:8]) +func NamespaceForSub(sub string) string { + h := sha256.Sum256([]byte(sub)) + return "fission-" + hex.EncodeToString(h[:8]) +} + +// Authenticator — интерфейс аутентификации. Единственная точка входа. +// Сервер не знает как именно проверяется токен — только вызывает Authenticate. +type Authenticator interface { + Authenticate(ctx context.Context, token, env string) (UserIdentity, error) +} + +// --- DeckAuthenticator --- + +// DeckAPIs — карта URL Deck API по стенду. +type DeckAPIs map[string]string + +// DefaultDeckAPIs — стандартные URL для всех стендов облака. +var DefaultDeckAPIs = DeckAPIs{ + "prod": "https://deck-api.ngcloud.ru/api/v1", + "dev": "https://deck-api-dev.ngcloud.ru/api/v1", + "test": "https://deck-api-test.ngcloud.ru/api/v1", +} + +// DeckAuthenticator — production реализация. +// Валидирует токен через Deck API, декодирует sub + email из JWT payload. +type DeckAuthenticator struct { + apis DeckAPIs + http *http.Client + tokenCache sync.Map // key: "env:token", value: time.Time (expiry) +} + +// NewDeckAuthenticator создаёт DeckAuthenticator. +// Если httpClient == nil — используется клиент с таймаутом 5s. +func NewDeckAuthenticator(apis DeckAPIs, httpClient *http.Client) *DeckAuthenticator { + if httpClient == nil { + httpClient = &http.Client{Timeout: 5 * time.Second} + } + return &DeckAuthenticator{apis: apis, http: httpClient} +} + +// Authenticate проверяет токен через Deck API и возвращает UserIdentity. +func (d *DeckAuthenticator) Authenticate(ctx context.Context, token, env string) (UserIdentity, error) { + token = strings.TrimSpace(token) + if token == "" { + return UserIdentity{}, fmt.Errorf("token required") + } + if err := d.validateToken(ctx, token, env); err != nil { + return UserIdentity{}, err + } + return identityFromJWT(token) +} + +func (d *DeckAuthenticator) validateToken(ctx context.Context, token, env string) error { + cacheKey := env + ":" + token + if v, ok := d.tokenCache.Load(cacheKey); ok { + if time.Now().Before(v.(time.Time)) { + return nil + } + d.tokenCache.Delete(cacheKey) + } + + apiBase, ok := d.apis[env] + if !ok { + return fmt.Errorf("unknown env: %s", env) + } + + reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, apiBase+"/index.cfm/instances", nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := d.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + _, _ = io.ReadAll(resp.Body) + + if resp.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("invalid token") + } + + d.tokenCache.Store(cacheKey, time.Now().Add(5*time.Minute)) + return nil +} + +// --- TestAuthenticator --- + +// TestAuthenticator — реализация для тест-режима. Не ходит в Deck API. +// Принимает JWT с claim "sub" или строку с "@" как email-sub. +type TestAuthenticator struct{} + +// Authenticate в тест-режиме: декодирует JWT если возможно, +// иначе использует email-строку как sub. +func (t *TestAuthenticator) Authenticate(_ context.Context, token, _ string) (UserIdentity, error) { + token = strings.TrimSpace(token) + if token == "" { + return UserIdentity{}, fmt.Errorf("token required") + } + if identity, err := identityFromJWT(token); err == nil { + return identity, nil + } + if strings.Contains(token, "@") { + return UserIdentity{Sub: token, Email: token}, nil + } + return UserIdentity{}, fmt.Errorf("invalid token") +} + +// --- helpers --- + +// identityFromJWT декодирует JWT payload (без верификации подписи). +// Подпись не проверяется — токен уже валидирован через Deck API. +func identityFromJWT(token string) (UserIdentity, error) { + parts := strings.SplitN(token, ".", 3) + if len(parts) != 3 { + return UserIdentity{}, fmt.Errorf("invalid JWT format") + } + payload := parts[1] + switch len(payload) % 4 { + case 2: + payload += "==" + case 3: + payload += "=" + } + decoded, err := base64.URLEncoding.DecodeString(payload) + if err != nil { + decoded, err = base64.StdEncoding.DecodeString(payload) + if err != nil { + return UserIdentity{}, fmt.Errorf("decode JWT payload: %w", err) + } + } + var claims map[string]any + if err := json.Unmarshal(decoded, &claims); err != nil { + return UserIdentity{}, fmt.Errorf("unmarshal claims: %w", err) + } + sub, _ := claims["sub"].(string) + if sub == "" { + return UserIdentity{}, fmt.Errorf("JWT missing sub claim") + } + email, _ := claims["email"].(string) + return UserIdentity{Sub: sub, Email: email}, nil +} diff --git a/console/internal/auth/auth_test.go b/console/internal/auth/auth_test.go new file mode 100644 index 0000000..5d91959 --- /dev/null +++ b/console/internal/auth/auth_test.go @@ -0,0 +1,111 @@ +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +const validJWTForTests = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXVzZXItMTIzIn0.signature" + +func TestDeckAuthenticatorRejectsInvalidToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + a := NewDeckAuthenticator(DeckAPIs{"test": server.URL}, server.Client()) + if _, err := a.Authenticate(context.Background(), "bad-token", "test"); err == nil { + t.Fatal("expected error for invalid token") + } +} + +func TestDeckAuthenticatorAcceptsValidJWT(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "" { + t.Fatal("expected Authorization header") + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + a := NewDeckAuthenticator(DeckAPIs{"test": server.URL}, server.Client()) + identity, err := a.Authenticate(context.Background(), validJWTForTests, "test") + if err != nil { + t.Fatalf("expected success: %v", err) + } + if identity.Sub != "test-user-123" { + t.Fatalf("unexpected sub: %q", identity.Sub) + } + if NamespaceForSub(identity.Sub) != NamespaceForSub("test-user-123") { + t.Fatalf("unexpected namespace: %q", NamespaceForSub(identity.Sub)) + } +} + +func TestDeckAuthenticatorCachesValidToken(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + a := NewDeckAuthenticator(DeckAPIs{"test": server.URL}, server.Client()) + if _, err := a.Authenticate(context.Background(), validJWTForTests, "test"); err != nil { + t.Fatalf("first auth: %v", err) + } + if _, err := a.Authenticate(context.Background(), validJWTForTests, "test"); err != nil { + t.Fatalf("second auth: %v", err) + } + if requests != 1 { + t.Fatalf("expected 1 upstream request (cache), got %d", requests) + } + // проверяем что кэш выставлен + if v, ok := a.tokenCache.Load("test:" + validJWTForTests); !ok || time.Now().After(v.(time.Time)) { + t.Fatal("expected token to be cached with future expiry") + } +} + +func TestTestAuthenticatorAcceptsJWT(t *testing.T) { + a := &TestAuthenticator{} + identity, err := a.Authenticate(context.Background(), validJWTForTests, "test") + if err != nil { + t.Fatalf("expected success: %v", err) + } + if identity.Sub != "test-user-123" { + t.Fatalf("unexpected sub: %q", identity.Sub) + } +} + +func TestTestAuthenticatorAcceptsEmail(t *testing.T) { + a := &TestAuthenticator{} + identity, err := a.Authenticate(context.Background(), "user@example.com", "test") + if err != nil { + t.Fatalf("expected success: %v", err) + } + if identity.Sub != "user@example.com" { + t.Fatalf("unexpected sub: %q", identity.Sub) + } +} + +func TestTestAuthenticatorRejectsInvalidToken(t *testing.T) { + a := &TestAuthenticator{} + if _, err := a.Authenticate(context.Background(), "notajwt", "test"); err == nil { + t.Fatal("expected error for non-JWT non-email token") + } +} + +func TestNamespaceForSub(t *testing.T) { + ns := NamespaceForSub("test-user-123") + if len(ns) == 0 { + t.Fatal("empty namespace") + } + if ns != NamespaceForSub("test-user-123") { + t.Fatal("namespace not deterministic") + } + if NamespaceForSub("a") == NamespaceForSub("b") { + t.Fatal("different subs should produce different namespaces") + } +} diff --git a/console/ui/index.html b/console/ui/index.html index 09e0870..b8a317b 100644 --- a/console/ui/index.html +++ b/console/ui/index.html @@ -95,6 +95,7 @@
diff --git a/doc/descriptions/auth-flow.md b/doc/descriptions/auth-flow.md new file mode 100644 index 0000000..ea2bc24 --- /dev/null +++ b/doc/descriptions/auth-flow.md @@ -0,0 +1,109 @@ +# Логика авторизации — Fission Console + +## Что вводит пользователь + +В форме логина (`index.html`, overlay `#login-overlay`): +- **Стенд** (`#l-env`): `dev` / `test` / `prod` +- **Токен** (`#l-token`): JWT-токен из личного кабинета NUBES (Профиль → Токены) + +--- + +## Фронтенд: `doLogin()` → `js/auth.js` + +1. Берёт токен и стенд из формы +2. Делает `POST /console/api/auth` с `{ token, env }` в теле +3. Если ответ OK: + - Сохраняет `auth_token` и `auth_env` в **localStorage** браузера + - Вызывает `setUserAvatar(token)` — декодирует email из JWT payload и показывает в navbar + - Скрывает overlay логина +4. Проверяет `GET /console/api/ns/status` — если namespace не готов, показывает overlay инициализации + +--- + +## Бэкенд: `handleAuth` → `handlers.go:1022` + +1. Принимает `POST /console/api/auth` +2. Валидирует токен через `resolveNamespaceForToken(token, env, testMode)` +3. Вызывает `EnsureUserNS` — гарантирует что K8s namespace + RBAC + quota существуют +4. Возвращает JSON: `{ ok: true, env: "test", namespace: "fission-..." }` + +--- + +## Валидация токена: `validateDeckToken` → `server.go:279` + +**Deck API** — внешний сервис облачной платформы NUBES: +``` +dev → https://deck-api-dev.ngcloud.ru/api/v1 +test → https://deck-api-test.ngcloud.ru/api/v1 +prod → https://deck-api.ngcloud.ru/api/v1 +``` + +Проверка: `GET {deckAPI}/index.cfm/instances` с заголовком `Authorization: Bearer {token}` +- `401` → токен невалиден +- Любой другой ответ → токен принят +- Результат кэшируется на **5 минут** (`sync.Map`) + +--- + +## Вычисление namespace: `namespaceFromJWT` → `auth.go:131` + +1. JWT токен **не верифицируется по подписи** — Deck API уже это сделал +2. Декодируется payload (base64url) +3. Извлекается claim `"sub"` (идентификатор пользователя в облаке) +4. Namespace = `"fission-" + hex(SHA256(sub)[:8])` + +Пример: sub = `c3fce59430e41b0f...` → namespace `fission-c3fce59430e41b0f` + +--- + +## Email в navbar: `setUserAvatar` / `jwtEmail` → `auth.js` + +- `jwtEmail(token)` — декодирует JWT payload на **фронтенде** (без обращения к серверу) +- Берёт `payload.email` или `payload.sub` +- Отображает в `#user-avatar` в левом верхнем углу (вместо синего кружка) + +--- + +## Последующие запросы + +Каждый API-запрос от фронтенда включает заголовки: +``` +X-Auth-Token: <токен из localStorage> +X-Auth-Env: <стенд из localStorage> +``` + +`authMiddleware` (`auth.go`) снова валидирует токен (но из кэша — без нового HTTP-запроса к Deck API), +вычисляет namespace и кладёт его в `context.Context` → все хендлеры работают в нужном namespace. + +--- + +## Test-режим (FISSION_TEST_MODE=true) + +Вместо реального Deck API — проверяет заголовок `X-Test-Sub`. +Если токен содержит `@` — трактуется как email и используется как sub для вычисления namespace. +Используется в тестах и для live-тестирования без реальных токенов. + +--- + +## Итого — схема + +``` +Пользователь вводит токен + стенд + │ + ▼ +POST /console/api/auth + │ + ├─► validateDeckToken → Deck API ({env}.ngcloud.ru) → 200 = ОК + │ + ├─► namespaceFromJWT → decode JWT payload → claim "sub" → SHA256[:8] → "fission-XXXXXXXX" + │ + ├─► EnsureUserNS → K8s namespace + RBAC создаются если нет + │ + └─► { ok: true, namespace: "fission-..." } + +Фронтенд: + ├─► localStorage.setItem('auth_token', token) + ├─► localStorage.setItem('auth_env', env) + ├─► jwtEmail(token) → email из JWT payload → показать в navbar + └─► reloadAll() — загрузить функции +```