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).
This commit is contained in:
Repinoid
2026-10-10 19:12:48 +03:00
parent 6ff5576e7d
commit c3492cb0f1
+25 -8
View File
@@ -19,23 +19,27 @@ import (
// - false — проверка JWT: sub + exp; если задан hmacSecret — обязательна // - false — проверка JWT: sub + exp; если задан hmacSecret — обязательна
// HS256-подпись (HMAC-SHA256, env JWT_HMAC_SECRET). Без секрета — // HS256-подпись (HMAC-SHA256, env JWT_HMAC_SECRET). Без секрета —
// структурная проверка (периметр обеспечивает платформа). // структурная проверка (периметр обеспечивает платформа).
//
// Тест-режим игнорируется при заданном hmacSecret (иначе обходил бы подпись).
func Auth(authTestMode bool, hmacSecret string, log *slog.Logger, next http.Handler) http.Handler { func Auth(authTestMode bool, hmacSecret string, 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 == "" {
log.Warn("auth: missing authorization header", "remote", r.RemoteAddr, "path", r.URL.Path) log.Warn("auth: missing authorization header", "remote", r.RemoteAddr, "path", r.URL.Path)
http.Error(w, `{"error":"authorization required"}`, http.StatusUnauthorized) authErr(w, http.StatusUnauthorized, "authorization required")
return return
} }
parts := strings.SplitN(header, " ", 2) parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") { if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
log.Warn("auth: invalid authorization format", "remote", r.RemoteAddr) log.Warn("auth: invalid authorization format", "remote", r.RemoteAddr)
http.Error(w, `{"error":"invalid authorization format, use Bearer <token>"}`, http.StatusUnauthorized) authErr(w, http.StatusUnauthorized, "invalid authorization format, use Bearer <token>")
return return
} }
token := parts[1] token := parts[1]
if authTestMode && isPlainToken(token) { // Тест-режим только без секрета: при заданном 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) log.Info("auth: test mode — plain token accepted", "remote", r.RemoteAddr, "path", r.URL.Path)
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
@@ -45,14 +49,16 @@ func Auth(authTestMode bool, hmacSecret string, log *slog.Logger, next http.Hand
// токен можно подделать — отказываем в обслуживании, а не проверяем «структурно». // токен можно подделать — отказываем в обслуживании, а не проверяем «структурно».
if !authTestMode && hmacSecret == "" { if !authTestMode && hmacSecret == "" {
log.Error("auth: JWT_HMAC_SECRET не задан при AUTH_TEST_MODE=false — отказ (fail-closed)") log.Error("auth: JWT_HMAC_SECRET не задан при AUTH_TEST_MODE=false — отказ (fail-closed)")
http.Error(w, `{"error":"auth misconfigured"}`, http.StatusInternalServerError) authErr(w, http.StatusInternalServerError, "auth misconfigured")
return return
} }
sub, err := validateJWT(token, hmacSecret) sub, err := validateJWT(token, hmacSecret)
if err != nil { if err != nil {
// 401, а не 403: невалидный/просроченный токен — не аутентифицирован
// (клиент/Terraform должны отличать «протух» от «чужой namespace»).
log.Warn("auth: invalid token", "remote", r.RemoteAddr, "path", r.URL.Path, "reason", err.Error()) log.Warn("auth: invalid token", "remote", r.RemoteAddr, "path", r.URL.Path, "reason", err.Error())
http.Error(w, `{"error":"invalid token"}`, http.StatusForbidden) authErr(w, http.StatusUnauthorized, "invalid token")
return return
} }
@@ -60,7 +66,7 @@ func Auth(authTestMode bool, hmacSecret string, log *slog.Logger, next http.Hand
// Иначе любой валидный токен читал/писал бы любой namespace (горизонтальный доступ). // Иначе любой валидный токен читал/писал бы любой namespace (горизонтальный доступ).
if ns := mux.Vars(r)["namespace"]; ns != "" && ns != sub { 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) log.Warn("auth: namespace mismatch — forbidden", "remote", r.RemoteAddr, "sub", sub, "namespace", ns, "path", r.URL.Path)
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) authErr(w, http.StatusForbidden, "forbidden")
return return
} }
@@ -68,6 +74,14 @@ func Auth(authTestMode bool, hmacSecret string, log *slog.Logger, next http.Hand
}) })
} }
// 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 (три части через точку). // isPlainToken — непустая строка без пробелов, не похожая на JWT (три части через точку).
func isPlainToken(token string) bool { func isPlainToken(token string) bool {
if token == "" || strings.ContainsAny(token, " \t\n\r") { if token == "" || strings.ContainsAny(token, " \t\n\r") {
@@ -137,10 +151,13 @@ func validateJWT(token, secret string) (string, error) {
if claims.Sub == "" { if claims.Sub == "" {
return "", &jwtError{"JWT missing sub claim"} return "", &jwtError{"JWT missing sub claim"}
} }
if claims.Exp > 0 { // 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 { if now := float64(timeNow().Unix()); claims.Exp < now {
return "", &jwtError{"JWT expired"} return "", &jwtError{"JWT expired"}
} }
}
return claims.Sub, nil return claims.Sub, nil
} }