Files
IoT/internal/service/api/middleware/auth.go
T
Repinoid c3492cb0f1 fix(iot): JWT — обязательный exp, 401 вместо 403, guard тест-режима, JSON-ошибки (Этап 4)
- validateJWT: exp теперь ОБЯЗАТЕЛЕН (без него токен был вечным).
- middleware: невалидный/просроченный токен → 401 (было 403) — клиент/Terraform
  отличают «протух» от «чужой namespace» (там осталось 403).
- AUTH_TEST_MODE игнорируется при заданном JWT_HMAC_SECRET (иначе обходил подпись).
- Все ошибки middleware — application/json через authErr (были text/plain).
2026-10-10 19:12:48 +03:00

164 lines
6.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package middleware — HTTP-промежуточные слои API.
package middleware
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"log/slog"
"net/http"
"strings"
"github.com/gorilla/mux"
)
// Auth — проверка Bearer-токена.
//
// authTestMode (env AUTH_TEST_MODE, дефолт false):
// - true — принимается любая строка без пробелов (для локальных тестов);
// - false — проверка JWT: sub + exp; если задан hmacSecret — обязательна
// HS256-подпись (HMAC-SHA256, env JWT_HMAC_SECRET). Без секрета —
// структурная проверка (периметр обеспечивает платформа).
//
// Тест-режим игнорируется при заданном hmacSecret (иначе обходил бы подпись).
func Auth(authTestMode bool, hmacSecret string, 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)
authErr(w, http.StatusUnauthorized, "authorization required")
return
}
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
log.Warn("auth: invalid authorization format", "remote", r.RemoteAddr)
authErr(w, http.StatusUnauthorized, "invalid authorization format, use Bearer <token>")
return
}
token := parts[1]
// Тест-режим только без секрета: при заданном JWT_HMAC_SECRET он обходил
// бы проверку подписи (ревью 2026-10-10).
if authTestMode && hmacSecret == "" && isPlainToken(token) {
log.Info("auth: test mode — plain token accepted", "remote", r.RemoteAddr, "path", r.URL.Path)
next.ServeHTTP(w, r)
return
}
// Fail-closed: вне тестового режима подпись ОБЯЗАТЕЛЬНА. Без секрета
// токен можно подделать — отказываем в обслуживании, а не проверяем «структурно».
if !authTestMode && hmacSecret == "" {
log.Error("auth: JWT_HMAC_SECRET не задан при AUTH_TEST_MODE=false — отказ (fail-closed)")
authErr(w, http.StatusInternalServerError, "auth misconfigured")
return
}
sub, err := validateJWT(token, hmacSecret)
if err != nil {
// 401, а не 403: невалидный/просроченный токен — не аутентифицирован
// (клиент/Terraform должны отличать «протух» от «чужой namespace»).
log.Warn("auth: invalid token", "remote", r.RemoteAddr, "path", r.URL.Path, "reason", err.Error())
authErr(w, http.StatusUnauthorized, "invalid token")
return
}
// Tenant-граница: `sub` из токена ОБЯЗАН совпадать с {namespace} из URL.
// Иначе любой валидный токен читал/писал бы любой namespace (горизонтальный доступ).
if ns := mux.Vars(r)["namespace"]; ns != "" && ns != sub {
log.Warn("auth: namespace mismatch — forbidden", "remote", r.RemoteAddr, "sub", sub, "namespace", ns, "path", r.URL.Path)
authErr(w, http.StatusForbidden, "forbidden")
return
}
next.ServeHTTP(w, r)
})
}
// authErr — единый JSON-ответ об ошибке аутентификации (middleware раньше отдавал
// text/plain через http.Error, а хендлеры — JSON; ревью 2026-10-10 — единый контракт).
func authErr(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_, _ = w.Write([]byte(`{"error":"` + msg + `"}`))
}
// 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 (sub + exp) и, если задан secret,
// HS256-подпись. Возвращает `sub` (идентичность пользователя) для сверки с namespace.
func validateJWT(token, secret string) (string, error) {
jwtParts := strings.Split(token, ".")
if len(jwtParts) != 3 {
return "", &jwtError{"not a JWT: expected 3 parts"}
}
if secret != "" {
var header struct {
Alg string `json:"alg"`
}
headerBytes, err := base64.RawURLEncoding.DecodeString(jwtParts[0])
if err != nil {
headerBytes, err = base64.StdEncoding.DecodeString(jwtParts[0])
}
if err != nil || jsonUnmarshal(headerBytes, &header) != nil || header.Alg != "HS256" {
return "", &jwtError{"JWT must be HS256 when JWT_HMAC_SECRET is set"}
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(jwtParts[0] + "." + jwtParts[1]))
expected := mac.Sum(nil)
sig, err := base64.RawURLEncoding.DecodeString(jwtParts[2])
if err != nil {
return "", &jwtError{"cannot decode JWT signature"}
}
if !hmac.Equal(expected, sig) {
return "", &jwtError{"JWT signature mismatch"}
}
}
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"}
}
// exp ОБЯЗАТЕЛЕН: без него токен вечный. Раньше проверка шла только при
// claims.Exp > 0, т.е. отсутствие exp = бессрочный токен (ревью 2026-10-10).
if claims.Exp == 0 {
return "", &jwtError{"JWT missing exp claim"}
}
if now := float64(timeNow().Unix()); claims.Exp < now {
return "", &jwtError{"JWT expired"}
}
return claims.Sub, nil
}