Files

257 lines
9.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}