diff --git a/internal/service/api/middleware/auth.go b/internal/service/api/middleware/auth.go index 54cec01..cb997df 100644 --- a/internal/service/api/middleware/auth.go +++ b/internal/service/api/middleware/auth.go @@ -8,6 +8,8 @@ import ( "log/slog" "net/http" "strings" + + "github.com/gorilla/mux" ) // Auth — проверка Bearer-токена. @@ -39,11 +41,29 @@ func Auth(authTestMode bool, hmacSecret string, log *slog.Logger, next http.Hand return } - if err := validateJWT(token, hmacSecret); err != nil { + // Fail-closed: вне тестового режима подпись ОБЯЗАТЕЛЬНА. Без секрета + // токен можно подделать — отказываем в обслуживании, а не проверяем «структурно». + if !authTestMode && hmacSecret == "" { + log.Error("auth: JWT_HMAC_SECRET не задан при AUTH_TEST_MODE=false — отказ (fail-closed)") + http.Error(w, `{"error":"auth misconfigured"}`, http.StatusInternalServerError) + return + } + + sub, err := validateJWT(token, hmacSecret) + if err != nil { log.Warn("auth: invalid token", "remote", r.RemoteAddr, "path", r.URL.Path, "reason", err.Error()) http.Error(w, `{"error":"invalid token"}`, http.StatusForbidden) 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) + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + next.ServeHTTP(w, r) }) } @@ -62,11 +82,11 @@ type jwtError struct{ msg string } func (e *jwtError) Error() string { return e.msg } // validateJWT проверяет структуру JWT (sub + exp) и, если задан secret, -// HS256-подпись. Без secret подпись не проверяется — см. комментарий Auth. -func validateJWT(token, secret string) error { +// 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"} + return "", &jwtError{"not a JWT: expected 3 parts"} } if secret != "" { @@ -78,17 +98,17 @@ func validateJWT(token, secret string) error { 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"} + 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"} + return "", &jwtError{"cannot decode JWT signature"} } if !hmac.Equal(expected, sig) { - return &jwtError{"JWT signature mismatch"} + return "", &jwtError{"JWT signature mismatch"} } } @@ -104,7 +124,7 @@ func validateJWT(token, secret string) error { decoded, err = base64.StdEncoding.DecodeString(payload) } if err != nil { - return &jwtError{"cannot decode JWT payload"} + return "", &jwtError{"cannot decode JWT payload"} } var claims struct { @@ -112,15 +132,15 @@ func validateJWT(token, secret string) error { Exp float64 `json:"exp"` } if err := jsonUnmarshal(decoded, &claims); err != nil { - return &jwtError{"cannot parse JWT payload"} + return "", &jwtError{"cannot parse JWT payload"} } if claims.Sub == "" { - return &jwtError{"JWT missing sub claim"} + return "", &jwtError{"JWT missing sub claim"} } if claims.Exp > 0 { if now := float64(timeNow().Unix()); claims.Exp < now { - return &jwtError{"JWT expired"} + return "", &jwtError{"JWT expired"} } } - return nil + return claims.Sub, nil } diff --git a/internal/service/config/config.go b/internal/service/config/config.go index 56583dd..8d2b0f1 100644 --- a/internal/service/config/config.go +++ b/internal/service/config/config.go @@ -149,6 +149,11 @@ func Load() (*Config, error) { if cfg.MQTTPassword == "" { missing = append(missing, "MQTT_PASSWORD") } + // Вне тестового режима JWT_HMAC_SECRET ОБЯЗАТЕЛЕН: без него подпись не + // проверяется и токен можно подделать (fail-closed на старте). + if !cfg.AuthTestMode && cfg.JwtHMACSecret == "" { + missing = append(missing, "JWT_HMAC_SECRET (или включите AUTH_TEST_MODE для тестов)") + } if len(missing) > 0 { return nil, fmt.Errorf("required env vars not set: %s", strings.Join(missing, ", ")) }