From 495c8ddbb9f036faf05512a754b360df7ae99101 Mon Sep 17 00:00:00 2001 From: Uwe Schuster Date: Tue, 21 Apr 2026 21:48:43 +0200 Subject: [PATCH] v0.2.0: IAuthBackend/IAuthPolicy/IRuntimeConfig seams + AuthInterceptor port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the fewo-webapp AuthInterceptor + requireAdmin onto three abstract interfaces so consumer apps plug in their own user store, public paths, and runtime config without forking: auth/AuthPrincipal.hpp library-owned {id, username, role} value auth/IAuthBackend.hpp resolveBy{Session,ApiKey,Cert}, hasActiveUsers, deleteExpiredSessions auth/IAuthPolicy.hpp isPublicPath, adminRoles, readonlyRoles, setupModeActive (defaults: admin/readonly, no public paths, setup off) auth/IRuntimeConfig.hpp bindAddress, isLoopback auth/AuthInterceptor.hpp intercept() running the same 6-step ladder as fewo's original (public → setup → cert DN → session/API key → CSRF → readonly) auth/RequireRole.hpp requireUser + requireAdmin helpers reading bundle data (config-driven role sets, not hard-coded 'admin') TokenHasher is passed in so the library doesn't prescribe SHA-256 vs. whatever. Bundle keys match fewo's existing controllers so the consumer migration in #418 is a straightforward adapter swap. Smoke-compiled against oatpp 1.3.0 headers. Closes fewo-webapp#413 Co-Authored-By: Claude Opus 4.7 (1M context) --- CMakeLists.txt | 2 +- .../oatpp-authkit/auth/AuthInterceptor.hpp | 172 ++++++++++++++++++ include/oatpp-authkit/auth/AuthPrincipal.hpp | 23 +++ include/oatpp-authkit/auth/IAuthBackend.hpp | 50 +++++ include/oatpp-authkit/auth/IAuthPolicy.hpp | 47 +++++ include/oatpp-authkit/auth/IRuntimeConfig.hpp | 32 ++++ include/oatpp-authkit/auth/RequireRole.hpp | 55 ++++++ 7 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 include/oatpp-authkit/auth/AuthInterceptor.hpp create mode 100644 include/oatpp-authkit/auth/AuthPrincipal.hpp create mode 100644 include/oatpp-authkit/auth/IAuthBackend.hpp create mode 100644 include/oatpp-authkit/auth/IAuthPolicy.hpp create mode 100644 include/oatpp-authkit/auth/IRuntimeConfig.hpp create mode 100644 include/oatpp-authkit/auth/RequireRole.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e3a4eaa..e26cfb7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.14) -project(oatpp-authkit VERSION 0.1.0 LANGUAGES CXX) +project(oatpp-authkit VERSION 0.2.0 LANGUAGES CXX) # Header-only interface library — no compilation, just an include path and # a CMake config package so consumers do: diff --git a/include/oatpp-authkit/auth/AuthInterceptor.hpp b/include/oatpp-authkit/auth/AuthInterceptor.hpp new file mode 100644 index 0000000..101273c --- /dev/null +++ b/include/oatpp-authkit/auth/AuthInterceptor.hpp @@ -0,0 +1,172 @@ +#ifndef OATPP_AUTHKIT_AUTH_INTERCEPTOR_HPP +#define OATPP_AUTHKIT_AUTH_INTERCEPTOR_HPP + +#include +#include +#include +#include + +#include "oatpp/web/server/interceptor/RequestInterceptor.hpp" +#include "oatpp/web/protocol/http/outgoing/Response.hpp" +#include "oatpp/web/protocol/http/outgoing/ResponseFactory.hpp" +#include "oatpp/web/protocol/http/Http.hpp" + +#include "IAuthBackend.hpp" +#include "IAuthPolicy.hpp" +#include "IRuntimeConfig.hpp" +#include "../util/TokenExtract.hpp" + +namespace oatpp_authkit { + +/** @brief Caller-supplied hash function — SHA-256 on the raw token typically. */ +using TokenHasher = std::function; + +/** + * @brief Generic request interceptor built on IAuthBackend + IAuthPolicy + IRuntimeConfig. + * + * Order of checks: + * 1. Public path → pass. + * 2. Setup mode (empty users table + policy->setupModeActive()) → pseudo-admin. + * 3. X-SSL-Client-DN header (only trusted when bound to loopback) → cert auth. + * 4. Session cookie / Bearer token → backend->resolveBySessionHash / resolveByApiKeyHash. + * 5. CSRF defence: sessions reject state-changing requests without X-Requested-With. + * 6. Readonly roles cannot mutate. + * + * Bundle data written on success (consumed by requireAdmin / requireUser): + * auth_user_id (oatpp::String, decimal int) + * auth_user_role (oatpp::String) + * auth_username (oatpp::String) + */ +class AuthInterceptor : public oatpp::web::server::interceptor::RequestInterceptor { +private: + std::shared_ptr m_backend; + std::shared_ptr m_policy; + std::shared_ptr m_runtime; + TokenHasher m_hashToken; + + using Status = oatpp::web::protocol::http::Status; + using ResponseFactory = oatpp::web::protocol::http::outgoing::ResponseFactory; + + std::shared_ptr makeJsonError(Status status, const std::string& body) { + auto r = ResponseFactory::createResponse(status, body.c_str()); + r->putHeader("Content-Type", "application/json"); + return r; + } + std::shared_ptr makeUnauthorized() { + return makeJsonError(Status::CODE_401, "{\"status\":\"Unauthorized\"}"); + } + std::shared_ptr makeForbidden(const std::string& msg = "") { + if (msg.empty()) return makeJsonError(Status::CODE_403, "{\"status\":\"Forbidden\"}"); + return makeJsonError(Status::CODE_403, + "{\"status\":\"Forbidden\",\"message\":\"" + msg + "\"}"); + } + + void writeBundle(const std::shared_ptr& req, const AuthPrincipal& p) { + req->putBundleData("auth_user_id", oatpp::String(std::to_string(p.id).c_str())); + req->putBundleData("auth_user_role", oatpp::String(p.role.c_str())); + req->putBundleData("auth_username", oatpp::String(p.username.c_str())); + } + + static void logEvent(int status, const std::string& method, + const std::string& path, const std::string& reason) { + OATPP_LOGW("authkit", "[%d] %s %s — %s", + status, method.c_str(), path.c_str(), reason.c_str()); + } + + bool isMutation(const std::string& method) { + return method != "GET" && method != "HEAD" && method != "OPTIONS"; + } + bool isReadonly(const std::string& role) { + return m_policy->readonlyRoles().count(role) > 0; + } + +public: + AuthInterceptor(std::shared_ptr backend, + std::shared_ptr policy, + std::shared_ptr runtime, + TokenHasher hashToken) + : m_backend(std::move(backend)) + , m_policy(std::move(policy)) + , m_runtime(std::move(runtime)) + , m_hashToken(std::move(hashToken)) {} + + std::shared_ptr intercept( + const std::shared_ptr& request) override + { + // Periodic expired-session sweep — at most once per hour. + { + using Clock = std::chrono::steady_clock; + static Clock::time_point lastCleanup = Clock::now(); + auto now = Clock::now(); + if (std::chrono::duration_cast(now - lastCleanup).count() >= 1) { + lastCleanup = now; + m_backend->deleteExpiredSessions(); + } + } + + const std::string path = request->getStartingLine().path.std_str(); + const std::string method = request->getStartingLine().method.std_str(); + + if (m_policy->isPublicPath(path)) return nullptr; + + // Setup mode: empty users + policy opts in → pseudo-admin. + if (m_policy->setupModeActive() && !m_backend->hasActiveUsers()) { + AuthPrincipal p{0, "setup", "admin"}; + writeBundle(request, p); + return nullptr; + } + + // TLS cert DN — only trusted when we're behind a reverse proxy (loopback). + auto certDnH = request->getHeader("X-SSL-Client-DN"); + if (m_runtime->isLoopback() && certDnH && !certDnH->empty()) { + if (auto p = m_backend->resolveByCertDn(std::string(*certDnH))) { + writeBundle(request, *p); + if (isReadonly(p->role) && isMutation(method)) { + logEvent(403, method, path, "readonly cert user mutation"); + return makeForbidden(); + } + return nullptr; + } + } + + // Session / API key token. + std::string token = extractToken(request); + if (token.empty()) { + logEvent(401, method, path, "no token"); + return makeUnauthorized(); + } + std::string hash = m_hashToken(token); + + std::optional p; + bool viaSession = false; + if ((p = m_backend->resolveBySessionHash(hash))) { + viaSession = true; + } else if ((p = m_backend->resolveByApiKeyHash(hash))) { + viaSession = false; + } else { + logEvent(401, method, path, "invalid token"); + return makeUnauthorized(); + } + + // CSRF defence-in-depth: session cookie + mutation requires X-Requested-With. + if (viaSession && isMutation(method)) { + auto xrw = request->getHeader("X-Requested-With"); + if (!xrw || xrw->empty()) { + logEvent(403, method, path, "missing X-Requested-With"); + return makeForbidden("Missing X-Requested-With header"); + } + } + + writeBundle(request, *p); + + if (isReadonly(p->role) && isMutation(method)) { + logEvent(403, method, path, "readonly user mutation"); + return makeForbidden(); + } + return nullptr; + } +}; + +} // namespace oatpp_authkit + +#endif diff --git a/include/oatpp-authkit/auth/AuthPrincipal.hpp b/include/oatpp-authkit/auth/AuthPrincipal.hpp new file mode 100644 index 0000000..a294b05 --- /dev/null +++ b/include/oatpp-authkit/auth/AuthPrincipal.hpp @@ -0,0 +1,23 @@ +#ifndef OATPP_AUTHKIT_AUTH_PRINCIPAL_HPP +#define OATPP_AUTHKIT_AUTH_PRINCIPAL_HPP + +#include + +namespace oatpp_authkit { + +/** + * @brief Library-owned authenticated-user value. + * + * Intentionally decoupled from any consumer-specific DTO so the library + * stays portable. Consumers translate from their own UserDto (or whatever) + * into this struct inside their IAuthBackend implementation. + */ +struct AuthPrincipal { + int id{0}; ///< Stable numeric id from the user store. + std::string username; + std::string role; ///< Arbitrary string; policy decides what "admin"/"readonly" mean. +}; + +} // namespace oatpp_authkit + +#endif diff --git a/include/oatpp-authkit/auth/IAuthBackend.hpp b/include/oatpp-authkit/auth/IAuthBackend.hpp new file mode 100644 index 0000000..71c86d4 --- /dev/null +++ b/include/oatpp-authkit/auth/IAuthBackend.hpp @@ -0,0 +1,50 @@ +#ifndef OATPP_AUTHKIT_AUTH_IAUTH_BACKEND_HPP +#define OATPP_AUTHKIT_AUTH_IAUTH_BACKEND_HPP + +#include +#include + +#include "AuthPrincipal.hpp" + +namespace oatpp_authkit { + +/** + * @brief Consumer-supplied adapter from library primitives → user store. + * + * The library never reads the database directly. The interceptor calls + * these methods, the concrete implementation (owned by the consumer app) + * wraps `UserDb` / `CertificateDb` / whatever and returns library-owned + * `AuthPrincipal` structs. + * + * All methods must be thread-safe (the interceptor is invoked from oatpp + * worker threads). + */ +class IAuthBackend { +public: + virtual ~IAuthBackend() = default; + + /** @brief Look up an active session by its hashed token. */ + virtual std::optional resolveBySessionHash(const std::string& hash) = 0; + + /** @brief Look up an API key by its hashed token; also touch `last_used_at`. */ + virtual std::optional resolveByApiKeyHash(const std::string& hash) = 0; + + /** + * @brief Look up a user by TLS client cert DN. Return nullopt if your + * app doesn't support cert auth — the interceptor silently skips + * this step. + */ + virtual std::optional resolveByCertDn(const std::string& /*dn*/) { + return std::nullopt; + } + + /** @brief True iff at least one active user exists. Used for setup-mode gate. */ + virtual bool hasActiveUsers() = 0; + + /** @brief Delete expired session rows. Called periodically by the interceptor. */ + virtual void deleteExpiredSessions() = 0; +}; + +} // namespace oatpp_authkit + +#endif diff --git a/include/oatpp-authkit/auth/IAuthPolicy.hpp b/include/oatpp-authkit/auth/IAuthPolicy.hpp new file mode 100644 index 0000000..7718e27 --- /dev/null +++ b/include/oatpp-authkit/auth/IAuthPolicy.hpp @@ -0,0 +1,47 @@ +#ifndef OATPP_AUTHKIT_AUTH_IAUTH_POLICY_HPP +#define OATPP_AUTHKIT_AUTH_IAUTH_POLICY_HPP + +#include +#include + +namespace oatpp_authkit { + +/** + * @brief Consumer-supplied policy for public paths, roles, and setup mode. + * + * Ships with a conservative default impl (no public paths, `admin`/`readonly` + * role conventions, setup mode always off). Subclass to add your app's + * public-path list (`/guest/*`, `/calendar.ics`, etc.) and to expose the + * `SETUP_MODE` sentinel check. + */ +class IAuthPolicy { +public: + virtual ~IAuthPolicy() = default; + + /** @brief True iff the given path bypasses auth entirely. */ + virtual bool isPublicPath(const std::string& /*path*/) { return false; } + + /** @brief Roles that pass an admin-required check. */ + virtual const std::set& adminRoles() { + static const std::set k{"admin"}; + return k; + } + + /** @brief Roles that may only read (GET/HEAD/OPTIONS); mutations → 403. */ + virtual const std::set& readonlyRoles() { + static const std::set k{"readonly"}; + return k; + } + + /** + * @brief Setup-mode escape hatch: when true AND the user table is empty, + * the interceptor allows unauthenticated requests and injects a + * pseudo-admin into the bundle. Consumers typically gate this on + * the presence of a `SETUP_MODE` sentinel file. + */ + virtual bool setupModeActive() { return false; } +}; + +} // namespace oatpp_authkit + +#endif diff --git a/include/oatpp-authkit/auth/IRuntimeConfig.hpp b/include/oatpp-authkit/auth/IRuntimeConfig.hpp new file mode 100644 index 0000000..6ece0ff --- /dev/null +++ b/include/oatpp-authkit/auth/IRuntimeConfig.hpp @@ -0,0 +1,32 @@ +#ifndef OATPP_AUTHKIT_AUTH_IRUNTIME_CONFIG_HPP +#define OATPP_AUTHKIT_AUTH_IRUNTIME_CONFIG_HPP + +#include + +namespace oatpp_authkit { + +/** + * @brief Runtime config surface the interceptor needs. + * + * Small enough that consumers typically implement it inline against their + * existing Config globals. Provided as an interface rather than a struct + * so the values can change at runtime (e.g. bind address flipping during + * test setup) without restarting the interceptor. + */ +class IRuntimeConfig { +public: + virtual ~IRuntimeConfig() = default; + + /** @brief Host the service is bound to ("127.0.0.1", "::1", "0.0.0.0", ...). */ + virtual std::string bindAddress() = 0; + + /** @brief Convenience: true iff `bindAddress()` is a loopback literal. */ + virtual bool isLoopback() { + const std::string a = bindAddress(); + return a == "127.0.0.1" || a == "::1" || a == "localhost"; + } +}; + +} // namespace oatpp_authkit + +#endif diff --git a/include/oatpp-authkit/auth/RequireRole.hpp b/include/oatpp-authkit/auth/RequireRole.hpp new file mode 100644 index 0000000..bd303d2 --- /dev/null +++ b/include/oatpp-authkit/auth/RequireRole.hpp @@ -0,0 +1,55 @@ +#ifndef OATPP_AUTHKIT_AUTH_REQUIRE_ROLE_HPP +#define OATPP_AUTHKIT_AUTH_REQUIRE_ROLE_HPP + +#include +#include + +#include "oatpp/web/protocol/http/Http.hpp" +#include "oatpp/web/server/api/ApiController.hpp" +#include "oatpp/core/macro/codegen.hpp" + +#include "IAuthPolicy.hpp" + +namespace oatpp_authkit { + +using IncomingRequest = oatpp::web::protocol::http::incoming::Request; +using Status = oatpp::web::protocol::http::Status; + +/** + * @brief Pull the authenticated user into local scope inside a controller + * endpoint. Throws 401 when no principal is present in the bundle. + * + * Usage inside an ENDPOINT: + * auto me = oatpp_authkit::requireUser(request); + * // me.id, me.role, me.username + */ +inline AuthPrincipal requireUser(const std::shared_ptr& request) { + auto id = request->getBundleData("auth_user_id"); + auto role = request->getBundleData("auth_user_role"); + auto username = request->getBundleData("auth_username"); + + OATPP_ASSERT_HTTP(id && role, Status::CODE_401, "Authentication required"); + + AuthPrincipal p; + p.id = std::stoi(std::string(*id)); + p.role = std::string(*role); + p.username = username ? std::string(*username) : ""; + return p; +} + +/** + * @brief Reject the request with 403 unless the authenticated user is in + * the policy's admin role set. + */ +inline AuthPrincipal requireAdmin(const std::shared_ptr& request, + IAuthPolicy& policy) +{ + auto me = requireUser(request); + OATPP_ASSERT_HTTP(policy.adminRoles().count(me.role) > 0, + Status::CODE_403, "Admin required"); + return me; +} + +} // namespace oatpp_authkit + +#endif