Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<T> (std::expected + QString error codes)
libs/core/ modulo_core — foundations: version(), Result<T>, 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 (<modulo/testing/...>) 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
Expand Down Expand Up @@ -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

Expand Down
23 changes: 20 additions & 3 deletions docs/high_level_design.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,21 @@ 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)"])
clientexe(["modulo_client (exe)"])

api --> core
cfg --> core
authm --> core
authm --> dbm
httpm --> api
httpm --> cfg
server --> httpm
Expand All @@ -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

Expand Down Expand Up @@ -198,18 +208,25 @@ 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
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

Expand Down
2 changes: 1 addition & 1 deletion libs/core/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
17 changes: 17 additions & 0 deletions libs/core/include/modulo/core/password_policy.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#pragma once

#include <modulo/core/result.h>

#include <QStringView>

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
17 changes: 17 additions & 0 deletions libs/core/src/password_policy.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#include <modulo/core/password_policy.h>

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
6 changes: 6 additions & 0 deletions libs/core/tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
48 changes: 48 additions & 0 deletions libs/core/tests/test_password_policy.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#include <modulo/core/password_policy.h>

#include <QTest>

using namespace modulo::core;

class PasswordPolicyTest : public QObject {
Q_OBJECT

private slots:

void acceptsPasswordsWithinBounds_data() {
QTest::addColumn<QString>("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<QString>("password");
QTest::addColumn<QString>("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"
1 change: 1 addition & 0 deletions server/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

add_subdirectory(modules/config)
add_subdirectory(modules/db)
add_subdirectory(modules/auth)
add_subdirectory(modules/http)

add_subdirectory(app)
Expand Down
13 changes: 13 additions & 0 deletions server/modules/auth/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
28 changes: 28 additions & 0 deletions server/modules/auth/include/modulo/server/auth/password_hasher.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#pragma once

#include <modulo/core/result.h>

#include <QString>

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<QString> 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
22 changes: 22 additions & 0 deletions server/modules/auth/include/modulo/server/auth/roles.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#pragma once

#include <QString>
#include <QStringView>

#include <optional>

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<Role> roleFromName(QStringView name);

/// Database id → Role; std::nullopt for ids not in the catalogue.
std::optional<Role> roleFromId(qint16 id);

} // namespace modulo::server::auth
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#pragma once

#include <modulo/core/result.h>
#include <modulo/server/db/connection_pool.h>

#include <QByteArray>
#include <QDateTime>
#include <QString>

#include <optional>

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<SessionRecord> create(const QString& userId, const QByteArray& tokenDigest,
const QDateTime& expiresAt);

/// Only sessions that are neither revoked nor expired. nullopt otherwise.
core::Result<std::optional<SessionRecord>> 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<qint64> revokeAllForUser(const QString& userId);

private:
db::ConnectionPool& pool_;
};

} // namespace modulo::server::auth
22 changes: 22 additions & 0 deletions server/modules/auth/include/modulo/server/auth/token.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#pragma once

#include <QByteArray>
#include <QString>

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
Loading
Loading