// Изменено: 2026-04-05 // Auth middleware — проверяет Bearer JWT-токен из заголовка Authorization. // // Архитектура аутентификации: // - В v1 токен — это JWT облака (nubes), выданный при логине. // - Оператор НЕ проверяет подпись JWT (публичный ключ nubes недоступен внутри кластера). // - Проверяется только структура JWT и claim "sub" (не пустой) и "exp" (не истёк). // - Полная проверка подлинности токена происходит в провайдере terraform через PingNubesAPI. // - Такой подход называют "trusted perimeter": оператор доступен только внутри кластера, // внешний доступ — через Ingress, где токен уже проверен на уровне API-шлюза. // // TODO v2: получать публичный ключ из nubes JWKS endpoint и проверять подпись RS256. // // ────────────────────────────────────────────────────────────────────────────── // ТЕСТОВЫЙ РЕЖИМ (authTestMode = true): // Принимается ЛЮБАЯ строка без пробелов — не обязательно JWT. // Это позволяет тестировать UI/API без реального токена nubes. // Строка используется как идентификатор пользователя (аналог JWT.sub), // namespace выводится из неё так же: SHA256 → первые 16 байт hex → "sless-{hex}". // // ⚠️ ПЕРЕД ВЫХОДОМ В ПРОД: установить authTestMode = false. // Для возврата к строгой JWT-валидации: одна строка ниже. // ────────────────────────────────────────────────────────────────────────────── package middleware import ( "encoding/base64" "encoding/json" "log/slog" "net/http" "strings" "time" ) // authTestMode — ТЕСТОВЫЙ РЕЖИМ аутентификации. // // true → принимается любая строка без пробелов (не обязательно JWT). // // Используется при разработке UI когда реальный токен nubes не нужен. // // false → строгая проверка JWT (структура + sub + exp). // // Необходимо установить перед деплоем в прод. // // Чтобы вернуться к JWT: изменить на false. const authTestMode = true // Auth возвращает middleware которое требует заголовок: // // Authorization: Bearer // // В тестовом режиме (authTestMode=true): принимает любую строку без пробелов. // В боевом режиме (authTestMode=false): требует валидный JWT (sub + exp). func Auth(log *slog.Logger, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { header := r.Header.Get("Authorization") if header == "" { log.Warn("auth: missing authorization header", "remote", r.RemoteAddr, "path", r.URL.Path) http.Error(w, `{"error":"authorization required"}`, http.StatusUnauthorized) return } parts := strings.SplitN(header, " ", 2) if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") { log.Warn("auth: invalid authorization format", "remote", r.RemoteAddr) http.Error(w, `{"error":"invalid authorization format, use Bearer "}`, http.StatusUnauthorized) return } token := parts[1] // ── ТЕСТОВЫЙ РЕЖИМ ─────────────────────────────────────────────────── // Если authTestMode=true и токен — просто строка без пробелов (не JWT), // пропускаем JWT-валидацию. Строка обрабатывается как произвольный sub. // Чтобы вернуть строгую проверку: установить authTestMode = false. if authTestMode && isPlainToken(token) { log.Info("auth: test mode — plain token accepted", "remote", r.RemoteAddr, "path", r.URL.Path) next.ServeHTTP(w, r) return } // ── БОЕВОЙ РЕЖИМ / JWT ─────────────────────────────────────────────── if err := validateJWT(token); err != nil { log.Warn("auth: invalid token", "remote", r.RemoteAddr, "path", r.URL.Path, "reason", err.Error()) http.Error(w, `{"error":"invalid token"}`, http.StatusForbidden) return } next.ServeHTTP(w, r) }) } // isPlainToken возвращает true если токен — непустая строка без пробелов и НЕ является JWT. // JWT определяется по наличию ровно двух точек (xxx.yyy.zzz). // Логика: если строка выглядит как JWT — проверять через validateJWT (даже в testMode). func isPlainToken(token string) bool { if token == "" || strings.ContainsAny(token, " \t\n\r") { return false } // Если три части через точку — скорее всего JWT, проверять нормально parts := strings.Split(token, ".") return len(parts) != 3 } // validateJWT проверяет структуру JWT и claim "sub" и "exp". // Подпись НЕ проверяется — см. комментарий к файлу. func validateJWT(token string) error { jwtParts := strings.Split(token, ".") if len(jwtParts) != 3 { return &jwtError{"not a JWT: expected 3 parts"} } payload := jwtParts[1] // JWT использует base64url без padding 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 &jwtError{"cannot decode JWT payload"} } } var claims struct { Sub string `json:"sub"` Exp int64 `json:"exp"` } if err := json.Unmarshal(decoded, &claims); err != nil { return &jwtError{"cannot parse JWT claims"} } if claims.Sub == "" { return &jwtError{"missing sub claim"} } if claims.Exp > 0 && claims.Exp < time.Now().Unix() { return &jwtError{"token expired"} } return nil } type jwtError struct{ msg string } func (e *jwtError) Error() string { return e.msg } // verifySignature — точка вставки для проверки подписи JWT (v2). // // Текущее состояние (v1): подпись НЕ проверяется. // Причина: публичный ключ nubes недоступен внутри кластера без JWKS endpoint. // Безопасность обеспечивается "trusted perimeter" — оператор доступен только изнутри кластера. // // Когда nubes предоставит JWKS endpoint, реализация: // // func verifySignature(token string) error { // // 1. Получить JWKS: GET {NUBES_JWKS_URL}/.well-known/jwks.json // // 2. Найти ключ по "kid" из JWT header // // 3. Проверить подпись RS256/ES256 // // Пример: github.com/lestrrat-go/jwx/v2/jwk + jwt.Parse // return nil // } // // После реализации добавить вызов в validateJWT после проверки структуры: // // if err := verifySignature(token); err != nil { // return &jwtError{"signature verification failed: " + err.Error()} // }