diff --git a/.env.example b/.env.example index a6369fb..4c77130 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,7 @@ MODULO_HTTP_PORT=8080 # Root directory for server-managed files (uploaded documents live under # documents/). Gitignored; becomes a mounted volume when containerized. MODULO_DATA_DIR=./var/data + +# Whether new accounts may be registered after the first one exists. The very +# first account (which becomes the admin) can always be created. +MODULO_ALLOW_REGISTRATION=true diff --git a/README.md b/README.md index dd8272f..9259f72 100644 --- a/README.md +++ b/README.md @@ -138,12 +138,33 @@ scripts/db-up.sh # 1. database (the health endpoint does no ./build/dev/client/modulo_client # 3. desktop client (separate terminal) ``` -The server exposes `GET /api/v1/health` → `{"status":"ok","version":"0.1.0"}`; any -unknown route returns the uniform error envelope -`{"error":{"code":"not_found","message":"..."}}` with the matching HTTP status. The -client window (placeholder) polls health every 3 s and shows a live -green/red status indicator. `MODULO_HTTP_PORT` and `MODULO_API_URL` override the -server port and the client's target. +The server needs `MODULO_DB_URL` (connections open lazily, so start-up itself does not +touch the database). Any unknown route returns the uniform error envelope +`{"error":{"code":"not_found","message":"..."}}` with the matching HTTP status, and every +response carries `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, +`X-Frame-Options: DENY` and `Referrer-Policy: no-referrer`. The client window +(placeholder) polls health every 3 s and shows a live green/red status indicator. +`MODULO_HTTP_PORT` and `MODULO_API_URL` override the server port and the client's target. + +### API + +| Endpoint | Auth | Body / result | +|---|---|---| +| `GET /api/v1/health` | - | `{"status":"ok","version":"0.1.0"}` | +| `POST /api/v1/auth/register` | - | `{email, displayName, password}` → 201 user. The first account becomes `admin` + `user`; later accounts are `user` and require `MODULO_ALLOW_REGISTRATION=true` (403 `auth.registration_disabled` otherwise) | +| `POST /api/v1/auth/login` | - | `{email, password}` → 200 `{token, user}`. Unknown email, wrong password and disabled account all answer 401 `auth.invalid_credentials` with identical timing | +| `GET /api/v1/auth/me` | Bearer | 200 user | +| `POST /api/v1/auth/logout` | Bearer | 204; the token is revoked immediately | + +Authentication is an opaque bearer token (`Authorization: Bearer `) issued at login; +the server stores only its SHA-256 digest. Sessions last 30 days and slide forward on use. +Missing or invalid tokens answer 401 `auth.unauthenticated`; a valid token without the +required role answers 403 `auth.forbidden`. Error codes map to statuses centrally +(`api.*`/`password.*`/`auth.invalid_*` → 400, `auth.email_taken` → 409, `db.*` → 503). + +Logging uses `QLoggingCategory`: `modulo.auth` (registration, login, logout, session events - +ids only, never tokens or emails) and `modulo.http` (lifecycle at info, one line per request +at debug). Enable request logging with `QT_LOGGING_RULES="modulo.http.debug=true"`. ## Development database @@ -191,7 +212,7 @@ the underlying CLI (`--url`, `--dir`). Current schema: `0001_init` (metadata) and `0002_auth` (`users`, `roles`, `user_roles`, `sessions`, plus a reusable `set_updated_at()` trigger and the `citext` extension for case-insensitive emails). The data model is diagrammed in -[`docs/high_level_design.md`](docs/high_level_design.md#5-data-model). +[`docs/high_level_design.md`](docs/high_level_design.md#6-data-model). ## Testing @@ -220,6 +241,7 @@ binary, data-driven rows via `_data()` slots: | `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_auth_flow_tests` | integration | the auth endpoints over real HTTP: register → login → me → logout → 401; missing/bogus tokens; identical 401 for wrong password and unknown email; 409 duplicate, 400 bad input; second account is a plain user | | `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 @@ -268,14 +290,14 @@ 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, shared password policy -libs/api/ modulo_api — Q_GADGET DTOs + validating QJson mappings shared by server and client +libs/api/ modulo_api — Q_GADGET DTOs (health, error, auth) + 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 + 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 + modules/auth/ modulo_server_auth — Argon2id hashing, session tokens, repositories, AuthService + modules/http/ modulo_server_http — QHttpServer wrapper, auth guards, routes, error envelope, security headers app/ modulo_server — REST API server executable migrate/ modulo_migrate — CLI migration runner client/ modulo_client — QML desktop app (ApiClient + dark-theme shell) @@ -316,6 +338,7 @@ CMakePresets.json configure/build/test presets (dev, dev-asan, dev-tidy, release | 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 | +| 2.3 — Auth service, routes & guards | `AuthService` (register/login/logout/authenticate, first account = admin, sliding expiry, uniform-timing login), `authed()`/`requireRole()` guards, `/api/v1/auth/*` endpoints, auth DTOs, central error-code → status mapping, security headers, `QLoggingCategory` logging, `MODULO_ALLOW_REGISTRATION`; `modulo_auth_flow_tests` over real HTTP | ## License diff --git a/docs/high_level_design.md b/docs/high_level_design.md index 5ec2bf1..616209e 100644 --- a/docs/high_level_design.md +++ b/docs/high_level_design.md @@ -17,12 +17,19 @@ QML_ELEMENT"] subgraph serverproc["modulo_server (C++23 / QCoreApplication)"] http["http module -QHttpServer · routes -JSON error envelope"] +QHttpServer · routes · guards +error envelope · security headers"] + authsvc["auth module +AuthService · repositories +Argon2id · tokens"] config["config module env → Config (MODULO_* vars)"] + pool["db module +ConnectionPool"] http --> config + http --> authsvc + authsvc --> pool end subgraph migrate["modulo_migrate (CLI, Qt-free)"] @@ -43,11 +50,11 @@ volume: modulo_pgdata")] NNNN_name.sql (append-only)"] - apiclient -- "HTTP GET /api/v1/health + apiclient -- "HTTP /api/v1/health, /api/v1/auth/* 127.0.0.1:8080 (loopback only)" --> http migrator -- "SQL over libpq" --> pg sql --> migrator - http -. "libpqxx pool — Increment 2" .-> pg + pool -- "libpqxx" --> pg ``` ## 2. Static library dependency graph @@ -62,11 +69,14 @@ Q_GADGET DTOs Health / Error api::json::require*"] cfg["modulo_server_config"] - httpm["modulo_server_http"] + httpm["modulo_server_http +Server · guards · routes +responses · lcHttp"] dbm["modulo_server_db (Qt-free · libpqxx) Migrator · ConnectionPool"] authm["modulo_server_auth +AuthService · lcAuth PasswordHasher (Argon2id) token · Role UserRepository · SessionRepository"] @@ -81,6 +91,7 @@ UserRepository · SessionRepository"] authm --> dbm httpm --> api httpm --> cfg + httpm --> authm server --> httpm migrateexe --> dbm clientexe --> api @@ -120,7 +131,37 @@ sequenceDiagram Note over Q: green pulsing dot · "server ok (v0.1.0)" ``` -## 4. Runtime flow — migrations +## 4. Runtime flow — login and an authenticated request + +```mermaid +sequenceDiagram + participant C as Client + participant R as auth routes (http) + participant G as authed() guard + participant A as AuthService + participant P as PostgreSQL + + C->>R: POST /api/v1/auth/login {email, password} + R->>A: login(email, password) + A->>P: users by email (citext) + A->>A: Argon2id verify (dummy hash if unknown, same timing) + A->>A: token::generate (32 CSPRNG bytes, base64url) + A->>P: INSERT sessions (sha256 digest, expires_at = now + 30 d) + A-->>R: LoginResult {token, user} + R-->>C: 200 {token, user} + security headers + + C->>G: GET /api/v1/auth/me, Authorization: Bearer token + G->>A: authenticate(token) + A->>P: sessions by digest (not revoked, not expired) + A->>P: user by id (not disabled) + roles + A->>P: touch session (sliding expiry, throttled) + A-->>G: AuthContext {userId, sessionId, roles} + G->>R: handler(context, request) + R-->>C: 200 UserDto + Note over G: no or bad token gives 401 auth.unauthenticated, missing role gives 403 auth.forbidden +``` + +## 5. Runtime flow — migrations ```mermaid sequenceDiagram @@ -146,7 +187,7 @@ sequenceDiagram M-->>U: "N applied, M skipped" (exit code) ``` -## 5. Data model +## 6. Data model ```mermaid erDiagram @@ -198,7 +239,7 @@ Migrations so far: `0001_init` (meta), `0002_auth` (users, roles, user_roles, se (`modules/db`, `migrator.cpp`) creates it on first run and owns it. Tokens are never stored: the server keeps only the SHA-256 digest, so a database leak cannot be replayed as a login. -## 6. Test architecture +## 7. Test architecture ```mermaid flowchart LR @@ -219,6 +260,8 @@ in-process QHttpServer on port 0 t7["modulo_auth_repositories_tests ConnectionPool + repositories against modulo_test"] + t8["modulo_auth_flow_tests +auth endpoints over real HTTP"] end subgraph ui["label: ui — Qt Quick Test, offscreen"] t5["modulo_client_qml_tests @@ -227,6 +270,7 @@ tst_*.qml via QUICK_TEST_MAIN"] env["MODULO_TEST_DB_URL"] -. "unset → QSKIP → CTest Skipped" .-> t4 env -.-> t7 + env -.-> t8 support["tests/support/include/modulo/testing/ integration.h: MODULO_REQUIRE_TEST_DATABASE(), httpGet()"] --> t4 diff --git a/libs/api/CMakeLists.txt b/libs/api/CMakeLists.txt index 36fda59..d2ff106 100644 --- a/libs/api/CMakeLists.txt +++ b/libs/api/CMakeLists.txt @@ -3,5 +3,5 @@ modulo_add_library( modulo_api - SOURCES src/error.cpp src/health.cpp src/json.cpp + SOURCES src/auth.cpp src/error.cpp src/health.cpp src/json.cpp PUBLIC_DEPS modulo_core Qt6::Core) diff --git a/libs/api/include/modulo/api/auth.h b/libs/api/include/modulo/api/auth.h new file mode 100644 index 0000000..37d4df6 --- /dev/null +++ b/libs/api/include/modulo/api/auth.h @@ -0,0 +1,73 @@ +#pragma once + +#include + +#include +#include +#include + +namespace modulo::api { + +/// POST /api/v1/auth/register body. +struct RegisterRequest { + Q_GADGET + Q_PROPERTY(QString email MEMBER email) + Q_PROPERTY(QString displayName MEMBER displayName) + Q_PROPERTY(QString password MEMBER password) + +public: + QString email; + QString displayName; + QString password; + + QJsonObject toJson() const; + static core::Result fromJson(const QJsonObject& json); +}; + +/// POST /api/v1/auth/login body. +struct LoginRequest { + Q_GADGET + Q_PROPERTY(QString email MEMBER email) + Q_PROPERTY(QString password MEMBER password) + +public: + QString email; + QString password; + + QJsonObject toJson() const; + static core::Result fromJson(const QJsonObject& json); +}; + +/// A user as exposed by the API (never the password hash). +struct UserDto { + Q_GADGET + Q_PROPERTY(QString id MEMBER id) + Q_PROPERTY(QString email MEMBER email) + Q_PROPERTY(QString displayName MEMBER displayName) + Q_PROPERTY(QStringList roles MEMBER roles) + +public: + QString id; + QString email; + QString displayName; + QStringList roles; ///< role names: "admin", "user" + + QJsonObject toJson() const; + static core::Result fromJson(const QJsonObject& json); +}; + +/// POST /api/v1/auth/login response: the opaque bearer token plus the user. +struct LoginResponse { + Q_GADGET + Q_PROPERTY(QString token MEMBER token) + Q_PROPERTY(modulo::api::UserDto user MEMBER user) + +public: + QString token; + UserDto user; + + QJsonObject toJson() const; + static core::Result fromJson(const QJsonObject& json); +}; + +} // namespace modulo::api diff --git a/libs/api/include/modulo/api/json.h b/libs/api/include/modulo/api/json.h index 114e00d..c2e258e 100644 --- a/libs/api/include/modulo/api/json.h +++ b/libs/api/include/modulo/api/json.h @@ -4,6 +4,7 @@ #include #include +#include namespace modulo::api::json { @@ -17,4 +18,7 @@ core::Result requireString(const QJsonObject& object, QLatin1StringView core::Result requireObject(const QJsonObject& object, QLatin1StringView key); +/// A JSON array whose elements must all be strings. +core::Result requireStringList(const QJsonObject& object, QLatin1StringView key); + } // namespace modulo::api::json diff --git a/libs/api/src/auth.cpp b/libs/api/src/auth.cpp new file mode 100644 index 0000000..2112e3e --- /dev/null +++ b/libs/api/src/auth.cpp @@ -0,0 +1,116 @@ +#include +#include + +#include + +#include + +namespace modulo::api { + +namespace { + +template +std::unexpected fail(core::Result&& result) { + return std::unexpected{std::move(result).error()}; +} + +} // namespace + +// --- RegisterRequest --------------------------------------------------------- + +QJsonObject RegisterRequest::toJson() const { + return QJsonObject{{QStringLiteral("email"), email}, + {QStringLiteral("displayName"), displayName}, + {QStringLiteral("password"), password}}; +} + +core::Result RegisterRequest::fromJson(const QJsonObject& json) { + auto email = json::requireString(json, QLatin1StringView{"email"}); + if (!email) { + return fail(std::move(email)); + } + auto displayName = json::requireString(json, QLatin1StringView{"displayName"}); + if (!displayName) { + return fail(std::move(displayName)); + } + auto password = json::requireString(json, QLatin1StringView{"password"}); + if (!password) { + return fail(std::move(password)); + } + return RegisterRequest{ + .email = std::move(*email), .displayName = std::move(*displayName), .password = std::move(*password)}; +} + +// --- LoginRequest ------------------------------------------------------------ + +QJsonObject LoginRequest::toJson() const { + return QJsonObject{{QStringLiteral("email"), email}, {QStringLiteral("password"), password}}; +} + +core::Result LoginRequest::fromJson(const QJsonObject& json) { + auto email = json::requireString(json, QLatin1StringView{"email"}); + if (!email) { + return fail(std::move(email)); + } + auto password = json::requireString(json, QLatin1StringView{"password"}); + if (!password) { + return fail(std::move(password)); + } + return LoginRequest{.email = std::move(*email), .password = std::move(*password)}; +} + +// --- UserDto ----------------------------------------------------------------- + +QJsonObject UserDto::toJson() const { + return QJsonObject{{QStringLiteral("id"), id}, + {QStringLiteral("email"), email}, + {QStringLiteral("displayName"), displayName}, + {QStringLiteral("roles"), QJsonArray::fromStringList(roles)}}; +} + +core::Result UserDto::fromJson(const QJsonObject& json) { + auto id = json::requireString(json, QLatin1StringView{"id"}); + if (!id) { + return fail(std::move(id)); + } + auto email = json::requireString(json, QLatin1StringView{"email"}); + if (!email) { + return fail(std::move(email)); + } + auto displayName = json::requireString(json, QLatin1StringView{"displayName"}); + if (!displayName) { + return fail(std::move(displayName)); + } + auto roles = json::requireStringList(json, QLatin1StringView{"roles"}); + if (!roles) { + return fail(std::move(roles)); + } + return UserDto{.id = std::move(*id), + .email = std::move(*email), + .displayName = std::move(*displayName), + .roles = std::move(*roles)}; +} + +// --- LoginResponse ----------------------------------------------------------- + +QJsonObject LoginResponse::toJson() const { + return QJsonObject{{QStringLiteral("token"), token}, {QStringLiteral("user"), user.toJson()}}; +} + +core::Result LoginResponse::fromJson(const QJsonObject& json) { + auto token = json::requireString(json, QLatin1StringView{"token"}); + if (!token) { + return fail(std::move(token)); + } + auto userJson = json::requireObject(json, QLatin1StringView{"user"}); + if (!userJson) { + return fail(std::move(userJson)); + } + auto user = UserDto::fromJson(*userJson); + if (!user) { + return fail(std::move(user)); + } + return LoginResponse{.token = std::move(*token), .user = std::move(*user)}; +} + +} // namespace modulo::api diff --git a/libs/api/src/json.cpp b/libs/api/src/json.cpp index 5979845..26fabbb 100644 --- a/libs/api/src/json.cpp +++ b/libs/api/src/json.cpp @@ -1,5 +1,6 @@ #include +#include #include namespace modulo::api::json { @@ -22,4 +23,21 @@ core::Result requireObject(const QJsonObject& object, QLatin1String return value.toObject(); } +core::Result requireStringList(const QJsonObject& object, QLatin1StringView key) { + const QJsonValue value = object.value(key); + if (!value.isArray()) { + return core::makeError(QStringLiteral("api.invalid_field"), + QStringLiteral("missing or non-array field '%1'").arg(key)); + } + QStringList list; + for (const QJsonValue& element : value.toArray()) { + if (!element.isString()) { + return core::makeError(QStringLiteral("api.invalid_field"), + QStringLiteral("field '%1' must contain only strings").arg(key)); + } + list.append(element.toString()); + } + return list; +} + } // namespace modulo::api::json diff --git a/server/app/main.cpp b/server/app/main.cpp index f1ee167..4257ec3 100644 --- a/server/app/main.cpp +++ b/server/app/main.cpp @@ -1,33 +1,56 @@ -// modulo_server — Modulo REST API server. +// modulo_server - Modulo REST API server. // // Configuration from environment variables (see .env.example). // Runs until interrupted; serves on 127.0.0.1 only. #include +#include #include +#include #include #include #include +namespace { + +constexpr std::size_t kPoolCapacity = 4; + +int fail(const modulo::core::Error& error) { + qCritical().noquote() << QStringLiteral("error [%1]: %2").arg(error.code, error.message); + return EXIT_FAILURE; +} + +} // namespace + int main(int argc, char* argv[]) { QCoreApplication app{argc, argv}; + QCoreApplication::setApplicationName(QStringLiteral("modulo_server")); const auto config = modulo::server::config::Config::fromEnvironment(); if (!config) { - qCritical().noquote() << QStringLiteral("error [%1]: %2").arg(config.error().code, config.error().message); - return EXIT_FAILURE; + return fail(config.error()); + } + if (config->databaseUrl.isEmpty()) { + return fail({QStringLiteral("config.missing_database_url"), + QStringLiteral("MODULO_DB_URL must be set (see .env.example)")}); } - modulo::server::http::Server server{*config}; + // The pool opens connections lazily, so start-up does not require the + // database to be reachable; the first authenticated request does. + modulo::server::db::ConnectionPool pool{config->databaseUrl.toStdString(), kPoolCapacity}; + modulo::server::auth::AuthService authService{pool, {.allowRegistration = config->allowRegistration}}; + + modulo::server::http::Server server{*config, &authService}; const auto port = server.listen(); if (!port) { - qCritical().noquote() << QStringLiteral("error [%1]: %2").arg(port.error().code, port.error().message); - return EXIT_FAILURE; + return fail(port.error()); } - qInfo().noquote() - << QStringLiteral("modulo_server v%1 listening on http://127.0.0.1:%2").arg(modulo::core::version()).arg(*port); + qInfo().noquote() << QStringLiteral("modulo_server v%1 listening on http://127.0.0.1:%2 (registration %3)") + .arg(modulo::core::version()) + .arg(*port) + .arg(config->allowRegistration ? QStringLiteral("open") : QStringLiteral("closed")); return app.exec(); } diff --git a/server/modules/auth/CMakeLists.txt b/server/modules/auth/CMakeLists.txt index 24627dc..2120d48 100644 --- a/server/modules/auth/CMakeLists.txt +++ b/server/modules/auth/CMakeLists.txt @@ -1,9 +1,12 @@ -# modulo_server_auth — authentication & RBAC: Argon2id password hashing, -# opaque session tokens, and the user/session repositories. +# modulo_server_auth — authentication: Argon2id password hashing, opaque +# session tokens, the role catalogue, user/session repositories and the +# AuthService use cases on top of them. libsodium stays a private detail. modulo_add_library( modulo_server_auth - SOURCES src/password_hasher.cpp + SOURCES src/auth_service.cpp + src/logging.cpp + src/password_hasher.cpp src/roles.cpp src/session_repository.cpp src/sodium_init.cpp diff --git a/server/modules/auth/include/modulo/server/auth/auth_service.h b/server/modules/auth/include/modulo/server/auth/auth_service.h new file mode 100644 index 0000000..0a39564 --- /dev/null +++ b/server/modules/auth/include/modulo/server/auth/auth_service.h @@ -0,0 +1,77 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +#include + +namespace modulo::server::auth { + +/// Who is making an authenticated request. Built by AuthService::authenticate +/// from a bearer token and handed to route handlers by the HTTP guards. +struct AuthContext { + QString userId; + QString sessionId; + QList roles; + + bool hasRole(Role role) const { return roles.contains(role); } +}; + +struct LoginResult { + QString token; ///< Opaque bearer token; shown to the client exactly once. + UserRecord user; +}; + +struct AuthOptions { + /// Whether accounts beyond the first may register. The first account is + /// always allowed (it becomes admin), otherwise nobody could log in. + bool allowRegistration = true; + /// Session lifetime; authenticated requests slide it forward. + int sessionDays = 30; +}; + +/// Authentication use cases on top of the repositories: registration, login, +/// logout and bearer-token authentication with a sliding session expiry. +/// +/// Error codes (HTTP status is assigned by the http module): +/// auth.invalid_email, auth.invalid_display_name, password.too_short/too_long, +/// auth.registration_disabled, auth.email_taken, auth.invalid_credentials, +/// auth.session_not_found, db.*. +class AuthService { +public: + AuthService(db::ConnectionPool& pool, AuthOptions options = {}); + + /// The first account receives admin + user, later accounts user only. + core::Result registerUser(const QString& email, const QString& displayName, const QString& password); + + /// One generic auth.invalid_credentials for unknown email, wrong password + /// and disabled account, with uniform timing (a dummy hash is verified + /// when the account does not exist). + core::Result login(const QString& email, const QString& password); + + /// Revokes the session behind the token. auth.session_not_found if it is + /// unknown, expired or already revoked. + core::VoidResult logout(const QString& token); + + /// Resolves a bearer token to its AuthContext, or nullopt when the token is + /// unknown, expired, revoked, or belongs to a disabled account. Slides the + /// expiry forward (throttled to once per few minutes per session). + core::Result> authenticate(const QString& token); + + /// Full record of the authenticated user (nullopt if deleted meanwhile). + core::Result> userOf(const AuthContext& context); + +private: + UserRepository users_; + SessionRepository sessions_; + AuthOptions options_; + QString dummyHash_; ///< verified when login targets a non-existent account +}; + +} // namespace modulo::server::auth diff --git a/server/modules/auth/include/modulo/server/auth/logging.h b/server/modules/auth/include/modulo/server/auth/logging.h new file mode 100644 index 0000000..5519ac1 --- /dev/null +++ b/server/modules/auth/include/modulo/server/auth/logging.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +namespace modulo::server::auth { + +/// "modulo.auth" - registration, login, logout and session events. Never logs +/// tokens, password material or email addresses; user and session ids only. +/// Filter at runtime with QT_LOGGING_RULES, e.g. "modulo.auth.debug=true". +Q_DECLARE_LOGGING_CATEGORY(lcAuth) + +} // namespace modulo::server::auth diff --git a/server/modules/auth/src/auth_service.cpp b/server/modules/auth/src/auth_service.cpp new file mode 100644 index 0000000..417c590 --- /dev/null +++ b/server/modules/auth/src/auth_service.cpp @@ -0,0 +1,179 @@ +#include +#include +#include +#include +#include + +#include + +#include + +namespace modulo::server::auth { + +namespace { + +constexpr int kMaxEmailLength = 254; +constexpr int kMaxDisplayNameLength = 100; +constexpr qint64 kTouchIntervalSeconds = 5 * 60; + +QString normalizeEmail(const QString& email) { + return email.trimmed(); +} + +core::VoidResult validateEmail(const QString& email) { + const qsizetype at = email.indexOf(u'@'); + const bool shapeOk = + !email.isEmpty() && email.size() <= kMaxEmailLength && at > 0 && at < email.size() - 1 && !email.contains(u' '); + if (!shapeOk) { + return core::makeError(QStringLiteral("auth.invalid_email"), QStringLiteral("email address is not valid")); + } + return {}; +} + +core::VoidResult validateDisplayName(const QString& displayName) { + if (displayName.isEmpty() || displayName.size() > kMaxDisplayNameLength) { + return core::makeError(QStringLiteral("auth.invalid_display_name"), + QStringLiteral("display name must be 1 to %1 characters").arg(kMaxDisplayNameLength)); + } + return {}; +} + +std::unexpected invalidCredentials() { + return core::makeError(QStringLiteral("auth.invalid_credentials"), + QStringLiteral("email or password is incorrect")); +} + +} // namespace + +AuthService::AuthService(db::ConnectionPool& pool, AuthOptions options) + : users_{pool}, sessions_{pool}, options_{options} { + // Hashing an arbitrary password once at start-up gives login() something + // real to verify against when the account does not exist, so the response + // time does not reveal whether an email is registered. + if (auto hash = PasswordHasher::hash(QStringLiteral("modulo-dummy-password"))) { + dummyHash_ = std::move(*hash); + } +} + +core::Result AuthService::registerUser(const QString& rawEmail, const QString& rawDisplayName, + const QString& password) { + const QString email = normalizeEmail(rawEmail); + if (auto valid = validateEmail(email); !valid) { + return std::unexpected{valid.error()}; + } + const QString displayName = rawDisplayName.trimmed(); + if (auto valid = validateDisplayName(displayName); !valid) { + return std::unexpected{valid.error()}; + } + if (auto valid = core::validatePassword(password); !valid) { + return std::unexpected{valid.error()}; + } + + const auto existing = users_.count(); + if (!existing) { + return std::unexpected{existing.error()}; + } + const bool firstAccount = *existing == 0; + if (!firstAccount && !options_.allowRegistration) { + return core::makeError(QStringLiteral("auth.registration_disabled"), + QStringLiteral("registration is disabled on this server")); + } + + auto hash = PasswordHasher::hash(password); + if (!hash) { + return std::unexpected{hash.error()}; + } + + const QList roles = firstAccount ? QList{Role::Admin, Role::User} : QList{Role::User}; + auto user = users_.create(email, displayName, *hash, roles); + if (user) { + qCInfo(lcAuth).noquote() << QStringLiteral("user registered id=%1 roles=%2%3") + .arg(user->id) + .arg(roles.size()) + .arg(firstAccount ? QStringLiteral(" (first account, admin)") : QString{}); + } + return user; +} + +core::Result AuthService::login(const QString& rawEmail, const QString& password) { + const QString email = normalizeEmail(rawEmail); + + const auto found = users_.findByEmail(email); + if (!found) { + return std::unexpected{found.error()}; + } + + // Always verify against a real hash so a missing account costs the same + // time as a wrong password. + const QString& hash = found->has_value() ? (*found)->passwordHash : dummyHash_; + const bool passwordOk = PasswordHasher::verify(hash, password); + if (!found->has_value() || !passwordOk || (*found)->disabled) { + qCInfo(lcAuth) << "login failed"; + return invalidCredentials(); + } + + const UserRecord& user = **found; + const QString token = token::generate(); + const QDateTime expires = QDateTime::currentDateTimeUtc().addDays(options_.sessionDays); + const auto session = sessions_.create(user.id, token::digest(token), expires); + if (!session) { + return std::unexpected{session.error()}; + } + + qCInfo(lcAuth).noquote() << QStringLiteral("login ok user=%1 session=%2").arg(user.id, session->id); + return LoginResult{.token = token, .user = user}; +} + +core::VoidResult AuthService::logout(const QString& token) { + const auto session = sessions_.findActiveByDigest(token::digest(token)); + if (!session) { + return std::unexpected{session.error()}; + } + if (!session->has_value()) { + return core::makeError(QStringLiteral("auth.session_not_found"), QStringLiteral("session is not active")); + } + const auto revoked = sessions_.revoke((*session)->id); + if (revoked) { + qCInfo(lcAuth).noquote() << QStringLiteral("logout session=%1").arg((*session)->id); + } + return revoked; +} + +core::Result> AuthService::authenticate(const QString& token) { + if (token.isEmpty()) { + return std::optional{}; + } + + const auto session = sessions_.findActiveByDigest(token::digest(token)); + if (!session) { + return std::unexpected{session.error()}; + } + if (!session->has_value()) { + return std::optional{}; + } + + const auto user = users_.findById((*session)->userId); + if (!user) { + return std::unexpected{user.error()}; + } + if (!user->has_value() || (*user)->disabled) { + return std::optional{}; + } + + // Sliding expiry, throttled: one UPDATE per session per interval, not per request. + const QDateTime now = QDateTime::currentDateTimeUtc(); + if ((*session)->lastSeenAt.secsTo(now) >= kTouchIntervalSeconds) { + if (const auto touched = sessions_.touch((*session)->id, now.addDays(options_.sessionDays)); !touched) { + qCWarning(lcAuth).noquote() + << QStringLiteral("could not slide session %1: %2").arg((*session)->id, touched.error().message); + } + } + + return AuthContext{.userId = (*user)->id, .sessionId = (*session)->id, .roles = (*user)->roles}; +} + +core::Result> AuthService::userOf(const AuthContext& context) { + return users_.findById(context.userId); +} + +} // namespace modulo::server::auth diff --git a/server/modules/auth/src/logging.cpp b/server/modules/auth/src/logging.cpp new file mode 100644 index 0000000..296c738 --- /dev/null +++ b/server/modules/auth/src/logging.cpp @@ -0,0 +1,7 @@ +#include + +namespace modulo::server::auth { + +Q_LOGGING_CATEGORY(lcAuth, "modulo.auth") + +} // namespace modulo::server::auth diff --git a/server/modules/config/include/modulo/server/config/config.h b/server/modules/config/include/modulo/server/config/config.h index 97b1a9b..b956b66 100644 --- a/server/modules/config/include/modulo/server/config/config.h +++ b/server/modules/config/include/modulo/server/config/config.h @@ -20,6 +20,11 @@ struct Config { /// MODULO_DATA_DIR — root for server-managed files (document uploads). QString dataDir = QStringLiteral("./var/data"); + /// MODULO_ALLOW_REGISTRATION - whether POST /api/v1/auth/register accepts + /// new accounts once the first (admin) account exists. The very first + /// account can always be created, otherwise there would be no way in. + bool allowRegistration = true; + /// Build a Config from the process environment. Unset or empty variables /// keep their defaults; malformed values yield an Error whose code is /// prefixed "config.". diff --git a/server/modules/config/src/config.cpp b/server/modules/config/src/config.cpp index 60ba3dd..27040ed 100644 --- a/server/modules/config/src/config.cpp +++ b/server/modules/config/src/config.cpp @@ -28,6 +28,19 @@ core::Result Config::fromEnvironment() { } config.httpPort = port; + const QString registration = envOr("MODULO_ALLOW_REGISTRATION", QStringLiteral("true")).toLower(); + if (registration == QLatin1StringView{"true"} || registration == QLatin1StringView{"1"} || + registration == QLatin1StringView{"yes"}) { + config.allowRegistration = true; + } else if (registration == QLatin1StringView{"false"} || registration == QLatin1StringView{"0"} || + registration == QLatin1StringView{"no"}) { + config.allowRegistration = false; + } else { + return core::makeError(QStringLiteral("config.invalid_bool"), + QStringLiteral("MODULO_ALLOW_REGISTRATION must be true/false (or 1/0, yes/no), got '%1'") + .arg(registration)); + } + return config; } diff --git a/server/modules/http/CMakeLists.txt b/server/modules/http/CMakeLists.txt index 8b3891b..6149643 100644 --- a/server/modules/http/CMakeLists.txt +++ b/server/modules/http/CMakeLists.txt @@ -1,8 +1,9 @@ -# modulo_server_http — the REST API server: owns the QHttpServer, registers -# every route, and enforces the uniform JSON error envelope. +# modulo_server_http — the REST API: QHttpServer wrapper, routes, auth guards, +# the JSON error envelope and security headers. Links the feature modules +# whose routes it registers. modulo_add_library( modulo_server_http - SOURCES src/server.cpp - PUBLIC_DEPS modulo_api modulo_server_config Qt6::HttpServer + SOURCES src/auth_guard.cpp src/auth_routes.cpp src/responses.cpp src/server.cpp + PUBLIC_DEPS modulo_api modulo_server_auth modulo_server_config Qt6::HttpServer PRIVATE_DEPS Qt6::Network) diff --git a/server/modules/http/include/modulo/server/http/auth_guard.h b/server/modules/http/include/modulo/server/http/auth_guard.h new file mode 100644 index 0000000..e80aa9d --- /dev/null +++ b/server/modules/http/include/modulo/server/http/auth_guard.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include + +#include + +namespace modulo::server::http { + +/// The token from an "Authorization: Bearer " header; empty when the +/// header is absent or not a bearer scheme. +QString bearerToken(const QHttpServerRequest& request); + +/// Resolves the request's bearer token through AuthService. Errors carry +/// auth.unauthenticated (missing/invalid/expired token) or a db.* code. +core::Result authenticateRequest(auth::AuthService& service, const QHttpServerRequest& request); + +/// Wraps a handler `(const AuthContext&, const QHttpServerRequest&) -> QHttpServerResponse` +/// so it only runs for authenticated requests; everything else gets the 401 envelope. +/// +/// server.route("/api/v1/auth/me", Method::Get, authed(service, [](const auto& ctx, const auto&) { ... })); +template +auto authed(auth::AuthService& service, Handler handler) { + return [&service, handler = std::move(handler)](const QHttpServerRequest& request) -> QHttpServerResponse { + auto context = authenticateRequest(service, request); + if (!context) { + return errorResponse(context.error()); + } + return handler(*context, request); + }; +} + +/// authed() plus a role check: authenticated callers without the role get 403. +template +auto requireRole(auth::AuthService& service, auth::Role role, Handler handler) { + return authed(service, [role, handler = std::move(handler)](const auth::AuthContext& context, + const QHttpServerRequest& request) { + if (!context.hasRole(role)) { + return errorResponse(QHttpServerResponse::StatusCode::Forbidden, QStringLiteral("auth.forbidden"), + QStringLiteral("this action requires the '%1' role").arg(auth::roleName(role))); + } + return handler(context, request); + }); +} + +} // namespace modulo::server::http diff --git a/server/modules/http/include/modulo/server/http/auth_routes.h b/server/modules/http/include/modulo/server/http/auth_routes.h new file mode 100644 index 0000000..59a29b8 --- /dev/null +++ b/server/modules/http/include/modulo/server/http/auth_routes.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +#include + +namespace modulo::server::http { + +/// Registers the authentication endpoints: +/// POST /api/v1/auth/register {email, displayName, password} -> 201 UserDto +/// POST /api/v1/auth/login {email, password} -> 200 LoginResponse +/// POST /api/v1/auth/logout (bearer) -> 204 +/// GET /api/v1/auth/me (bearer) -> 200 UserDto +/// `service` must outlive `server`. +void registerAuthRoutes(QHttpServer& server, auth::AuthService& service); + +} // namespace modulo::server::http diff --git a/server/modules/http/include/modulo/server/http/logging.h b/server/modules/http/include/modulo/server/http/logging.h new file mode 100644 index 0000000..43deab9 --- /dev/null +++ b/server/modules/http/include/modulo/server/http/logging.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +namespace modulo::server::http { + +/// "modulo.http" - server lifecycle at info level, one line per request at +/// debug level (method, path, status). Enable with +/// QT_LOGGING_RULES="modulo.http.debug=true". +Q_DECLARE_LOGGING_CATEGORY(lcHttp) + +} // namespace modulo::server::http diff --git a/server/modules/http/include/modulo/server/http/responses.h b/server/modules/http/include/modulo/server/http/responses.h new file mode 100644 index 0000000..9de4f54 --- /dev/null +++ b/server/modules/http/include/modulo/server/http/responses.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace modulo::server::http { + +/// JSON body with the given status. +QHttpServerResponse jsonResponse(const QJsonObject& body, + QHttpServerResponse::StatusCode status = QHttpServerResponse::StatusCode::Ok); + +/// The uniform error envelope {"error":{"code","message"}}, status derived +/// from the code via statusFor(). +QHttpServerResponse errorResponse(const core::Error& error); + +QHttpServerResponse errorResponse(QHttpServerResponse::StatusCode status, const QString& code, const QString& message); + +/// Maps stable error codes to HTTP statuses: auth.invalid_credentials / +/// auth.unauthenticated -> 401, auth.forbidden / auth.registration_disabled +/// -> 403, auth.email_taken -> 409, *not_found -> 404, api.* / password.* / +/// auth.invalid_* -> 400, db.* -> 503, anything else -> 500. +QHttpServerResponse::StatusCode statusFor(const QString& code); + +/// The request body parsed as a JSON object; api.invalid_json otherwise. +core::Result jsonBody(const QHttpServerRequest& request); + +} // namespace modulo::server::http diff --git a/server/modules/http/include/modulo/server/http/server.h b/server/modules/http/include/modulo/server/http/server.h index 248bd5d..d8946a1 100644 --- a/server/modules/http/include/modulo/server/http/server.h +++ b/server/modules/http/include/modulo/server/http/server.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -10,11 +11,18 @@ namespace modulo::server::http { /// The Modulo REST API server. /// /// Owns the QHttpServer instance and registers every route. Feature modules -/// contribute their routes here as increments land (auth, transactions, etc.). +/// contribute their routes here as increments land (auth, transactions, ...). /// Requires a running Qt event loop (QCoreApplication) to serve requests. +/// +/// Every response carries security headers (Cache-Control: no-store, +/// X-Content-Type-Options: nosniff, X-Frame-Options: DENY, +/// Referrer-Policy: no-referrer) and unrouted paths answer with the JSON +/// error envelope. class Server { public: - explicit Server(config::Config config); + /// `authService` may be null for a health-only server (used by tests); + /// when given it must outlive the Server. + explicit Server(config::Config config, auth::AuthService* authService = nullptr); Server(const Server&) = delete; Server& operator=(const Server&) = delete; @@ -27,6 +35,7 @@ class Server { void registerRoutes(); config::Config config_; + auth::AuthService* authService_; QHttpServer server_; }; diff --git a/server/modules/http/src/auth_guard.cpp b/server/modules/http/src/auth_guard.cpp new file mode 100644 index 0000000..8a53906 --- /dev/null +++ b/server/modules/http/src/auth_guard.cpp @@ -0,0 +1,34 @@ +#include + +#include + +namespace modulo::server::http { + +QString bearerToken(const QHttpServerRequest& request) { + const QByteArray header = request.headers().value(QHttpHeaders::WellKnownHeader::Authorization).toByteArray(); + constexpr QByteArrayView kScheme{"Bearer "}; + if (!header.startsWith(kScheme)) { + return {}; + } + return QString::fromLatin1(header.mid(kScheme.size()).trimmed()); +} + +core::Result authenticateRequest(auth::AuthService& service, const QHttpServerRequest& request) { + const QString token = bearerToken(request); + if (token.isEmpty()) { + return core::makeError(QStringLiteral("auth.unauthenticated"), + QStringLiteral("missing bearer token (Authorization: Bearer )")); + } + + auto context = service.authenticate(token); + if (!context) { + return std::unexpected{context.error()}; + } + if (!context->has_value()) { + return core::makeError(QStringLiteral("auth.unauthenticated"), + QStringLiteral("token is invalid, expired or revoked")); + } + return std::move(**context); +} + +} // namespace modulo::server::http diff --git a/server/modules/http/src/auth_routes.cpp b/server/modules/http/src/auth_routes.cpp new file mode 100644 index 0000000..4c04f25 --- /dev/null +++ b/server/modules/http/src/auth_routes.cpp @@ -0,0 +1,82 @@ +#include +#include +#include +#include +#include + +namespace modulo::server::http { + +namespace { + +using Method = QHttpServerRequest::Method; +using Status = QHttpServerResponse::StatusCode; + +api::UserDto toDto(const auth::UserRecord& user) { + QStringList roles; + for (const auth::Role role : user.roles) { + roles.append(auth::roleName(role)); + } + return api::UserDto{.id = user.id, .email = user.email, .displayName = user.displayName, .roles = roles}; +} + +} // namespace + +void registerAuthRoutes(QHttpServer& server, auth::AuthService& service) { + server.route(QStringLiteral("/api/v1/auth/register"), Method::Post, + [&service](const QHttpServerRequest& request) -> QHttpServerResponse { + const auto body = jsonBody(request); + if (!body) { + return errorResponse(body.error()); + } + const auto dto = api::RegisterRequest::fromJson(*body); + if (!dto) { + return errorResponse(dto.error()); + } + const auto user = service.registerUser(dto->email, dto->displayName, dto->password); + if (!user) { + return errorResponse(user.error()); + } + return jsonResponse(toDto(*user).toJson(), Status::Created); + }); + + server.route(QStringLiteral("/api/v1/auth/login"), Method::Post, + [&service](const QHttpServerRequest& request) -> QHttpServerResponse { + const auto body = jsonBody(request); + if (!body) { + return errorResponse(body.error()); + } + const auto dto = api::LoginRequest::fromJson(*body); + if (!dto) { + return errorResponse(dto.error()); + } + const auto login = service.login(dto->email, dto->password); + if (!login) { + return errorResponse(login.error()); + } + const api::LoginResponse response{.token = login->token, .user = toDto(login->user)}; + return jsonResponse(response.toJson()); + }); + + server.route(QStringLiteral("/api/v1/auth/logout"), Method::Post, + authed(service, [&service](const auth::AuthContext&, const QHttpServerRequest& request) { + if (const auto revoked = service.logout(bearerToken(request)); !revoked) { + return errorResponse(revoked.error()); + } + return QHttpServerResponse{Status::NoContent}; + })); + + server.route(QStringLiteral("/api/v1/auth/me"), Method::Get, + authed(service, [&service](const auth::AuthContext& context, const QHttpServerRequest&) { + const auto user = service.userOf(context); + if (!user) { + return errorResponse(user.error()); + } + if (!user->has_value()) { + return errorResponse(Status::NotFound, QStringLiteral("auth.user_not_found"), + QStringLiteral("the authenticated user no longer exists")); + } + return jsonResponse(toDto(**user).toJson()); + })); +} + +} // namespace modulo::server::http diff --git a/server/modules/http/src/responses.cpp b/server/modules/http/src/responses.cpp new file mode 100644 index 0000000..ce8e50f --- /dev/null +++ b/server/modules/http/src/responses.cpp @@ -0,0 +1,65 @@ +#include +#include + +#include +#include + +namespace modulo::server::http { + +namespace { + +QByteArray toBody(const QJsonObject& json) { + return QJsonDocument{json}.toJson(QJsonDocument::Compact); +} + +} // namespace + +QHttpServerResponse jsonResponse(const QJsonObject& body, QHttpServerResponse::StatusCode status) { + return QHttpServerResponse{"application/json", toBody(body), status}; +} + +QHttpServerResponse errorResponse(const core::Error& error) { + return errorResponse(statusFor(error.code), error.code, error.message); +} + +QHttpServerResponse errorResponse(QHttpServerResponse::StatusCode status, const QString& code, const QString& message) { + const api::ErrorResponse envelope{.code = code, .message = message}; + return jsonResponse(envelope.toJson(), status); +} + +QHttpServerResponse::StatusCode statusFor(const QString& code) { + using Status = QHttpServerResponse::StatusCode; + + if (code == QLatin1StringView{"auth.invalid_credentials"} || code == QLatin1StringView{"auth.unauthenticated"}) { + return Status::Unauthorized; + } + if (code == QLatin1StringView{"auth.forbidden"} || code == QLatin1StringView{"auth.registration_disabled"}) { + return Status::Forbidden; + } + if (code == QLatin1StringView{"auth.email_taken"}) { + return Status::Conflict; + } + if (code.endsWith(QLatin1StringView{"not_found"})) { + return Status::NotFound; + } + if (code.startsWith(QLatin1StringView{"api."}) || code.startsWith(QLatin1StringView{"password."}) || + code.startsWith(QLatin1StringView{"auth.invalid_"})) { + return Status::BadRequest; + } + if (code.startsWith(QLatin1StringView{"db."})) { + return Status::ServiceUnavailable; + } + return Status::InternalServerError; +} + +core::Result jsonBody(const QHttpServerRequest& request) { + QJsonParseError parseError{}; + const QJsonDocument document = QJsonDocument::fromJson(request.body(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + return core::makeError(QStringLiteral("api.invalid_json"), + QStringLiteral("request body must be a JSON object")); + } + return document.object(); +} + +} // namespace modulo::server::http diff --git a/server/modules/http/src/server.cpp b/server/modules/http/src/server.cpp index 9b9d812..dd89b48 100644 --- a/server/modules/http/src/server.cpp +++ b/server/modules/http/src/server.cpp @@ -1,8 +1,13 @@ #include #include #include +#include +#include +#include #include +#include +#include #include #include #include @@ -12,19 +17,31 @@ namespace modulo::server::http { -namespace { +Q_LOGGING_CATEGORY(lcHttp, "modulo.http") -QByteArray toBody(const QJsonObject& json) { - return QJsonDocument{json}.toJson(QJsonDocument::Compact); -} +namespace { -QHttpServerResponse jsonResponse(const QJsonObject& body, QHttpServerResponse::StatusCode status) { - return QHttpServerResponse{"application/json", toBody(body), status}; +QByteArray methodName(QHttpServerRequest::Method method) { + switch (method) { + case QHttpServerRequest::Method::Get: + return "GET"; + case QHttpServerRequest::Method::Post: + return "POST"; + case QHttpServerRequest::Method::Put: + return "PUT"; + case QHttpServerRequest::Method::Delete: + return "DELETE"; + case QHttpServerRequest::Method::Patch: + return "PATCH"; + default: + return "OTHER"; + } } } // namespace -Server::Server(config::Config config) : config_{std::move(config)} { +Server::Server(config::Config config, auth::AuthService* authService) + : config_{std::move(config)}, authService_{authService} { registerRoutes(); } @@ -45,21 +62,42 @@ core::Result Server::listen() { } tcpServer.release(); // ownership transferred to server_ by bind() + qCInfo(lcHttp).noquote() << QStringLiteral("listening on 127.0.0.1:%1 (auth routes: %2)") + .arg(port) + .arg(authService_ != nullptr ? QStringLiteral("on") : QStringLiteral("off")); return port; } void Server::registerRoutes() { - server_.route("/api/v1/health", QHttpServerRequest::Method::Get, [] { + server_.route(QStringLiteral("/api/v1/health"), QHttpServerRequest::Method::Get, [] { const api::HealthResponse health{.status = QStringLiteral("ok"), .version = core::version()}; - return jsonResponse(health.toJson(), QHttpServerResponse::StatusCode::Ok); + return jsonResponse(health.toJson()); }); + if (authService_ != nullptr) { + registerAuthRoutes(server_, *authService_); + } + // Anything unrouted gets the uniform error envelope instead of Qt's // default HTML 404 page. server_.setMissingHandler(&server_, [](const QHttpServerRequest&, QHttpServerResponder& responder) { const api::ErrorResponse error{.code = QStringLiteral("not_found"), .message = QStringLiteral("resource not found")}; - responder.write(toBody(error.toJson()), "application/json", QHttpServerResponder::StatusCode::NotFound); + responder.write(QJsonDocument{error.toJson()}.toJson(QJsonDocument::Compact), "application/json", + QHttpServerResponder::StatusCode::NotFound); + }); + + // Security headers on every response + one debug log line per request. + server_.addAfterRequestHandler(&server_, [](const QHttpServerRequest& request, QHttpServerResponse& response) { + QHttpHeaders headers = response.headers(); + headers.append(QHttpHeaders::WellKnownHeader::CacheControl, "no-store"); + headers.append("X-Content-Type-Options", "nosniff"); + headers.append("X-Frame-Options", "DENY"); + headers.append("Referrer-Policy", "no-referrer"); + response.setHeaders(std::move(headers)); + + qCDebug(lcHttp).noquote() << methodName(request.method()) << request.url().path() + << static_cast(response.statusCode()); }); } diff --git a/server/tests/integration/CMakeLists.txt b/server/tests/integration/CMakeLists.txt index 80457a5..3dccfa3 100644 --- a/server/tests/integration/CMakeLists.txt +++ b/server/tests/integration/CMakeLists.txt @@ -13,3 +13,9 @@ modulo_add_test( LABEL integration SOURCES test_auth_repositories.cpp DEPS modulo_server_auth Qt6::Network) + +modulo_add_test( + modulo_auth_flow_tests + LABEL integration + SOURCES test_auth_flow.cpp + DEPS modulo_server_http Qt6::Network) diff --git a/server/tests/integration/test_auth_flow.cpp b/server/tests/integration/test_auth_flow.cpp new file mode 100644 index 0000000..e7e4aa3 --- /dev/null +++ b/server/tests/integration/test_auth_flow.cpp @@ -0,0 +1,185 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +using namespace modulo; +using namespace modulo::server; + +/// End-to-end authentication over real HTTP: server with auth routes on an +/// OS-assigned port, real client, real modulo_test database. +class AuthFlowTest : 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"); + tx.commit(); + } + service_ = std::make_unique(*pool_); + server_ = std::make_unique(config::Config{.httpPort = 0}, service_.get()); + const auto port = server_->listen(); + QVERIFY(port.has_value()); + baseUrl_ = QUrl{QStringLiteral("http://127.0.0.1:%1").arg(*port)}; + } + + void cleanup() { + server_.reset(); + service_.reset(); + pool_.reset(); + } + + void registerLoginMeLogout() { + MODULO_REQUIRE_TEST_DATABASE(); + + // register -> 201, first account is admin + user + const auto registered = post(QStringLiteral("/api/v1/auth/register"), + api::RegisterRequest{.email = QStringLiteral("angelo@example.com"), + .displayName = QStringLiteral("Angelo"), + .password = QStringLiteral("correct horse battery")} + .toJson()); + QCOMPARE(registered.status, 201); + const auto user = api::UserDto::fromJson(object(registered.body)); + QVERIFY(user.has_value()); + QCOMPARE(user->email, QStringLiteral("angelo@example.com")); + QCOMPARE(user->roles, (QStringList{QStringLiteral("admin"), QStringLiteral("user")})); + + // login -> 200 with token + const auto loggedIn = post(QStringLiteral("/api/v1/auth/login"), + api::LoginRequest{.email = QStringLiteral("ANGELO@example.com"), + .password = QStringLiteral("correct horse battery")} + .toJson()); + QCOMPARE(loggedIn.status, 200); + const auto login = api::LoginResponse::fromJson(object(loggedIn.body)); + QVERIFY(login.has_value()); + QCOMPARE(login->token.size(), 43); + QCOMPARE(login->user.id, user->id); + + // me -> 200 for the bearer + const auto me = testing::httpGet(url(QStringLiteral("/api/v1/auth/me")), login->token); + QCOMPARE(me.status, 200); + QCOMPARE(api::UserDto::fromJson(object(me.body))->id, user->id); + + // logout -> 204, then the same token is rejected + QCOMPARE(testing::httpRequest("POST", url(QStringLiteral("/api/v1/auth/logout")), {}, login->token).status, + 204); + const auto afterLogout = testing::httpGet(url(QStringLiteral("/api/v1/auth/me")), login->token); + QCOMPARE(afterLogout.status, 401); + QCOMPARE(errorCode(afterLogout.body), QStringLiteral("auth.unauthenticated")); + } + + void protectedRoutesRejectMissingAndBogusTokens() { + MODULO_REQUIRE_TEST_DATABASE(); + + const auto missing = testing::httpGet(url(QStringLiteral("/api/v1/auth/me"))); + QCOMPARE(missing.status, 401); + QCOMPARE(errorCode(missing.body), QStringLiteral("auth.unauthenticated")); + + const auto bogus = testing::httpGet(url(QStringLiteral("/api/v1/auth/me")), QStringLiteral("not-a-token")); + QCOMPARE(bogus.status, 401); + } + + void wrongPasswordAndUnknownEmailLookIdentical() { + MODULO_REQUIRE_TEST_DATABASE(); + registerAccount(QStringLiteral("a@example.com")); + + const auto wrong = post( + QStringLiteral("/api/v1/auth/login"), + api::LoginRequest{.email = QStringLiteral("a@example.com"), .password = QStringLiteral("definitely not it")} + .toJson()); + const auto unknown = post(QStringLiteral("/api/v1/auth/login"), + api::LoginRequest{.email = QStringLiteral("nobody@example.com"), + .password = QStringLiteral("definitely not it")} + .toJson()); + QCOMPARE(wrong.status, 401); + QCOMPARE(unknown.status, 401); + QCOMPARE(wrong.body, unknown.body); // identical envelope, no account enumeration + QCOMPARE(errorCode(wrong.body), QStringLiteral("auth.invalid_credentials")); + } + + void duplicateEmailIs409AndBadInputIs400() { + MODULO_REQUIRE_TEST_DATABASE(); + registerAccount(QStringLiteral("dup@example.com")); + + const auto duplicate = post(QStringLiteral("/api/v1/auth/register"), + api::RegisterRequest{.email = QStringLiteral("DUP@example.com"), + .displayName = QStringLiteral("Again"), + .password = QStringLiteral("correct horse battery")} + .toJson()); + QCOMPARE(duplicate.status, 409); + QCOMPARE(errorCode(duplicate.body), QStringLiteral("auth.email_taken")); + + const auto shortPassword = post(QStringLiteral("/api/v1/auth/register"), + api::RegisterRequest{.email = QStringLiteral("new@example.com"), + .displayName = QStringLiteral("New"), + .password = QStringLiteral("short")} + .toJson()); + QCOMPARE(shortPassword.status, 400); + QCOMPARE(errorCode(shortPassword.body), QStringLiteral("password.too_short")); + + const auto notJson = testing::httpRequest("POST", url(QStringLiteral("/api/v1/auth/register")), "{oops"); + QCOMPARE(notJson.status, 400); + QCOMPARE(errorCode(notJson.body), QStringLiteral("api.invalid_json")); + } + + void secondAccountIsPlainUser() { + MODULO_REQUIRE_TEST_DATABASE(); + registerAccount(QStringLiteral("first@example.com")); + + const auto second = post(QStringLiteral("/api/v1/auth/register"), + api::RegisterRequest{.email = QStringLiteral("second@example.com"), + .displayName = QStringLiteral("Second"), + .password = QStringLiteral("correct horse battery")} + .toJson()); + QCOMPARE(second.status, 201); + QCOMPARE(api::UserDto::fromJson(object(second.body))->roles, QStringList{QStringLiteral("user")}); + } + +private: + QUrl url(const QString& path) const { return baseUrl_.resolved(QUrl{path}); } + + testing::HttpResponse post(const QString& path, const QJsonObject& body) const { + return testing::httpRequest("POST", url(path), QJsonDocument{body}.toJson(QJsonDocument::Compact)); + } + + void registerAccount(const QString& email) { + const auto response = post(QStringLiteral("/api/v1/auth/register"), + api::RegisterRequest{.email = email, + .displayName = QStringLiteral("User"), + .password = QStringLiteral("correct horse battery")} + .toJson()); + QCOMPARE(response.status, 201); + } + + static QJsonObject object(const QByteArray& body) { return QJsonDocument::fromJson(body).object(); } + + static QString errorCode(const QByteArray& body) { + const auto error = api::ErrorResponse::fromJson(object(body)); + return error ? error->code : QStringLiteral(""); + } + + std::unique_ptr pool_; + std::unique_ptr service_; + std::unique_ptr server_; + QUrl baseUrl_; +}; + +QTEST_GUILESS_MAIN(AuthFlowTest) +#include "test_auth_flow.moc" diff --git a/tests/support/include/modulo/testing/integration.h b/tests/support/include/modulo/testing/integration.h index 84e3740..b853fbf 100644 --- a/tests/support/include/modulo/testing/integration.h +++ b/tests/support/include/modulo/testing/integration.h @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -38,11 +39,20 @@ struct HttpResponse { QByteArray body; }; -/// Blocking HTTP GET against an in-process server, with a timeout so a dead -/// server fails the test instead of hanging it. -inline HttpResponse httpGet(const QUrl& url, int timeoutMs = 5000) { +/// Blocking HTTP request against an in-process server, with a timeout so a +/// dead server fails the test instead of hanging it. `body` is sent as JSON; +/// `bearer`, when non-empty, becomes the Authorization header. +inline HttpResponse httpRequest(const QByteArray& method, const QUrl& url, const QByteArray& body = {}, + const QString& bearer = {}, int timeoutMs = 5000) { QNetworkAccessManager network; - QNetworkReply* reply = network.get(QNetworkRequest{url}); + QNetworkRequest request{url}; + if (!body.isEmpty()) { + request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); + } + if (!bearer.isEmpty()) { + request.setRawHeader("Authorization", "Bearer " + bearer.toLatin1()); + } + QNetworkReply* reply = network.sendCustomRequest(request, method, body); QEventLoop loop; QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); @@ -56,4 +66,8 @@ inline HttpResponse httpGet(const QUrl& url, int timeoutMs = 5000) { return response; } +inline HttpResponse httpGet(const QUrl& url, const QString& bearer = {}, int timeoutMs = 5000) { + return httpRequest("GET", url, {}, bearer, timeoutMs); +} + } // namespace modulo::testing