oatpp-authkit/include/oatpp-authkit/auth/AuthInterceptor.hpp
Uwe Schuster f43f5f0633 #6: route ad-hoc JSON through ObjectMapper (Option A — DI everywhere, all-in-one)
- New dto/InternalDto.hpp with JsonErrorDto, WsEntityEventDto,
  WsPresenceUpdateDto, WsClientMsgDto.

- JsonErrorHandler: now takes a shared ObjectMapper (DI). Body built
  via writeToString on JsonErrorDto. Closes the audit's concrete bug
  where status.description was embedded raw — a Status with a `"`/`\\`
  in the description previously emitted invalid JSON.

- AuthInterceptor: takes an optional ObjectMapper ctor arg (defaults to
  a fresh mapper). makeForbidden's `msg` is now serialised via
  JsonErrorDto + ObjectMapper, so a `"` in a forbidden-reason no longer
  breaks the response envelope.

- Hub: process-wide sharedMapper() with optional setObjectMapper()
  override. buildPresenceMsg / notifyBooking / notifyPerson all go
  through ObjectMapper-emitted DTOs. User-supplied IDs / property IDs
  / usernames containing `"`/`\\`/control chars are now escaped.

- Listener: jsonStr/jsonInt regex parsers gone. handleMessage parses
  inbound frames via ObjectMapper::readFromString into WsClientMsgDto.
  Malformed JSON / nested objects / escaped quotes — previously silent
  corruption — now produce a clean drop of the frame.

- test/test_json_serialization.cpp: 4 cases pinning the round-trip
  behaviour (special chars in usernames, IDs, status.description, and
  malformed-input rejection).

Bump to 0.4.0 — ctor signatures changed (additive defaults, but the
behaviour of the JSON envelopes is now governed by ObjectMapper).

Closes #6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:56:05 +02:00

288 lines
12 KiB
C++

