-
Notifications
You must be signed in to change notification settings - Fork 54
[service] Bearer HTTP auth for admin/debug HTTP services #202
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
45e6cc9
a61334b
365cc5c
559738d
23eb10a
badea52
9f7dfdb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| #include "kv_cache_manager/service/http_service/auth/auth_util.h" | ||
|
|
||
| namespace kv_cache_manager { | ||
|
|
||
| bool AuthUtil::ConstantTimeEquals(std::string_view a, std::string_view b) { | ||
| if (a.size() != b.size()) { | ||
| return false; | ||
| } | ||
| unsigned char diff = 0; | ||
| for (std::size_t i = 0; i < a.size(); ++i) { | ||
| diff |= static_cast<unsigned char>(a[i]) ^ static_cast<unsigned char>(b[i]); | ||
| } | ||
| return diff == 0; | ||
| } | ||
|
|
||
| bool AuthUtil::ICaseEqualsAscii(std::string_view a, std::string_view b) { | ||
| if (a.size() != b.size()) { | ||
| return false; | ||
| } | ||
| for (std::size_t i = 0; i < a.size(); ++i) { | ||
| unsigned char ca = static_cast<unsigned char>(a[i]); | ||
| unsigned char cb = static_cast<unsigned char>(b[i]); | ||
| if (ca >= 'A' && ca <= 'Z') { | ||
| ca = static_cast<unsigned char>(ca + ('a' - 'A')); | ||
| } | ||
| if (cb >= 'A' && cb <= 'Z') { | ||
| cb = static_cast<unsigned char>(cb + ('a' - 'A')); | ||
| } | ||
| if (ca != cb) { | ||
| return false; | ||
| } | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| } // namespace kv_cache_manager |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| #pragma once | ||
|
|
||
| #include <string_view> | ||
|
|
||
| namespace kv_cache_manager { | ||
|
|
||
| class AuthUtil { | ||
| public: | ||
| // length-revealing constant-time equality compare; returns true | ||
| // iff a and b have the same length and the same bytes. the | ||
| // comparison cost is O(min(len(a), len(b))) regardless of where | ||
| // the first mismatch occurs, defeating naive timing oracles on | ||
| // the matching prefix. callers should keep secrets at a | ||
| // bounded length to avoid leaking length itself | ||
| static bool ConstantTimeEquals(std::string_view a, std::string_view b); | ||
|
|
||
| // case-insensitive ASCII equality, used to match the scheme | ||
| // name "Bearer" per RFC 7235 §2.1 (scheme is case-insensitive) | ||
| static bool ICaseEqualsAscii(std::string_view a, std::string_view b); | ||
| }; | ||
|
|
||
| } // namespace kv_cache_manager | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| #include "kv_cache_manager/service/http_service/auth/static_bearer_token_verifier.h" | ||
|
|
||
| #include <utility> | ||
|
|
||
| #include "kv_cache_manager/service/http_service/auth/auth_util.h" | ||
|
|
||
| namespace kv_cache_manager { | ||
|
|
||
| namespace { | ||
|
|
||
| // trim ASCII SP/HTAB at both ends | ||
| std::string_view TrimOWS(std::string_view sv) { | ||
| while (!sv.empty() && (sv.front() == ' ' || sv.front() == '\t')) { | ||
| sv.remove_prefix(1); | ||
| } | ||
| while (!sv.empty() && (sv.back() == ' ' || sv.back() == '\t')) { | ||
| sv.remove_suffix(1); | ||
| } | ||
| return sv; | ||
| } | ||
|
|
||
| } // namespace | ||
|
|
||
| StaticBearerTokenVerifier::StaticBearerTokenVerifier(std::vector<std::string> accepted_tokens, std::string realm) | ||
| : tokens_(std::move(accepted_tokens)), realm_(std::move(realm)) {} | ||
|
|
||
| AuthOutcome StaticBearerTokenVerifier::Verify(std::string_view authz_header) const { | ||
| // RFC 7235 §4.2: an absent Authorization header means "no | ||
| // credentials supplied" | ||
| auto h = TrimOWS(authz_header); | ||
| if (h.empty()) { | ||
| return AuthOutcome::kMissingCredentials; | ||
| } | ||
|
|
||
| // scheme (RFC 7235 §2.1): scheme is case-insensitive, followed | ||
| // by 1*SP and a token68 | ||
| constexpr std::string_view kScheme = "Bearer"; | ||
| if (h.size() < kScheme.size() + 1) { | ||
| return AuthOutcome::kInvalidRequest; | ||
| } | ||
| if (!AuthUtil::ICaseEqualsAscii(h.substr(0, kScheme.size()), kScheme)) { | ||
| return AuthOutcome::kInvalidRequest; | ||
| } | ||
| char sep = h[kScheme.size()]; | ||
| if (sep != ' ' && sep != '\t') { | ||
| // adjacent token without separator (e.g. "BearerXYZ") is | ||
| // not a valid Bearer credential | ||
| return AuthOutcome::kInvalidRequest; | ||
| } | ||
|
|
||
| auto rest = h.substr(kScheme.size()); | ||
| // skip 1*SP (also tolerate HTAB; some clients use it) | ||
| std::size_t i = 0; | ||
| while (i < rest.size() && (rest[i] == ' ' || rest[i] == '\t')) { | ||
| ++i; | ||
| } | ||
| if (i == 0 || i == rest.size()) { | ||
|
oldsharp marked this conversation as resolved.
|
||
| return AuthOutcome::kInvalidRequest; | ||
| } | ||
| auto token = rest.substr(i); | ||
| // token68 has no internal whitespace; reject if any | ||
| if (token.find_first_of(" \t") != std::string_view::npos) { | ||
| return AuthOutcome::kInvalidRequest; | ||
| } | ||
|
|
||
| for (const auto &accepted : tokens_) { | ||
| if (AuthUtil::ConstantTimeEquals(token, accepted)) { | ||
| return AuthOutcome::kOk; | ||
| } | ||
| } | ||
| return AuthOutcome::kInvalidToken; | ||
| } | ||
|
|
||
| } // namespace kv_cache_manager | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| #pragma once | ||
|
|
||
| #include <string> | ||
| #include <string_view> | ||
| #include <vector> | ||
|
|
||
| #include "kv_cache_manager/service/http_service/auth/token_verifier.h" | ||
|
|
||
| namespace kv_cache_manager { | ||
|
|
||
| // verifies HTTP Authorization headers carrying a Bearer token (RFC | ||
| // 6750) against a fixed list of accepted tokens. multiple tokens | ||
| // allow zero-downtime rotation: deploy with both old and new in the | ||
| // list, switch clients, then remove the old one | ||
| class StaticBearerTokenVerifier : public TokenVerifier { | ||
| public: | ||
| explicit StaticBearerTokenVerifier(std::vector<std::string> accepted_tokens, std::string realm = "kvcm"); | ||
|
oldsharp marked this conversation as resolved.
|
||
|
|
||
| AuthOutcome Verify(std::string_view authz_header) const override; | ||
| std::string Realm() const override { return realm_; } | ||
|
|
||
| private: | ||
| std::vector<std::string> tokens_; | ||
| std::string realm_; | ||
| }; | ||
|
|
||
| } // namespace kv_cache_manager | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| #pragma once | ||
|
|
||
| #include <string> | ||
| #include <string_view> | ||
|
|
||
| namespace kv_cache_manager { | ||
|
|
||
| // outcome of an authorization attempt; mapped to RFC 6750 §3.1 | ||
| // WWW-Authenticate `error` parameter values | ||
| enum class AuthOutcome { | ||
| kOk, | ||
| kMissingCredentials, // no Authorization header present | ||
| kInvalidRequest, // header malformed or scheme not Bearer | ||
| kInvalidToken, // scheme is Bearer but token not accepted | ||
| }; | ||
|
|
||
| class TokenVerifier { | ||
| public: | ||
| virtual ~TokenVerifier() = default; | ||
|
|
||
| // verify the raw value of an HTTP Authorization header | ||
| // (may be empty if the client sent no header) | ||
| virtual AuthOutcome Verify(std::string_view authz_header) const = 0; | ||
|
|
||
| // realm advertised in the WWW-Authenticate response header | ||
| virtual std::string Realm() const { return "kvcm"; } | ||
| }; | ||
|
|
||
| } // namespace kv_cache_manager |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -40,11 +40,14 @@ bool CoroHttpService::Start(int32_t port, size_t thread_num) { | |
| server_ = std::make_unique<coro_http::coro_http_server>(thread_num, static_cast<unsigned short>(port), "0.0.0.0"); | ||
|
|
||
| // 注册所有 GET/POST handler | ||
| // chain order: logger(auth(handler)) — so 401 responses produced | ||
| // by the auth middleware still go through the request/response | ||
| // logger for audit purposes | ||
| for (const auto &[path, handler] : get_handlers_) { | ||
| server_->set_http_handler<coro_http::GET>(path, WrapWithLogger(path, handler)); | ||
| server_->set_http_handler<coro_http::GET>(path, WrapWithLogger(path, WrapWithAuth(path, handler))); | ||
| } | ||
| for (const auto &[path, handler] : post_handlers_) { | ||
| server_->set_http_handler<coro_http::POST>(path, WrapWithLogger(path, handler)); | ||
| server_->set_http_handler<coro_http::POST>(path, WrapWithLogger(path, WrapWithAuth(path, handler))); | ||
| } | ||
|
|
||
| auto ec = server_->async_start().get(); // 注意这里用 get() | ||
|
|
@@ -101,4 +104,58 @@ CoroHttpService::HandlerType CoroHttpService::WrapWithLogger(const std::string & | |
| }; | ||
| } | ||
|
|
||
| void CoroHttpService::SetTokenVerifier(std::shared_ptr<TokenVerifier> verifier) { | ||
| token_verifier_ = std::move(verifier); | ||
| } | ||
|
|
||
| CoroHttpService::HandlerType CoroHttpService::WrapWithAuth(const std::string &api, HandlerType handler) { | ||
| // when no verifier is configured the service runs in open mode; | ||
| // return the handler unchanged so there is zero per-request cost | ||
| if (!token_verifier_) { | ||
| return handler; | ||
| } | ||
| auto verifier = token_verifier_; | ||
| return [api, handler, verifier](coro_http::coro_http_request &req, | ||
| coro_http::coro_http_response &res) -> async_simple::coro::Lazy<void> { | ||
| auto authz = req.get_header_value("Authorization"); | ||
|
oldsharp marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. HTTP header field names are case-insensitive (RFC 9110 §5.1), but 🤖 Generated by Qoder 🤖 Generated by Qoder |
||
| auto outcome = verifier->Verify(authz); | ||
| if (outcome == AuthOutcome::kOk) { | ||
| co_await handler(req, res); | ||
| co_return; | ||
| } | ||
|
|
||
| // RFC 6750 §3: respond with 401 and a WWW-Authenticate | ||
| // challenge advertising the Bearer scheme. the optional | ||
| // `error` parameter distinguishes malformed requests from | ||
| // bad tokens; absent credentials get the bare challenge | ||
| std::string www_auth = "Bearer realm=\"" + verifier->Realm() + "\""; | ||
| const char *err = nullptr; | ||
| switch (outcome) { | ||
| case AuthOutcome::kInvalidRequest: | ||
| err = "invalid_request"; | ||
| break; | ||
| case AuthOutcome::kInvalidToken: | ||
| err = "invalid_token"; | ||
| break; | ||
| case AuthOutcome::kMissingCredentials: | ||
| case AuthOutcome::kOk: | ||
| break; | ||
| } | ||
| if (err != nullptr) { | ||
| www_auth += ", error=\""; | ||
| www_auth += err; | ||
| www_auth += "\""; | ||
| } | ||
| res.add_header("WWW-Authenticate", www_auth); | ||
| res.add_header("Content-Type", "application/json"); | ||
| res.set_status_and_content(coro_http::status_type::unauthorized, std::string(R"({"error":"unauthorized"})")); | ||
|
|
||
| KVCM_LOG_WARN("[AUTH] denied api=%s outcome=%d ip=%s", | ||
|
oldsharp marked this conversation as resolved.
|
||
| api.c_str(), | ||
| static_cast<int>(outcome), | ||
| GetHttpClientIp(req.get_conn()).c_str()); | ||
| co_return; | ||
| }; | ||
| } | ||
|
|
||
| } // namespace kv_cache_manager | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package(default_visibility = ["//visibility:private"]) | ||
|
|
||
| cc_test( | ||
| name = "AuthUtilTest", | ||
| srcs = [ | ||
| "auth_util_test.cc", | ||
| ], | ||
| copts = ["-std=c++20"], | ||
| deps = [ | ||
| "//kv_cache_manager/common:unittest", | ||
| "//kv_cache_manager/service/http_service:auth", | ||
| ], | ||
| ) | ||
|
|
||
| cc_test( | ||
| name = "StaticBearerTokenVerifierTest", | ||
| srcs = [ | ||
| "static_bearer_token_verifier_test.cc", | ||
| ], | ||
| copts = ["-std=c++20"], | ||
| deps = [ | ||
| "//kv_cache_manager/common:unittest", | ||
| "//kv_cache_manager/service/http_service:auth", | ||
| ], | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| #include "kv_cache_manager/common/unittest.h" | ||
| #include "kv_cache_manager/service/http_service/auth/auth_util.h" | ||
|
|
||
| using namespace kv_cache_manager; | ||
|
|
||
| class AuthUtilTest : public TESTBASE {}; | ||
|
|
||
| TEST_F(AuthUtilTest, ConstantTimeEqualsBasic) { | ||
| ASSERT_TRUE(AuthUtil::ConstantTimeEquals("", "")); | ||
| ASSERT_TRUE(AuthUtil::ConstantTimeEquals("abc", "abc")); | ||
| ASSERT_FALSE(AuthUtil::ConstantTimeEquals("abc", "abd")); | ||
| } | ||
|
|
||
| TEST_F(AuthUtilTest, ConstantTimeEqualsLengthMismatch) { | ||
| ASSERT_FALSE(AuthUtil::ConstantTimeEquals("abc", "abcd")); | ||
| ASSERT_FALSE(AuthUtil::ConstantTimeEquals("abcd", "abc")); | ||
| ASSERT_FALSE(AuthUtil::ConstantTimeEquals("", "x")); | ||
| } | ||
|
|
||
| TEST_F(AuthUtilTest, ConstantTimeEqualsBinarySafe) { | ||
| std::string a("ab\0cd", 5); | ||
| std::string b("ab\0cd", 5); | ||
| std::string c("ab\0ce", 5); | ||
| ASSERT_TRUE(AuthUtil::ConstantTimeEquals(a, b)); | ||
| ASSERT_FALSE(AuthUtil::ConstantTimeEquals(a, c)); | ||
| } | ||
|
|
||
| TEST_F(AuthUtilTest, ICaseEqualsAscii) { | ||
| ASSERT_TRUE(AuthUtil::ICaseEqualsAscii("Bearer", "bearer")); | ||
| ASSERT_TRUE(AuthUtil::ICaseEqualsAscii("BEARER", "bearer")); | ||
| ASSERT_TRUE(AuthUtil::ICaseEqualsAscii("BeArEr", "bEaReR")); | ||
| ASSERT_FALSE(AuthUtil::ICaseEqualsAscii("Bearer", "Basic")); | ||
| ASSERT_FALSE(AuthUtil::ICaseEqualsAscii("Bearer", "Bearers")); | ||
| ASSERT_FALSE(AuthUtil::ICaseEqualsAscii("", "x")); | ||
| ASSERT_TRUE(AuthUtil::ICaseEqualsAscii("", "")); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.