Skip to content
Open
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,7 @@ MODULO_HTTP_PORT=8080
# Root directory for server-managed files (uploaded documents live under
# documents/<uuid>). 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
43 changes: 33 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>`) 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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<T>, 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 (<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 + 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)
Expand Down Expand Up @@ -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

Expand Down
60 changes: 52 additions & 8 deletions docs/high_level_design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)"]
Expand All @@ -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
Expand All @@ -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"]
Expand All @@ -81,6 +91,7 @@ UserRepository · SessionRepository"]
authm --> dbm
httpm --> api
httpm --> cfg
httpm --> authm
server --> httpm
migrateexe --> dbm
clientexe --> api
Expand Down Expand Up @@ -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
Expand All @@ -146,7 +187,7 @@ sequenceDiagram
M-->>U: "N applied, M skipped" (exit code)
```

## 5. Data model
## 6. Data model

```mermaid
erDiagram
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

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

#include <modulo/core/result.h>

#include <QJsonObject>
#include <QString>
#include <QStringList>

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<RegisterRequest> 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<LoginRequest> 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<UserDto> 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<LoginResponse> fromJson(const QJsonObject& json);
};

} // namespace modulo::api
4 changes: 4 additions & 0 deletions libs/api/include/modulo/api/json.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

#include <QJsonObject>
#include <QString>
#include <QStringList>

namespace modulo::api::json {

Expand All @@ -17,4 +18,7 @@ core::Result<QString> requireString(const QJsonObject& object, QLatin1StringView

core::Result<QJsonObject> requireObject(const QJsonObject& object, QLatin1StringView key);

/// A JSON array whose elements must all be strings.
core::Result<QStringList> requireStringList(const QJsonObject& object, QLatin1StringView key);

} // namespace modulo::api::json
Loading
Loading