L-1 RequireRole: guard std::stoi on the bundle id — a non-numeric/out-of-range
value now yields a clean 401 instead of an uncaught exception → 500.
AuthPrincipal::id documented as numeric-only (carry UUIDs in username).
L-2 SmtpTransport: require TLS (CURLUSESSL_ALL) for non-loopback relays so a
stripped STARTTLS can't downgrade credentials/body to cleartext; localhost
relay stays opportunistic.
L-3 AuditLog: escapeJson now escapes all control chars (RFC 8259) so a newline
in a field can't forge/corrupt the audit JSON; SKIP_FIELDS gains credential
names (password/passwordHash/tlsCertDn/apiKey/token/secret) so secrets never
land in changed_fields.
L-4 ws/Hub: consume the thread_local auth handoff once, up front, and clear it
unconditionally — a stale value can't attach to a later connection on a
reused worker thread.
L-5 TemporalRepository: default id generator draws 128 bits from the platform
CSPRNG (std::random_device) per call instead of a once-seeded mt19937_64,
so entity_ids aren't predictable from observed output.
L-6 AuthInterceptor: expired-session sweep is now a lock-free atomic timer and
exception-isolated; documented that resolveBySessionHash() must enforce
expiry at query time (the sweep is GC only).
L-7 new util/ConstantTime.hpp (constantTimeEquals) + TokenHasher doc requiring a
>=256-bit cryptographic hash.
L-8 IQueryable: likeEscape + Field::likeContains/likePrefix emit
`LIKE ? ESCAPE '\'` with %/_/\ escaped for untrusted terms; documented the
compile-time identifier-source invariant.
Tests: new test_constant_time; likeEscape/likeContains/likePrefix cases added to
test_queryable. All 20 ctest targets pass. README + header docs updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
133 lines
12 KiB
Markdown
133 lines
12 KiB
Markdown
# oatpp-authkit
|
|
|
|
Header-only C++ library distilled from [fewo-webapp](https://git.uwe-schuster.info/uwe.admin/fewo-webapp)'s
|
|
hardened auth / security stack. Header-only, oatpp 1.3+, C++17.
|
|
|
|
## What's in v0.1 (the clean-lift set)
|
|
|
|
| Header | Purpose |
|
|
|--------|---------|
|
|
| `interceptor/SecurityHeadersInterceptor.hpp` | CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy. Strict defaults. |
|
|
| `interceptor/BodySizeLimitInterceptor.hpp` | Reject request bodies above a configurable limit with 413 before they hit your handlers. |
|
|
| `handler/JsonErrorHandler.hpp` | Normalises thrown exceptions into `{status, message}` JSON so controllers never leak raw HTML error pages. |
|
|
| `util/RateLimiter.hpp` | In-memory token-bucket keyed on an arbitrary string (typically the client IP from `clientIpTrusted`). The constructor validates its args (`capacity` finite ≥1, `refillRate` finite >0) and throws `std::invalid_argument` otherwise — a zero/negative/NaN rate previously disabled the limiter silently (authkit#16 M-7). |
|
|
| `util/TokenExtract.hpp` | `extractToken` (Cookie/Bearer) + `cookieValue(header,name)` exact-name cookie parse (authkit#16 M-1 — no substring matching, so a sibling `xsession=` can't shadow `session=`), `isValidIp` (IPv4/IPv6 via `inet_pton`), `clientIpTrusted` (loopback-gated XFF; returns the `"unknown"`/`"invalid"` sentinels off-proxy — treat as one shared rate-limit bucket, M-8). |
|
|
| `util/OriginCheck.hpp` | `originHostname`, `sameOrigin(originOrReferer, host)`, `originAllowed(origin, allowlist)` — pure CSRF/CSWSH origin helpers (authkit#16 M-4/M-10). Used by `AuthInterceptor` for session mutations; call `sameOrigin`/`originAllowed` in your WSController to block Cross-Site WebSocket Hijacking at the handshake. |
|
|
| `util/SessionCookie.hpp` | `buildSetSessionCookie(token, opts)` / `buildClearSessionCookie(opts)` — safe-by-default `Set-Cookie` builder (HttpOnly + Secure + SameSite=Strict + Path=/ by default; opt out explicitly). Rejects control chars / `;` in fields (authkit#16 M-9). Returns the header value only; framework-agnostic. |
|
|
| `util/ConstantTime.hpp` | `constantTimeEquals(a, b)` — branch-free secret comparison for consumers that compare a token/HMAC/hash in memory rather than via an indexed store lookup (authkit#16 L-7). |
|
|
| `mail/SmtpTransport.hpp` | libcurl SMTP+MIME sender. Requires TLS (`CURLUSESSL_ALL`) for non-loopback relays so credentials/body can't be sent cleartext if STARTTLS is stripped (authkit#16 L-2); a `localhost`/`127.0.0.1` relay stays opportunistic. Rejects CR/LF/NUL in `to`/`fromAddress` (header-injection guard, authkit#16 H-5). |
|
|
| `startup/RequireEncryptionKey.hpp` | `requireEncryptionKey(envVarName, encryptionEnabled, allowPlaintext)` — refuse startup without a symmetric key unless a dev flag overrides. |
|
|
| `repo/Repository.hpp` + `IHistoryRepository.hpp` + `TemporalFieldTraits.hpp` + `TemporalAt.hpp` + `ActorContext.hpp` | Pure-abstract `Repository<TDto>` interface set distilled from fewo-webapp's per-entity `*Db` clients. Mixed UUID allocation on `save`, separate `IHistoryRepository<T>` for temporal versions, `TemporalFieldTraits<T>` to map canonical (entity_id, valid_from, valid_until) onto whatever a DTO actually calls them, `ActorContext` placeholder for the scope-guard decorator. |
|
|
| `repo/TemporalRepository.hpp` | Decorator that wraps any `Repository<TDto>` and turns it into a temporally-versioned one. **Stable-live + historical-copy semantics (authkit#13):** the live row's `id` PK is preserved across updates; each prior version is captured as a fresh row with a new `id`. `softDelete` closes the live row in place; with `ON UPDATE CASCADE` on consumer-side composite child FKs, child rows follow automatically. `findByEntityIdAt(id, at)` returns the version live at a point in time; implements `IHistoryRepository<T>`. Inner adapter is expected to expose all rows (live + historical) and treat `save` as upsert keyed by **`id`** (per-row PK). DTOs register their four temporal columns via `OATPP_AUTHKIT_REGISTER_TEMPORAL(Dto, id, entity_id, valid_from, valid_until)`. |
|
|
| `repo/ScopeGuardRepository.hpp` | Generic resource-scope decorator. Takes a `bool(ActorContext, TDto)` predicate, an actor accessor, and an `entity_id` accessor at construction; gates every method on the predicate. On `save` the predicate must pass on the incoming DTO **and**, for an update, on the row as it currently stands — so an actor can't reparent an out-of-scope row into its own scope by relabelling it in the request body. Throws `ScopeDeniedException` on deny (catchers translate to 403). Knows nothing about consumer-specific concepts like "property" or "tenant" — the predicate decides. **`ScopeGuardQueryable<T>`** (same header) is the variant for `IQueryable` inners: it filters `query()` results through the predicate too, so the queryable surface can't bypass the guard. |
|
|
| `repo/IQueryable.hpp` | Optional capability for repos that resolve a typed query AST. `field<&Dto::col>().eq(...)` style DSL composes via `&&` / `||` / `!`; `Query<TDto>::toSql()` emits parameterised SQL plus a bind bag. Bounded surface — equality, range, IN, LIKE, NULL, ORDER BY, LIMIT/OFFSET. No joins, subqueries, or aggregates. For user-supplied search terms use `likeContains`/`likePrefix` (or `likeEscape`), which escape `%`/`_`/`\` and emit `LIKE ? ESCAPE '\'` (authkit#16 L-8); raw `like()` binds the pattern verbatim (trusted patterns only). Column/table identifiers come only from compile-time registration — never from request data. Concrete repos opt in by deriving `IQueryable<TDto>`. Wrap a scope-guarded `IQueryable` with `ScopeGuardQueryable<T>` (not the plain `ScopeGuardRepository`) so `query()` is scope-filtered. |
|
|
| `repo/IAuditSink.hpp` + `repo/AuditLogRepository.hpp` | Cross-cutting audit-trail decorator. Emits an `AuditEvent` (actor, entity type/id, op, timestamp) per mutation through a consumer-supplied `IAuditSink`. Ops are `Create` / `Update` / `Delete` / `Read`; pre-write `findByEntityId` lookup distinguishes Create from Update. Configurable enabled-op set (default `{Create,Update,Delete}` — `Read` is opt-in, `list()` never audited). Sink failures are caught and swallowed unless a `bool(const std::exception&)` handler asks to rethrow. Stacks with `TemporalRepository` and `ScopeGuardRepository`. |
|
|
| `repo/SchemaContract.hpp` | Declarative schema model for the decorator stack (authkit#14). Each decorator exposes a `static constexpr DecoratorSchema kSchema` listing the columns/indexes it contributes to the entity table plus any sidecar tables it owns. `SchemaBuilder<Decorators…>::create(table, exec)` composes contributions into a single `CREATE TABLE` per entity table; sidecars emit separately. `SchemaContract<Decorators…>::verify(table, probe)` is a runtime introspect-and-assert that throws `SchemaContractViolation` if any required column or sidecar is missing. Decorator code never runs ALTER at runtime — Atlas (atlasgo.io) owns evolution between deploys; the C++ side only declares desired state and checks it. |
|
|
| `repo/RedactedFieldRepository.hpp` | Decorator that nulls out named fields on **historical** rows only (authkit#15). Sits below `TemporalRepository` and inspects each `save`: if `valid_until != SENTINEL`, the row is being closed as a historical version, so the configured fields (e.g. `passwordHash`, `tlsCertDn`) are set to null before persisting. The live row keeps its values intact. Built for the case where a credential rides a temporal row — every change creates a historical version with the prior secret preserved, and the redaction prevents a DB breach from yielding every credential a user has ever had. The constructor throws `std::invalid_argument` if a configured field name isn't a DTO member (authkit#16 M-6) — a typo would otherwise silently redact nothing. |
|
|
|
|
## Decorator schema contributions
|
|
|
|
| Decorator | Entity columns | Entity indexes | Sidecar tables |
|
|
|-----------|----------------|----------------|----------------|
|
|
| `TemporalRepository<T>` | `valid_from TEXT NOT NULL DEFAULT ''`, `valid_until TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'` | `UNIQUE INDEX ux_{table}_entity_valid_until ON {table}(entity_id, valid_until)` | (none) |
|
|
| `AuditLogRepository<T>` | (none) | (none) | `audit_log (id, actor_user_id, entity_type, entity_id, op, timestamp_ms)` |
|
|
| `ScopeGuardRepository<T>` | (none) | (none) | (none) |
|
|
|
|
The concrete repo at the bottom of the stack contributes the entity_id +
|
|
business columns. Stacking is declarative; column dedup keeps duplicate
|
|
contributions safe.
|
|
|
|
Wiring it up:
|
|
|
|
```cpp
|
|
#include "oatpp-authkit/repo/SchemaContract.hpp"
|
|
|
|
auto exec = [&](const std::string& sql) { /* run DDL */ };
|
|
auto probe = [&](const std::string& sql) { /* run SELECT, return bool */ };
|
|
|
|
// On a fresh DB (e.g. CI dev DB that Atlas inspects):
|
|
oatpp_authkit::repo::SchemaBuilder<
|
|
ConcretePersonRepository,
|
|
oatpp_authkit::repo::TemporalRepository<PersonDto>,
|
|
oatpp_authkit::repo::AuditLogRepository<PersonDto>>::create("persons", exec);
|
|
|
|
// At every app startup, against a populated DB:
|
|
oatpp_authkit::repo::SchemaContract<
|
|
ConcretePersonRepository,
|
|
oatpp_authkit::repo::TemporalRepository<PersonDto>,
|
|
oatpp_authkit::repo::AuditLogRepository<PersonDto>>::verify("persons", probe);
|
|
```
|
|
|
|
Atlas wiring (out of scope for this header): point `atlas migrate diff`'s
|
|
`--dev-url` at a SQLite that `SchemaBuilder` has populated, and `--url`
|
|
at the live prod DB. Atlas emits versioned migration SQL; the deploy
|
|
pipeline applies it. The decorator code stays unchanged across schema
|
|
evolutions.
|
|
|
|
## Consume via CMake
|
|
|
|
```cmake
|
|
# FetchContent (pin to a tag):
|
|
include(FetchContent)
|
|
FetchContent_Declare(oatpp-authkit
|
|
GIT_REPOSITORY https://git.uwe-schuster.info/uwe.admin/oatpp-authkit.git
|
|
GIT_TAG v0.1.0)
|
|
FetchContent_MakeAvailable(oatpp-authkit)
|
|
|
|
target_link_libraries(app PRIVATE oatpp::authkit)
|
|
```
|
|
|
|
Or after `cmake --install`:
|
|
|
|
```cmake
|
|
find_package(oatpp-authkit 0.1 REQUIRED)
|
|
target_link_libraries(app PRIVATE oatpp::authkit)
|
|
```
|
|
|
|
## Browser-friendly 401/403
|
|
|
|
By default `AuthInterceptor` returns `application/json` for every rejection,
|
|
which is correct for `/api/*` callers but breaks browser navigation: a user
|
|
following a stale link or an expired password-reset URL sees a raw
|
|
`{"status":"Unauthorized"}` instead of a real page.
|
|
|
|
Override `IAuthPolicy::unauthenticatedRedirect(path)` to redirect browser
|
|
navigations to a login or landing page while keeping JSON responses for
|
|
`fetch`/`axios` callers (detected via path prefix `/api/`,
|
|
`X-Requested-With: XMLHttpRequest`, or an `Accept` header that prefers
|
|
`application/json`):
|
|
|
|
```cpp
|
|
class AppAuthPolicy : public oatpp_authkit::IAuthPolicy {
|
|
public:
|
|
std::optional<std::string>
|
|
unauthenticatedRedirect(const std::string& path) override {
|
|
return "/?next=" + oatpp_authkit::AuthInterceptor::urlEncode(path);
|
|
}
|
|
};
|
|
```
|
|
|
|
Returning `std::nullopt` (the default) preserves the legacy JSON behaviour
|
|
for all responses.
|
|
|
|
## Tests
|
|
|
|
```bash
|
|
cmake -B build -DOATPP_AUTHKIT_BUILD_TESTS=ON
|
|
cmake --build build
|
|
ctest --test-dir build --output-on-failure
|
|
```
|
|
|
|
Tests are off by default so consumers pulling the library in via
|
|
`FetchContent` don't pay the cost.
|
|
|
|
## Roadmap
|
|
|
|
- **v0.2** — `AuthInterceptor` + `requireAdmin` ported onto three seams
|
|
(`IAuthBackend`, `IAuthPolicy`, `IRuntimeConfig`) so consumers plug in their
|
|
own user store, public-path list, and admin role set without forking the
|
|
interceptor.
|
|
- **Later** — session cookie helpers, API-key rotation, re-encryption migration.
|
|
|
|
See `docs/security-baseline.md` for language-neutral CSP / rate-limit / body-size
|
|
constants that non-C++ consumers can re-implement directly.
|