feat: монолит iot-service (Фаза 1) — config/store/api/bridge/consumer без k8s, устройства в PG, новые Dockerfile/Makefile

This commit is contained in:
“Naeel”
2026-08-16 10:07:51 +04:00
parent f0c36a11ee
commit 9d08473d5f
27 changed files with 3716 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
// Package middleware — HTTP-промежуточные слои API.
package middleware
import (
"encoding/base64"
"log/slog"
"net/http"
"strings"
)
// Auth — проверка Bearer-токена.
//
// authTestMode (env AUTH_TEST_MODE, дефолт false):
// - true — принимается любая строка без пробелов (для локальных тестов);
// - false — структурная проверка JWT (sub + exp), как в старом коде.
//
// В новой архитектуре подпись JWT не проверяется (как и раньше): внешний
// периметр обеспечивает платформа, полная валидация — на стороне шлюза.
func Auth(authTestMode bool, 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]
if authTestMode && isPlainToken(token) {
log.Info("auth: test mode — plain token accepted", "remote", r.RemoteAddr, "path", r.URL.Path)
next.ServeHTTP(w, r)
return
}
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 — непустая строка без пробелов, не похожая на JWT (три части через точку).
func isPlainToken(token string) bool {
if token == "" || strings.ContainsAny(token, " \t\n\r") {
return false
}
return len(strings.Split(token, ".")) != 3
}
// jwtError — структурная ошибка JWT.
type jwtError struct{ msg string }
func (e *jwtError) Error() string { return e.msg }
// validateJWT проверяет структуру JWT: три части, корректный payload, 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]
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 float64 `json:"exp"`
}
if err := jsonUnmarshal(decoded, &claims); err != nil {
return &jwtError{"cannot parse JWT payload"}
}
if claims.Sub == "" {
return &jwtError{"JWT missing sub claim"}
}
if claims.Exp > 0 {
if now := float64(timeNow().Unix()); claims.Exp < now {
return &jwtError{"JWT expired"}
}
}
return nil
}
@@ -0,0 +1,15 @@
package middleware
import (
"encoding/json"
"time"
)
// jsonUnmarshal и timeNow вынесены для простоты тестирования.
func jsonUnmarshal(data []byte, v any) error {
return json.Unmarshal(data, v)
}
func timeNow() time.Time {
return time.Now()
}
@@ -0,0 +1,35 @@
// Package middleware — HTTP-промежуточные слои API.
package middleware
import (
"log/slog"
"net/http"
"time"
)
// responseWriter перехватывает статус ответа для логирования.
type responseWriter struct {
http.ResponseWriter
status int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.status = code
rw.ResponseWriter.WriteHeader(code)
}
// Logging логирует каждый HTTP-запрос: метод, путь, статус, длительность.
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,
)
})
}