feat(auth): auth layer decoupling, версия v1.3.24 в navbar и Help
This commit is contained in:
@@ -5,6 +5,14 @@
|
||||
- Ничего лишнего.
|
||||
- Код — только по запросу.
|
||||
|
||||
## ⛔⛔⛔ ВОПРОС = СТОП
|
||||
|
||||
**Если в сообщении есть вопрос в ЛЮБОЙ форме** ("так ?", "верно ?", "почему ?", "как ?", "так же ?" и т.д.):
|
||||
1. ТОЛЬКО ответить на вопрос
|
||||
2. ОСТАНОВИТЬСЯ
|
||||
3. ЖДАТЬ следующей команды
|
||||
**ЗАПРЕЩЕНО** начинать работу, писать код, запускать команды — без явного "делай".
|
||||
|
||||
1. Не трогать рабочий код без явного указания.
|
||||
|
||||
2. Файлы редактируются локально:
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
+45
-110
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 и возвращает время истечения.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,7 @@
|
||||
</div>
|
||||
|
||||
<div class="navbar">
|
||||
<div style="font-size:0.65rem; color:var(--text-secondary); margin-right:10px; align-self:center; opacity:0.7;">v1.3.24</div>
|
||||
<div class="brand-mark" id="user-avatar">N</div>
|
||||
<div class="brand-text">
|
||||
<div class="nubes">NUBES</div>
|
||||
@@ -380,7 +381,7 @@
|
||||
</div>
|
||||
|
||||
<div class="actions" style="justify-content:space-between; align-items:center;">
|
||||
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.21</span>
|
||||
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.24</span>
|
||||
<button class="btn ghost" onclick="closeHelp()">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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() — загрузить функции
|
||||
```
|
||||
Reference in New Issue
Block a user