feat: перенос IoT managed service из sless в отдельную репу
Компоненты: - iot-operator: controller-manager (IoTDevice CRD) + REST API (порт 9090) - mqtt-bridge: MQTT (EMQX) → Kafka bridge - kafka-consumer: Kafka → Postgres pipeline Модуль: gitea.services.ngcloud.ru/Nail/IoT Все 3 бинарника собираются, import paths адаптированы.
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
// Изменено: 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 <token>
|
||||
//
|
||||
// В тестовом режиме (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 <token>"}`, 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()}
|
||||
// }
|
||||
@@ -0,0 +1,38 @@
|
||||
// Изменено: 2026-03-07
|
||||
// Logging middleware — логирует каждый HTTP запрос через slog.
|
||||
// Записывает метод, путь, статус и длительность.
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// responseWriter оборачивает http.ResponseWriter чтобы захватить статус ответа.
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.status = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// Logging возвращает middleware которое логирует все запросы через slog.
|
||||
func Logging(log *slog.Logger, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(rw, r)
|
||||
log.Info("http",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", rw.status,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
"remote", r.RemoteAddr,
|
||||
)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user