// 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 ") 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 }