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 }