// Package auth реализует слой аутентификации консоли. // // Сервер получает UserIdentity (Sub + Email) и больше не знает // ни про Deck API, ни про JWT подписи, ни про тест/прод режимы. package auth import ( "context" "crypto/sha1" "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. // Если sub начинается с "test-" → "fission-test-" + hex(SHA256(sub)[:8]) // Иначе → "fission-" + hex(SHA256(sub)[:8]) func NamespaceForSub(sub string) string { h := sha256.Sum256([]byte(sub)) if strings.HasPrefix(sub, "test-") { return "fission-test-" + hex.EncodeToString(h[:8]) } 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 } // isJWT возвращает true если строка похожа на JWT (три непустые части через точку). func isJWT(s string) bool { parts := strings.SplitN(s, ".", 4) return len(parts) == 3 && parts[0] != "" && parts[1] != "" && parts[2] != "" } // uuidV5 генерирует детерминированный UUID v5 (RFC 4122) из строки. // Namespace: произвольный фиксированный UUID для demo-логинов. func uuidV5(name string) string { // Фиксированный namespace UUID для demo-логинов ns := []byte{0xf4, 0x7a, 0xc1, 0x0b, 0x58, 0xcc, 0x43, 0x72, 0xa5, 0x67, 0x0e, 0x02, 0xb2, 0xc3, 0xd4, 0x79} h := sha1.New() _, _ = h.Write(ns) _, _ = h.Write([]byte(name)) sum := h.Sum(nil) sum[6] = (sum[6] & 0x0f) | 0x50 // version 5 sum[8] = (sum[8] & 0x3f) | 0x80 // RFC 4122 variant return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", sum[0:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16]) } // demoEmail формирует маскированный email для демо-логина. // Пример: "mylogin" → "demo-my***in" func demoEmail(login string) string { r := []rune(login) n := len(r) if n <= 4 { return "demo-" + string(r) } return "demo-" + string(r[:2]) + strings.Repeat("*", n-4) + string(r[n-2:]) } // --- DemoAuthenticator --- // DemoAuthenticator — аутентификация по произвольному логину (≥6 символов). // Не обращается ни к каким внешним сервисам. // Sub = UUID v5 от логина (детерминированный), Email = маскированный логин. type DemoAuthenticator struct{} // Authenticate принимает логин ≥6 символов и возвращает UserIdentity. func (d *DemoAuthenticator) Authenticate(_ context.Context, login, _ string) (UserIdentity, error) { login = strings.TrimSpace(login) if len([]rune(login)) < 6 { return UserIdentity{}, fmt.Errorf("login must be at least 6 characters") } sub := uuidV5(login) // Если логин начинается с "test-" — помечаем Sub так же, чтобы // NamespaceForSub мог создать fission-test-* namespace. if strings.HasPrefix(login, "test-") { sub = "test-" + sub } return UserIdentity{Sub: sub, Email: demoEmail(login)}, nil } // --- MultiAuthenticator --- // MultiAuthenticator роутит аутентификацию: // - JWT (три части через точку) → JWT authenticator (DeckAuthenticator или TestAuthenticator) // - Иначе → DemoAuthenticator // Оба пути равноправны и возвращают одинаковый UserIdentity. type MultiAuthenticator struct { JWT Authenticator Demo *DemoAuthenticator } // Authenticate определяет тип входных данных и делегирует нужному authenticator. func (m *MultiAuthenticator) Authenticate(ctx context.Context, token, env string) (UserIdentity, error) { token = strings.TrimSpace(token) if token == "" { return UserIdentity{}, fmt.Errorf("token or login required") } if isJWT(token) { return m.JWT.Authenticate(ctx, token, env) } return m.Demo.Authenticate(ctx, token, env) }