Files
DarkflameServer/dDashboardServer/auth/RequireAuthMiddleware.cpp
Aaron Kimbrell 3f2a9cc5b0 feat(dashboard): scoped API keys with rate limits and quotas
Bearer keys (dlk_...) are checked per request against their owner's
current account (ban, lock, demotion, sign out everywhere stop or narrow
them at once) and their scope: permission routes need the permission in
scope, read-only keys only read, level-only routes need an all-permission
key, and session-only paths (sign-in, password, 2FA, key management)
are never reachable with a key. Per-key rate limit and daily quota with
429 and X-RateLimit/X-Quota/Retry-After headers; usage is written in
batches every minute. WebSocket subscriptions honour the scope too.

Routes to list, make, rotate and revoke keys; staff with
api_keys_manage can see and revoke others' keys under the rank rules.
POST /api/auth/token now makes an all-permission key. Audit entries for
create/rotate/revoke/denied, and actions done with a key are attributed
to "user (key name)".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:31:03 -05:00

142 lines
6.5 KiB
C++

#include "RequireAuthMiddleware.h"
#include "HTTPContext.h"
#include "Web.h"
#include "Game.h"
#include "Logger.h"
namespace {
bool IsApiRequest(const HTTPContext& context) {
return context.path.starts_with("/api/");
}
// What an account that still has to set up two-factor login may reach: signing in and out, its own account
bool AllowedBeforeTwoFactor(const HTTPContext& context) {
const auto& path = context.path;
return path.starts_with("/api/auth/") || path.starts_with("/api/account/") || path == "/account" || path == "/api/status" ||
path == "/accounts/" + std::to_string(context.accountId) || path == "/api/accounts/" + std::to_string(context.accountId) ||
path.starts_with("/js/") || path.starts_with("/css/") || path == "/favicon.ico";
}
std::function<bool(uint8_t)> g_ApiAccessAllowed;
std::function<void(const HTTPContext&, HTTPReply&)> g_ForbiddenPage;
std::function<void(const HTTPContext&, const std::string&)> g_ApiKeyDenied;
bool RefuseApiKey(const HTTPContext& context, HTTPReply& reply, const std::string& reason, const std::string& message) {
if (g_ApiKeyDenied) g_ApiKeyDenied(context, reason);
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = nlohmann::json{ {"success", false}, {"error", message}, {"code", "api_key_scope"} }.dump();
reply.contentType = eContentType::APPLICATION_JSON;
return false;
}
bool IsSafeMethod(const HTTPContext& context) {
return context.method == "GET" || context.method == "HEAD" || context.method == "OPTIONS";
}
}
void RequireAuthMiddleware::SetApiAccessCheck(std::function<bool(uint8_t gmLevel)> check) {
g_ApiAccessAllowed = std::move(check);
}
void RequireAuthMiddleware::SetForbiddenPage(std::function<void(const HTTPContext& context, HTTPReply& reply)> render) {
g_ForbiddenPage = std::move(render);
}
RequireAuthMiddleware::RequireAuthMiddleware(uint8_t minGmLevel) : requiredLevel([minGmLevel] { return minGmLevel; }) {}
RequireAuthMiddleware::RequireAuthMiddleware(std::function<uint8_t()> requiredLevel) : requiredLevel(std::move(requiredLevel)) {}
RequireAuthMiddleware::RequireAuthMiddleware(std::function<uint8_t()> requiredLevel, std::string permission)
: requiredLevel(std::move(requiredLevel)), permission(std::move(permission)) {}
void RequireAuthMiddleware::SetApiKeyDeniedHook(std::function<void(const HTTPContext& context, const std::string& reason)> hook) {
g_ApiKeyDenied = std::move(hook);
}
bool RequireAuthMiddleware::Process(HTTPContext& context, HTTPReply& reply) {
if (!context.isAuthenticated) {
LOG_DEBUG("Unauthorized access attempt to %s from %s", context.path.c_str(), context.clientIP.c_str());
if (IsApiRequest(context)) {
reply.status = eHTTPStatusCode::UNAUTHORIZED;
reply.message = "{\"success\":false,\"error\":\"Authentication required\"}";
reply.contentType = eContentType::APPLICATION_JSON;
} else {
reply.status = eHTTPStatusCode::FOUND;
reply.message = "";
reply.location = "/login";
reply.contentType = eContentType::TEXT_HTML;
}
return false;
}
// Cookies are sent automatically by the browser, so state-changing requests authenticated by
// cookie must also carry a header a cross-site form cannot set. Bearer-token clients are exempt.
const auto authSource = context.userData.find("auth_source");
if (!IsSafeMethod(context) && authSource != context.userData.end() && authSource->second == "cookie" &&
context.GetHeader("X-Requested-With").empty()) {
LOG("Rejected cookie-authenticated %s %s without X-Requested-With (possible CSRF) from %s",
context.method.c_str(), context.path.c_str(), context.clientIP.c_str());
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"success\":false,\"error\":\"Missing X-Requested-With header\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return false;
}
// A token in the Authorization header is API use, which a GM level may not be allowed
if (authSource != context.userData.end() && authSource->second == "header" && g_ApiAccessAllowed && !g_ApiAccessAllowed(context.gmLevel)) {
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"success\":false,\"error\":\"API access isn't allowed for your account\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return false;
}
if (context.userData.contains("needs_2fa") && !AllowedBeforeTwoFactor(context)) {
if (IsApiRequest(context)) {
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"success\":false,\"error\":\"Set up two-factor login on your account page first\",\"code\":\"2fa_required\"}";
reply.contentType = eContentType::APPLICATION_JSON;
} else {
reply.status = eHTTPStatusCode::FOUND;
reply.message = "";
reply.location = "/accounts/" + std::to_string(context.accountId) + "#two-factor";
reply.contentType = eContentType::TEXT_HTML;
}
return false;
}
const auto minGmLevel = requiredLevel();
if (context.gmLevel < minGmLevel) {
LOG_DEBUG("Forbidden access attempt by user %s (GM level %d < %d required) to %s from %s",
context.authenticatedUser.c_str(), context.gmLevel, minGmLevel,
context.path.c_str(), context.clientIP.c_str());
// A page opened in the browser gets the dashboard's error page rather than raw JSON
if (!IsApiRequest(context) && g_ForbiddenPage) {
g_ForbiddenPage(context, reply);
reply.status = eHTTPStatusCode::FORBIDDEN;
return false;
}
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"success\":false,\"error\":\"Insufficient permissions\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return false;
}
// An API key can only narrow what its owner may do: read-only keys make no changes, and a route guarded by a
// permission needs that permission in the key's scope. Routes guarded only by a GM level above 0 have no
// permission to scope them by, so only keys with all of the owner's permissions reach them.
if (context.apiKey) {
const auto& key = *context.apiKey;
if (key.readOnly && !readsOnly && !IsSafeMethod(context)) {
return RefuseApiKey(context, reply, context.method + " " + context.path + " with a read-only key", "This API key is read-only");
}
if (!permission.empty() && !key.Has(permission)) {
return RefuseApiKey(context, reply, "no " + permission + " permission for " + context.path, "This API key doesn't have the " + permission + " permission");
}
if (permission.empty() && minGmLevel > 0 && !key.allPermissions) {
return RefuseApiKey(context, reply, context.path + " needs a key with all permissions", "This needs an API key with all of your permissions");
}
}
return true;
}