From 45b7517e53acb696b454e3236fb61b0e47398dc3 Mon Sep 17 00:00:00 2001 From: mishasychev Date: Sun, 26 Jul 2026 23:38:45 +0300 Subject: [PATCH] feat s3api: support aws signature v4 --- .../s3api/authenticators/access_key.hpp | 2 +- .../s3api/authenticators/interface.hpp | 2 +- .../s3api/authenticators/signature_v4.hpp | 51 +++ .../src/s3api/authenticators/access_key.cpp | 2 +- .../src/s3api/authenticators/signature_v4.cpp | 380 ++++++++++++++++++ .../authenticators/signature_v4_test.cpp | 160 ++++++++ .../s3api/src/s3api/authenticators/utils.cpp | 33 +- libraries/s3api/src/s3api/clients/client.cpp | 20 +- libraries/s3api/src/s3api/s3_connection.cpp | 13 +- libraries/s3api/src/s3api/s3_connection.hpp | 6 +- .../s3api/src/s3api/s3_connection_test.cpp | 18 +- 11 files changed, 633 insertions(+), 54 deletions(-) create mode 100644 libraries/s3api/include/userver/s3api/authenticators/signature_v4.hpp create mode 100644 libraries/s3api/src/s3api/authenticators/signature_v4.cpp create mode 100644 libraries/s3api/src/s3api/authenticators/signature_v4_test.cpp diff --git a/libraries/s3api/include/userver/s3api/authenticators/access_key.hpp b/libraries/s3api/include/userver/s3api/authenticators/access_key.hpp index d3399e43c9c1..9ebf184bea5a 100644 --- a/libraries/s3api/include/userver/s3api/authenticators/access_key.hpp +++ b/libraries/s3api/include/userver/s3api/authenticators/access_key.hpp @@ -20,7 +20,7 @@ class AccessKey : public Authenticator { secret_key_{std::move(secret_key)} {} std::unordered_map Auth(const Request& request) const override; - std::unordered_map Sign(const Request& request, time_t expires) const override; + std::unordered_map Sign(const Request& request, std::time_t expires) const override; private: std::string access_key_; diff --git a/libraries/s3api/include/userver/s3api/authenticators/interface.hpp b/libraries/s3api/include/userver/s3api/authenticators/interface.hpp index cf6b83a64627..f81d090f4379 100644 --- a/libraries/s3api/include/userver/s3api/authenticators/interface.hpp +++ b/libraries/s3api/include/userver/s3api/authenticators/interface.hpp @@ -21,7 +21,7 @@ namespace authenticators { /// @brief Base class for all authenticators - classes that sign the request with auth data struct Authenticator { virtual std::unordered_map Auth(const Request& request) const = 0; - virtual std::unordered_map Sign(const Request& request, time_t expires) const = 0; + virtual std::unordered_map Sign(const Request& request, std::time_t expires) const = 0; virtual ~Authenticator() = default; }; diff --git a/libraries/s3api/include/userver/s3api/authenticators/signature_v4.hpp b/libraries/s3api/include/userver/s3api/authenticators/signature_v4.hpp new file mode 100644 index 000000000000..92bf158cacd6 --- /dev/null +++ b/libraries/s3api/include/userver/s3api/authenticators/signature_v4.hpp @@ -0,0 +1,51 @@ +#pragma once + +/// @file userver/s3api/authenticators/signature_v4.hpp +/// @brief Authenticator implementing AWS Signature Version 4 + +#include +#include + +#include +#include +#include + +USERVER_NAMESPACE_BEGIN + +namespace s3api::authenticators { + +/// @brief Authenticator implementing AWS Signature Version 4. +/// +/// `Auth` signs a request with headers (`Authorization`, `X-Amz-Date`, +/// `X-Amz-Content-Sha256`), `Sign` produces query parameters for a presigned +/// URL (`X-Amz-Algorithm`, `X-Amz-Credential`, `X-Amz-Date`, `X-Amz-Expires`, +/// `X-Amz-SignedHeaders`, `X-Amz-Signature`). +/// +/// See https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html +class SignatureV4 : public Authenticator { +public: + SignatureV4(std::string access_key, Secret secret_key, std::string region, std::string service = "s3") + : access_key_{std::move(access_key)}, + secret_key_{std::move(secret_key)}, + region_{std::move(region)}, + service_{std::move(service)} + {} + + std::unordered_map Auth(const Request& request) const override; + + /// @note `expires` is an absolute unix timestamp of the moment the + /// presigned URL stops being valid, the same way as in + /// @ref AccessKey::Sign. It is converted to the `X-Amz-Expires` duration + /// relative to the current time. + std::unordered_map Sign(const Request& request, std::time_t expires) const override; + +private: + std::string access_key_; + Secret secret_key_; + std::string region_; + std::string service_; +}; + +} // namespace s3api::authenticators + +USERVER_NAMESPACE_END diff --git a/libraries/s3api/src/s3api/authenticators/access_key.cpp b/libraries/s3api/src/s3api/authenticators/access_key.cpp index fbe5b63943a3..a679f0204296 100644 --- a/libraries/s3api/src/s3api/authenticators/access_key.cpp +++ b/libraries/s3api/src/s3api/authenticators/access_key.cpp @@ -38,7 +38,7 @@ std::unordered_map AccessKey::Auth(const Request& requ return auth_headers; } -std::unordered_map AccessKey::Sign(const Request& request, time_t expires) const { +std::unordered_map AccessKey::Sign(const Request& request, std::time_t expires) const { static const std::string kExpires{"Expires"}; static const std::string kSignature{"Signature"}; static const std::string kAWSAccessKeyId{"AWSAccessKeyId"}; diff --git a/libraries/s3api/src/s3api/authenticators/signature_v4.cpp b/libraries/s3api/src/s3api/authenticators/signature_v4.cpp new file mode 100644 index 000000000000..78f5959196e1 --- /dev/null +++ b/libraries/s3api/src/s3api/authenticators/signature_v4.cpp @@ -0,0 +1,380 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +USERVER_NAMESPACE_BEGIN + +namespace s3api::authenticators { + +namespace { + +constexpr std::string_view kAlgorithm = "AWS4-HMAC-SHA256"; +constexpr std::string_view kAws4Request = "aws4_request"; +constexpr std::string_view kUnsignedPayload = "UNSIGNED-PAYLOAD"; + +bool IsUnreservedChar(char c) { + if ((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) { + return true; + } + return c == '-' || c == '_' || c == '.' || c == '~'; +} + +void PercentEncodeByteTo(unsigned char byte, std::string& result) { + static constexpr char kHexDigits[] = "0123456789ABCDEF"; + result.push_back('%'); + result.push_back(kHexDigits[byte >> 4]); + result.push_back(kHexDigits[byte & 0x0F]); +} + +std::string UriEncode(std::string_view value, bool encode_slash) { + std::string result; + result.reserve(value.size()); + + for (auto c : value) { + if (IsUnreservedChar(c) || (c == '/' && !encode_slash)) { + result.push_back(c); + } else { + PercentEncodeByteTo(static_cast(c), result); + } + } + + return result; +} + +std::optional ParseHexDigit(char c) { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'A' && c <= 'F') { + return c - 'A' + 10; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + return std::nullopt; +} + +std::string PercentDecode(std::string_view value) { + std::string result; + result.reserve(value.size()); + + for (std::size_t i = 0; i < value.size(); ++i) { + if (value[i] == '%' && i + 2 < value.size()) { + const auto high = ParseHexDigit(value[i + 1]); + const auto low = ParseHexDigit(value[i + 2]); + if (high && low) { + result.push_back(static_cast((*high * 16) + *low)); + i += 2; + continue; + } + } + result.push_back(value[i]); + } + + return result; +} + +std::string TrimAndCollapseSpaces(std::string_view value) { + std::string result; + result.reserve(value.size()); + + bool pending_space = false; + for (auto c : value) { + if (std::isspace(static_cast(c))) { + pending_space = !result.empty(); + continue; + } + + if (pending_space) { + result.push_back(' '); + pending_space = false; + } + + result.push_back(c); + } + + return result; +} + +struct RequestTarget { + std::string_view path; + std::string_view query; +}; + +RequestTarget SplitRequestTarget(const std::string& req) { + const std::string_view target{req}; + const auto query_pos = target.find('?'); + + if (query_pos == std::string_view::npos) { + return RequestTarget{ + .path = target, + .query = {}, + }; + } + + return RequestTarget{ + .path = target.substr(0, query_pos), + .query = target.substr(query_pos + 1), + }; +} + +bool IsVirtualHostAddressing(std::string_view host, std::string_view bucket) { + if (bucket.empty()) { + return true; + } + if (host.size() <= bucket.size() || host[bucket.size()] != '.') { + return false; + } + return host.substr(0, bucket.size()) == bucket; +} + +std::string MakeCanonicalUri(const Request& request, std::string_view host, std::string_view path) { + std::string raw_path; + + if (!IsVirtualHostAddressing(host, request.bucket)) { + raw_path = request.bucket + "/"; + } + + raw_path += PercentDecode(path); + + return "/" + UriEncode(raw_path, /*encode_slash=*/false); +} + +using QueryParams = std::vector>; + +QueryParams ParseQuery(std::string_view query) { + QueryParams result; + + while (!query.empty()) { + const auto param = query.substr(0, query.find('&')); + query.remove_prefix(std::min(query.size(), param.size() + 1)); + + if (param.empty()) { + continue; + } + + const auto eq_pos = param.find('='); + if (eq_pos == std::string_view::npos) { + result.emplace_back(PercentDecode(param), std::string{}); + } else { + result.emplace_back(PercentDecode(param.substr(0, eq_pos)), PercentDecode(param.substr(eq_pos + 1))); + } + } + + return result; +} + +std::string MakeCanonicalQueryString(QueryParams params) { + for (auto& [name, value] : params) { + name = UriEncode(name, /*encode_slash=*/true); + value = UriEncode(value, /*encode_slash=*/true); + } + std::ranges::sort(params); + + std::string result; + + for (const auto& [name, value] : params) { + if (!result.empty()) { + result.push_back('&'); + } + result.append(name); + result.push_back('='); + result.append(value); + } + + return result; +} + +struct CanonicalHeaders { + // "name1:value1\nname2:value2\n" with lowercase names sorted alphabetically + std::string headers; + // "name1;name2" + std::string signed_headers; +}; + +CanonicalHeaders MakeCanonicalHeaders(const std::map& headers) { + CanonicalHeaders result; + + for (const auto& [name, value] : headers) { + result.headers += fmt::format("{}:{}\n", name, value); + if (!result.signed_headers.empty()) { + result.signed_headers.push_back(';'); + } + result.signed_headers += name; + } + + return result; +} + +std::string MakeCanonicalRequest( + const Request& request, + std::string_view host, + const CanonicalHeaders& canonical_headers, + QueryParams extra_query_params, + std::string_view payload_hash +) { + const auto target = SplitRequestTarget(request.req); + + auto query_params = ParseQuery(target.query); + std::ranges::move(extra_query_params, std::back_inserter(query_params)); + + return fmt::format( + "{}\n{}\n{}\n{}\n{}\n{}", + ToStringView(request.method), + MakeCanonicalUri(request, host, target.path), + MakeCanonicalQueryString(std::move(query_params)), + canonical_headers.headers, + canonical_headers.signed_headers, + payload_hash + ); +} + +struct SigningScope { + std::time_t now{}; + std::string amz_date; + std::string date_stamp; + std::string credential_scope; +}; + +SigningScope MakeSigningScope(std::string_view region, std::string_view service) { + const auto now = utils::datetime::Now(); + + SigningScope scope; + scope.now = std::chrono::system_clock::to_time_t(now); + scope.amz_date = utils::datetime::UtcTimestring(now, "%Y%m%dT%H%M%SZ"); + scope.date_stamp = scope.amz_date.substr(0, 8); // 4 - year, 2 - month, 2 - day + scope.credential_scope = fmt::format("{}/{}/{}/{}", scope.date_stamp, region, service, kAws4Request); + + return scope; +} + +std::string MakeStringToSign(std::string_view canonical_request, const SigningScope& scope) { + return fmt::format( + "{}\n{}\n{}\n{}", + kAlgorithm, + scope.amz_date, + scope.credential_scope, + crypto::hash::Sha256(canonical_request, crypto::hash::OutputEncoding::kHex) + ); +} + +std::string MakeSignature( + std::string_view string_to_sign, + const SigningScope& scope, + std::string_view region, + std::string_view service, + const Secret& secret_key +) { + // https://docs.aws.amazon.com/AmazonS3/latest/developerguide/sigv4-query-string-auth.html#query-string-auth-v4-signing + + static constexpr auto kBinary = crypto::hash::OutputEncoding::kBinary; + + auto key = crypto::hash::HmacSha256("AWS4" + secret_key.GetUnderlying(), scope.date_stamp, kBinary); + key = crypto::hash::HmacSha256(key, region, kBinary); + key = crypto::hash::HmacSha256(key, service, kBinary); + key = crypto::hash::HmacSha256(key, kAws4Request, kBinary); + + return crypto::hash::HmacSha256(key, string_to_sign, crypto::hash::OutputEncoding::kHex); +} + +std::string GetHostHeaderValue(const Request& request) { + const auto it = request.headers.find(USERVER_NAMESPACE::http::headers::kHost); + if (it == request.headers.end() || it->second.empty()) { + throw std::runtime_error("AWS Signature V4 requires the 'Host' header, set it before signing the request"); + } + return TrimAndCollapseSpaces(it->second); +} + +} // namespace + +std::unordered_map SignatureV4::Auth(const Request& request) const { + // https://docs.aws.amazon.com/AmazonS3/latest/developerguide/sig-v4-header-based-auth.html + + const auto scope = MakeSigningScope(region_, service_); + const auto host = GetHostHeaderValue(request); + auto payload_hash = crypto::hash::Sha256(request.body, crypto::hash::OutputEncoding::kHex); + + std::map headers_to_sign; + for (const auto& [name, value] : request.headers) { + headers_to_sign[boost::algorithm::to_lower_copy(name)] = TrimAndCollapseSpaces(value); + } + headers_to_sign["host"] = host; + headers_to_sign["x-amz-date"] = scope.amz_date; + headers_to_sign["x-amz-content-sha256"] = payload_hash; + + const auto canonical_headers = MakeCanonicalHeaders(headers_to_sign); + const auto canonical_request = MakeCanonicalRequest(request, host, canonical_headers, {}, payload_hash); + const auto string_to_sign = MakeStringToSign(canonical_request, scope); + const auto signature = MakeSignature(string_to_sign, scope, region_, service_, secret_key_); + + auto authorization = fmt::format( + "{} Credential={}/{}, SignedHeaders={}, Signature={}", + kAlgorithm, + access_key_, + scope.credential_scope, + canonical_headers.signed_headers, + signature + ); + + return { + {"Authorization", std::move(authorization)}, + {"X-Amz-Date", scope.amz_date}, + {"X-Amz-Content-Sha256", std::move(payload_hash)}, + }; +} + +std::unordered_map SignatureV4::Sign(const Request& request, std::time_t expires) const { + // https://docs.aws.amazon.com/AmazonS3/latest/developerguide/sigv4-query-string-auth.html + + const auto scope = MakeSigningScope(region_, service_); + const auto host = GetHostHeaderValue(request); + + const auto expires_in = std::max(expires - scope.now, 1); + + std::unordered_map sign_params{ + {"X-Amz-Algorithm", std::string{kAlgorithm}}, + {"X-Amz-Credential", fmt::format("{}/{}", access_key_, scope.credential_scope)}, + {"X-Amz-Date", scope.amz_date}, + {"X-Amz-Expires", std::to_string(expires_in)}, + {"X-Amz-SignedHeaders", "host"}, + }; + + const CanonicalHeaders canonical_headers{ + .headers = fmt::format("host:{}\n", host), + .signed_headers = "host", + }; + + const auto canonical_request = MakeCanonicalRequest( + request, + host, + canonical_headers, + QueryParams{sign_params.begin(), sign_params.end()}, + kUnsignedPayload + ); + + const auto string_to_sign = MakeStringToSign(canonical_request, scope); + + sign_params.emplace("X-Amz-Signature", MakeSignature(string_to_sign, scope, region_, service_, secret_key_)); + + return sign_params; +} + +} // namespace s3api::authenticators + +USERVER_NAMESPACE_END diff --git a/libraries/s3api/src/s3api/authenticators/signature_v4_test.cpp b/libraries/s3api/src/s3api/authenticators/signature_v4_test.cpp new file mode 100644 index 000000000000..f8fc8391f813 --- /dev/null +++ b/libraries/s3api/src/s3api/authenticators/signature_v4_test.cpp @@ -0,0 +1,160 @@ +#include + +#include +#include +#include + +#include + +USERVER_NAMESPACE_BEGIN + +namespace s3api::authenticators { + +namespace { + +// Test vectors from +// https://docs.aws.amazon.com/AmazonS3/latest/developerguide/sig-v4-header-based-auth.html +constexpr std::string_view kAccessKey = "AKIAIOSFODNN7EXAMPLE"; +constexpr std::string_view kSecretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; +constexpr std::string_view kRegion = "us-east-1"; +constexpr std::string_view kBucket = "examplebucket"; +constexpr std::string_view kVirtualHost = "examplebucket.s3.amazonaws.com"; + +constexpr std::string_view kEmptyPayloadHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + +// Fri, 24 May 2013 00:00:00 GMT +constexpr time_t kMockedNowEpoch = 1369353600; + +SignatureV4 MakeAuthenticator() { + return SignatureV4{std::string{kAccessKey}, Secret{std::string{kSecretKey}}, std::string{kRegion}}; +} + +Request MakeRequest(clients::http::HttpMethod method, std::string req) { + utils::datetime::MockNowSet(std::chrono::system_clock::from_time_t(kMockedNowEpoch)); + + Request request; + request.method = method; + request.bucket = kBucket; + request.req = std::move(req); + request.headers[USERVER_NAMESPACE::http::headers::kHost] = std::string{kVirtualHost}; + return request; +} + +} // namespace + +TEST(S3ApiSignatureV4, AuthGetObject) { + auto request = MakeRequest(clients::http::HttpMethod::kGet, "test.txt"); + request.headers[USERVER_NAMESPACE::http::headers::kRange] = "bytes=0-9"; + + const auto headers = MakeAuthenticator().Auth(request); + + EXPECT_EQ(headers.at("X-Amz-Date"), "20130524T000000Z"); + EXPECT_EQ(headers.at("X-Amz-Content-Sha256"), kEmptyPayloadHash); + EXPECT_EQ( + headers.at("Authorization"), + "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, " + "SignedHeaders=host;range;x-amz-content-sha256;x-amz-date, " + "Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41" + ); +} + +TEST(S3ApiSignatureV4, AuthPutObject) { + auto request = MakeRequest(clients::http::HttpMethod::kPut, "test$file.text"); + request.body = "Welcome to Amazon S3."; + request.headers[USERVER_NAMESPACE::http::headers::kDate] = "Fri, 24 May 2013 00:00:00 GMT"; + request.headers[std::string_view{"x-amz-storage-class"}] = "REDUCED_REDUNDANCY"; + + const auto headers = MakeAuthenticator().Auth(request); + + EXPECT_EQ(headers.at("X-Amz-Content-Sha256"), "44ce7dd67c959e0d3524ffac1771dfbba87d2b6b4b4e99e42034a8b803f8b072"); + EXPECT_EQ( + headers.at("Authorization"), + "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, " + "SignedHeaders=date;host;x-amz-content-sha256;x-amz-date;x-amz-storage-class, " + "Signature=98ad721746da40c64f1a55b78f14c238d841ea1380cd77a1b5971af0ece108bd" + ); +} + +TEST(S3ApiSignatureV4, AuthQueryParameterWithoutValue) { + const auto request = MakeRequest(clients::http::HttpMethod::kGet, "?lifecycle"); + + const auto headers = MakeAuthenticator().Auth(request); + + EXPECT_EQ( + headers.at("Authorization"), + "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, " + "SignedHeaders=host;x-amz-content-sha256;x-amz-date, " + "Signature=fea454ca298b7da1c68078a5d1bdbfbbe0d65c699e0f91ac7a200a0136783543" + ); +} + +TEST(S3ApiSignatureV4, AuthQueryParametersSorted) { + const auto request = MakeRequest(clients::http::HttpMethod::kGet, "?prefix=J&max-keys=2"); + + const auto headers = MakeAuthenticator().Auth(request); + + EXPECT_EQ( + headers.at("Authorization"), + "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, " + "SignedHeaders=host;x-amz-content-sha256;x-amz-date, " + "Signature=34b48302e7b5fa45bde8084f4b7868a86f0a534bc59db6670ed5711ef69dc6f7" + ); +} + +TEST(S3ApiSignatureV4, AuthPathStyleAddressing) { + auto request = MakeRequest(clients::http::HttpMethod::kGet, "test.txt"); + // the bucket is not a subdomain, so it becomes a part of the canonical URI + request.headers[USERVER_NAMESPACE::http::headers::kHost] = "s3.amazonaws.com"; + + const auto headers = MakeAuthenticator().Auth(request); + + EXPECT_EQ( + headers.at("Authorization"), + "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, " + "SignedHeaders=host;x-amz-content-sha256;x-amz-date, " + "Signature=0fcb291c4b47980ad34dd9a29532ceae67b48e45de3d6054873b430740567ec2" + ); +} + +TEST(S3ApiSignatureV4, AuthEncodedPathAndQuery) { + // the path is encoded the same way api_methods do: http::EncodeS3Key + http::MakeQuery + const auto request = MakeRequest(clients::http::HttpMethod::kGet, "my%20folder/my%20file.txt?versionId=abc%20123"); + + const auto headers = MakeAuthenticator().Auth(request); + + EXPECT_EQ( + headers.at("Authorization"), + "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, " + "SignedHeaders=host;x-amz-content-sha256;x-amz-date, " + "Signature=d6e900c34937a984ac3eeff28c985f58b4cbfa4ecfdb216af808afdef4145933" + ); +} + +TEST(S3ApiSignatureV4, AuthRequiresHostHeader) { + utils::datetime::MockNowSet(std::chrono::system_clock::from_time_t(kMockedNowEpoch)); + + Request request; + request.method = clients::http::HttpMethod::kGet; + request.bucket = kBucket; + request.req = "test.txt"; + + EXPECT_THROW(MakeAuthenticator().Auth(request), std::runtime_error); + EXPECT_THROW(MakeAuthenticator().Sign(request, kMockedNowEpoch + 60), std::runtime_error); +} + +TEST(S3ApiSignatureV4, SignPresignedUrl) { + const auto request = MakeRequest(clients::http::HttpMethod::kGet, "test.txt"); + + const auto params = MakeAuthenticator().Sign(request, kMockedNowEpoch + 86400); + + EXPECT_EQ(params.at("X-Amz-Algorithm"), "AWS4-HMAC-SHA256"); + EXPECT_EQ(params.at("X-Amz-Credential"), "AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request"); + EXPECT_EQ(params.at("X-Amz-Date"), "20130524T000000Z"); + EXPECT_EQ(params.at("X-Amz-Expires"), "86400"); + EXPECT_EQ(params.at("X-Amz-SignedHeaders"), "host"); + EXPECT_EQ(params.at("X-Amz-Signature"), "aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404"); +} + +} // namespace s3api::authenticators + +USERVER_NAMESPACE_END diff --git a/libraries/s3api/src/s3api/authenticators/utils.cpp b/libraries/s3api/src/s3api/authenticators/utils.cpp index 07b75b6b9ff9..54853d7bc528 100644 --- a/libraries/s3api/src/s3api/authenticators/utils.cpp +++ b/libraries/s3api/src/s3api/authenticators/utils.cpp @@ -18,34 +18,7 @@ USERVER_NAMESPACE_BEGIN namespace s3api::authenticators { -std::string HttpMethodToString(const clients::http::HttpMethod http_method) { - std::string http_method_string; - - switch (http_method) { - case clients::http::HttpMethod::kDelete: - http_method_string = "DELETE"; - break; - case clients::http::HttpMethod::kGet: - http_method_string = "GET"; - break; - case clients::http::HttpMethod::kHead: - http_method_string = "HEAD"; - break; - case clients::http::HttpMethod::kPost: - http_method_string = "POST"; - break; - case clients::http::HttpMethod::kPut: - http_method_string = "PUT"; - break; - case clients::http::HttpMethod::kPatch: - http_method_string = "PATCH"; - break; - default: - throw std::runtime_error("Unknown http method"); - } - - return http_method_string; -} +namespace { std::string RemoveExcessiveSpaces(std::string value) { std::ranges::replace(value, '\n', ' '); @@ -54,6 +27,8 @@ std::string RemoveExcessiveSpaces(std::string value) { return value; } +} // namespace + std::string MakeHeaderDate() { return utils::datetime::UtcTimestring(utils::datetime::Now(), "%a, %d %b %Y %T %z"); } std::string MakeHeaderContentMd5(const std::string& data) { @@ -67,7 +42,7 @@ std::string MakeStringToSign( ) { std::ostringstream signature; - signature << HttpMethodToString(request.method) << '\n'; + signature << ToStringView(request.method) << '\n'; // md5 { diff --git a/libraries/s3api/src/s3api/clients/client.cpp b/libraries/s3api/src/s3api/clients/client.cpp index 8d36d89c61ef..8d9be2b95819 100644 --- a/libraries/s3api/src/s3api/clients/client.cpp +++ b/libraries/s3api/src/s3api/clients/client.cpp @@ -81,7 +81,7 @@ void AddQueryParamsToPresignedUrl( } std::string GeneratePresignedUrl( - const Request& request, + Request& request, std::string_view host, std::string_view protocol, const std::chrono::system_clock::time_point& expires_at, @@ -90,7 +90,9 @@ std::string GeneratePresignedUrl( std::ostringstream generated_url; // both internal (s3.mds(t)) and private (s3-private) // balancers support virtual host addressing and https - generated_url << protocol << request.bucket << "." << host; + request.headers[USERVER_NAMESPACE::http::headers::kHost] = fmt::format("{}.{}", request.bucket, host); + generated_url << protocol << request.headers[USERVER_NAMESPACE::http::headers::kHost]; + const auto expires_at_time_t = std::chrono::system_clock::to_time_t(expires_at); AddQueryParamsToPresignedUrl(generated_url, expires_at_time_t, request, std::move(authenticator)); return generated_url.str(); @@ -306,14 +308,19 @@ std::optional ClientImpl::GetObjectHead( std::ostringstream generated_url; auto host = conn_->GetHost(); - if (host.find("://") == std::string::npos) { - generated_url << (use_ssl ? "https" : "http") << "://"; + + if (const auto scheme_pos = host.find("://"); scheme_pos == std::string::npos) { + generated_url << (use_ssl ? "https" : "http") << "://" << host; + req.headers[USERVER_NAMESPACE::http::headers::kHost] = host; + } else { + generated_url << host; + req.headers[USERVER_NAMESPACE::http::headers::kHost] = host.substr(scheme_pos + 3); } - generated_url << host; if (!req.bucket.empty()) { - generated_url << "/" + req.bucket; + generated_url << '/' << req.bucket; } + AddQueryParamsToPresignedUrl(generated_url, expires_at, req, authenticator_); return generated_url.str(); } @@ -372,6 +379,7 @@ std::string ClientImpl::RequestApi( HeadersDataResponse* headers_data, const HeaderDataRequest& headers_request ) const { + request.headers[USERVER_NAMESPACE::http::headers::kHost] = conn_->GetHostHeader(request); Auth(request); auto response = conn_->RequestApi(request, method_name); diff --git a/libraries/s3api/src/s3api/s3_connection.cpp b/libraries/s3api/src/s3api/s3_connection.cpp index 5e015ecb9f0d..006377654b50 100644 --- a/libraries/s3api/src/s3api/s3_connection.cpp +++ b/libraries/s3api/src/s3api/s3_connection.cpp @@ -41,21 +41,26 @@ clients::http::Request& GetMethod( // Префикс "bucket." сохраняем всегда: при обращении к localhost GetUrl не // кладёт bucket в путь, и mock определяет bucket именно по Host. Внешнее // связывание — используется в unit-тесте. -std::string MakeHostHeader(std::string_view api_url, std::string_view bucket) { +std::string S3Connection::MakeHostHeader(std::string_view api_url, std::string_view bucket) { const auto schema_pos = api_url.find("://"); - std::string_view authority = schema_pos == std::string_view::npos ? api_url : api_url.substr(schema_pos + 3); + auto authority = schema_pos == std::string_view::npos ? api_url : api_url.substr(schema_pos + 3); + const auto path_pos = authority.find('/'); if (path_pos != std::string_view::npos) { authority = authority.substr(0, path_pos); } + if (!bucket.empty()) { return fmt::format("{}.{}", bucket, authority); } + return std::string{authority}; } +std::string S3Connection::GetHostHeader(const Request& r) const { return MakeHostHeader(api_url_, r.bucket); } + std::shared_ptr S3Connection::RequestApi(Request& r, std::string_view method_name) { - r.headers[USERVER_NAMESPACE::http::headers::kHost] = MakeHostHeader(api_url_, r.bucket); + r.headers[USERVER_NAMESPACE::http::headers::kHost] = GetHostHeader(r); LOG_DEBUG() << "S3 Host: " << r.headers[USERVER_NAMESPACE::http::headers::kHost]; const std::string full_url = GetUrl(r, connection_type_); @@ -86,7 +91,7 @@ std::shared_ptr S3Connection::RequestApi(Request& r, st std::shared_ptr S3Connection::DoStartApiRequest(const Request& r) const { auto headers = r.headers; - headers[USERVER_NAMESPACE::http::headers::kHost] = MakeHostHeader(api_url_, r.bucket); + headers[USERVER_NAMESPACE::http::headers::kHost] = GetHostHeader(r); const std::string full_url = GetUrl(r, connection_type_); diff --git a/libraries/s3api/src/s3api/s3_connection.hpp b/libraries/s3api/src/s3api/s3_connection.hpp index fba536222727..7ef99003c45f 100644 --- a/libraries/s3api/src/s3api/s3_connection.hpp +++ b/libraries/s3api/src/s3api/s3_connection.hpp @@ -1,7 +1,7 @@ #pragma once -#include #include +#include #include #include @@ -39,6 +39,8 @@ class S3Connection : public std::enable_shared_from_this { ~S3Connection() = default; + static std::string MakeHostHeader(std::string_view api_url, std::string_view bucket); + std::shared_ptr RequestApi(Request& r, std::string_view method_name); std::shared_ptr DoStartApiRequest(const Request& r) const; @@ -47,6 +49,8 @@ class S3Connection : public std::enable_shared_from_this { std::string GetHost() const { return api_url_; } + std::string GetHostHeader(const Request& r) const; + void UpdateConfig(ConnectionCfg&& config) { config_ = config; } private: diff --git a/libraries/s3api/src/s3api/s3_connection_test.cpp b/libraries/s3api/src/s3api/s3_connection_test.cpp index dca47f48df65..8a3529c38b7c 100644 --- a/libraries/s3api/src/s3api/s3_connection_test.cpp +++ b/libraries/s3api/src/s3api/s3_connection_test.cpp @@ -1,5 +1,4 @@ -#include -#include +#include #include @@ -7,38 +6,35 @@ USERVER_NAMESPACE_BEGIN namespace s3api { -// Внутренняя функция из s3_connection.cpp с внешним связыванием. -std::string MakeHostHeader(std::string_view api_url, std::string_view bucket); - namespace { TEST(S3ConnectionHostHeader, BareHostWithBucket) { // Продовый случай: api_url_ — голый хост, virtual-host адресация. - EXPECT_EQ(MakeHostHeader("s3.mds.yandex.net", "mybucket"), "mybucket.s3.mds.yandex.net"); + EXPECT_EQ(S3Connection::MakeHostHeader("s3.mds.yandex.net", "mybucket"), "mybucket.s3.mds.yandex.net"); } TEST(S3ConnectionHostHeader, BareHostWithoutBucket) { - EXPECT_EQ(MakeHostHeader("s3.mds.yandex.net", ""), "s3.mds.yandex.net"); + EXPECT_EQ(S3Connection::MakeHostHeader("s3.mds.yandex.net", ""), "s3.mds.yandex.net"); } TEST(S3ConnectionHostHeader, LocalhostWithSchemeAndBucket) { // Тестовый endpoint на mockserver: схему и путь отбрасываем, но bucket // сохраняем — mock определяет его по Host (virtual-host). - EXPECT_EQ(MakeHostHeader("http://localhost:41871/s3mds", "bucket"), "bucket.localhost:41871"); + EXPECT_EQ(S3Connection::MakeHostHeader("http://localhost:41871/s3mds", "bucket"), "bucket.localhost:41871"); } TEST(S3ConnectionHostHeader, LocalhostWithSchemeAndPathWithoutBucket) { // Bucket пуст, имя мока зашито в путь endpoint (кейс fintech): остаётся // только authority. - EXPECT_EQ(MakeHostHeader("http://localhost:41507/risk-collection-ui", ""), "localhost:41507"); + EXPECT_EQ(S3Connection::MakeHostHeader("http://localhost:41507/risk-collection-ui", ""), "localhost:41507"); } TEST(S3ConnectionHostHeader, RemoteHostWithScheme) { - EXPECT_EQ(MakeHostHeader("http://s3.example.com", "bucket"), "bucket.s3.example.com"); + EXPECT_EQ(S3Connection::MakeHostHeader("http://s3.example.com", "bucket"), "bucket.s3.example.com"); } TEST(S3ConnectionHostHeader, RemoteHostWithSchemeAndPath) { - EXPECT_EQ(MakeHostHeader("https://s3.example.com/prefix", "bucket"), "bucket.s3.example.com"); + EXPECT_EQ(S3Connection::MakeHostHeader("https://s3.example.com/prefix", "bucket"), "bucket.s3.example.com"); } } // namespace