diff --git a/README.md b/README.md index 32999f9..dd8272f 100644 --- a/README.md +++ b/README.md @@ -210,11 +210,16 @@ binary, data-driven rows via `_data()` slots: | Test binary | Label | What it covers | |---|---|---| | `modulo_core_tests` | unit | `version()` matches the CMake project version, semver shape | +| `modulo_core_password_policy_tests` | unit | shared password rules (length bounds, stable `password.*` codes) | | `modulo_api_health_dto_tests` | unit | `HealthResponse` JSON round-trip; `fromJson` rejecting missing/mistyped fields | | `modulo_api_error_dto_tests` | unit | `ErrorResponse` envelope shape and round-trip; rejection of flat/incomplete envelopes | | `modulo_api_json_tests` | unit | `api::json::require*` never falling back to QJson's silent defaults (missing, number, object, array, null) | | `modulo_server_config_tests` | unit | defaults, every variable, empty-means-unset, port 0, malformed ports → `config.invalid_port` | +| `modulo_server_auth_password_hasher_tests` | unit | Argon2id hash/verify, unique salts, unicode, malformed hashes, rehash detection | +| `modulo_server_auth_token_tests` | unit | 43-char base64url tokens, uniqueness, SHA-256 digest (known answer) | +| `modulo_server_auth_roles_tests` | unit | role ids/names match the schema catalogue | | `modulo_integration_tests` | integration | real `QHttpServer` on an OS-assigned port + real HTTP client: `/api/v1/health` body and version, 404 error envelope | +| `modulo_auth_repositories_tests` | integration | connection pool (lazy open, reuse) and user/session repositories against `modulo_test`: case-insensitive lookup, `auth.email_taken`, session create/find/touch/revoke, expiry, revoke-all | | `modulo_client_qml_tests` | ui | `QUICK_TEST_MAIN` runner over `client/tests/qml/tst_*.qml` (Qt Quick + Material smoke) | Conventions: every module's tests live in its own `tests/` directory (auto-discovered by @@ -262,13 +267,14 @@ cmake/ CMake toolkit: all build logic as modulo_* functions db/migrations/ append-only SQL schema migrations (NNNN_name.sql) docs/ high_level_design.md (Mermaid architecture diagrams) docker/ docker-compose.yml (Postgres 16 on :5433) + one-time initdb scripts -libs/core/ modulo_core — foundations: version(), Result (std::expected + QString error codes) +libs/core/ modulo_core — foundations: version(), Result, shared password policy libs/api/ modulo_api — Q_GADGET DTOs + validating QJson mappings shared by server and client scripts/ db-up.sh, db-down.sh, migrate.sh, format.sh tests/support/ shared test fixtures () for integration tests server/ backend: per-module static libraries + executables (each module has its own tests/) modules/config/ modulo_server_config — env-based process configuration - modules/db/ modulo_server_db — migration engine (connection pool arrives in Increment 2) + modules/db/ modulo_server_db — migration engine + libpqxx connection pool (Qt-free) + modules/auth/ modulo_server_auth — Argon2id hashing, session tokens, user/session repositories modules/http/ modulo_server_http — QHttpServer wrapper, routes, error envelope app/ modulo_server — REST API server executable migrate/ modulo_migrate — CLI migration runner @@ -309,6 +315,7 @@ CMakePresets.json configure/build/test presets (dev, dev-asan, dev-tidy, release | 1.7 — Docs finalization | README restructured for a public audience (status, contents, architecture, workflow, roadmap); HLD gained the test-architecture view; local working agreement (CLAUDE.md) refreshed | | 1.8 — Public-repo readiness | MIT `LICENSE`; GitHub Actions CI (macOS runner: brew deps, `ci` preset, `-Werror` build, format check, unit + ui tests); README badges + License section; repository made public and tagged `v0.1.0` | | 2.1 — Auth schema | `0002_auth.sql`: `users` (citext email, Argon2id hash, disabled_at), `roles` (admin/user), `user_roles`, `sessions` (SHA-256 token digest, sliding expiry, revocation, partial index); `set_updated_at()` trigger; ER diagram in the HLD | +| 2.2 — Auth crypto & data layer | `modulo_server_auth` module: Argon2id `PasswordHasher` (libsodium), opaque `token::generate`/`digest`, `Role` catalogue, `UserRepository` + `SessionRepository` (Result-returning, never throw); libpqxx `ConnectionPool` in `modules/db`; shared `core::validatePassword`; 5 new test binaries | ## License diff --git a/docs/high_level_design.md b/docs/high_level_design.md index 4f56418..5ec2bf1 100644 --- a/docs/high_level_design.md +++ b/docs/high_level_design.md @@ -64,7 +64,12 @@ api::json::require*"] cfg["modulo_server_config"] httpm["modulo_server_http"] dbm["modulo_server_db -(Qt-free · libpqxx)"] +(Qt-free · libpqxx) +Migrator · ConnectionPool"] + authm["modulo_server_auth +PasswordHasher (Argon2id) +token · Role +UserRepository · SessionRepository"] server(["modulo_server (exe)"]) migrateexe(["modulo_migrate (exe, Qt-free)"]) @@ -72,6 +77,8 @@ api::json::require*"] api --> core cfg --> core + authm --> core + authm --> dbm httpm --> api httpm --> cfg server --> httpm @@ -80,16 +87,19 @@ api::json::require*"] qt["Qt6: Core · Network · HttpServer · Quick"] pqxx["libpqxx 8"] + sodium["libsodium"] httpm -.-> qt clientexe -.-> qt core -.-> qt dbm -.-> pqxx + authm -.-> sodium ``` Every server-side module is its own static library (`CMakeLists.txt` + `include/modulo/...` + `src/`), created by the `modulo_*` CMake toolkit functions (warnings, sanitizers, clang-tidy, version -injection, qt.conf generation applied uniformly). Coming next: `modulo_server_auth` (Increment 2), -then transactions / transfers / holdings / rates / documents as sibling modules. +injection, qt.conf generation applied uniformly). `modulo_server_auth` holds the crypto and data layer +of Increment 2; the HTTP routes that use it (service + guards) are wired through `modulo_server_http` +next. Transactions / transfers / holdings / rates / documents follow as sibling modules. ## 3. Runtime flow — health check @@ -198,11 +208,17 @@ flowchart LR modulo_api_error_dto_tests modulo_api_json_tests"] t3["modulo_server_config_tests"] + t6["modulo_core_password_policy_tests +modulo_server_auth_*_tests +(hasher · token · roles)"] end subgraph integ["label: integration — opt-in"] t4["modulo_integration_tests in-process QHttpServer on port 0 + QNetworkAccessManager client"] + t7["modulo_auth_repositories_tests +ConnectionPool + repositories +against modulo_test"] end subgraph ui["label: ui — Qt Quick Test, offscreen"] t5["modulo_client_qml_tests @@ -210,6 +226,7 @@ tst_*.qml via QUICK_TEST_MAIN"] end env["MODULO_TEST_DB_URL"] -. "unset → QSKIP → CTest Skipped" .-> t4 + env -.-> t7 support["tests/support/include/modulo/testing/ integration.h: MODULO_REQUIRE_TEST_DATABASE(), httpGet()"] --> t4 diff --git a/libs/core/CMakeLists.txt b/libs/core/CMakeLists.txt index 3d170b8..b219525 100644 --- a/libs/core/CMakeLists.txt +++ b/libs/core/CMakeLists.txt @@ -2,5 +2,5 @@ modulo_add_library( modulo_core - SOURCES src/version.cpp + SOURCES src/password_policy.cpp src/version.cpp PUBLIC_DEPS Qt6::Core) diff --git a/libs/core/include/modulo/core/password_policy.h b/libs/core/include/modulo/core/password_policy.h new file mode 100644 index 0000000..a676ca5 --- /dev/null +++ b/libs/core/include/modulo/core/password_policy.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +#include + +namespace modulo::core { + +/// Password rules shared by the server (registration) and the client (form +/// validation) so both sides agree before a request is ever sent. +inline constexpr qsizetype kMinPasswordLength = 10; +inline constexpr qsizetype kMaxPasswordLength = 128; + +/// Error codes: "password.too_short", "password.too_long". +VoidResult validatePassword(QStringView password); + +} // namespace modulo::core diff --git a/libs/core/src/password_policy.cpp b/libs/core/src/password_policy.cpp new file mode 100644 index 0000000..d995c03 --- /dev/null +++ b/libs/core/src/password_policy.cpp @@ -0,0 +1,17 @@ +#include + +namespace modulo::core { + +VoidResult validatePassword(QStringView password) { + if (password.size() < kMinPasswordLength) { + return makeError(QStringLiteral("password.too_short"), + QStringLiteral("password must be at least %1 characters").arg(kMinPasswordLength)); + } + if (password.size() > kMaxPasswordLength) { + return makeError(QStringLiteral("password.too_long"), + QStringLiteral("password must be at most %1 characters").arg(kMaxPasswordLength)); + } + return {}; +} + +} // namespace modulo::core diff --git a/libs/core/tests/CMakeLists.txt b/libs/core/tests/CMakeLists.txt index 50319af..94a8ee9 100644 --- a/libs/core/tests/CMakeLists.txt +++ b/libs/core/tests/CMakeLists.txt @@ -3,3 +3,9 @@ modulo_add_test( LABEL unit SOURCES test_version.cpp DEPS modulo_core) + +modulo_add_test( + modulo_core_password_policy_tests + LABEL unit + SOURCES test_password_policy.cpp + DEPS modulo_core) diff --git a/libs/core/tests/test_password_policy.cpp b/libs/core/tests/test_password_policy.cpp new file mode 100644 index 0000000..8d7aa29 --- /dev/null +++ b/libs/core/tests/test_password_policy.cpp @@ -0,0 +1,48 @@ +#include + +#include + +using namespace modulo::core; + +class PasswordPolicyTest : public QObject { + Q_OBJECT + +private slots: + + void acceptsPasswordsWithinBounds_data() { + QTest::addColumn("password"); + + QTest::newRow("exactly minimum") << QString{kMinPasswordLength, u'a'}; + QTest::newRow("typical") << QStringLiteral("correct horse battery"); + QTest::newRow("exactly maximum") << QString{kMaxPasswordLength, u'z'}; + QTest::newRow("unicode counts characters, not bytes") << QString{kMinPasswordLength, u'é'}; + } + + void acceptsPasswordsWithinBounds() { + QFETCH(QString, password); + QVERIFY(validatePassword(password).has_value()); + } + + void rejectsPasswordsOutsideBounds_data() { + QTest::addColumn("password"); + QTest::addColumn("code"); + + QTest::newRow("empty") << QString{} << QStringLiteral("password.too_short"); + QTest::newRow("one below minimum") + << QString{kMinPasswordLength - 1, u'a'} << QStringLiteral("password.too_short"); + QTest::newRow("one above maximum") + << QString{kMaxPasswordLength + 1, u'a'} << QStringLiteral("password.too_long"); + } + + void rejectsPasswordsOutsideBounds() { + QFETCH(QString, password); + QFETCH(QString, code); + + const auto result = validatePassword(password); + QVERIFY(!result.has_value()); + QCOMPARE(result.error().code, code); + } +}; + +QTEST_GUILESS_MAIN(PasswordPolicyTest) +#include "test_password_policy.moc" diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index 7c741e9..7e56a02 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -2,6 +2,7 @@ add_subdirectory(modules/config) add_subdirectory(modules/db) +add_subdirectory(modules/auth) add_subdirectory(modules/http) add_subdirectory(app) diff --git a/server/modules/auth/CMakeLists.txt b/server/modules/auth/CMakeLists.txt new file mode 100644 index 0000000..24627dc --- /dev/null +++ b/server/modules/auth/CMakeLists.txt @@ -0,0 +1,13 @@ +# modulo_server_auth — authentication & RBAC: Argon2id password hashing, +# opaque session tokens, and the user/session repositories. + +modulo_add_library( + modulo_server_auth + SOURCES src/password_hasher.cpp + src/roles.cpp + src/session_repository.cpp + src/sodium_init.cpp + src/token.cpp + src/user_repository.cpp + PUBLIC_DEPS modulo_core modulo_server_db Qt6::Core + PRIVATE_DEPS sodium::sodium) diff --git a/server/modules/auth/include/modulo/server/auth/password_hasher.h b/server/modules/auth/include/modulo/server/auth/password_hasher.h new file mode 100644 index 0000000..aca71f9 --- /dev/null +++ b/server/modules/auth/include/modulo/server/auth/password_hasher.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +#include + +namespace modulo::server::auth { + +/// Argon2id password hashing via libsodium (crypto_pwhash_str). +/// +/// Hashes are self-describing strings (algorithm, parameters, salt, digest) +/// and are stored verbatim in users.password_hash. Cost parameters are +/// libsodium's INTERACTIVE profile (64 MiB, 2 passes): above the OWASP +/// minimum for Argon2id and fast enough for a login round-trip. +class PasswordHasher { +public: + /// Error code "auth.hash_failed" only if libsodium cannot allocate. + static core::Result hash(const QString& password); + + /// Constant-time verification; false for a malformed hash. + static bool verify(const QString& hash, const QString& password); + + /// True when the stored hash used weaker parameters than the current + /// profile - callers may re-hash after a successful verify(). + static bool needsRehash(const QString& hash); +}; + +} // namespace modulo::server::auth diff --git a/server/modules/auth/include/modulo/server/auth/roles.h b/server/modules/auth/include/modulo/server/auth/roles.h new file mode 100644 index 0000000..9af0e5a --- /dev/null +++ b/server/modules/auth/include/modulo/server/auth/roles.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +#include + +namespace modulo::server::auth { + +/// Fixed role catalogue; ids match the rows seeded by db/migrations/0002_auth.sql. +enum class Role : qint16 { Admin = 1, User = 2 }; + +/// Wire/log name: "admin" / "user". +QString roleName(Role role); + +/// Inverse of roleName(); std::nullopt for unknown names. +std::optional roleFromName(QStringView name); + +/// Database id → Role; std::nullopt for ids not in the catalogue. +std::optional roleFromId(qint16 id); + +} // namespace modulo::server::auth diff --git a/server/modules/auth/include/modulo/server/auth/session_repository.h b/server/modules/auth/include/modulo/server/auth/session_repository.h new file mode 100644 index 0000000..0e9af07 --- /dev/null +++ b/server/modules/auth/include/modulo/server/auth/session_repository.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +#include +#include +#include + +#include + +namespace modulo::server::auth { + +/// A row of `sessions`. The token itself is never stored; rows are found by +/// the SHA-256 digest of the token the client presents. +struct SessionRecord { + QString id; ///< uuid as text + QString userId; + QDateTime createdAt; + QDateTime expiresAt; + QDateTime lastSeenAt; + bool revoked = false; +}; + +/// Data access for sessions. Every method runs in its own transaction on a +/// pooled connection and returns Result - it never throws. +/// Error codes: "auth.session_not_found" (touch/revoke on a missing or already +/// revoked session), "db.*" for infrastructure failures. +class SessionRepository { +public: + explicit SessionRepository(db::ConnectionPool& pool); + + /// `tokenDigest` must be exactly 32 bytes (token::digest output). + core::Result create(const QString& userId, const QByteArray& tokenDigest, + const QDateTime& expiresAt); + + /// Only sessions that are neither revoked nor expired. nullopt otherwise. + core::Result> findActiveByDigest(const QByteArray& tokenDigest); + + /// Sliding expiry: records activity and pushes expires_at forward. + core::VoidResult touch(const QString& sessionId, const QDateTime& newExpiresAt); + + core::VoidResult revoke(const QString& sessionId); + + /// "Log out everywhere": returns the number of sessions revoked. + core::Result revokeAllForUser(const QString& userId); + +private: + db::ConnectionPool& pool_; +}; + +} // namespace modulo::server::auth diff --git a/server/modules/auth/include/modulo/server/auth/token.h b/server/modules/auth/include/modulo/server/auth/token.h new file mode 100644 index 0000000..a55fa26 --- /dev/null +++ b/server/modules/auth/include/modulo/server/auth/token.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +namespace modulo::server::auth::token { + +inline constexpr int kTokenBytes = 32; +inline constexpr int kDigestBytes = 32; + +/// A new opaque session token: 32 CSPRNG bytes (libsodium randombytes_buf - +/// an explicit cryptographic guarantee, which QRandomGenerator does not make) +/// encoded as base64url without padding (43 characters). Returned to the +/// client exactly once and never stored. +QString generate(); + +/// SHA-256 digest (32 bytes, QCryptographicHash) of a token as presented by +/// the client - the only form ever stored or compared. Any string is +/// accepted; a malformed token simply never matches a session row. +QByteArray digest(const QString& token); + +} // namespace modulo::server::auth::token diff --git a/server/modules/auth/include/modulo/server/auth/user_repository.h b/server/modules/auth/include/modulo/server/auth/user_repository.h new file mode 100644 index 0000000..f8ff2d9 --- /dev/null +++ b/server/modules/auth/include/modulo/server/auth/user_repository.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +#include + +namespace modulo::server::auth { + +/// A row of `users` plus its roles. `passwordHash` is the Argon2id string +/// (never exposed on the wire - the service layer maps to DTOs). +struct UserRecord { + QString id; ///< uuid as text + QString email; + QString displayName; + QString passwordHash; + QDateTime createdAt; + bool disabled = false; + QList roles; +}; + +/// Data access for users and their roles. Every method runs in its own +/// transaction on a pooled connection and returns Result - it never throws. +/// Error codes: "auth.email_taken" (duplicate email, case-insensitive), +/// "db.*" for infrastructure failures. +class UserRepository { +public: + explicit UserRepository(db::ConnectionPool& pool); + + /// Inserts the user and its role assignments atomically. + core::Result create(const QString& email, const QString& displayName, const QString& passwordHash, + const QList& roles); + + /// Case-insensitive lookup (email is citext). nullopt = no such user. + core::Result> findByEmail(const QString& email); + + core::Result> findById(const QString& id); + + /// Total number of users (the first registration becomes admin). + core::Result count(); + +private: + db::ConnectionPool& pool_; +}; + +} // namespace modulo::server::auth diff --git a/server/modules/auth/src/password_hasher.cpp b/server/modules/auth/src/password_hasher.cpp new file mode 100644 index 0000000..e9e80ed --- /dev/null +++ b/server/modules/auth/src/password_hasher.cpp @@ -0,0 +1,60 @@ +#include "sodium_init.h" + +#include + +#include + +#include +#include + +namespace modulo::server::auth { + +namespace { + +constexpr unsigned long long kOpsLimit = crypto_pwhash_OPSLIMIT_INTERACTIVE; +constexpr std::size_t kMemLimit = crypto_pwhash_MEMLIMIT_INTERACTIVE; + +} // namespace + +core::Result PasswordHasher::hash(const QString& password) { + ensureSodium(); + + const QByteArray utf8 = password.toUtf8(); + std::array encoded{}; + if (crypto_pwhash_str(encoded.data(), utf8.constData(), static_cast(utf8.size()), kOpsLimit, + kMemLimit) != 0) { + return core::makeError(QStringLiteral("auth.hash_failed"), + QStringLiteral("password hashing failed (out of memory)")); + } + return QString::fromLatin1(encoded.data()); // NUL-terminated ASCII +} + +bool PasswordHasher::verify(const QString& hash, const QString& password) { + ensureSodium(); + + const QByteArray encoded = hash.toLatin1(); + if (encoded.size() >= static_cast(crypto_pwhash_STRBYTES)) { + return false; // cannot be a valid crypto_pwhash_str output + } + std::array buffer{}; + std::copy(encoded.cbegin(), encoded.cend(), buffer.begin()); + + const QByteArray utf8 = password.toUtf8(); + return crypto_pwhash_str_verify(buffer.data(), utf8.constData(), static_cast(utf8.size())) == 0; +} + +bool PasswordHasher::needsRehash(const QString& hash) { + ensureSodium(); + + const QByteArray encoded = hash.toLatin1(); + if (encoded.size() >= static_cast(crypto_pwhash_STRBYTES)) { + return true; + } + std::array buffer{}; + std::copy(encoded.cbegin(), encoded.cend(), buffer.begin()); + + // 0 = parameters match; 1 = weaker than current; -1 = unparsable (treat as rehash). + return crypto_pwhash_str_needs_rehash(buffer.data(), kOpsLimit, kMemLimit) != 0; +} + +} // namespace modulo::server::auth diff --git a/server/modules/auth/src/pg.h b/server/modules/auth/src/pg.h new file mode 100644 index 0000000..4512598 --- /dev/null +++ b/server/modules/auth/src/pg.h @@ -0,0 +1,82 @@ +#pragma once + +// Internal to the auth module: the Qt <-> libpqxx boundary used by the +// repositories. Converts types at the edge and maps libpqxx failures to +// core::Error so repository methods return Result, never throw. + +#include +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace modulo::server::auth::pg { + +inline std::string toStd(const QString& value) { + return value.toStdString(); +} + +inline QString fromStd(const std::string& value) { + return QString::fromStdString(value); +} + +inline pqxx::bytes toBytes(const QByteArray& value) { + const auto* begin = reinterpret_cast(value.constData()); + return pqxx::bytes(begin, begin + value.size()); +} + +/// Timestamps travel as ISO-8601 UTC text into SQL and as epoch milliseconds +/// out of it (see the `epochMs` SQL helper), avoiding PostgreSQL's +/// locale-dependent timestamp text format. +inline std::string toIso(const QDateTime& value) { + return value.toUTC().toString(Qt::ISODateWithMs).toStdString(); +} + +inline QDateTime fromEpochMs(long long milliseconds) { + return QDateTime::fromMSecsSinceEpoch(milliseconds, QTimeZone::UTC); +} + +/// SQL fragment turning a timestamptz column into epoch milliseconds. +inline std::string epochMs(const char* column) { + return std::string{"(extract(epoch from "} + column + ") * 1000)::bigint"; +} + +/// Runs `fn(pqxx::work&)` inside one transaction on a pooled connection and +/// commits; any libpqxx failure becomes an Error with a stable "db.*" code. +template +auto withTransaction(db::ConnectionPool& pool, Fn&& fn) -> core::Result> { + using Value = std::invoke_result_t; + try { + auto lease = pool.acquire(); + pqxx::work tx{lease.connection()}; + if constexpr (std::is_void_v) { + std::forward(fn)(tx); + tx.commit(); + return {}; + } else { + Value value = std::forward(fn)(tx); + tx.commit(); + return value; + } + } catch (const pqxx::unique_violation& error) { + return core::makeError(QStringLiteral("db.unique_violation"), fromStd(error.what())); + } catch (const pqxx::sql_error& error) { + return core::makeError(QStringLiteral("db.query_failed"), fromStd(error.what())); + } catch (const pqxx::broken_connection& error) { + return core::makeError(QStringLiteral("db.unavailable"), fromStd(error.what())); + } catch (const std::exception& error) { + return core::makeError(QStringLiteral("db.error"), fromStd(error.what())); + } +} + +} // namespace modulo::server::auth::pg diff --git a/server/modules/auth/src/roles.cpp b/server/modules/auth/src/roles.cpp new file mode 100644 index 0000000..1282efa --- /dev/null +++ b/server/modules/auth/src/roles.cpp @@ -0,0 +1,36 @@ +#include + +namespace modulo::server::auth { + +QString roleName(Role role) { + switch (role) { + case Role::Admin: + return QStringLiteral("admin"); + case Role::User: + return QStringLiteral("user"); + } + return {}; +} + +std::optional roleFromName(QStringView name) { + if (name == QLatin1StringView{"admin"}) { + return Role::Admin; + } + if (name == QLatin1StringView{"user"}) { + return Role::User; + } + return std::nullopt; +} + +std::optional roleFromId(qint16 id) { + switch (id) { + case 1: + return Role::Admin; + case 2: + return Role::User; + default: + return std::nullopt; + } +} + +} // namespace modulo::server::auth diff --git a/server/modules/auth/src/session_repository.cpp b/server/modules/auth/src/session_repository.cpp new file mode 100644 index 0000000..cfb77b8 --- /dev/null +++ b/server/modules/auth/src/session_repository.cpp @@ -0,0 +1,114 @@ +#include "pg.h" + +#include + +#include + +namespace modulo::server::auth { + +namespace { + +const std::string kSessionColumns = "id::text, user_id::text, " + pg::epochMs("created_at") + ", " + + pg::epochMs("expires_at") + ", " + pg::epochMs("last_seen_at") + + ", (revoked_at IS NOT NULL)"; + +SessionRecord toRecord(const pqxx::row& row) { + SessionRecord record; + record.id = pg::fromStd(row[0].as()); + record.userId = pg::fromStd(row[1].as()); + record.createdAt = pg::fromEpochMs(row[2].as()); + record.expiresAt = pg::fromEpochMs(row[3].as()); + record.lastSeenAt = pg::fromEpochMs(row[4].as()); + record.revoked = row[5].as(); + return record; +} + +core::VoidResult sessionNotFound() { + return core::makeError(QStringLiteral("auth.session_not_found"), + QStringLiteral("session does not exist or is already revoked")); +} + +/// Maps an UPDATE's affected-row count to the repository contract: zero rows +/// means the session does not exist or is already revoked. +core::VoidResult requireAffected(const core::Result& affected) { + if (!affected) { + return std::unexpected{affected.error()}; + } + if (*affected == 0) { + return sessionNotFound(); + } + return {}; +} + +bool isUuid(const QString& id) { + return !QUuid::fromString(id).isNull(); +} + +} // namespace + +SessionRepository::SessionRepository(db::ConnectionPool& pool) : pool_{pool} { +} + +core::Result SessionRepository::create(const QString& userId, const QByteArray& tokenDigest, + const QDateTime& expiresAt) { + return pg::withTransaction(pool_, [&](pqxx::work& tx) { + const auto row = tx.exec("INSERT INTO sessions (user_id, token_sha256, expires_at) " + "VALUES ($1::uuid, $2, $3::timestamptz) RETURNING " + + kSessionColumns, + pqxx::params{pg::toStd(userId), pg::toBytes(tokenDigest), pg::toIso(expiresAt)}) + .one_row(); + return toRecord(row); + }); +} + +core::Result> SessionRepository::findActiveByDigest(const QByteArray& tokenDigest) { + return pg::withTransaction(pool_, [&](pqxx::work& tx) -> std::optional { + const auto rows = tx.exec("SELECT " + kSessionColumns + + " FROM sessions WHERE token_sha256 = $1 AND revoked_at IS NULL " + "AND expires_at > now()", + pqxx::params{pg::toBytes(tokenDigest)}); + if (rows.empty()) { + return std::nullopt; + } + return toRecord(rows.one_row()); + }); +} + +core::VoidResult SessionRepository::touch(const QString& sessionId, const QDateTime& newExpiresAt) { + if (!isUuid(sessionId)) { + return sessionNotFound(); + } + return requireAffected(pg::withTransaction(pool_, [&](pqxx::work& tx) -> long long { + return tx + .exec("UPDATE sessions SET last_seen_at = now(), expires_at = $2::timestamptz " + "WHERE id = $1::uuid AND revoked_at IS NULL", + pqxx::params{pg::toStd(sessionId), pg::toIso(newExpiresAt)}) + .affected_rows(); + })); +} + +core::VoidResult SessionRepository::revoke(const QString& sessionId) { + if (!isUuid(sessionId)) { + return sessionNotFound(); + } + return requireAffected(pg::withTransaction(pool_, [&](pqxx::work& tx) -> long long { + return tx + .exec("UPDATE sessions SET revoked_at = now() WHERE id = $1::uuid AND revoked_at IS NULL", + pqxx::params{pg::toStd(sessionId)}) + .affected_rows(); + })); +} + +core::Result SessionRepository::revokeAllForUser(const QString& userId) { + if (!isUuid(userId)) { + return qint64{0}; + } + return pg::withTransaction(pool_, [&](pqxx::work& tx) { + return static_cast( + tx.exec("UPDATE sessions SET revoked_at = now() WHERE user_id = $1::uuid AND revoked_at IS NULL", + pqxx::params{pg::toStd(userId)}) + .affected_rows()); + }); +} + +} // namespace modulo::server::auth diff --git a/server/modules/auth/src/sodium_init.cpp b/server/modules/auth/src/sodium_init.cpp new file mode 100644 index 0000000..49358b3 --- /dev/null +++ b/server/modules/auth/src/sodium_init.cpp @@ -0,0 +1,19 @@ +#include "sodium_init.h" + +#include + +#include +#include + +namespace modulo::server::auth { + +void ensureSodium() { + static std::once_flag once; + std::call_once(once, [] { + if (sodium_init() < 0) { + throw std::runtime_error("libsodium failed to initialise"); + } + }); +} + +} // namespace modulo::server::auth diff --git a/server/modules/auth/src/sodium_init.h b/server/modules/auth/src/sodium_init.h new file mode 100644 index 0000000..9962e51 --- /dev/null +++ b/server/modules/auth/src/sodium_init.h @@ -0,0 +1,12 @@ +#pragma once + +// Internal to the auth module: libsodium must be initialised exactly once +// before any of its functions is used. + +namespace modulo::server::auth { + +/// Idempotent, thread-safe. Throws std::runtime_error if libsodium cannot +/// initialise (a broken install - genuinely exceptional, not a Result path). +void ensureSodium(); + +} // namespace modulo::server::auth diff --git a/server/modules/auth/src/token.cpp b/server/modules/auth/src/token.cpp new file mode 100644 index 0000000..e0c78c0 --- /dev/null +++ b/server/modules/auth/src/token.cpp @@ -0,0 +1,23 @@ +#include "sodium_init.h" + +#include + +#include + +#include + +namespace modulo::server::auth::token { + +QString generate() { + ensureSodium(); + + QByteArray random{kTokenBytes, Qt::Uninitialized}; + randombytes_buf(random.data(), static_cast(random.size())); + return QString::fromLatin1(random.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals)); +} + +QByteArray digest(const QString& token) { + return QCryptographicHash::hash(token.toUtf8(), QCryptographicHash::Sha256); +} + +} // namespace modulo::server::auth::token diff --git a/server/modules/auth/src/user_repository.cpp b/server/modules/auth/src/user_repository.cpp new file mode 100644 index 0000000..4e7e347 --- /dev/null +++ b/server/modules/auth/src/user_repository.cpp @@ -0,0 +1,96 @@ +#include "pg.h" + +#include + +#include + +namespace modulo::server::auth { + +namespace { + +const std::string kUserColumns = + "id::text, email::text, display_name, password_hash, " + pg::epochMs("created_at") + ", (disabled_at IS NOT NULL)"; + +QList loadRoles(pqxx::work& tx, const std::string& userId) { + QList roles; + for (const auto& row : + tx.exec("SELECT role_id FROM user_roles WHERE user_id = $1::uuid ORDER BY role_id", pqxx::params{userId})) { + if (const auto role = roleFromId(row[0].as())) { + roles.append(*role); + } + } + return roles; +} + +UserRecord toRecord(pqxx::work& tx, const pqxx::row& row) { + UserRecord record; + record.id = pg::fromStd(row[0].as()); + record.email = pg::fromStd(row[1].as()); + record.displayName = pg::fromStd(row[2].as()); + record.passwordHash = pg::fromStd(row[3].as()); + record.createdAt = pg::fromEpochMs(row[4].as()); + record.disabled = row[5].as(); + record.roles = loadRoles(tx, row[0].as()); + return record; +} + +std::optional findOne(pqxx::work& tx, const std::string& whereClause, const pqxx::params& params) { + const auto rows = tx.exec("SELECT " + kUserColumns + " FROM users WHERE " + whereClause, params); + if (rows.empty()) { + return std::nullopt; + } + return toRecord(tx, rows.one_row()); +} + +} // namespace + +UserRepository::UserRepository(db::ConnectionPool& pool) : pool_{pool} { +} + +core::Result UserRepository::create(const QString& email, const QString& displayName, + const QString& passwordHash, const QList& roles) { + auto result = pg::withTransaction(pool_, [&](pqxx::work& tx) { + const auto row = tx.exec("INSERT INTO users (email, display_name, password_hash) VALUES ($1, $2, $3) " + "RETURNING " + + kUserColumns, + pqxx::params{pg::toStd(email), pg::toStd(displayName), pg::toStd(passwordHash)}) + .one_row(); + const std::string userId = row[0].as(); + // Idempotent: a repeated role is not an error, so the only unique + // violation this transaction can raise is the email one. + for (const Role role : roles) { + tx.exec("INSERT INTO user_roles (user_id, role_id) VALUES ($1::uuid, $2) ON CONFLICT DO NOTHING", + pqxx::params{userId, static_cast(role)}); + } + return toRecord(tx, row); + }); + + if (!result && result.error().code == QStringLiteral("db.unique_violation")) { + return core::makeError(QStringLiteral("auth.email_taken"), + QStringLiteral("an account with this email already exists")); + } + return result; +} + +core::Result> UserRepository::findByEmail(const QString& email) { + return pg::withTransaction( + pool_, [&](pqxx::work& tx) { return findOne(tx, "email = $1::citext", pqxx::params{pg::toStd(email)}); }); +} + +core::Result> UserRepository::findById(const QString& id) { + // A malformed id can never match a row; reject it here instead of letting + // the $1::uuid cast fail inside PostgreSQL and surface as db.query_failed. + if (QUuid::fromString(id).isNull()) { + return std::optional{}; + } + return pg::withTransaction( + pool_, [&](pqxx::work& tx) { return findOne(tx, "id = $1::uuid", pqxx::params{pg::toStd(id)}); }); +} + +core::Result UserRepository::count() { + return pg::withTransaction(pool_, [](pqxx::work& tx) { + return static_cast(tx.query_value("SELECT count(*) FROM users")); + }); +} + +} // namespace modulo::server::auth diff --git a/server/modules/auth/tests/CMakeLists.txt b/server/modules/auth/tests/CMakeLists.txt new file mode 100644 index 0000000..c376bb4 --- /dev/null +++ b/server/modules/auth/tests/CMakeLists.txt @@ -0,0 +1,17 @@ +modulo_add_test( + modulo_server_auth_password_hasher_tests + LABEL unit + SOURCES test_password_hasher.cpp + DEPS modulo_server_auth) + +modulo_add_test( + modulo_server_auth_token_tests + LABEL unit + SOURCES test_token.cpp + DEPS modulo_server_auth) + +modulo_add_test( + modulo_server_auth_roles_tests + LABEL unit + SOURCES test_roles.cpp + DEPS modulo_server_auth) diff --git a/server/modules/auth/tests/test_password_hasher.cpp b/server/modules/auth/tests/test_password_hasher.cpp new file mode 100644 index 0000000..40d5e1f --- /dev/null +++ b/server/modules/auth/tests/test_password_hasher.cpp @@ -0,0 +1,56 @@ +#include + +#include + +using modulo::server::auth::PasswordHasher; + +class PasswordHasherTest : public QObject { + Q_OBJECT + +private slots: + + void hashIsArgon2idAndVerifies() { + const auto hash = PasswordHasher::hash(QStringLiteral("correct horse battery")); + QVERIFY(hash.has_value()); + QVERIFY(hash->startsWith(QStringLiteral("$argon2id$"))); + QVERIFY(PasswordHasher::verify(*hash, QStringLiteral("correct horse battery"))); + } + + void wrongPasswordDoesNotVerify() { + const auto hash = PasswordHasher::hash(QStringLiteral("correct horse battery")); + QVERIFY(hash.has_value()); + QVERIFY(!PasswordHasher::verify(*hash, QStringLiteral("correct horse batteries"))); + QVERIFY(!PasswordHasher::verify(*hash, QString{})); + } + + void sameHashNeverRepeats() { + // A fresh random salt per hash: equal passwords must yield different strings. + const auto first = PasswordHasher::hash(QStringLiteral("same password")); + const auto second = PasswordHasher::hash(QStringLiteral("same password")); + QVERIFY(first.has_value() && second.has_value()); + QVERIFY(*first != *second); + } + + void unicodePasswordsRoundTrip() { + const QString password = QStringLiteral("pässwörd-日本語-🙂"); + const auto hash = PasswordHasher::hash(password); + QVERIFY(hash.has_value()); + QVERIFY(PasswordHasher::verify(*hash, password)); + } + + void malformedHashesNeverVerify() { + QVERIFY(!PasswordHasher::verify(QString{}, QStringLiteral("anything"))); + QVERIFY(!PasswordHasher::verify(QStringLiteral("not a hash"), QStringLiteral("anything"))); + QVERIFY(!PasswordHasher::verify(QString{200, u'x'}, QStringLiteral("anything"))); // longer than STRBYTES + } + + void freshHashDoesNotNeedRehash() { + const auto hash = PasswordHasher::hash(QStringLiteral("correct horse battery")); + QVERIFY(hash.has_value()); + QVERIFY(!PasswordHasher::needsRehash(*hash)); + QVERIFY(PasswordHasher::needsRehash(QStringLiteral("garbage"))); // unparsable → rehash + } +}; + +QTEST_GUILESS_MAIN(PasswordHasherTest) +#include "test_password_hasher.moc" diff --git a/server/modules/auth/tests/test_roles.cpp b/server/modules/auth/tests/test_roles.cpp new file mode 100644 index 0000000..025c016 --- /dev/null +++ b/server/modules/auth/tests/test_roles.cpp @@ -0,0 +1,36 @@ +#include + +#include + +using namespace modulo::server::auth; + +class RolesTest : public QObject { + Q_OBJECT + +private slots: + + void namesAndIdsMatchTheSchemaCatalogue() { + // Ids are the rows seeded by 0002_auth.sql; names are the wire form. + QCOMPARE(static_cast(Role::Admin), qint16{1}); + QCOMPARE(static_cast(Role::User), qint16{2}); + QCOMPARE(roleName(Role::Admin), QStringLiteral("admin")); + QCOMPARE(roleName(Role::User), QStringLiteral("user")); + } + + void roundTripsThroughNamesAndIds() { + for (const Role role : {Role::Admin, Role::User}) { + QCOMPARE(roleFromName(roleName(role)), std::optional{role}); + QCOMPARE(roleFromId(static_cast(role)), std::optional{role}); + } + } + + void rejectsUnknownValues() { + QVERIFY(!roleFromName(QStringLiteral("root")).has_value()); + QVERIFY(!roleFromName(QStringLiteral("Admin")).has_value()); // names are exact, lower-case + QVERIFY(!roleFromId(0).has_value()); + QVERIFY(!roleFromId(3).has_value()); + } +}; + +QTEST_GUILESS_MAIN(RolesTest) +#include "test_roles.moc" diff --git a/server/modules/auth/tests/test_token.cpp b/server/modules/auth/tests/test_token.cpp new file mode 100644 index 0000000..1ae641f --- /dev/null +++ b/server/modules/auth/tests/test_token.cpp @@ -0,0 +1,46 @@ +#include + +#include +#include +#include + +namespace token = modulo::server::auth::token; + +class TokenTest : public QObject { + Q_OBJECT + +private slots: + + void generatedTokenIsUnpaddedBase64Url() { + const QString value = token::generate(); + QCOMPARE(value.size(), 43); // ceil(32 * 4 / 3) without '=' padding + const QRegularExpression alphabet{QStringLiteral("^[A-Za-z0-9_-]+$")}; + QVERIFY(alphabet.match(value).hasMatch()); + } + + void generatedTokensAreUnique() { + QSet seen; + for (int i = 0; i < 1000; ++i) { + seen.insert(token::generate()); + } + QCOMPARE(seen.size(), 1000); + } + + void digestIsDeterministicSha256() { + const QString value = token::generate(); + const QByteArray first = token::digest(value); + QCOMPARE(first.size(), token::kDigestBytes); + QCOMPARE(token::digest(value), first); + + // Known answer: SHA-256("abc") + QCOMPARE(token::digest(QStringLiteral("abc")).toHex(), + QByteArray{"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"}); + } + + void differentTokensHaveDifferentDigests() { + QVERIFY(token::digest(token::generate()) != token::digest(token::generate())); + } +}; + +QTEST_GUILESS_MAIN(TokenTest) +#include "test_token.moc" diff --git a/server/modules/db/CMakeLists.txt b/server/modules/db/CMakeLists.txt index 3b78e52..029119e 100644 --- a/server/modules/db/CMakeLists.txt +++ b/server/modules/db/CMakeLists.txt @@ -1,6 +1,8 @@ -# modulo_server_db — database access module and migration engine. +# modulo_server_db — database access module: migration engine and the +# libpqxx connection pool shared by every repository. Qt-free by design. +# libpqxx is PUBLIC because the pool hands out pqxx::connection leases. modulo_add_library( modulo_server_db - SOURCES src/migrator.cpp - PRIVATE_DEPS libpqxx::pqxx) + SOURCES src/connection_pool.cpp src/migrator.cpp + PUBLIC_DEPS libpqxx::pqxx) diff --git a/server/modules/db/include/modulo/server/db/connection_pool.h b/server/modules/db/include/modulo/server/db/connection_pool.h new file mode 100644 index 0000000..13eed2a --- /dev/null +++ b/server/modules/db/include/modulo/server/db/connection_pool.h @@ -0,0 +1,73 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +namespace modulo::server::db { + +/// Fixed-capacity pool of libpqxx connections, safe to share between threads. +/// +/// Connections are opened lazily on first use (constructing a pool never +/// touches the network) and handed out as RAII leases; acquire() blocks while +/// every connection is in use. A connection found closed when its lease ends +/// is discarded and re-created on demand. Connection failures propagate as +/// pqxx exceptions - this module is the Qt-free zone; callers translate them +/// into core::Result errors at the boundary. +class ConnectionPool { +public: + /// Exclusive access to one pooled connection; returns it on destruction. + /// A lease must not outlive its pool. + class Lease { + public: + Lease(Lease&& other) noexcept; + Lease& operator=(Lease&& other) noexcept; + ~Lease(); + + Lease(const Lease&) = delete; + Lease& operator=(const Lease&) = delete; + + pqxx::connection& connection() const { return *connection_; } + + pqxx::connection* operator->() const { return connection_.get(); } + + private: + friend class ConnectionPool; + Lease(ConnectionPool& pool, std::unique_ptr connection); + + ConnectionPool* pool_ = nullptr; + std::unique_ptr connection_; + }; + + explicit ConnectionPool(std::string databaseUrl, std::size_t capacity = 4); + + ConnectionPool(const ConnectionPool&) = delete; + ConnectionPool& operator=(const ConnectionPool&) = delete; + + /// Blocks until a connection is free. Throws pqxx::broken_connection (or + /// another pqxx::failure) if a new connection cannot be opened. + Lease acquire(); + + std::size_t capacity() const { return capacity_; } + + /// Connections currently open (idle + leased). Observability for tests. + std::size_t openConnections(); + +private: + void release(std::unique_ptr connection); + + std::string databaseUrl_; + std::size_t capacity_; + + std::mutex mutex_; + std::condition_variable available_; + std::vector> idle_; + std::size_t open_ = 0; +}; + +} // namespace modulo::server::db diff --git a/server/modules/db/src/connection_pool.cpp b/server/modules/db/src/connection_pool.cpp new file mode 100644 index 0000000..918a54c --- /dev/null +++ b/server/modules/db/src/connection_pool.cpp @@ -0,0 +1,87 @@ +#include + +#include +#include + +namespace modulo::server::db { + +// --- Lease ------------------------------------------------------------------- + +ConnectionPool::Lease::Lease(ConnectionPool& pool, std::unique_ptr connection) + : pool_{&pool}, connection_{std::move(connection)} { +} + +ConnectionPool::Lease::Lease(Lease&& other) noexcept + : pool_{std::exchange(other.pool_, nullptr)}, connection_{std::move(other.connection_)} { +} + +ConnectionPool::Lease& ConnectionPool::Lease::operator=(Lease&& other) noexcept { + if (this != &other) { + if (pool_ != nullptr && connection_) { + pool_->release(std::move(connection_)); + } + pool_ = std::exchange(other.pool_, nullptr); + connection_ = std::move(other.connection_); + } + return *this; +} + +ConnectionPool::Lease::~Lease() { + if (pool_ != nullptr && connection_) { + pool_->release(std::move(connection_)); + } +} + +// --- ConnectionPool ---------------------------------------------------------- + +ConnectionPool::ConnectionPool(std::string databaseUrl, std::size_t capacity) + : databaseUrl_{std::move(databaseUrl)}, capacity_{capacity} { + if (capacity_ == 0) { + throw std::invalid_argument("ConnectionPool capacity must be at least 1"); + } +} + +ConnectionPool::Lease ConnectionPool::acquire() { + std::unique_lock lock{mutex_}; + for (;;) { + if (!idle_.empty()) { + auto connection = std::move(idle_.back()); + idle_.pop_back(); + return Lease{*this, std::move(connection)}; + } + + if (open_ < capacity_) { + // Reserve the slot before connecting so concurrent callers cannot + // overshoot the capacity while this connection is being opened. + ++open_; + lock.unlock(); + try { + return Lease{*this, std::make_unique(databaseUrl_)}; + } catch (...) { + lock.lock(); + --open_; + available_.notify_one(); + throw; + } + } + + available_.wait(lock); + } +} + +std::size_t ConnectionPool::openConnections() { + const std::lock_guard lock{mutex_}; + return open_; +} + +void ConnectionPool::release(std::unique_ptr connection) { + const std::lock_guard lock{mutex_}; + if (connection && connection->is_open()) { + idle_.push_back(std::move(connection)); + } else { + --open_; // broken connection: drop it, a fresh one is opened on demand + } + available_.notify_one(); +} + +} // namespace modulo::server::db diff --git a/server/tests/integration/CMakeLists.txt b/server/tests/integration/CMakeLists.txt index d221a35..80457a5 100644 --- a/server/tests/integration/CMakeLists.txt +++ b/server/tests/integration/CMakeLists.txt @@ -1,5 +1,5 @@ # Cross-module integration tests: real QHttpServer in-process, real HTTP -# client, and (from Increment 2) the real dockerized test database. +# client, and the real dockerized test database (schema from db/migrations). # Opt-in via MODULO_TEST_DB_URL — see tests/support/include/modulo/testing/integration.h. modulo_add_test( @@ -7,3 +7,9 @@ modulo_add_test( LABEL integration SOURCES test_health_endpoint.cpp DEPS modulo_server_http Qt6::Network) + +modulo_add_test( + modulo_auth_repositories_tests + LABEL integration + SOURCES test_auth_repositories.cpp + DEPS modulo_server_auth Qt6::Network) diff --git a/server/tests/integration/test_auth_repositories.cpp b/server/tests/integration/test_auth_repositories.cpp new file mode 100644 index 0000000..8f502ad --- /dev/null +++ b/server/tests/integration/test_auth_repositories.cpp @@ -0,0 +1,238 @@ +#include +#include +#include +#include +#include +#include + +#include + +#include + +using namespace modulo; +using namespace modulo::server; + +/// Exercises the connection pool and both repositories against the real +/// modulo_test database (schema from db/migrations). Each test function +/// starts from empty users/sessions tables. +class AuthRepositoriesTest : public QObject { + Q_OBJECT + +private slots: + + void init() { + if (testing::testDatabaseUrl().isEmpty()) { + return; // every test function QSKIPs itself + } + pool_ = std::make_unique(testing::testDatabaseUrl().toStdString(), 2); + auto lease = pool_->acquire(); + pqxx::work tx{lease.connection()}; + tx.exec("TRUNCATE users CASCADE"); // cascades to user_roles and sessions + tx.commit(); + } + + void cleanup() { pool_.reset(); } + + // --- pool --------------------------------------------------------------- + + void poolOpensLazilyAndReusesConnections() { + MODULO_REQUIRE_TEST_DATABASE(); + QCOMPARE(pool_->openConnections(), std::size_t{1}); // init() used one + + { + auto first = pool_->acquire(); + auto second = pool_->acquire(); + QCOMPARE(pool_->openConnections(), std::size_t{2}); + QVERIFY(first->is_open() && second->is_open()); + } + auto reused = pool_->acquire(); // no third connection is opened + QCOMPARE(pool_->openConnections(), std::size_t{2}); + } + + // --- users -------------------------------------------------------------- + + void createsUsersWithRolesAndFindsThemCaseInsensitively() { + MODULO_REQUIRE_TEST_DATABASE(); + auth::UserRepository users{*pool_}; + + const auto created = users.create(QStringLiteral("Angelo@Example.com"), QStringLiteral("Angelo"), + QStringLiteral("$argon2id$fake"), {auth::Role::Admin, auth::Role::User}); + QVERIFY2(created.has_value(), qPrintable(created ? QString{} : created.error().message)); + QVERIFY(!created->id.isEmpty()); + QCOMPARE(created->roles, (QList{auth::Role::Admin, auth::Role::User})); + QVERIFY(!created->disabled); + QVERIFY(created->createdAt.isValid()); + + const auto found = users.findByEmail(QStringLiteral("angelo@example.com")); + QVERIFY(found.has_value()); + QVERIFY(found->has_value()); + QCOMPARE((*found)->id, created->id); + QCOMPARE((*found)->email, QStringLiteral("Angelo@Example.com")); // stored as typed, matched case-insensitively + QCOMPARE((*found)->passwordHash, QStringLiteral("$argon2id$fake")); + QCOMPARE((*found)->roles, created->roles); + + const auto byId = users.findById(created->id); + QVERIFY(byId.has_value() && byId->has_value()); + QCOMPARE((*byId)->email, created->email); + + const auto count = users.count(); + QVERIFY(count.has_value()); + QCOMPARE(*count, qint64{1}); + } + + void rejectsDuplicateEmailsWithStableCode() { + MODULO_REQUIRE_TEST_DATABASE(); + auth::UserRepository users{*pool_}; + + QVERIFY(users.create(QStringLiteral("dup@example.com"), QStringLiteral("One"), QStringLiteral("h"), {})); + const auto duplicate = + users.create(QStringLiteral("DUP@example.com"), QStringLiteral("Two"), QStringLiteral("h"), {}); + QVERIFY(!duplicate.has_value()); + QCOMPARE(duplicate.error().code, QStringLiteral("auth.email_taken")); + + const auto count = users.count(); + QVERIFY(count.has_value()); + QCOMPARE(*count, qint64{1}); // the failed insert was rolled back + } + + void duplicateRolesAreAssignedOnce() { + MODULO_REQUIRE_TEST_DATABASE(); + auth::UserRepository users{*pool_}; + + const auto created = users.create(QStringLiteral("r@example.com"), QStringLiteral("R"), QStringLiteral("h"), + {auth::Role::User, auth::Role::User}); + QVERIFY2(created.has_value(), qPrintable(created ? QString{} : created.error().message)); + QCOMPARE(created->roles, (QList{auth::Role::User})); + } + + void malformedIdsAreNotFoundNotErrors() { + MODULO_REQUIRE_TEST_DATABASE(); + auth::UserRepository users{*pool_}; + auth::SessionRepository sessions{*pool_}; + + const auto user = users.findById(QStringLiteral("not-a-uuid")); + QVERIFY(user.has_value() && !user->has_value()); + QCOMPARE(sessions.revoke(QStringLiteral("not-a-uuid")).error().code, QStringLiteral("auth.session_not_found")); + QCOMPARE(sessions.touch(QStringLiteral("not-a-uuid"), QDateTime::currentDateTimeUtc()).error().code, + QStringLiteral("auth.session_not_found")); + const auto revoked = sessions.revokeAllForUser(QStringLiteral("not-a-uuid")); + QVERIFY(revoked.has_value()); + QCOMPARE(*revoked, qint64{0}); + } + + void missingUsersAreNulloptNotErrors() { + MODULO_REQUIRE_TEST_DATABASE(); + auth::UserRepository users{*pool_}; + + const auto byEmail = users.findByEmail(QStringLiteral("nobody@example.com")); + QVERIFY(byEmail.has_value()); + QVERIFY(!byEmail->has_value()); + + const auto byId = users.findById(QStringLiteral("00000000-0000-0000-0000-000000000000")); + QVERIFY(byId.has_value()); + QVERIFY(!byId->has_value()); + } + + // --- sessions ----------------------------------------------------------- + + void sessionLifecycle() { + MODULO_REQUIRE_TEST_DATABASE(); + auth::UserRepository users{*pool_}; + auth::SessionRepository sessions{*pool_}; + const auto user = users.create(QStringLiteral("s@example.com"), QStringLiteral("S"), QStringLiteral("h"), {}); + QVERIFY(user.has_value()); + + const QString token = auth::token::generate(); + const QByteArray digest = auth::token::digest(token); + const QDateTime expires = QDateTime::currentDateTimeUtc().addDays(30); + + const auto created = sessions.create(user->id, digest, expires); + QVERIFY2(created.has_value(), qPrintable(created ? QString{} : created.error().message)); + QCOMPARE(created->userId, user->id); + QVERIFY(!created->revoked); + // Millisecond precision survives the ISO-in / epoch-out round trip. + QCOMPARE(created->expiresAt.toMSecsSinceEpoch(), expires.toMSecsSinceEpoch()); + + const auto active = sessions.findActiveByDigest(digest); + QVERIFY(active.has_value() && active->has_value()); + QCOMPARE((*active)->id, created->id); + + // The raw token is never a lookup key - only its digest is. + const auto byRawToken = sessions.findActiveByDigest(token.toUtf8()); + QVERIFY(byRawToken.has_value() && !byRawToken->has_value()); + + const QDateTime later = expires.addDays(1); + QVERIFY(sessions.touch(created->id, later).has_value()); + const auto touched = sessions.findActiveByDigest(digest); + QVERIFY(touched.has_value() && touched->has_value()); + QCOMPARE((*touched)->expiresAt.toMSecsSinceEpoch(), later.toMSecsSinceEpoch()); + QVERIFY((*touched)->lastSeenAt >= created->lastSeenAt); + + QVERIFY(sessions.revoke(created->id).has_value()); + const auto afterRevoke = sessions.findActiveByDigest(digest); + QVERIFY(afterRevoke.has_value() && !afterRevoke->has_value()); + + // Touching or revoking again reports the session as gone. + QCOMPARE(sessions.revoke(created->id).error().code, QStringLiteral("auth.session_not_found")); + QCOMPARE(sessions.touch(created->id, later).error().code, QStringLiteral("auth.session_not_found")); + } + + void expiredSessionsAreNotActive() { + MODULO_REQUIRE_TEST_DATABASE(); + auth::UserRepository users{*pool_}; + auth::SessionRepository sessions{*pool_}; + const auto user = users.create(QStringLiteral("e@example.com"), QStringLiteral("E"), QStringLiteral("h"), {}); + QVERIFY(user.has_value()); + + const QByteArray digest = auth::token::digest(auth::token::generate()); + const auto created = sessions.create(user->id, digest, QDateTime::currentDateTimeUtc().addSecs(60)); + QVERIFY(created.has_value()); + + // Expire it behind the repository's back. The schema forbids creating an + // already-expired session and requires expires_at > created_at, so both + // timestamps move into the past. + { + auto lease = pool_->acquire(); + pqxx::work tx{lease.connection()}; + tx.exec("UPDATE sessions SET created_at = now() - interval '2 seconds', " + "expires_at = now() - interval '1 second' WHERE id = $1::uuid", + pqxx::params{created->id.toStdString()}); + tx.commit(); + } + + const auto active = sessions.findActiveByDigest(digest); + QVERIFY(active.has_value() && !active->has_value()); + } + + void revokeAllForUserOnlyTouchesThatUser() { + MODULO_REQUIRE_TEST_DATABASE(); + auth::UserRepository users{*pool_}; + auth::SessionRepository sessions{*pool_}; + const auto alice = users.create(QStringLiteral("a@example.com"), QStringLiteral("A"), QStringLiteral("h"), {}); + const auto bob = users.create(QStringLiteral("b@example.com"), QStringLiteral("B"), QStringLiteral("h"), {}); + QVERIFY(alice.has_value() && bob.has_value()); + + const QDateTime expires = QDateTime::currentDateTimeUtc().addDays(1); + QVERIFY(sessions.create(alice->id, auth::token::digest(auth::token::generate()), expires)); + QVERIFY(sessions.create(alice->id, auth::token::digest(auth::token::generate()), expires)); + const QByteArray bobDigest = auth::token::digest(auth::token::generate()); + QVERIFY(sessions.create(bob->id, bobDigest, expires)); + + const auto revoked = sessions.revokeAllForUser(alice->id); + QVERIFY(revoked.has_value()); + QCOMPARE(*revoked, qint64{2}); + + const auto bobStillActive = sessions.findActiveByDigest(bobDigest); + QVERIFY(bobStillActive.has_value() && bobStillActive->has_value()); + + const auto again = sessions.revokeAllForUser(alice->id); + QVERIFY(again.has_value()); + QCOMPARE(*again, qint64{0}); + } + +private: + std::unique_ptr pool_; +}; + +QTEST_GUILESS_MAIN(AuthRepositoriesTest) +#include "test_auth_repositories.moc"