diff --git a/CMakeLists.txt b/CMakeLists.txt index 410d30e..4277981 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -239,6 +239,7 @@ add_library(flapi-lib STATIC src/extended_yaml_parser.cpp src/heartbeat_worker.cpp src/open_api_doc_generator.cpp + src/password_hasher.cpp src/path_utils.cpp src/query_executor.cpp src/request_handler.cpp diff --git a/src/auth_middleware.cpp b/src/auth_middleware.cpp index 740fe22..83fd87c 100644 --- a/src/auth_middleware.cpp +++ b/src/auth_middleware.cpp @@ -16,6 +16,7 @@ #include "duckdb/main/secret/secret_manager.hpp" #include "auth_middleware.hpp" +#include "password_hasher.hpp" #include "database_manager.hpp" #include "duckdb.hpp" #include "duckdb/common/types/blob.hpp" @@ -297,13 +298,12 @@ std::string AuthMiddleware::md5Hash(const std::string& input) { } bool AuthMiddleware::verifyPassword(const std::string& provided_password, const std::string& stored_password) { - // Check if the stored password is an MD5 hash - if (stored_password.length() == 32 && std::all_of(stored_password.begin(), stored_password.end(), - [](char c) { return std::isxdigit(c); })) { - return md5Hash(provided_password) == stored_password; - } - - return provided_password == stored_password; + // W1.1: delegate to PasswordHasher so all supported formats + // (PBKDF2-SHA256 modern, MD5 deprecated, plaintext deprecated) are + // handled in one place. The Wave 0 startup auditor surfaces + // deprecation warnings; the runtime keeps accepting legacy hashes so + // existing configs do not break on upgrade. + return PasswordHasher{}.verify(provided_password, stored_password); } bool AuthMiddleware::authenticateBearer(const std::string& auth_header, const EndpointConfig& endpoint, context& ctx) { diff --git a/src/include/password_hasher.hpp b/src/include/password_hasher.hpp new file mode 100644 index 0000000..8002d6a --- /dev/null +++ b/src/include/password_hasher.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include + +namespace flapi { + +// Recognised storage formats for credentials in the inline `auth.users[]` +// block. New deployments should always use Pbkdf2Sha256; the others exist +// for upgrade compatibility and surface as warnings via the Wave 0 +// startup auditor. +enum class PasswordFormat { + Pbkdf2Sha256, // $pbkdf2-sha256$$$ + Md5Deprecated, // 32 lowercase hex chars + BcryptUnsupported, // $2a$ | $2b$ | $2y$ — recognised but not verifiable + PlaintextDeprecated, // anything else, including empty +}; + +// W1.1: Modern password hashing for Basic-auth users. +// +// Hash output uses the standard Modular Crypt Format +// $pbkdf2-sha256$$$ +// produced via OpenSSL's `PKCS5_PBKDF2_HMAC` with SHA-256. This is the +// same shape passlib emits and is FIPS-approved (NIST SP 800-132). +// +// We deliberately do NOT ship a bcrypt implementation. A stored bcrypt +// hash is recognised (so configs that paste one don't silently fail) but +// `verify()` returns false — better than a slow-failing or partially-correct +// drop-in. +class PasswordHasher { +public: + // Defaults: 600,000 iterations (OWASP 2023 minimum), 16-byte salt, + // 32-byte derived key. Caller-tunable variants can be added later + // without breaking the storage format. + static constexpr std::uint32_t kDefaultIterations = 600'000; + static constexpr std::size_t kSaltBytes = 16; + static constexpr std::size_t kKeyBytes = 32; + + // Generate a fresh hash for `password` using a random salt. + std::string hashWithDefaults(const std::string& password) const; + + // Verify `provided` against `stored`. Format auto-detected by prefix. + bool verify(const std::string& provided, const std::string& stored) const; + + // Inspect what shape `stored` is. Pure function, exported for tests + // and for the startup auditor's deprecation warnings. + static PasswordFormat classifyFormat(const std::string& stored); +}; + +} // namespace flapi diff --git a/src/password_hasher.cpp b/src/password_hasher.cpp new file mode 100644 index 0000000..f77b3ba --- /dev/null +++ b/src/password_hasher.cpp @@ -0,0 +1,242 @@ +#include "password_hasher.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace flapi { + +namespace { + +constexpr const char* kPbkdf2Prefix = "$pbkdf2-sha256$"; +constexpr const char* kRedactionUnused = ""; + +bool isMd5HexDigest(const std::string& s) { + if (s.size() != 32) { + return false; + } + return std::all_of(s.begin(), s.end(), [](char c) { + return std::isxdigit(static_cast(c)) != 0; + }); +} + +bool isBcryptPrefix(const std::string& s) { + if (s.size() < 4 || s[0] != '$' || s[1] != '2' || s[3] != '$') { + return false; + } + const char v = s[2]; + return v == 'a' || v == 'b' || v == 'y'; +} + +bool startsWith(const std::string& s, const char* prefix) { + const std::size_t n = std::strlen(prefix); + return s.size() >= n && s.compare(0, n, prefix) == 0; +} + +// Minimal URL-safe base64 (no padding) — sufficient for the salt + hash +// in our MCF string. Avoids pulling another library just for encoding. +std::string base64UrlEncode(const std::vector& bytes) { + static const char* kAlphabet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + std::string out; + out.reserve(((bytes.size() + 2) / 3) * 4); + std::size_t i = 0; + for (; i + 3 <= bytes.size(); i += 3) { + const std::uint32_t triple = + (static_cast(bytes[i]) << 16) | + (static_cast(bytes[i + 1]) << 8) | + static_cast(bytes[i + 2]); + out.push_back(kAlphabet[(triple >> 18) & 0x3F]); + out.push_back(kAlphabet[(triple >> 12) & 0x3F]); + out.push_back(kAlphabet[(triple >> 6) & 0x3F]); + out.push_back(kAlphabet[triple & 0x3F]); + } + if (i < bytes.size()) { + std::uint32_t triple = static_cast(bytes[i]) << 16; + if (i + 1 < bytes.size()) { + triple |= static_cast(bytes[i + 1]) << 8; + } + out.push_back(kAlphabet[(triple >> 18) & 0x3F]); + out.push_back(kAlphabet[(triple >> 12) & 0x3F]); + if (i + 1 < bytes.size()) { + out.push_back(kAlphabet[(triple >> 6) & 0x3F]); + } + } + return out; +} + +std::vector base64UrlDecode(const std::string& s) { + static const std::int8_t kTable[256] = { + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,62,-1,-1, + 52,53,54,55,56,57,58,59,60,61,-1,-1,-1,-1,-1,-1, + -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10,11,12,13,14, + 15,16,17,18,19,20,21,22,23,24,25,-1,-1,-1,-1,63, + -1,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40, + 41,42,43,44,45,46,47,48,49,50,51,-1,-1,-1,-1,-1, + }; + std::vector out; + out.reserve((s.size() * 3) / 4); + std::uint32_t buf = 0; + int bits = 0; + for (char c : s) { + std::int8_t v = (static_cast(c) < 128) ? kTable[static_cast(c)] : -1; + if (v < 0) { + return {}; // invalid char + } + buf = (buf << 6) | static_cast(v); + bits += 6; + if (bits >= 8) { + bits -= 8; + out.push_back(static_cast((buf >> bits) & 0xFF)); + } + } + return out; +} + +std::vector randomSalt(std::size_t n) { + std::random_device rd; + std::vector salt(n); + for (std::size_t i = 0; i < n; ++i) { + salt[i] = static_cast(rd() & 0xFF); + } + return salt; +} + +std::vector pbkdf2(const std::string& password, + const std::vector& salt, + std::uint32_t iterations, + std::size_t out_bytes) { + std::vector out(out_bytes); + const int ok = PKCS5_PBKDF2_HMAC( + password.data(), static_cast(password.size()), + salt.data(), static_cast(salt.size()), + static_cast(iterations), + EVP_sha256(), + static_cast(out.size()), out.data()); + if (ok != 1) { + throw std::runtime_error("PBKDF2: OpenSSL derivation failed"); + } + return out; +} + +bool constantTimeEqual(const std::vector& a, + const std::vector& b) { + if (a.size() != b.size() || a.empty()) { + return false; + } + return CRYPTO_memcmp(a.data(), b.data(), a.size()) == 0; +} + +bool verifyPbkdf2(const std::string& provided, const std::string& stored) { + // stored: $pbkdf2-sha256$$$ + auto rest = stored.substr(std::strlen(kPbkdf2Prefix)); + auto dollar1 = rest.find('$'); + auto dollar2 = rest.find('$', dollar1 + 1); + if (dollar1 == std::string::npos || dollar2 == std::string::npos) { + return false; + } + std::uint32_t iter = 0; + try { + iter = static_cast(std::stoul(rest.substr(0, dollar1))); + } catch (...) { + return false; + } + if (iter == 0 || iter > 10'000'000) { + // Refuse pathological iteration counts. Upper bound is generous; + // anything past it is almost certainly a config typo or an attempt + // to wedge the verify thread. + return false; + } + auto salt = base64UrlDecode(rest.substr(dollar1 + 1, dollar2 - dollar1 - 1)); + auto expected = base64UrlDecode(rest.substr(dollar2 + 1)); + if (salt.empty() || expected.empty()) { + return false; + } + auto actual = pbkdf2(provided, salt, iter, expected.size()); + return constantTimeEqual(actual, expected); +} + +// MD5 verification — kept here for upgrade-compat. The Wave 0 startup +// auditor already warns operators that this format is deprecated. +std::string md5Hex(const std::string& input) { + std::vector digest(EVP_MAX_MD_SIZE); + unsigned int digest_len = 0; + EVP_MD_CTX* ctx = EVP_MD_CTX_new(); + if (!ctx) { + throw std::runtime_error("MD5: cannot create EVP_MD_CTX"); + } + if (EVP_DigestInit_ex(ctx, EVP_md5(), nullptr) != 1 || + EVP_DigestUpdate(ctx, input.data(), input.size()) != 1 || + EVP_DigestFinal_ex(ctx, digest.data(), &digest_len) != 1) { + EVP_MD_CTX_free(ctx); + throw std::runtime_error("MD5: hashing failed"); + } + EVP_MD_CTX_free(ctx); + std::ostringstream oss; + for (unsigned int i = 0; i < digest_len; ++i) { + oss << std::hex; + oss.width(2); + oss.fill('0'); + oss << static_cast(digest[i]); + } + return oss.str(); +} + +} // namespace + +PasswordFormat PasswordHasher::classifyFormat(const std::string& stored) { + if (startsWith(stored, kPbkdf2Prefix)) { + return PasswordFormat::Pbkdf2Sha256; + } + if (isBcryptPrefix(stored)) { + return PasswordFormat::BcryptUnsupported; + } + if (isMd5HexDigest(stored)) { + return PasswordFormat::Md5Deprecated; + } + return PasswordFormat::PlaintextDeprecated; +} + +std::string PasswordHasher::hashWithDefaults(const std::string& password) const { + auto salt = randomSalt(kSaltBytes); + auto derived = pbkdf2(password, salt, kDefaultIterations, kKeyBytes); + std::ostringstream oss; + oss << kPbkdf2Prefix << kDefaultIterations << '$' + << base64UrlEncode(salt) << '$' + << base64UrlEncode(derived); + (void) kRedactionUnused; + return oss.str(); +} + +bool PasswordHasher::verify(const std::string& provided, const std::string& stored) const { + switch (classifyFormat(stored)) { + case PasswordFormat::Pbkdf2Sha256: + return verifyPbkdf2(provided, stored); + case PasswordFormat::Md5Deprecated: { + try { + return md5Hex(provided) == stored; + } catch (...) { + return false; + } + } + case PasswordFormat::BcryptUnsupported: + // We refuse to silently fail-open against a bcrypt hash. The + // operator needs to migrate to PBKDF2 (see SECURITY docs / the + // Wave 0 auditor warning). + return false; + case PasswordFormat::PlaintextDeprecated: + return provided == stored; + } + return false; +} + +} // namespace flapi diff --git a/test/cpp/CMakeLists.txt b/test/cpp/CMakeLists.txt index 5c196c6..1bf09fc 100644 --- a/test/cpp/CMakeLists.txt +++ b/test/cpp/CMakeLists.txt @@ -20,6 +20,7 @@ add_executable(flapi_tests cache_manager_test.cpp mcp_prompt_handler_test.cpp mcp_request_validator_test.cpp + password_hasher_test.cpp query_executor_test.cpp rate_limit_middleware_test.cpp request_handler_test.cpp diff --git a/test/cpp/password_hasher_test.cpp b/test/cpp/password_hasher_test.cpp new file mode 100644 index 0000000..4a1e78e --- /dev/null +++ b/test/cpp/password_hasher_test.cpp @@ -0,0 +1,97 @@ +#include + +#include "password_hasher.hpp" + +namespace flapi { +namespace test { + +TEST_CASE("PasswordHasher: hash+verify roundtrip on the same password", + "[security][password]") { + PasswordHasher hasher; + auto stored = hasher.hashWithDefaults("hunter2"); + REQUIRE_FALSE(stored.empty()); + REQUIRE(hasher.verify("hunter2", stored)); +} + +TEST_CASE("PasswordHasher: verify rejects the wrong password", + "[security][password]") { + PasswordHasher hasher; + auto stored = hasher.hashWithDefaults("hunter2"); + REQUIRE_FALSE(hasher.verify("hunter3", stored)); + REQUIRE_FALSE(hasher.verify("", stored)); +} + +TEST_CASE("PasswordHasher: hash output uses the documented $pbkdf2-sha256$ format", + "[security][password]") { + // The format string is part of the operator-facing contract — admins + // copy it into YAML, so it must be stable and self-describing. + PasswordHasher hasher; + auto stored = hasher.hashWithDefaults("any"); + REQUIRE(stored.find("$pbkdf2-sha256$") == 0); +} + +TEST_CASE("PasswordHasher: two hashes of the same password are different (random salt)", + "[security][password]") { + PasswordHasher hasher; + auto a = hasher.hashWithDefaults("hunter2"); + auto b = hasher.hashWithDefaults("hunter2"); + REQUIRE(a != b); + REQUIRE(hasher.verify("hunter2", a)); + REQUIRE(hasher.verify("hunter2", b)); +} + +TEST_CASE("PasswordHasher: classifyFormat tags every supported and legacy form", + "[security][password]") { + REQUIRE(PasswordHasher::classifyFormat("$pbkdf2-sha256$600000$salt$hash") == + PasswordFormat::Pbkdf2Sha256); + REQUIRE(PasswordHasher::classifyFormat("2ab96390c7dbe3439de74d0c9b0b1767") == + PasswordFormat::Md5Deprecated); + REQUIRE(PasswordHasher::classifyFormat("hunter2") == + PasswordFormat::PlaintextDeprecated); + REQUIRE(PasswordHasher::classifyFormat("$2b$12$abcdefghij...") == + PasswordFormat::BcryptUnsupported); + REQUIRE(PasswordHasher::classifyFormat("") == + PasswordFormat::PlaintextDeprecated); +} + +TEST_CASE("PasswordHasher: MD5-hashed password still verifies (deprecated path)", + "[security][password]") { + // The Wave 0 startup auditor warns about MD5; the runtime continues to + // accept it so existing configs do not break on upgrade. + PasswordHasher hasher; + // MD5("hunter2") + REQUIRE(hasher.verify("hunter2", "2ab96390c7dbe3439de74d0c9b0b1767")); +} + +TEST_CASE("PasswordHasher: plaintext password still verifies (deprecated path)", + "[security][password]") { + PasswordHasher hasher; + REQUIRE(hasher.verify("hunter2", "hunter2")); + REQUIRE_FALSE(hasher.verify("hunter3", "hunter2")); +} + +TEST_CASE("PasswordHasher: bcrypt-prefixed stored value is recognised but rejected", + "[security][password]") { + // Until a bcrypt verifier ships, we deliberately do NOT attempt a + // best-effort match — silently failing a bcrypt verify would be the + // worst possible UX (admin thinks their hash works but it doesn't). + PasswordHasher hasher; + REQUIRE_FALSE(hasher.verify("hunter2", "$2b$12$abcdefghijklmnopqrstuv")); + REQUIRE_FALSE(hasher.verify("anything", "$2a$10$shortbcrypt")); + REQUIRE_FALSE(hasher.verify("anything", "$2y$10$shortbcrypt")); +} + +TEST_CASE("PasswordHasher: verify is constant-time-ish across stored format", + "[security][password]") { + // Smoke test: the same wrong-password verify path must work the same + // way regardless of format. (Real timing-attack hardening lives in + // OpenSSL's PKCS5_PBKDF2_HMAC and CRYPTO_memcmp.) + PasswordHasher hasher; + REQUIRE_FALSE(hasher.verify("wrong", "plaintext")); + REQUIRE_FALSE(hasher.verify("wrong", "2ab96390c7dbe3439de74d0c9b0b1767")); + auto pbkdf2 = hasher.hashWithDefaults("right"); + REQUIRE_FALSE(hasher.verify("wrong", pbkdf2)); +} + +} // namespace test +} // namespace flapi diff --git a/test/integration/test_password_hashing.py b/test/integration/test_password_hashing.py new file mode 100644 index 0000000..e8f4e2b --- /dev/null +++ b/test/integration/test_password_hashing.py @@ -0,0 +1,241 @@ +"""End-to-end tests for modern password hashing (issue #23, W1.1). + +Verifies the PBKDF2-SHA256 verify path end-to-end via the real flapi +binary. A test JWT-protected endpoint is configured to require Basic +auth with a known PBKDF2 hash; the test issues requests with both the +correct and an incorrect password and asserts the binary's verify path +matches the locally-computed expectation. + +The test deliberately uses Python's stdlib `hashlib.pbkdf2_hmac` to +generate fresh, deterministic hashes — no fixture files to drift. + +Configuration validation alone (no DB) confirms the parser accepts the +hash; runtime auth verification requires a running server. + +Marked `standalone_server` so the conftest autouse fixture does not +spin up the shared api_configuration server. Skips cleanly when flapi +cannot boot (local DuckDB extension-cache mismatch); CI runs against +fresh extensions. +""" + +import base64 +import hashlib +import os +import socket +import subprocess +import tempfile +import time +from typing import Dict, Iterator, List, Tuple + +import pytest +import requests + + +def _b64u(b: bytes) -> str: + return base64.urlsafe_b64encode(b).rstrip(b"=").decode() + + +def _pbkdf2_mcf(password: str, salt: bytes, iterations: int = 600_000) -> str: + """Produce a `$pbkdf2-sha256$...$...$...` Modular Crypt Format string.""" + dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations, dklen=32) + return f"$pbkdf2-sha256${iterations}${_b64u(salt)}${_b64u(dk)}" + + +def _repo_root() -> str: + return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + + +def _flapi_binary() -> str: + candidates: List[str] = [] + for build_type in ("release", "debug"): + path = os.path.join(_repo_root(), "build", build_type, "flapi") + if os.path.exists(path): + candidates.append(path) + if not candidates: + pytest.skip("flapi binary not found in build/release or build/debug") + candidates.sort(key=os.path.getmtime, reverse=True) + return candidates[0] + + +def _free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _basic_auth_header(user: str, password: str) -> str: + raw = f"{user}:{password}".encode() + return "Basic " + base64.b64encode(raw).decode() + + +def _validate_config_with_pbkdf2_user() -> Tuple[str, str, str]: + """Return (binary, config_path, tempdir) for a --validate-config run.""" + binary = _flapi_binary() + tmpdir = tempfile.mkdtemp(prefix="flapi_pbkdf2_") + sqls = os.path.join(tmpdir, "sqls") + os.makedirs(sqls) + config_path = os.path.join(tmpdir, "flapi.yaml") + pwd_hash = _pbkdf2_mcf("hunter2", os.urandom(16)) + with open(config_path, "w") as f: + f.write( + "project-name: pbkdf2-validate\n" + "project-description: PBKDF2 config parses\n" + "http-port: 8080\n" + "template:\n" + " path: ./sqls\n" + "connections:\n" + " inmem:\n" + " properties:\n" + " database: ':memory:'\n" + ) + with open(os.path.join(sqls, "ping.yaml"), "w") as f: + f.write(f""" +url-path: /ping +method: GET +template-source: ping.sql +connection: [inmem] +auth: + enabled: true + type: basic + users: + - username: alice + password: "{pwd_hash}" + roles: [reader] +""") + with open(os.path.join(sqls, "ping.sql"), "w") as f: + f.write("SELECT 1 AS ok\n") + return binary, config_path, tmpdir + + +def _write_server_config(dirpath: str, port: int) -> Tuple[str, str, str]: + """Write a server config with a known PBKDF2 user; return (config_path, user, password).""" + sqls = os.path.join(dirpath, "sqls") + os.makedirs(sqls) + pwd_hash = _pbkdf2_mcf("hunter2", os.urandom(16)) + config_path = os.path.join(dirpath, "flapi.yaml") + with open(config_path, "w") as f: + f.write( + f"project-name: pbkdf2-runtime\n" + f"project-description: PBKDF2 runtime auth\n" + f"http-port: {port}\n" + f"template:\n" + f" path: ./sqls\n" + f"connections:\n" + f" inmem:\n" + f" properties:\n" + f" database: ':memory:'\n" + ) + with open(os.path.join(sqls, "ping.yaml"), "w") as f: + f.write(f""" +url-path: /ping +method: GET +template-source: ping.sql +connection: [inmem] +auth: + enabled: true + type: basic + users: + - username: alice + password: "{pwd_hash}" + roles: [reader] +""") + with open(os.path.join(sqls, "ping.sql"), "w") as f: + f.write("SELECT 1 AS ok\n") + return config_path, "alice", "hunter2" + + +@pytest.fixture +def pbkdf2_server() -> Iterator[Dict[str, str]]: + binary = _flapi_binary() + port = _free_port() + with tempfile.TemporaryDirectory(prefix="flapi_pbkdf2_srv_") as tmpdir: + config_path, user, password = _write_server_config(tmpdir, port) + log_path = os.path.join(tmpdir, "server.log") + log_file = open(log_path, "w") + proc = subprocess.Popen( + [binary, "-c", config_path, "--no-telemetry"], + cwd=tmpdir, + stdout=log_file, + stderr=subprocess.STDOUT, + ) + try: + base_url = f"http://127.0.0.1:{port}" + deadline = time.time() + 30 + up = False + while time.time() < deadline: + if proc.poll() is not None: + break + try: + # We probe an unauthenticated path - the auth check applies + # to /ping specifically. We just need any live response. + r = requests.get(f"{base_url}/", timeout=1) + if r.status_code < 500: + up = True + break + except requests.exceptions.RequestException: + time.sleep(0.5) + if not up: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + log_file.close() + with open(log_path) as f: + log_text = f.read() + if "core_functions_duckdb_cpp_init" in log_text and "unique_ptr that is NULL" in log_text: + pytest.skip( + "flapi could not boot: local DuckDB extension cache is " + "incompatible with the in-tree DuckDB submodule. CI exercises this path." + ) + raise RuntimeError(f"flapi failed to start. Log:\n{log_text}") + yield {"base_url": base_url, "user": user, "password": password} + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + log_file.close() + + +@pytest.mark.standalone_server +class TestPasswordHashing: + """End-to-end coverage for PBKDF2-SHA256 password storage.""" + + def test_validate_config_accepts_pbkdf2_user(self): + """The parser must accept a PBKDF2 MCF string as an inline user password.""" + binary, config_path, tmpdir = _validate_config_with_pbkdf2_user() + try: + result = subprocess.run( + [binary, "-c", config_path, "--validate-config"], + capture_output=True, text=True, cwd=tmpdir, timeout=30, + ) + assert result.returncode == 0, ( + f"validate-config rejected the PBKDF2 user:\n" + f"stdout={result.stdout}\nstderr={result.stderr}" + ) + assert "Validation PASSED" in (result.stdout + result.stderr) + finally: + import shutil + shutil.rmtree(tmpdir, ignore_errors=True) + + def test_basic_auth_with_correct_password_succeeds(self, pbkdf2_server): + r = requests.get( + f"{pbkdf2_server['base_url']}/ping", + headers={"Authorization": _basic_auth_header( + pbkdf2_server["user"], pbkdf2_server["password"])}, + timeout=5, + ) + # 200 if DB env happy, 500 otherwise — the proof is that auth did + # NOT return 401. + assert r.status_code != 401, f"correct password rejected: {r.text}" + + def test_basic_auth_with_wrong_password_is_rejected(self, pbkdf2_server): + r = requests.get( + f"{pbkdf2_server['base_url']}/ping", + headers={"Authorization": _basic_auth_header( + pbkdf2_server["user"], "wrong-password")}, + timeout=5, + ) + assert r.status_code == 401, f"wrong password unexpectedly accepted: {r.status_code} {r.text}"