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>
This commit is contained in:
Aaron Kimbrell
2026-09-27 08:43:12 -05:00
parent 03d5fae0c5
commit fc4e4eda9f
30 changed files with 1194 additions and 33 deletions

View File

@@ -2,6 +2,7 @@
#include "PropertyAssets.h"
#include "OpenApi.h"
#include "RouteUtils.h"
#include "ApiKeyService.h"
#include "Permissions.h"
#include "DashboardAuthService.h"
#include "DashboardRoutes.h"
@@ -103,9 +104,19 @@ namespace {
return json.dump();
}
// Whether the request's API key (if any) may use a documented route: its scope, read-only and session-only paths
bool KeyMayUse(const HTTPContext& context, const RouteDoc& doc) {
if (!context.apiKey) return true;
const auto& key = *context.apiKey;
if (ApiKeyService::SessionOnlyPath(doc.path)) return false;
if (key.readOnly && doc.method != "GET") return false;
return doc.permission.empty() ? (doc.minGmLevel <= 0 || key.allPermissions) : key.Has(doc.permission);
}
// Register a DataTables endpoint. The fetcher returns the DB layer's JSON string. Access: a GM level or a Perm.
template<typename Access>
void TableRoute(const std::string& path, const Access& access, const std::string& description, TableFetcher fetcher) {
ReadRoutes reads; // the query is a POST body, but it only reads
Route(eHTTPMethod::POST, path, access, description, [fetcher = std::move(fetcher)](HTTPReply& reply, const HTTPContext& context) {
const auto request = ParseDataTablesRequest(context.body);
const auto body = ParseBody(context);
@@ -421,11 +432,11 @@ namespace {
nlohmann::json routes = nlohmann::json::array();
for (const auto& doc : GetRouteDocs()) {
const int16_t level = doc.permission.empty() ? doc.minGmLevel : Permissions::Level(doc.permission);
if (level > context.gmLevel || !doc.path.starts_with("/api/")) continue;
if (level > context.gmLevel || !doc.path.starts_with("/api/") || !KeyMayUse(context, doc)) continue;
routes.push_back({ {"method", doc.method}, {"path", doc.path}, {"minGmLevel", level}, {"permission", doc.permission}, {"description", doc.description} });
}
JsonReply(reply, eHTTPStatusCode::OK, {
{"authentication", "Send 'Authorization: Bearer <token>'. Create a token on your account page (POST /api/auth/token). "
{"authentication", "Send 'Authorization: Bearer <key>'. Make API keys on your account page; each has its own permissions, limits and expiry. "
"Requests without an Authorization header, including POST /api/auth/login, must send 'X-Requested-With' (any value)."},
{"routes", routes}
});
@@ -436,7 +447,7 @@ namespace {
std::vector<OpenApi::Route> routes;
for (const auto& doc : GetRouteDocs()) {
const int16_t level = doc.permission.empty() ? doc.minGmLevel : Permissions::Level(doc.permission);
if (level > context.gmLevel || !doc.path.starts_with("/api/")) continue;
if (level > context.gmLevel || !doc.path.starts_with("/api/") || !KeyMayUse(context, doc)) continue;
routes.push_back({ doc.method, doc.path, doc.description, level, doc.permission });
}
JsonReply(reply, eHTTPStatusCode::OK, OpenApi::Build(routes, "DarkflameServer dashboard"));
@@ -1489,7 +1500,7 @@ namespace {
if (recipient == "0") {
auto characters = Database::Get()->GetCharacterIdsAndNames();
// Items to everyone skip the sender's own characters unless they may give themselves items (self_items; GM 9 always)
const bool includeOwn = !hasAttachment || context.gmLevel >= OPERATOR_LEVEL || Can(context, "self_items");
const bool includeOwn = !hasAttachment || (context.gmLevel >= OPERATOR_LEVEL && !context.apiKey) || Can(context, "self_items");
if (!includeOwn) {
const auto own = Database::Get()->GetAccountCharacterIds(context.accountId);
std::erase_if(characters, [&](const auto& entry) { return std::find(own.begin(), own.end(), entry.first) != own.end(); });

View File

@@ -0,0 +1,287 @@
#include "ApiKeyRoutes.h"
#include <algorithm>
#include <ctime>
#include "AccountRules.h"
#include "ApiKeyScope.h"
#include "ApiKeyService.h"
#include "Database.h"
#include "eHTTPMethod.h"
#include "GeneralUtils.h"
#include "HTTPContext.h"
#include "Permissions.h"
#include "RequireAuthMiddleware.h"
#include "RouteUtils.h"
using namespace RouteUtils;
using AccountRules::eAccountAction;
namespace {
constexpr size_t MAX_NAME = 32;
constexpr size_t MAX_NOTE = 255;
constexpr size_t MAX_LIST = 512;
constexpr size_t MAX_ACTIVE_KEYS = 25;
constexpr int64_t MAX_DAYS = 3650;
int64_t Now() { return static_cast<int64_t>(std::time(nullptr)); }
bool FromSession(const HTTPContext& context) {
const auto source = context.userData.find("auth_source");
return !context.apiKey && source != context.userData.end() && source->second == "cookie";
}
std::string Trim(std::string text) {
text.erase(0, text.find_first_not_of(" \t\r\n"));
text.erase(text.find_last_not_of(" \t\r\n") + 1);
return text;
}
// A comma-separated list checked entry by entry; nullopt if an entry isn't allowed
std::optional<std::string> CleanList(const std::string& text, bool paths) {
std::string out;
for (auto entry : ApiKeys::SplitList(text)) {
if (paths) {
std::ranges::transform(entry, entry.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
if (!entry.starts_with('/') || entry.find_first_of(" ,\"'<>") != std::string::npos) return std::nullopt;
} else if (!std::ranges::all_of(entry, [](char c) { return std::isxdigit(static_cast<unsigned char>(c)) || c == '.' || c == ':'; })) {
return std::nullopt;
}
if (!out.empty()) out += ',';
out += entry;
}
if (out.size() > MAX_LIST) return std::nullopt;
return out;
}
std::string KeyStatus(const IApiKeys::ApiKey& key, int64_t sessionsValidAfter, int64_t now) {
if (key.revokedAt != 0) return "revoked";
if (key.expiresAt != 0 && key.expiresAt <= now) return "expired";
if (key.issuedAt < sessionsValidAfter) return "signed_out";
return "active";
}
nlohmann::json KeyJson(const IApiKeys::ApiKey& key, uint8_t ownerLevel, int64_t sessionsValidAfter) {
const auto now = Now();
bool all = false;
std::set<std::string> permissions;
ApiKeys::ParsePermissions(key.permissions, all, permissions);
// What the key names that its owner can't do any more (a demotion or a changed permission): it doesn't work
nlohmann::json lost = nlohmann::json::array();
for (const auto& permission : permissions) if (!Permissions::Allowed(ownerLevel, permission)) lost.push_back(permission);
auto requests = key.requestCount;
auto lastUsed = key.lastUsedAt;
auto lastIp = key.lastIp;
const auto today = static_cast<int32_t>(now / 86400);
uint32_t todayCount = key.quotaDay == today ? key.dayCount : 0;
if (const auto pending = ApiKeyService::PendingUsage(key.id)) {
requests += pending->requests;
if (pending->lastUsedAt >= lastUsed) {
lastUsed = pending->lastUsedAt;
lastIp = pending->lastIp;
}
if (pending->quotaDay == today) todayCount = pending->dayCount;
}
return {
{"id", key.id}, {"accountId", key.accountId}, {"name", key.name}, {"note", key.note}, {"prefix", key.keyPrefix},
{"allPermissions", all}, {"permissions", permissions}, {"lostPermissions", lost}, {"readOnly", key.readOnly},
{"allowedIps", key.allowedIps}, {"allowedPaths", key.allowedPaths},
{"rateLimit", key.rateLimit}, {"effectiveRateLimit", key.rateLimit > 0 ? key.rateLimit : ApiKeyService::DefaultRateLimit()},
{"dailyQuota", key.dailyQuota}, {"todayCount", todayCount},
{"createdAt", key.createdAt}, {"createdBy", key.createdBy}, {"issuedAt", key.issuedAt}, {"expiresAt", key.expiresAt},
{"revokedAt", key.revokedAt}, {"revokedBy", key.revokedBy},
{"lastUsedAt", lastUsed}, {"lastIp", lastIp}, {"requestCount", requests},
{"status", KeyStatus(key, sessionsValidAfter, now)},
};
}
std::string Describe(const IApiKeys::ApiKey& key) {
std::string text = "'" + key.name + "' (" + key.keyPrefix + "...): ";
text += key.permissions == ApiKeys::ALL_PERMISSIONS ? "all of their permissions" : (key.permissions.empty() ? "no permissions" : key.permissions);
if (key.readOnly) text += "; read-only";
if (!key.allowedIps.empty()) text += "; from " + key.allowedIps;
if (!key.allowedPaths.empty()) text += "; paths " + key.allowedPaths;
text += "; " + (key.rateLimit > 0 ? std::to_string(key.rateLimit) : "default") + " requests/min";
if (key.dailyQuota > 0) text += "; " + std::to_string(key.dailyQuota) + " a day";
text += key.expiresAt > 0 ? "; expires " + std::to_string(key.expiresAt) : "; no expiry";
return text;
}
uint32_t ActiveKeyCount(uint32_t accountId, int64_t sessionsValidAfter) {
const auto now = Now();
const auto keys = Database::Get()->GetApiKeys(accountId);
return static_cast<uint32_t>(std::ranges::count_if(keys, [&](const auto& key) { return KeyStatus(key, sessionsValidAfter, now) == "active"; }));
}
// The key, if the signed-in person may act on it: their own, or (to see or revoke) another account's with
// api_keys_manage under the rank rules. Writes the error reply otherwise.
std::optional<IApiKeys::ApiKey> KeyForAction(const HTTPContext& context, HTTPReply& reply, bool ownOnly) {
const auto id = PathId<uint64_t>(context.path, 2);
const auto key = id ? Database::Get()->GetApiKey(*id) : std::nullopt;
if (!key) {
JsonError(reply, eHTTPStatusCode::NOT_FOUND, "API key not found");
return std::nullopt;
}
if (key->accountId == context.accountId) return key;
if (ownOnly || !Can(context, "api_keys_manage")) {
JsonError(reply, eHTTPStatusCode::NOT_FOUND, "API key not found");
return std::nullopt;
}
if (!AuthorizeAccountAction(context, key->accountId, reply, eAccountAction::TOOLS)) return std::nullopt;
return key;
}
IApiKeys::ApiKey NewKey(const HTTPContext& context, const ApiKeyService::NewSecret& secret) {
IApiKeys::ApiKey key;
key.accountId = context.accountId;
key.keyHash = secret.hash;
key.keyPrefix = secret.prefix;
key.createdAt = key.issuedAt = Now();
key.createdBy = context.authenticatedUser;
return key;
}
}
namespace ApiKeyRoutes {
std::string CreateFullKey(const HTTPContext& context, const std::string& name, int64_t days) {
const auto secret = ApiKeyService::GenerateSecret();
auto key = NewKey(context, secret);
key.name = name;
key.permissions = std::string(ApiKeys::ALL_PERMISSIONS);
key.expiresAt = days > 0 ? key.createdAt + std::clamp<int64_t>(days, 1, MAX_DAYS) * 86400 : 0;
key.id = Database::Get()->InsertApiKey(key);
Audit(context, "create_api_key", "Made API key " + Describe(key), AuditTarget::Account(context.accountId));
return secret.token;
}
void RegisterRoutes() {
// Keys only ever narrow their owner, so the caller's own permissions are what may be picked
Route(eHTTPMethod::GET, "/api/api_keys/permissions", 0,
"The permissions an API key can be given, grouped like the Permissions page; 'allowed' marks the ones you have (only those can be picked)",
[](HTTPReply& reply, const HTTPContext& context) {
nlohmann::json permissions = nlohmann::json::array();
for (const auto& permission : Permissions::All()) {
permissions.push_back({ {"key", permission.key}, {"category", permission.category}, {"title", permission.title},
{"description", permission.description}, {"allowed", Permissions::Allowed(context.gmLevel, permission.key)} });
}
JsonSuccess(reply, { {"permissions", permissions}, {"defaultRateLimit", ApiKeyService::DefaultRateLimit()},
{"maxRateLimit", ApiKeyService::MAX_RATE_LIMIT}, {"maxDailyQuota", ApiKeyService::MAX_DAILY_QUOTA},
{"apiAccess", Permissions::Allowed(context.gmLevel, "api_access")} });
});
Route(eHTTPMethod::GET, "/api/accounts/:id/api_keys", 0,
"An account's API keys, newest first (never the keys themselves). Your own, or anyone's you may manage with api_keys_manage",
[](HTTPReply& reply, const HTTPContext& context) {
const auto accountId = PathId<uint32_t>(context.path, 2);
if (!accountId) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid account ID");
const bool own = *accountId == context.accountId;
if (!own) {
if (!Can(context, "api_keys_manage") || !Can(context, "accounts_view")) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Insufficient permissions");
if (!AuthorizeAccountAction(context, *accountId, reply, eAccountAction::TOOLS)) return;
}
const auto account = Database::Get()->GetAccountById(*accountId);
if (account.contains("error")) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Account not found");
const auto ownerLevel = static_cast<uint8_t>(account.value("gm_level", 0));
const auto validAfter = Database::Get()->GetSessionsValidAfter(*accountId);
nlohmann::json keys = nlohmann::json::array();
for (const auto& key : Database::Get()->GetApiKeys(*accountId)) keys.push_back(KeyJson(key, ownerLevel, validAfter));
JsonSuccess(reply, { {"keys", keys}, {"own", own} });
});
Route(eHTTPMethod::POST, "/api/api_keys", 0,
"Make an API key (signed in with the browser only). Body: {name, note, permissions: [names] or \"*\" (all of yours), readOnly, "
"allowedIps, allowedPaths (comma-separated), rateLimit (a minute, 0: default), dailyQuota (0: none), expiresInDays (0: never)}. "
"Returns {key}, shown only this once",
[](HTTPReply& reply, const HTTPContext& context) {
if (!FromSession(context)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Make API keys from your account page while signed in");
if (!Can(context, "api_access")) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "API access isn't allowed for your account");
const auto body = ParseBody(context);
if (!body || !body->is_object()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const auto secret = ApiKeyService::GenerateSecret();
auto key = NewKey(context, secret);
key.name = Trim(body->value("name", ""));
if (key.name.empty() || key.name.size() > MAX_NAME) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Give the key a name of up to 32 characters");
key.note = Trim(body->value("note", ""));
if (key.note.size() > MAX_NOTE) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "The note is too long");
const auto& requested = (*body)["permissions"];
if (requested.is_string() && requested.get<std::string>() == ApiKeys::ALL_PERMISSIONS) {
key.permissions = std::string(ApiKeys::ALL_PERMISSIONS);
} else if (requested.is_array()) {
std::set<std::string> permissions;
for (const auto& permission : requested) if (permission.is_string()) permissions.insert(permission.get<std::string>());
if (permissions.empty()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Pick at least one permission");
// Staff can't hand a key more than they have
const auto refused = Permissions::NotGrantable(context.gmLevel, permissions);
if (!refused.empty()) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You can't give a key permissions you don't have: " + *refused.begin());
key.permissions = ApiKeys::JoinPermissions(false, permissions);
} else {
return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "permissions must be a list of permission names or \"*\"");
}
key.readOnly = body->value("readOnly", false);
const auto ips = CleanList(body->value("allowedIps", ""), false);
if (!ips) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Allowed addresses must be IP addresses or prefixes like 10.0.0., separated by commas");
const auto paths = CleanList(body->value("allowedPaths", ""), true);
if (!paths) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Allowed paths must start with / and be separated by commas");
key.allowedIps = *ips;
key.allowedPaths = *paths;
const auto rate = body->value("rateLimit", int64_t{ 0 });
const auto quota = body->value("dailyQuota", int64_t{ 0 });
const auto days = body->value("expiresInDays", int64_t{ 0 });
if (rate < 0 || rate > ApiKeyService::MAX_RATE_LIMIT) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "The rate limit must be 0 to " + std::to_string(ApiKeyService::MAX_RATE_LIMIT) + " a minute");
if (quota < 0 || quota > ApiKeyService::MAX_DAILY_QUOTA) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "The daily quota is out of range");
if (days < 0 || days > MAX_DAYS) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Expiry must be 0 (never) to 3650 days");
key.rateLimit = static_cast<uint32_t>(rate);
key.dailyQuota = static_cast<uint32_t>(quota);
key.expiresAt = days > 0 ? key.createdAt + days * 86400 : 0;
if (ActiveKeyCount(context.accountId, Database::Get()->GetSessionsValidAfter(context.accountId)) >= MAX_ACTIVE_KEYS) {
return JsonError(reply, eHTTPStatusCode::CONFLICT, "You have too many API keys; revoke some first");
}
key.id = Database::Get()->InsertApiKey(key);
Audit(context, "create_api_key", "Made API key " + Describe(key), AuditTarget::Account(context.accountId));
JsonSuccess(reply, { {"id", key.id}, {"key", secret.token}, {"message", "API key made - copy it now, it won't be shown again"} });
});
Route(eHTTPMethod::POST, "/api/api_keys/:id/revoke", 0,
"Revoke an API key: yours, or another account's with api_keys_manage (the rank rules apply)",
[](HTTPReply& reply, const HTTPContext& context) {
if (!FromSession(context)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Revoke API keys from the dashboard while signed in");
const auto key = KeyForAction(context, reply, false);
if (!key) return;
if (key->revokedAt != 0) return JsonError(reply, eHTTPStatusCode::CONFLICT, "This key is already revoked");
Database::Get()->RevokeApiKey(key->id, context.authenticatedUser, Now());
ApiKeyService::Forget(key->id);
Audit(context, "revoke_api_key", "Revoked API key '" + key->name + "' (" + key->keyPrefix + "...)", AuditTarget::Account(key->accountId));
JsonSuccess(reply, { {"message", "API key revoked"} });
});
Route(eHTTPMethod::POST, "/api/api_keys/:id/rotate", 0,
"Give one of your API keys a new secret, keeping its name, permissions and limits; the old secret stops working. Returns {key}, shown once",
[](HTTPReply& reply, const HTTPContext& context) {
if (!FromSession(context)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Rotate API keys from your account page while signed in");
if (!Can(context, "api_access")) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "API access isn't allowed for your account");
const auto key = KeyForAction(context, reply, true);
if (!key) return;
if (key->revokedAt != 0) return JsonError(reply, eHTTPStatusCode::CONFLICT, "A revoked key can't be rotated");
if (key->expiresAt != 0 && key->expiresAt <= Now()) return JsonError(reply, eHTTPStatusCode::CONFLICT, "An expired key can't be rotated; make a new one");
// A scope the owner has since lost stays in the key but keeps not working; they can't regain it by rotating
const auto secret = ApiKeyService::GenerateSecret();
Database::Get()->RotateApiKey(key->id, secret.hash, secret.prefix, Now());
ApiKeyService::Forget(key->id);
Audit(context, "rotate_api_key", "Rotated API key '" + key->name + "' (" + key->keyPrefix + "... is now " + secret.prefix + "...)",
AuditTarget::Account(key->accountId));
JsonSuccess(reply, { {"key", secret.token}, {"message", "New secret made - copy it now, it won't be shown again"} });
});
// Refusals of keys (their scope, read-only, addresses, paths, limits) go to the audit log, a minute apart at most
ApiKeyService::SetDeniedHook([](const HTTPContext& context, const std::string& reason) {
Audit(context, "api_key_denied", reason, AuditTarget::Account(context.accountId));
});
RequireAuthMiddleware::SetApiKeyDeniedHook([](const HTTPContext& context, const std::string& reason) { ApiKeyService::NoteDenied(context, reason); });
ApiKeyService::SetClientAddress([](const HTTPContext& context) { return ClientAddress(context); });
}
}

View File

@@ -0,0 +1,21 @@
#pragma once
#include <cstdint>
#include <string>
struct HTTPContext;
/**
* Dashboard API keys: each person makes, rotates and revokes their own keys on their account page (with a signed-in
* browser session, never with a key). A key's scope is chosen from the permissions its maker has; staff with
* api_keys_manage can see and revoke other accounts' keys, following the rank rules.
*/
namespace ApiKeyRoutes {
void RegisterRoutes();
/**
* Make a key with all of its maker's permissions (what POST /api/auth/token hands out, as the old API tokens did).
* @return The key, shown once
*/
std::string CreateFullKey(const HTTPContext& context, const std::string& name, int64_t days);
}

View File

@@ -1,4 +1,5 @@
#include "AuthRoutes.h"
#include "ApiKeyRoutes.h"
#include "Permissions.h"
#include "DashboardAuthService.h"
#include "AuthTokenHandler.h"
@@ -216,7 +217,9 @@ void RegisterAuthRoutes() {
// POST /api/auth/token - Issue a bearer token for API clients (bots, scripts)
// Request body: { "days": number (1-365, default 30) }
// The token carries the caller's identity; its permissions always follow the account's current GM level.
// Kept for scripts written for the old API tokens: it now makes an API key named "API token" with all of the
// caller's permissions (which always follow the account's current GM level). Pick a narrower scope, limits and
// no expiry with POST /api/api_keys. Tokens made before API keys keep working until they expire.
Game::web.RegisterHTTPRoute({
.path = "/api/auth/token",
.method = eHTTPMethod::POST,
@@ -230,10 +233,7 @@ void RegisterAuthRoutes() {
}
const auto json = RouteUtils::ParseBody(context);
const int64_t days = std::clamp<int64_t>(json ? json->value("days", 30) : 30, 1, MAX_API_TOKEN_DAYS);
const auto token = JWTUtils::GenerateSessionToken(context.accountId, context.authenticatedUser, context.gmLevel, false, days * 24 * 60 * 60);
if (token.empty()) return RouteUtils::JsonError(reply, eHTTPStatusCode::INTERNAL_SERVER_ERROR, "Token generation failed");
RouteUtils::Audit(context, "create_api_token", "Valid for " + std::to_string(days) + " days");
const auto token = ApiKeyRoutes::CreateFullKey(context, "API token", days);
RouteUtils::JsonReply(reply, eHTTPStatusCode::OK, { {"token", token}, {"expiresInDays", days} });
}
});

View File

@@ -4,6 +4,7 @@ set(DASHBOARDROUTES_SOURCES
"DashboardRoutes.cpp"
"WSRoutes.cpp"
"AuthRoutes.cpp"
"ApiKeyRoutes.cpp"
"RouteUtils.cpp"
"PlayerActions.cpp"
"AccountRoutes.cpp"

View File

@@ -75,7 +75,7 @@ namespace ChatRoutes {
}
void RegisterChatRoutes() {
Game::web.RegisterWSSubscription("chat_message", std::function<uint8_t()>([] { return Permissions::Level("chat_view"); }));
Game::web.RegisterWSSubscription("chat_message", std::function<uint8_t()>([] { return Permissions::Level("chat_view"); }), "chat_view");
Route(eHTTPMethod::GET, "/api/chat", Perm("chat_view"),
"Chat messages, oldest first. Query: after (the last id you have; for bridges polling), limit (max 500), channel (zone, whisper, team, web), "

View File

@@ -468,7 +468,7 @@ void RegisterClientAssetRoutes() {
ReplyPng(reply, path.empty() ? std::nullopt : ClientAssets::TextureAsPng(path, 64));
});
Route(eHTTPMethod::POST, "/api/items/info", 0, "Item details for tooltips. Body: {lots: [..]} (max 500)",
ReadRoute(eHTTPMethod::POST, "/api/items/info", 0, "Item details for tooltips. Body: {lots: [..]} (max 500)",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
if (!body || !body->contains("lots") || !(*body)["lots"].is_array()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "lots must be an array");

View File

@@ -265,7 +265,7 @@ namespace {
void RegisterDashboardRoutes() {
Route(eHTTPMethod::GET, "/", 0, "Dashboard home", [](HTTPReply& reply, const HTTPContext& context) {
nlohmann::json data = Permissions::Allowed(context.gmLevel, "players_view") ? ServerState::GetServerStateJson() : ServerState::PlayerSafe(ServerState::GetServerStateJson());
nlohmann::json data = Can(context, "players_view") ? ServerState::GetServerStateJson() : ServerState::PlayerSafe(ServerState::GetServerStateJson());
data["my_characters"] = Database::Get()->GetAccountCharacters(context.accountId);
data["stats"]["totalAccounts"] = Database::Get()->GetAccountCount();
data["stats"]["totalCharacters"] = Database::Get()->GetCharacterCount();

View File

@@ -933,7 +933,7 @@ namespace EventsCalendar {
JsonSuccess(reply, { {"message", made.empty() ? "Event scheduled" : "Event scheduled, with an empty " + made + " to put its NPCs in"}, {"id", event.id} });
});
Route(eHTTPMethod::POST, "/api/events/check", 0,
ReadRoute(eHTTPMethod::POST, "/api/events/check", 0,
"Check a schedule and list when it is on. Body: {schedule (recurring rules), from (unix, default now), count (default 5)}. Returns {valid, error, schedule, on, windows: [{start, end}]}",
[](HTTPReply& reply, const HTTPContext& context) {
if (!CanView(context)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You may not see the scheduled events");

View File

@@ -480,7 +480,7 @@ namespace Inspector {
}
void RegisterRoutes() {
Game::web.RegisterWSSubscription(TOPIC, std::function<uint8_t()>([] { return Permissions::Level(PERMISSION); }));
Game::web.RegisterWSSubscription(TOPIC, std::function<uint8_t()>([] { return Permissions::Level(PERMISSION); }), PERMISSION);
Route(eHTTPMethod::GET, "/inspector", Perm(PERMISSION), "The game message inspector",
[](HTTPReply& reply, const HTTPContext& context) {

View File

@@ -211,7 +211,7 @@ namespace LiveWorld {
}
void RegisterRoutes() {
Game::web.RegisterWSSubscription("player_positions", std::function<uint8_t()>([] { return Permissions::Level("players_view"); }));
Game::web.RegisterWSSubscription("player_positions", std::function<uint8_t()>([] { return Permissions::Level("players_view"); }), "players_view");
Route(eHTTPMethod::GET, "/api/live/players", Perm("players_view"), "Where online players are right now (also pushed on the player_positions socket topic)",
[](HTTPReply& reply, const HTTPContext&) {

View File

@@ -303,7 +303,7 @@ namespace {
if (!context.isAuthenticated || context.userData.contains("needs_2fa")) return false;
const auto source = context.userData.find("auth_source");
if (source != context.userData.end() && source->second == "header" && !Permissions::Allowed(context.gmLevel, "api_access")) return false;
return Permissions::Allowed(context.gmLevel, "metrics_view");
return Permissions::Allowed(context.gmLevel, "metrics_view", context.apiKey.get());
}
}

View File

@@ -1,4 +1,5 @@
#include "RouteUtils.h"
#include "ApiKeyService.h"
#include "Permissions.h"
#include "Database.h"
@@ -32,8 +33,17 @@ namespace {
namespace RouteUtils {
namespace {
std::vector<RouteDoc> g_RouteDocs;
int g_ReadRoutes = 0;
std::shared_ptr<RequireAuthMiddleware> MakeRequireAuth(std::shared_ptr<RequireAuthMiddleware> middleware) {
if (g_ReadRoutes > 0) middleware->SetReadsOnly();
return middleware;
}
}
ReadRoutes::ReadRoutes() { g_ReadRoutes++; }
ReadRoutes::~ReadRoutes() { g_ReadRoutes--; }
void Register(eHTTPMethod method, const std::string& path, std::vector<MiddlewarePtr> middleware, Handler handler) {
Game::web.RegisterHTTPRoute({
.path = path,
@@ -53,7 +63,7 @@ namespace RouteUtils {
void Route(eHTTPMethod method, const std::string& path, int16_t minGmLevel, const std::string& description, Handler handler) {
std::vector<MiddlewarePtr> middleware;
if (minGmLevel >= 0) middleware.push_back(std::make_shared<RequireAuthMiddleware>(static_cast<uint8_t>(minGmLevel)));
if (minGmLevel >= 0) middleware.push_back(MakeRequireAuth(std::make_shared<RequireAuthMiddleware>(static_cast<uint8_t>(minGmLevel))));
g_RouteDocs.push_back({ std::string(magic_enum::enum_name(method)), path, minGmLevel, description, "" });
Register(method, path, std::move(middleware), std::move(handler));
}
@@ -61,13 +71,13 @@ namespace RouteUtils {
void Route(eHTTPMethod method, const std::string& path, const Perm& permission, const std::string& description, Handler handler) {
if (!Permissions::Find(permission.key)) LOG("Route %s uses unknown permission %s; nobody can use it", path.c_str(), permission.key.c_str());
std::vector<MiddlewarePtr> middleware;
middleware.push_back(std::make_shared<RequireAuthMiddleware>(std::function<uint8_t()>([key = permission.key] { return Permissions::Level(key); })));
middleware.push_back(MakeRequireAuth(std::make_shared<RequireAuthMiddleware>(std::function<uint8_t()>([key = permission.key] { return Permissions::Level(key); }), permission.key)));
g_RouteDocs.push_back({ std::string(magic_enum::enum_name(method)), path, Permissions::Level(permission.key), description, permission.key });
Register(method, path, std::move(middleware), std::move(handler));
}
bool Can(const HTTPContext& context, const std::string& permission) {
return context.isAuthenticated && Permissions::Allowed(context.gmLevel, permission);
return context.isAuthenticated && Permissions::Allowed(context.gmLevel, permission, context.apiKey.get());
}
std::optional<LWOOBJID> ResolveCharacter(std::string_view text) {
@@ -81,7 +91,7 @@ namespace RouteUtils {
}
bool CanViewCharacter(const HTTPContext& context, uint32_t ownerAccountId) {
return context.isAuthenticated && Permissions::CanViewCharacter(context.gmLevel, context.accountId, ownerAccountId);
return context.isAuthenticated && Permissions::CanViewCharacter(context.gmLevel, context.accountId, ownerAccountId, context.apiKey.get());
}
const std::vector<RouteDoc>& GetRouteDocs() {
@@ -125,13 +135,20 @@ namespace RouteUtils {
void Audit(const HTTPContext& context, const std::string& action, const std::string& description, const AuditTarget& target) {
// Staff acting on their own account or characters is called out, so it stands out in the log and in alerts
const auto text = description + OwnAccountNote(context.accountId, target.accountId);
// What was done with an API key says which key: "user (key name)"
auto actor = context.authenticatedUser;
if (context.apiKey) {
// The audit log's name column holds 64 characters; shorten the key's name rather than the account's
const auto room = actor.size() + 3 < 64 ? 64 - actor.size() - 3 : 0;
actor += " (" + context.apiKey->name.substr(0, room) + ")";
}
try {
Database::Get()->InsertAuditLog(context.accountId, context.authenticatedUser, action, text, target.accountId, target.characterId);
Database::Get()->InsertAuditLog(context.accountId, actor, action, text, target.accountId, target.characterId);
} catch (const std::exception& ex) {
LOG("Failed to write audit log entry %s: %s", action.c_str(), ex.what());
}
LOG("[audit] %s: %s %s", context.authenticatedUser.c_str(), action.c_str(), text.c_str());
Alerts::FromAudit(context.authenticatedUser, action, text);
LOG("[audit] %s: %s %s", actor.c_str(), action.c_str(), text.c_str());
Alerts::FromAudit(actor, action, text);
}
std::string HashPassword(const std::string& password) {
@@ -222,7 +239,7 @@ namespace RouteUtils {
}
bool CanManageAccount(const HTTPContext& context, uint8_t targetLevel, uint32_t targetAccountId, eAccountAction action) {
return context.isAuthenticated && AccountRules::ManageDenialNow(context.gmLevel, context.accountId, targetLevel, targetAccountId, action) == eManageDenial::NONE;
return context.isAuthenticated && AccountRules::ManageDenialNow(context.gmLevel, context.accountId, targetLevel, targetAccountId, action, context.apiKey.get()) == eManageDenial::NONE;
}
nlohmann::json ManageJson(const HTTPContext& context, uint8_t targetLevel, uint32_t targetAccountId) {
@@ -240,8 +257,14 @@ namespace RouteUtils {
return std::nullopt;
}
const uint8_t targetLevel = target.value("gm_level", 0);
const auto denial = AccountRules::ManageDenialNow(context.gmLevel, context.accountId, targetLevel, targetAccountId, action);
const auto denial = AccountRules::ManageDenialNow(context.gmLevel, context.accountId, targetLevel, targetAccountId, action, context.apiKey.get());
if (denial == eManageDenial::NONE) return targetLevel;
// The owner may do it, but the key's scope doesn't let it
if (context.apiKey && AccountRules::ManageDenialNow(context.gmLevel, context.accountId, targetLevel, targetAccountId, action) == eManageDenial::NONE) {
ApiKeyService::NoteDenied(context, AccountRules::DenialMessage(denial, action));
JsonError(reply, eHTTPStatusCode::FORBIDDEN, "This API key may not do this: " + AccountRules::DenialMessage(denial, action));
return std::nullopt;
}
JsonError(reply, eHTTPStatusCode::FORBIDDEN, AccountRules::DenialMessage(denial, action));
return std::nullopt;
}
@@ -261,7 +284,7 @@ namespace RouteUtils {
try {
data.merge_patch(context.GetUserDataJson());
data["current_page"] = page;
data["can"] = Permissions::ForLevel(context.isAuthenticated ? context.gmLevel : 0);
data["can"] = Permissions::ForLevel(context.isAuthenticated ? context.gmLevel : 0, context.apiKey.get());
// The account's view choices, on <body> so each page's toggles start as they were left (static/js/common.js)
// Names for the game's numbered values, from the server's enums (GameLabels.h)
data["labels"] = GameLabels::Json();

View File

@@ -259,7 +259,23 @@ namespace RouteUtils {
void Route(eHTTPMethod method, const std::string& path, int16_t minGmLevel, const std::string& description, Handler handler);
void Route(eHTTPMethod method, const std::string& path, const Perm& permission, const std::string& description, Handler handler);
// Whether the signed-in user has a permission
// Routes registered while one of these lives only read, even POSTs (DataTables and lookups send their query as a
// body), so read-only API keys may use them
struct ReadRoutes {
ReadRoutes();
~ReadRoutes();
ReadRoutes(const ReadRoutes&) = delete;
ReadRoutes& operator=(const ReadRoutes&) = delete;
};
// Route for a POST that only reads (see ReadRoutes)
template<typename Access>
void ReadRoute(eHTTPMethod method, const std::string& path, const Access& access, const std::string& description, Handler handler) {
ReadRoutes reads;
Route(method, path, access, description, std::move(handler));
}
// Whether the signed-in user has a permission (and, for a request made with an API key, the key's scope has it)
bool Can(const HTTPContext& context, const std::string& permission);
// A character typed by a person: a number is its ID, anything else its name. nullopt: no such character.

View File

@@ -328,6 +328,7 @@ namespace {
c.Add(Bool(DASHBOARD, "showcase_public", "Property showcase for everyone", "Let people who aren't signed in browse approved public properties at /showcase. Signed-in players need the showcase_view permission.", false));
c.AddSection("Metrics", "Prometheus metrics at /metrics: players, worlds, memory, chat, today's economy, moderation queues and scheduled tasks. Never names or addresses.");
c.Add(Unit(Int(DASHBOARD, "api_key_rate_limit", "API key rate limit", "Requests a minute an API key may make unless the key sets its own limit (up to 6000).", "120", 1, 6000), "/min"));
c.Add(Bool(DASHBOARD, "metrics_enabled", "Metrics endpoint", "Off: /metrics answers 404. On: scrapers send an API token of an account with metrics_view, or the token below.", false));
c.Add(When(Secret(DASHBOARD, "metrics_token", "Scraper token", "A shared secret scrapers may send as Authorization: Bearer instead of an API token. At least 16 characters; empty: API tokens only."), DASHBOARD, "metrics_enabled", { "1" }));
c.Add(When(Text(DASHBOARD, "metrics_allowed_ips", "Allowed addresses", "Comma separated addresses or IPv4 ranges (10.0.0.0/8) that may fetch metrics. Empty: any."), DASHBOARD, "metrics_enabled", { "1" }));

View File

@@ -455,7 +455,7 @@ void RegisterSettingsRoutes() {
Route(eHTTPMethod::GET, "/api/account/permissions", 0, "What you may do: {permissions: {name: bool}}",
[](HTTPReply& reply, const HTTPContext& context) {
JsonSuccess(reply, { {"gmLevel", context.gmLevel}, {"permissions", Permissions::ForLevel(context.gmLevel)} });
JsonSuccess(reply, { {"gmLevel", context.gmLevel}, {"permissions", Permissions::ForLevel(context.gmLevel, context.apiKey.get())} });
});
Route(eHTTPMethod::GET, "/api/permissions", Perm("permissions_manage"), "Every permission with its default and current minimum GM level, and where that comes from",

View File

@@ -52,7 +52,7 @@ namespace {
void RegisterWSRoutes() {
Game::web.RegisterWSSubscription("dashboard_update", 0);
Game::web.RegisterWSSubscription("table_changed", 1);
Game::web.RegisterWSSubscription("moderation_counts", std::function<uint8_t()>([] { return Permissions::Level("moderate_names"); }));
Game::web.RegisterWSSubscription("moderation_counts", std::function<uint8_t()>([] { return Permissions::Level("moderate_names"); }), "moderate_names");
// Delivered only to the account that started the action (Web::SendWSMessageToAccount), so players get their own
Game::web.RegisterWSSubscription("action_result", 0);
}