#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 "oatpp/parser/json/mapping/ObjectMapper.hpp"
#include "IAuthBackend.hpp"
#include "IAuthPolicy.hpp"
#include "IRuntimeConfig.hpp"
#include "../util/TokenExtract.hpp"
#include "../dto/InternalDto.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 `IRuntimeConfig::certAuthTrusted()`) → 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;
std::shared_ptr<oatpp::data::mapping::ObjectMapper> m_mapper;
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;
}
/** @brief Build a JsonErrorDto-shaped body via ObjectMapper (#6) — escapes
* any user-supplied `msg` so a stray `"`/`\\`/control character doesn't
* break the JSON envelope. */
std::shared_ptr<OutgoingResponse> makeJsonError(Status status,
const std::string& statusName,
const std::string& msg) {
auto dto = dto::JsonErrorDto::createShared();
dto->status = oatpp::String(statusName);
dto->code = status.code;
if (!msg.empty()) dto->message = oatpp::String(msg);
oatpp::String json = m_mapper->writeToString(dto);
auto r = ResponseFactory::createResponse(status, json);
r->putHeader("Content-Type", "application/json");
return r;
}
std::shared_ptr<OutgoingResponse> makeHtmlError(Status status, const std::string& title) {
std::string body = "<!doctype html><meta charset=\"utf-8\"><title>"
+ title + "</title><h1>" + title + "</h1>";
auto r = ResponseFactory::createResponse(status, body.c_str());
r->putHeader("Content-Type", "text/html; charset=utf-8");
return r;
}
std::shared_ptr<OutgoingResponse> makeRedirect(const std::string& location) {
auto r = ResponseFactory::createResponse(Status::CODE_302, "");
r->putHeader("Location", location.c_str());
r->putHeader("Cache-Control", "no-store");
return r;
}
public:
/**
* @brief Heuristic: does this caller expect a JSON error body?
*
* True when any of:
* - path begins with `/api/` (API surface — always JSON)
* - `X-Requested-With: XMLHttpRequest` (jQuery/axios/explicit AJAX)
* - `Accept` mentions `application/json` and does NOT prefer `text/html`
*
* Otherwise treated as a browser navigation that should get HTML or a
* redirect. Exposed as a static so the negotiation rule is unit-testable
* without spinning up a request.
*/
static bool wantsJson(const std::string& path,
const std::string& xRequestedWith,
const std::string& accept)
{
if (path.size() >= 5 && path.compare(0, 5, "/api/") == 0) return true;
if (!xRequestedWith.empty()) return true;
bool hasJson = accept.find("application/json") != std::string::npos;
bool hasHtml = accept.find("text/html") != std::string::npos;
if (hasJson && !hasHtml) return true;
return false;
}
/**
* @brief Percent-encode the unreserved subset for use in a `next=` param.
* Static + side-effect-free so consumers and tests can reuse it.
*/
static std::string urlEncode(const std::string& s) {
static const char* hex = "0123456789ABCDEF";
std::string out;
out.reserve(s.size());
for (unsigned char c : s) {
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~') {
out.push_back(static_cast<char>(c));
} else {
out.push_back('%');
out.push_back(hex[c >> 4]);
out.push_back(hex[c & 0xF]);
}
}
return out;
}
private:
bool requestWantsJson(const std::shared_ptr<IncomingRequest>& req,
const std::string& path)
{
auto xrw = req->getHeader("X-Requested-With");
auto accept = req->getHeader("Accept");
return wantsJson(path,
xrw ? *xrw : std::string{},
accept ? *accept : std::string{});
}
std::shared_ptr<OutgoingResponse> makeUnauthorized(
const std::shared_ptr<IncomingRequest>& req, const std::string& path)
{
if (requestWantsJson(req, path))
return makeJsonError(Status::CODE_401, "Unauthorized", "");
if (auto loc = m_policy->unauthenticatedRedirect(path))
return makeRedirect(*loc);
return makeHtmlError(Status::CODE_401, "Unauthorized");
}
std::shared_ptr<OutgoingResponse> makeForbidden(
const std::shared_ptr<IncomingRequest>& req, const std::string& path,
const std::string& msg = "")
{
if (requestWantsJson(req, path)) {
// #6: route through ObjectMapper so any caller-supplied `msg`
// containing `"`/`\\`/control chars is escaped instead of breaking
// the response envelope.
return makeJsonError(Status::CODE_403, "Forbidden", msg);
}
if (auto loc = m_policy->unauthenticatedRedirect(path))
return makeRedirect(*loc);
return makeHtmlError(Status::CODE_403, "Forbidden");
}
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,
std::shared_ptr<oatpp::data::mapping::ObjectMapper> mapper = nullptr)
: m_backend(std::move(backend))
, m_policy(std::move(policy))
, m_runtime(std::move(runtime))
, m_hashToken(std::move(hashToken))
, m_mapper(mapper ? mapper : oatpp::parser::json::mapping::ObjectMapper::createShared()) {}
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();
}
}
std::string path = request->getStartingLine().path.std_str();
const std::string method = request->getStartingLine().method.std_str();
// Strip query string — request-target includes it, but policy checks
// (and access logs) want just the path.
auto qpos = path.find('?');
if (qpos != std::string::npos) path.resize(qpos);
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 the runtime hook says so (#5).
// `certAuthTrusted()` defaults to `isLoopback()`; consumers can override
// it to gate more strictly (e.g. require an env-var or a TLS-only port).
auto certDnH = request->getHeader("X-SSL-Client-DN");
if (m_runtime->certAuthTrusted() && 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(request, path);
}
return nullptr;
}
}
// Session / API key token.
std::string token = extractToken(request);
if (token.empty()) {
logEvent(401, method, path, "no token");
return makeUnauthorized(request, path);
}
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(request, path);
}
// 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(request, path, "Missing X-Requested-With header");
}
}
writeBundle(request, *p);
if (isReadonly(p->role) && isMutation(method)) {
logEvent(403, method, path, "readonly user mutation");
return makeForbidden(request, path);
}
return nullptr;
}
};
} // namespace oatpp_authkit
#endif