v0.2.0: IAuthBackend/IAuthPolicy/IRuntimeConfig seams + AuthInterceptor port

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) <noreply@anthropic.com>
This commit is contained in:
Uwe Schuster 2026-04-21 21:48:43 +02:00
parent 32356ad226
commit 495c8ddbb9
7 changed files with 380 additions and 1 deletions

View file

@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.14) 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 # Header-only interface library — no compilation, just an include path and
# a CMake config package so consumers do: # a CMake config package so consumers do:

View file

@ -0,0 +1,172 @@
#ifndef OATPP_AUTHKIT_AUTH_INTERCEPTOR_HPP
#define OATPP_AUTHKIT_AUTH_INTERCEPTOR_HPP
#include <chrono>
#include <memory>
#include <string>
#include <functional>
#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<std::string(const std::string&)>;
/**
* @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<IAuthBackend> m_backend;
std::shared_ptr<IAuthPolicy> m_policy;
std::shared_ptr<IRuntimeConfig> m_runtime;
TokenHasher m_hashToken;
using Status = oatpp::web::protocol::http::Status;
using ResponseFactory = oatpp::web::protocol::http::outgoing::ResponseFactory;
std::shared_ptr<OutgoingResponse> 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<OutgoingResponse> makeUnauthorized() {
return makeJsonError(Status::CODE_401, "{\"status\":\"Unauthorized\"}");
}
std::shared_ptr<OutgoingResponse> 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<IncomingRequest>& 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<IAuthBackend> backend,
std::shared_ptr<IAuthPolicy> policy,
std::shared_ptr<IRuntimeConfig> 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<OutgoingResponse> intercept(
const std::shared_ptr<IncomingRequest>& 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<std::chrono::hours>(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<AuthPrincipal> 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

View file

@ -0,0 +1,23 @@
#ifndef OATPP_AUTHKIT_AUTH_PRINCIPAL_HPP
#define OATPP_AUTHKIT_AUTH_PRINCIPAL_HPP
#include <string>
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

View file

@ -0,0 +1,50 @@
#ifndef OATPP_AUTHKIT_AUTH_IAUTH_BACKEND_HPP
#define OATPP_AUTHKIT_AUTH_IAUTH_BACKEND_HPP
#include <optional>
#include <string>
#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<AuthPrincipal> resolveBySessionHash(const std::string& hash) = 0;
/** @brief Look up an API key by its hashed token; also touch `last_used_at`. */
virtual std::optional<AuthPrincipal> 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<AuthPrincipal> 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

View file

@ -0,0 +1,47 @@
#ifndef OATPP_AUTHKIT_AUTH_IAUTH_POLICY_HPP
#define OATPP_AUTHKIT_AUTH_IAUTH_POLICY_HPP
#include <set>
#include <string>
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<std::string>& adminRoles() {
static const std::set<std::string> k{"admin"};
return k;
}
/** @brief Roles that may only read (GET/HEAD/OPTIONS); mutations → 403. */
virtual const std::set<std::string>& readonlyRoles() {
static const std::set<std::string> 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

View file

@ -0,0 +1,32 @@
#ifndef OATPP_AUTHKIT_AUTH_IRUNTIME_CONFIG_HPP
#define OATPP_AUTHKIT_AUTH_IRUNTIME_CONFIG_HPP
#include <string>
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

View file

@ -0,0 +1,55 @@
#ifndef OATPP_AUTHKIT_AUTH_REQUIRE_ROLE_HPP
#define OATPP_AUTHKIT_AUTH_REQUIRE_ROLE_HPP
#include <memory>
#include <string>
#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<IncomingRequest>& request) {
auto id = request->getBundleData<oatpp::String>("auth_user_id");
auto role = request->getBundleData<oatpp::String>("auth_user_role");
auto username = request->getBundleData<oatpp::String>("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<IncomingRequest>& 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