- 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>
288 lines
12 KiB
C++
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
|