257 lines
9.1 KiB
Go
257 lines
9.1 KiB
Go
// 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)
|
||
}
|