From c3492cb0f1aa48e5318bff34cb802a94f4fa907a Mon Sep 17 00:00:00 2001 From: Repinoid Date: Sat, 10 Oct 2026 19:12:48 +0300 Subject: [PATCH] =?UTF-8?q?fix(iot):=20JWT=20=E2=80=94=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=8F=D0=B7=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?exp,=20401=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20403,=20guar?= =?UTF-8?q?d=20=D1=82=D0=B5=D1=81=D1=82-=D1=80=D0=B5=D0=B6=D0=B8=D0=BC?= =?UTF-8?q?=D0=B0,=20JSON-=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8=20(=D0=AD?= =?UTF-8?q?=D1=82=D0=B0=D0=BF=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validateJWT: exp теперь ОБЯЗАТЕЛЕН (без него токен был вечным). - middleware: невалидный/просроченный токен → 401 (было 403) — клиент/Terraform отличают «протух» от «чужой namespace» (там осталось 403). - AUTH_TEST_MODE игнорируется при заданном JWT_HMAC_SECRET (иначе обходил подпись). - Все ошибки middleware — application/json через authErr (были text/plain). --- internal/service/api/middleware/auth.go | 37 ++++++++++++++++++------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/internal/service/api/middleware/auth.go b/internal/service/api/middleware/auth.go index cb997df..b1ef793 100644 --- a/internal/service/api/middleware/auth.go +++ b/internal/service/api/middleware/auth.go @@ -19,23 +19,27 @@ import ( // - 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) - http.Error(w, `{"error":"authorization required"}`, http.StatusUnauthorized) + 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) - http.Error(w, `{"error":"invalid authorization format, use Bearer "}`, http.StatusUnauthorized) + authErr(w, http.StatusUnauthorized, "invalid authorization format, use Bearer ") return } 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) next.ServeHTTP(w, r) return @@ -45,14 +49,16 @@ func Auth(authTestMode bool, hmacSecret string, log *slog.Logger, next http.Hand // токен можно подделать — отказываем в обслуживании, а не проверяем «структурно». if !authTestMode && hmacSecret == "" { 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 } 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()) - http.Error(w, `{"error":"invalid token"}`, http.StatusForbidden) + authErr(w, http.StatusUnauthorized, "invalid token") return } @@ -60,7 +66,7 @@ func Auth(authTestMode bool, hmacSecret string, log *slog.Logger, next http.Hand // Иначе любой валидный токен читал/писал бы любой 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) - http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + authErr(w, http.StatusForbidden, "forbidden") 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 (три части через точку). func isPlainToken(token string) bool { if token == "" || strings.ContainsAny(token, " \t\n\r") { @@ -137,10 +151,13 @@ func validateJWT(token, secret string) (string, error) { 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"} - } + // 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 }