feat(auth): auth layer decoupling, версия v1.3.24 в navbar и Help

This commit is contained in:
“Naeel”
2026-05-02 19:18:34 +04:00
parent 7ebdaf743c
commit 81367f7a5d
11 changed files with 476 additions and 286 deletions
+174
View File
@@ -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
}