feat: JWT auth in operator + hello-node example updated

- operator: auth middleware теперь валидирует JWT (sub+exp), не статический токен
- operator: ensureNamespace идемпотентен при race condition (IsAlreadyExists)
- operator: NewRouter убран параметр apiToken — больше не нужен
- examples/hello-node: prod.token + nubes_endpoint + версия провайдера 0.1.12
- протестировано: namespace sless-cdd874dfa31ba6ca создан автоматически
This commit is contained in:
“Naeel”
2026-03-11 07:51:06 +04:00
parent f41cd39b26
commit 5ae2ee7f85
4 changed files with 75 additions and 17 deletions
+7 -3
View File
@@ -1,20 +1,24 @@
# 2026-03-08 # 2026-03-11
# main.tf — провайдеры. # main.tf — провайдеры.
# Функции и их код определены в отдельных файлах: # Функции и их код определены в отдельных файлах:
# http.tf — HTTP-триггер (code/handler-http.js) # http.tf — HTTP-триггер (code/handler-http.js)
# job.tf — одноразовый запуск (code/handler-job.js) # job.tf — одноразовый запуск (code/handler-job.js)
#
# nubes_endpoint — провайдер делает GET запрос для валидации токена.
# Namespace вычисляется автоматически из JWT sub: sless-{sha256[:8]}
terraform { terraform {
required_providers { required_providers {
sless = { sless = {
source = "terra.k8c.ru/naeel/sless" source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.11" version = "~> 0.1.12"
} }
} }
} }
provider "sless" { provider "sless" {
endpoint = "https://sless-api.kube5s.ru" endpoint = "https://sless-api.kube5s.ru"
token = "dev-token-change-me" token = file("${path.module}/../../secrets/prod.token")
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1"
} }
+63 -9
View File
@@ -1,22 +1,33 @@
// Изменено: 2026-03-07 // Изменено: 2026-03-11
// Auth middleware — проверяет Bearer токен из заголовка Authorization. // Auth middleware — проверяет Bearer JWT-токен из заголовка Authorization.
// В v1: сравниваем с SLESS_API_TOKEN из конфига. //
// В prod: вызываем auth-сервис nubes.ru (TODO v2). // Архитектура аутентификации:
// - В v1 токен — это JWT облака (nubes), выданный при логине.
// - Оператор НЕ проверяет подпись JWT (публичный ключ nubes недоступен внутри кластера).
// - Проверяется только структура JWT и claim "sub" (не пустой) и "exp" (не истёк).
// - Полная проверка подлинности токена происходит в провайдере terraform через PingNubesAPI.
// - Такой подход называют "trusted perimeter": оператор доступен только внутри кластера,
// внешний доступ — через Ingress, где токен уже проверен на уровне API-шлюза.
//
// TODO v2: получать публичный ключ из nubes JWKS endpoint и проверять подпись RS256.
package middleware package middleware
import ( import (
"encoding/base64"
"encoding/json"
"log/slog" "log/slog"
"net/http" "net/http"
"strings" "strings"
"time"
) )
// Auth возвращает middleware которое требует заголовок: // Auth возвращает middleware которое требует заголовок:
// //
// Authorization: Bearer <token> // Authorization: Bearer <jwt>
// //
// и сравнивает его с allowedToken. // Проверяет: структура JWT (3 части), наличие "sub", отсутствие истечения "exp".
func Auth(allowedToken string, log *slog.Logger, next http.Handler) http.Handler { func Auth(log *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization") header := r.Header.Get("Authorization")
if header == "" { 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 <token>"}`, http.StatusUnauthorized) http.Error(w, `{"error":"invalid authorization format, use Bearer <token>"}`, http.StatusUnauthorized)
return return
} }
if parts[1] != allowedToken { if err := validateJWT(parts[1]); err != nil {
log.Warn("auth: invalid token", "remote", r.RemoteAddr, "path", r.URL.Path) log.Warn("auth: invalid token", "remote", r.RemoteAddr, "path", r.URL.Path, "reason", err.Error())
http.Error(w, `{"error":"invalid token"}`, http.StatusForbidden) http.Error(w, `{"error":"invalid token"}`, http.StatusForbidden)
return return
} }
next.ServeHTTP(w, r) 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 }
+3 -3
View File
@@ -16,9 +16,9 @@ import (
) )
// NewRouter собирает gorilla/mux роутер со всеми маршрутами. // NewRouter собирает gorilla/mux роутер со всеми маршрутами.
// apiToken — статический Bearer-токен для v1 аутентификации.
// /fn/{namespace}/{name} — публичный прокси для вызова функций, без auth. // /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() r := mux.NewRouter()
// Публичный прокси для вызова HTTP-триггеров — без auth токена // Публичный прокси для вызова HTTP-триггеров — без auth токена
@@ -57,7 +57,7 @@ func NewRouter(h *handler.Handler, apiToken string, log *slog.Logger) http.Handl
// /fn/ — без auth, /v1/ — с auth. // /fn/ — без auth, /v1/ — с auth.
// Используем gorilla/mux Use() чтобы auth применялся только к v1 суброутеру. // Используем gorilla/mux Use() чтобы auth применялся только к v1 суброутеру.
v1.Use(func(next http.Handler) http.Handler { v1.Use(func(next http.Handler) http.Handler {
return middleware.Auth(apiToken, log, next) return middleware.Auth(log, next)
}) })
return middleware.Logging(log, r) return middleware.Logging(log, r)
+1 -1
View File
@@ -175,7 +175,7 @@ func main() {
S3: s3Client, S3: s3Client,
PG: pg, PG: pg,
Log: log, Log: log,
}, cfg.APIToken, log) }, log)
go func() { go func() {
addr := fmt.Sprintf(":%d", cfg.APIPort) addr := fmt.Sprintf(":%d", cfg.APIPort)