Files
DarkflameServer/dDashboardServer/auth/RequireAuthMiddleware.cpp
Aaron Kimbrell 821b7c8767 feat(dashboard): permission grants count in every dashboard permission check
What someone may do on the dashboard is now their GM level's permissions plus the grants on their account, minus its
denies (PermissionGrants.h). A deny beats a grant; denies never apply to GM 9, and settings and permissions_manage stay
GM 9 only. The account's grants are read with every request (like its GM level), so a change applies at once, and
they are passed through every check: RouteUtils::Can, CanViewCharacter, the rank rules (self_* and manage_equal_rank),
routes guarded by a permission, the templates' `can`, the API documentation, API access, API key scopes (a key never
does more than its owner may now) and WebSocket subscriptions.

New permission grants_manage (GM 9 by default) and the API to manage grants: GET /api/grants/catalog, GET /api/grants,
POST /api/grants, POST /api/grants/:id/remove. Nobody grants or takes away what they don't hold themselves (a
permission, every permission of a group, a command they may use, every command up to their own GM level), and only on
accounts the rank rules let them manage (their own with self_moderation). Commands with a fixed level or a floor
above GM 1 (/execute) can't be granted. Every change goes in the audit log (grant_permission, deny_permission,
remove_grant). Also: the Showcase gate and the traffic subscription now check their permission by name.

Check: grant a GM 2 account accounts_ban (it can ban, and the Ban button shows); deny a GM 8 account accounts_view (the
accounts list is refused); give an expiry a minute ahead and see it stop; try to grant a permission your account
doesn't have (refused); dWebTests PermissionGrantsTests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 01:16:38 -05:00

145 lines
6.7 KiB
C++

#include "RequireAuthMiddleware.h"
#include "HTTPContext.h"
#include "Web.h"
#include "Game.h"
#include "Logger.h"
#include "Permissions.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(const HTTPContext&)> 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(const HTTPContext& context)> 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)) {
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();
// A route guarded by a permission: the level, or a grant for this account (a deny takes it away)
const bool allowed = permission.empty() ? context.gmLevel >= minGmLevel : Permissions::Allowed(context.gmLevel, permission, nullptr, context.grants.get());
if (!allowed) {
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;
}