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:
parent
32356ad226
commit
495c8ddbb9
7 changed files with 380 additions and 1 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
172
include/oatpp-authkit/auth/AuthInterceptor.hpp
Normal file
172
include/oatpp-authkit/auth/AuthInterceptor.hpp
Normal 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
|
||||||
23
include/oatpp-authkit/auth/AuthPrincipal.hpp
Normal file
23
include/oatpp-authkit/auth/AuthPrincipal.hpp
Normal 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
|
||||||
50
include/oatpp-authkit/auth/IAuthBackend.hpp
Normal file
50
include/oatpp-authkit/auth/IAuthBackend.hpp
Normal 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
|
||||||
47
include/oatpp-authkit/auth/IAuthPolicy.hpp
Normal file
47
include/oatpp-authkit/auth/IAuthPolicy.hpp
Normal 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
|
||||||
32
include/oatpp-authkit/auth/IRuntimeConfig.hpp
Normal file
32
include/oatpp-authkit/auth/IRuntimeConfig.hpp
Normal 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
|
||||||
55
include/oatpp-authkit/auth/RequireRole.hpp
Normal file
55
include/oatpp-authkit/auth/RequireRole.hpp
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue