diff --git a/examples/hello-node/main.tf b/examples/hello-node/main.tf index 15b7bb1..613856c 100644 --- a/examples/hello-node/main.tf +++ b/examples/hello-node/main.tf @@ -1,20 +1,24 @@ -# 2026-03-08 +# 2026-03-11 # main.tf — провайдеры. # Функции и их код определены в отдельных файлах: # http.tf — HTTP-триггер (code/handler-http.js) # job.tf — одноразовый запуск (code/handler-job.js) +# +# nubes_endpoint — провайдер делает GET запрос для валидации токена. +# Namespace вычисляется автоматически из JWT sub: sless-{sha256[:8]} terraform { required_providers { sless = { source = "terra.k8c.ru/naeel/sless" - version = "~> 0.1.11" + version = "~> 0.1.12" } } } provider "sless" { - endpoint = "https://sless-api.kube5s.ru" - token = "dev-token-change-me" + endpoint = "https://sless-api.kube5s.ru" + token = file("${path.module}/../../secrets/prod.token") + nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1" } diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go index 6b99ecd..97853ff 100644 --- a/internal/api/middleware/auth.go +++ b/internal/api/middleware/auth.go @@ -1,22 +1,33 @@ -// Изменено: 2026-03-07 -// Auth middleware — проверяет Bearer токен из заголовка Authorization. -// В v1: сравниваем с SLESS_API_TOKEN из конфига. -// В prod: вызываем auth-сервис nubes.ru (TODO v2). +// Изменено: 2026-03-11 +// 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. package middleware import ( + "encoding/base64" + "encoding/json" "log/slog" "net/http" "strings" + "time" ) // Auth возвращает middleware которое требует заголовок: // -// Authorization: Bearer +// Authorization: Bearer // -// и сравнивает его с allowedToken. -func Auth(allowedToken string, log *slog.Logger, next http.Handler) http.Handler { +// Проверяет: структура JWT (3 части), наличие "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 == "" { @@ -30,11 +41,54 @@ func Auth(allowedToken string, log *slog.Logger, next http.Handler) http.Handler http.Error(w, `{"error":"invalid authorization format, use Bearer "}`, http.StatusUnauthorized) return } - if parts[1] != allowedToken { - log.Warn("auth: invalid token", "remote", r.RemoteAddr, "path", r.URL.Path) + if err := validateJWT(parts[1]); 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) }) } + +// 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 } + diff --git a/internal/api/router.go b/internal/api/router.go index 7014f7d..042fa59 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -16,9 +16,9 @@ import ( ) // NewRouter собирает gorilla/mux роутер со всеми маршрутами. -// apiToken — статический Bearer-токен для v1 аутентификации. // /fn/{namespace}/{name} — публичный прокси для вызова функций, без auth. -func NewRouter(h *handler.Handler, apiToken string, log *slog.Logger) http.Handler { +// /v1/ — защищён JWT-аутентификацией (middleware.Auth). +func NewRouter(h *handler.Handler, log *slog.Logger) http.Handler { r := mux.NewRouter() // Публичный прокси для вызова HTTP-триггеров — без auth токена @@ -57,7 +57,7 @@ func NewRouter(h *handler.Handler, apiToken string, log *slog.Logger) http.Handl // /fn/ — без auth, /v1/ — с auth. // Используем gorilla/mux Use() чтобы auth применялся только к v1 суброутеру. v1.Use(func(next http.Handler) http.Handler { - return middleware.Auth(apiToken, log, next) + return middleware.Auth(log, next) }) return middleware.Logging(log, r) diff --git a/main.go b/main.go index cda83f4..150bdfb 100644 --- a/main.go +++ b/main.go @@ -175,7 +175,7 @@ func main() { S3: s3Client, PG: pg, Log: log, - }, cfg.APIToken, log) + }, log) go func() { addr := fmt.Sprintf(":%d", cfg.APIPort)