diff --git a/dDashboardServer/DashboardServer.cpp b/dDashboardServer/DashboardServer.cpp index 292afcb51..19d08e381 100644 --- a/dDashboardServer/DashboardServer.cpp +++ b/dDashboardServer/DashboardServer.cpp @@ -96,6 +96,8 @@ #include "Alerts.h" #include "DashboardAuthService.h" #include "AuthTokenHandler.h" +#include "ApiKeyService.h" +#include "ApiKeyRoutes.h" #include "JWTUtils.h" #include "GeneralUtils.h" #include @@ -462,6 +464,13 @@ int main(int argc, char** argv) { // WebSocket connections carry the session cookie; the level gates which topics they may receive // Also called again for open sockets every minute and when an account changes (BroadcastTableChanged("accounts")) Game::web.SetWSAuthCallback([](const std::string& token) -> std::optional { + // An API key: its owner as of now, and its scope for the subscriptions + if (ApiKeyService::LooksLikeKey(token)) { + const auto key = ApiKeyService::Verify(token); + if (!key) return std::nullopt; + if (key->needsTwoFactorSetup) return WSAuth{ 0, key->accountId, key->scope }; + return WSAuth{ key->gmLevel, key->accountId, key->scope }; + } const auto result = AuthTokenHandler::ValidateToken(token); if (!result.isValid) return std::nullopt; // Until required two-factor login is set up the session only reaches its own account page @@ -504,6 +513,7 @@ int main(int argc, char** argv) { RegisterCharacterProgressRoutes(); RegisterServerRoutes(); RegisterLeaderboardRoutes(); + ApiKeyRoutes::RegisterRoutes(); RequireAuthMiddleware::SetApiAccessCheck([](uint8_t gmLevel) { return Permissions::Allowed(gmLevel, "api_access"); }); RequireAuthMiddleware::SetForbiddenPage([](const HTTPContext& context, HTTPReply& reply) { RouteUtils::RenderError(reply, context, eHTTPStatusCode::FORBIDDEN, "You don't have permission to open this page."); @@ -587,6 +597,7 @@ int main(int argc, char** argv) { ChallengeRoutes::Update(); InstanceLoad::Update(); Traffic::Update(); + ApiKeyService::Update(); // Broadcast dashboard updates periodically if (elapsedSinceBroadcast >= broadcastInterval) { @@ -602,6 +613,7 @@ int main(int argc, char** argv) { // Cleanup: the worker threads first (they answer deferred requests), then the web server's connections Workers::Stop(); Game::web.Shutdown(); + ApiKeyService::Flush(); Inspector::Shutdown(); EmailService::Shutdown(); ModeratorHelper::Shutdown(); diff --git a/dDashboardServer/auth/ApiKeyLimiter.h b/dDashboardServer/auth/ApiKeyLimiter.h new file mode 100644 index 000000000..ef7af3f9a --- /dev/null +++ b/dDashboardServer/auth/ApiKeyLimiter.h @@ -0,0 +1,92 @@ +#pragma once + +#include +#include +#include +#include + +/** + * Per API key request limits: a rate (requests a minute, as a token bucket that refills evenly, so a key can burst up + * to its whole minute and then goes at the steady rate) and an optional quota of requests per UTC day. Only accepted + * requests use up the rate and the quota. Kept in memory; the day count is seeded from the database when a key is + * first seen, so a restart doesn't hand out a fresh quota. + */ +class ApiKeyLimiter { +public: + using Clock = std::chrono::steady_clock; + + struct Decision { + bool allowed{}; + bool quotaExceeded{}; // refused by the daily quota rather than the rate + uint32_t limit{}; // requests a minute + uint32_t remaining{}; // left in the current minute's allowance + uint32_t retryAfterSeconds{}; // when refused + uint32_t quota{}; // requests a day, 0: none + uint32_t quotaRemaining{}; + uint32_t dayCount{}; // accepted requests today, after this one + }; + + /** + * @param day The UTC day (days since 1970) of the request + * @param secondsToNextDay Seconds until that day ends (for Retry-After once the quota is used up) + * @param storedDay, storedDayCount What the database last recorded for the key, used when it is first seen + */ + Decision Check(uint64_t keyId, uint32_t perMinute, uint32_t dailyQuota, int32_t day, uint32_t secondsToNextDay, + int32_t storedDay, uint32_t storedDayCount, Clock::time_point now) { + perMinute = std::max(perMinute, 1); + auto [it, inserted] = m_Keys.try_emplace(keyId); + auto& state = it->second; + if (inserted) { + state.tokens = perMinute; + state.refilledAt = now; + state.day = storedDay; + state.dayCount = storedDay == day ? storedDayCount : 0; + } + if (state.day != day) { + state.day = day; + state.dayCount = 0; + } + + // Refill at perMinute a minute, up to one minute's worth (a lowered limit takes effect at once) + const double elapsed = std::chrono::duration(now - state.refilledAt).count(); + state.tokens = std::min(perMinute, state.tokens + elapsed * perMinute / 60.0); + state.refilledAt = now; + + Decision decision; + decision.limit = perMinute; + decision.quota = dailyQuota; + if (dailyQuota > 0 && state.dayCount >= dailyQuota) { + decision.quotaExceeded = true; + decision.retryAfterSeconds = std::max(secondsToNextDay, 1); + } else if (state.tokens < 1.0) { + decision.retryAfterSeconds = static_cast((1.0 - state.tokens) * 60.0 / perMinute) + 1; + } else { + state.tokens -= 1.0; + state.dayCount++; + decision.allowed = true; + } + decision.remaining = static_cast(state.tokens); + decision.dayCount = state.dayCount; + decision.quotaRemaining = dailyQuota > state.dayCount ? dailyQuota - state.dayCount : 0; + return decision; + } + + // A revoked or rotated key starts over + void Forget(uint64_t keyId) { m_Keys.erase(keyId); } + + // Drop keys not used for a while, so memory stays bounded + void Prune(Clock::time_point now, std::chrono::seconds idle) { + std::erase_if(m_Keys, [&](const auto& entry) { return now - entry.second.refilledAt > idle; }); + } + + size_t Size() const { return m_Keys.size(); } + +private: + struct State { + double tokens{}; + Clock::time_point refilledAt; + int32_t day{}; + uint32_t dayCount{}; + }; + std::unordered_map m_Keys; +}; diff --git a/dDashboardServer/auth/ApiKeyService.cpp b/dDashboardServer/auth/ApiKeyService.cpp new file mode 100644 index 000000000..758e2eb6b --- /dev/null +++ b/dDashboardServer/auth/ApiKeyService.cpp @@ -0,0 +1,284 @@ +#include "ApiKeyService.h" + +#include +#include +#include +#include +#include + +#include + +#include "ApiKeyLimiter.h" +#include "Database.h" +#include "DashboardAuthService.h" +#include "dConfig.h" +#include "Game.h" +#include "GeneralUtils.h" +#include "HTTPContext.h" +#include "HTTPReply.h" +#include "Logger.h" + +namespace { + using Clock = std::chrono::steady_clock; + // How long what the database says about a key is trusted. Revoking and rotating on this server take effect at + // once (Forget); the owner's account is looked up on every request regardless. + constexpr auto CACHE_TTL = std::chrono::seconds(30); + constexpr size_t CACHE_MAX = 10000; + constexpr auto FLUSH_INTERVAL = std::chrono::seconds(60); + constexpr auto DENIED_THROTTLE = std::chrono::seconds(60); + constexpr auto LIMITER_IDLE = std::chrono::minutes(30); + + struct CachedKey { + std::optional key; // nullopt: no such key (so a stream of bad keys doesn't hit the database) + std::shared_ptr scope; + std::vector allowedIps; + std::vector allowedPaths; + Clock::time_point loadedAt; + }; + + struct State { + std::recursive_mutex mutex; + std::unordered_map cache; // by key hash + ApiKeyLimiter limiter; + std::map pending; + std::map, Clock::time_point> deniedAt; + Clock::time_point nextFlush = Clock::now() + FLUSH_INTERVAL; + std::function clientAddress; + std::function deniedHook; + }; + + State& GetState() { + static State state; + return state; + } + + const CachedKey& Lookup(State& state, const std::string& hash) { + const auto now = Clock::now(); + auto it = state.cache.find(hash); + if (it != state.cache.end() && now - it->second.loadedAt < CACHE_TTL) return it->second; + if (state.cache.size() >= CACHE_MAX) state.cache.clear(); + + CachedKey entry; + entry.loadedAt = now; + entry.key = Database::Get()->GetApiKeyByHash(hash); + if (entry.key) { + auto scope = std::make_shared(); + scope->keyId = entry.key->id; + scope->name = entry.key->name; + scope->readOnly = entry.key->readOnly; + ApiKeys::ParsePermissions(entry.key->permissions, scope->allPermissions, scope->permissions); + entry.scope = std::move(scope); + entry.allowedIps = ApiKeys::SplitList(entry.key->allowedIps); + entry.allowedPaths = ApiKeys::SplitList(entry.key->allowedPaths); + } + return state.cache[hash] = std::move(entry); + } + + struct Owner { + std::string username; + uint8_t gmLevel{}; + }; + + // The key is in use and its owner may still sign in; the owner's GM level as of now + std::optional CheckKeyAndOwner(const IApiKeys::ApiKey& key) { + const auto now = static_cast(std::time(nullptr)); + if (key.revokedAt != 0 || (key.expiresAt != 0 && key.expiresAt <= now)) return std::nullopt; + const auto account = Database::Get()->GetAccountById(key.accountId); + if (account.contains("error") || account.value("banned", false) || account.value("locked", false)) return std::nullopt; + const auto gmLevel = static_cast(account.value("gm_level", 0)); + if (!DashboardAuthService::HasDashboardAccess(gmLevel)) return std::nullopt; + // "Sign out everywhere" and password resets stop keys made before them, as they stop sessions + if (key.issuedAt < Database::Get()->GetSessionsValidAfter(key.accountId)) return std::nullopt; + return Owner{ account.value("name", std::string{}), gmLevel }; + } + + std::string Hex(const unsigned char* bytes, size_t size) { + static constexpr char digits[] = "0123456789abcdef"; + std::string out; + out.reserve(size * 2); + for (size_t i = 0; i < size; ++i) { + out += digits[bytes[i] >> 4]; + out += digits[bytes[i] & 15]; + } + return out; + } + + void Refuse(HTTPReply& reply, eHTTPStatusCode status, const std::string& message) { + reply.status = status; + reply.contentType = eContentType::APPLICATION_JSON; + reply.message = nlohmann::json{ {"success", false}, {"error", message} }.dump(); + } +} + +namespace ApiKeyService { + NewSecret GenerateSecret() { + unsigned char bytes[32]; + if (RAND_bytes(bytes, sizeof(bytes)) != 1) throw std::runtime_error("RAND_bytes failed"); + NewSecret secret; + secret.token = std::string(ApiKeys::TOKEN_PREFIX) + Hex(bytes, sizeof(bytes)); + secret.hash = DashboardAuthService::Sha256Hex(secret.token); + secret.prefix = secret.token.substr(0, ApiKeys::TOKEN_PREFIX.size() + 6); + return secret; + } + + bool LooksLikeKey(std::string_view token) { + return token.starts_with(ApiKeys::TOKEN_PREFIX); + } + + bool SessionOnlyPath(std::string_view path) { + if (path.starts_with("/api/auth/")) return true; + if (path.starts_with("/api/account/") && path != "/api/account/permissions") return true; + return path.find("/api_keys") != std::string_view::npos; + } + + uint32_t DefaultRateLimit() { + if (!Game::config) return DEFAULT_RATE_LIMIT; + const auto value = GeneralUtils::TryParse(Game::config->GetValue("api_key_rate_limit")); + return value && *value > 0 ? std::min(*value, MAX_RATE_LIMIT) : DEFAULT_RATE_LIMIT; + } + + eResult Authenticate(const std::string& token, HTTPContext& context, HTTPReply& reply) { + auto& state = GetState(); + std::lock_guard lock(state.mutex); + const auto& cached = Lookup(state, DashboardAuthService::Sha256Hex(token)); + if (!cached.key) return eResult::INVALID; + const auto& key = *cached.key; + const auto owner = CheckKeyAndOwner(key); + if (!owner) return eResult::INVALID; + + context.isAuthenticated = true; + context.authenticatedUser = owner->username; + context.accountId = key.accountId; + context.gmLevel = owner->gmLevel; + context.apiKey = cached.scope; + context.userData["auth_source"] = "header"; + context.userData["api_key"] = key.name; + if (DashboardAuthService::NeedsTwoFactorSetup(key.accountId, owner->gmLevel)) context.userData["needs_2fa"] = "1"; + + const auto address = state.clientAddress ? state.clientAddress(context) : context.clientIP; + if (!ApiKeys::AddressAllowed(cached.allowedIps, address)) { + NoteDenied(context, "address " + address + " isn't allowed"); + Refuse(reply, eHTTPStatusCode::FORBIDDEN, "This API key can't be used from this address"); + return eResult::REFUSED; + } + if (SessionOnlyPath(context.path)) { + NoteDenied(context, context.method + " " + context.path + " needs a signed-in browser session"); + Refuse(reply, eHTTPStatusCode::FORBIDDEN, "API keys can't be used for this; sign in to the dashboard instead"); + return eResult::REFUSED; + } + if (!ApiKeys::PathAllowed(cached.allowedPaths, context.path)) { + NoteDenied(context, context.method + " " + context.path + " is outside its allowed paths"); + Refuse(reply, eHTTPStatusCode::FORBIDDEN, "This API key isn't allowed to use this path"); + return eResult::REFUSED; + } + + const auto now = std::time(nullptr); + const auto day = static_cast(now / 86400); + const auto toNextDay = static_cast(86400 - now % 86400); + const auto perMinute = key.rateLimit > 0 ? std::min(key.rateLimit, MAX_RATE_LIMIT) : DefaultRateLimit(); + const auto decision = state.limiter.Check(key.id, perMinute, key.dailyQuota, day, toNextDay, key.quotaDay, key.dayCount, Clock::now()); + reply.headers.push_back("X-RateLimit-Limit: " + std::to_string(decision.limit)); + reply.headers.push_back("X-RateLimit-Remaining: " + std::to_string(decision.remaining)); + if (decision.quota > 0) { + reply.headers.push_back("X-Quota-Limit: " + std::to_string(decision.quota)); + reply.headers.push_back("X-Quota-Remaining: " + std::to_string(decision.quotaRemaining)); + } + if (!decision.allowed) { + reply.headers.push_back("Retry-After: " + std::to_string(decision.retryAfterSeconds)); + NoteDenied(context, decision.quotaExceeded ? "its daily quota is used up" : "it went over its rate limit"); + Refuse(reply, eHTTPStatusCode::TOO_MANY_REQUESTS, decision.quotaExceeded + ? "This API key has used up its daily quota" : "This API key is making too many requests; slow down"); + return eResult::REFUSED; + } + + auto& usage = state.pending[key.id]; + usage.id = key.id; + usage.requests++; + usage.lastUsedAt = now; + usage.lastIp = address.substr(0, 64); + usage.quotaDay = day; + usage.dayCount = decision.dayCount; + return eResult::OK; + } + + std::optional Verify(const std::string& token) { + auto& state = GetState(); + std::lock_guard lock(state.mutex); + const auto& cached = Lookup(state, DashboardAuthService::Sha256Hex(token)); + if (!cached.key) return std::nullopt; + const auto owner = CheckKeyAndOwner(*cached.key); + if (!owner) return std::nullopt; + return Verified{ cached.key->accountId, owner->username, owner->gmLevel, + DashboardAuthService::NeedsTwoFactorSetup(cached.key->accountId, owner->gmLevel), cached.scope }; + } + + void Forget(uint64_t keyId) { + auto& state = GetState(); + std::lock_guard lock(state.mutex); + std::erase_if(state.cache, [keyId](const auto& entry) { return entry.second.key && entry.second.key->id == keyId; }); + state.limiter.Forget(keyId); + } + + void ForgetAll() { + auto& state = GetState(); + std::lock_guard lock(state.mutex); + state.cache.clear(); + } + + std::optional PendingUsage(uint64_t keyId) { + auto& state = GetState(); + std::lock_guard lock(state.mutex); + const auto it = state.pending.find(keyId); + if (it == state.pending.end()) return std::nullopt; + return it->second; + } + + void Flush() { + auto& state = GetState(); + std::lock_guard lock(state.mutex); + state.nextFlush = Clock::now() + FLUSH_INTERVAL; + if (state.pending.empty()) return; + std::vector batch; + batch.reserve(state.pending.size()); + for (const auto& [_, usage] : state.pending) batch.push_back(usage); + try { + Database::Get()->RecordApiKeyUsage(batch); + state.pending.clear(); + } catch (const std::exception& ex) { + LOG("Failed to save API key usage: %s", ex.what()); + } + } + + void Update() { + auto& state = GetState(); + std::lock_guard lock(state.mutex); + const auto now = Clock::now(); + if (now < state.nextFlush) return; + Flush(); + state.limiter.Prune(now, LIMITER_IDLE); + std::erase_if(state.deniedAt, [&](const auto& entry) { return now - entry.second >= DENIED_THROTTLE; }); + } + + void SetClientAddress(std::function resolve) { + GetState().clientAddress = std::move(resolve); + } + + void SetDeniedHook(std::function hook) { + GetState().deniedHook = std::move(hook); + } + + void NoteDenied(const HTTPContext& context, const std::string& reason) { + if (!context.apiKey) return; + auto& state = GetState(); + std::lock_guard lock(state.mutex); + const auto now = Clock::now(); + auto [it, inserted] = state.deniedAt.try_emplace({ context.apiKey->keyId, reason }, now); + if (!inserted) { + if (now - it->second < DENIED_THROTTLE) return; + it->second = now; + } + LOG("API key %llu (%s) of %s refused: %s", static_cast(context.apiKey->keyId), context.apiKey->name.c_str(), + context.authenticatedUser.c_str(), reason.c_str()); + if (state.deniedHook) state.deniedHook(context, reason); + } +} diff --git a/dDashboardServer/auth/ApiKeyService.h b/dDashboardServer/auth/ApiKeyService.h new file mode 100644 index 000000000..2a2e4ff6a --- /dev/null +++ b/dDashboardServer/auth/ApiKeyService.h @@ -0,0 +1,77 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "ApiKeyScope.h" +#include "IApiKeys.h" + +struct HTTPContext; +struct HTTPReply; + +/** + * Dashboard API keys: checking them on requests, their rate limits and quotas, and their usage counters. + * A key's effective permissions are its scope AND its owner's current permissions: the owner is looked up on every + * request, so a ban, lock, demotion or "sign out everywhere" narrows or stops the key at once. + * Runs on the web thread only. + */ +namespace ApiKeyService { + constexpr uint32_t DEFAULT_RATE_LIMIT = 120; // requests a minute, unless api_key_rate_limit says otherwise + constexpr uint32_t MAX_RATE_LIMIT = 6000; + constexpr uint32_t MAX_DAILY_QUOTA = 10'000'000; + + struct NewSecret { + std::string token; // shown to the person once, never stored + std::string hash; // SHA-256 hex, stored + std::string prefix; // the start of the key, stored to tell keys apart + }; + NewSecret GenerateSecret(); + + bool LooksLikeKey(std::string_view token); + + // Paths a key may never use, whatever its scope: signing in, the account's own password, email, two-factor and + // sessions, and managing API keys (a leaked key must not be able to make itself new keys or lock its owner out) + bool SessionOnlyPath(std::string_view path); + + enum class eResult : uint8_t { + INVALID, // unknown, revoked or expired key, or an owner who can't sign in: the request is unauthenticated + OK, // context filled in + REFUSED, // reply written (403 for the key's IP/path restrictions, 429 for its limits) + }; + eResult Authenticate(const std::string& token, HTTPContext& context, HTTPReply& reply); + + // For WebSocket connections: the owner and scope, without counting against the limits + struct Verified { + uint32_t accountId{}; + std::string username; + uint8_t gmLevel{}; + bool needsTwoFactorSetup{}; + std::shared_ptr scope; + }; + std::optional Verify(const std::string& token); + + // Stop using what is cached about a key (after it is revoked or rotated) + void Forget(uint64_t keyId); + void ForgetAll(); + + // Requests since the last write, not yet in the database (for the key list) + std::optional PendingUsage(uint64_t keyId); + + // Write the usage counters now and then (call every tick) and before shutting down + void Update(); + void Flush(); + + // The address a request came from, for the key's allowed addresses (the dashboard's behind_proxy rule) + void SetClientAddress(std::function resolve); + + // Record that a key was refused something; at most once a minute per key and reason, so a busy script can't flood + // the audit log + void SetDeniedHook(std::function hook); + void NoteDenied(const HTTPContext& context, const std::string& reason); + + uint32_t DefaultRateLimit(); +} diff --git a/dDashboardServer/auth/AuthTokenHandler.cpp b/dDashboardServer/auth/AuthTokenHandler.cpp index 42d1465f8..dacaee236 100644 --- a/dDashboardServer/auth/AuthTokenHandler.cpp +++ b/dDashboardServer/auth/AuthTokenHandler.cpp @@ -1,4 +1,5 @@ #include "AuthTokenHandler.h" +#include "ApiKeyService.h" #include "DashboardAuthService.h" #include "Game.h" #include "Logger.h" @@ -77,6 +78,13 @@ bool AuthTokenHandler::ProcessHTTPContext(HTTPContext& context, HTTPReply& reply const auto token = ExtractToken(context.GetHeader("Cookie"), context.GetHeader("Authorization"), source); if (token.empty()) return true; + // API keys: their scope and limits are checked here; a key over its limits is refused outright + if (source == eTokenSource::HEADER && ApiKeyService::LooksLikeKey(token)) { + const auto keyResult = ApiKeyService::Authenticate(token, context, reply); + if (keyResult == ApiKeyService::eResult::INVALID) LOG_DEBUG("API key validation failed from %s", context.clientIP.c_str()); + return keyResult != ApiKeyService::eResult::REFUSED; + } + const auto result = ValidateToken(token); if (!result.isValid) { LOG_DEBUG("Authentication token validation failed: %s", result.errorMessage.c_str()); diff --git a/dDashboardServer/auth/CMakeLists.txt b/dDashboardServer/auth/CMakeLists.txt index 2f236cc09..e7e20eca0 100644 --- a/dDashboardServer/auth/CMakeLists.txt +++ b/dDashboardServer/auth/CMakeLists.txt @@ -4,6 +4,7 @@ set(DASHBOARDAUTH_SOURCES "DashboardAuthService.cpp" "AuthMiddleware.cpp" "AuthTokenHandler.cpp" + "ApiKeyService.cpp" "RequireAuthMiddleware.cpp" ) diff --git a/dDashboardServer/auth/RequireAuthMiddleware.cpp b/dDashboardServer/auth/RequireAuthMiddleware.cpp index 11577dc9d..8712952f9 100644 --- a/dDashboardServer/auth/RequireAuthMiddleware.cpp +++ b/dDashboardServer/auth/RequireAuthMiddleware.cpp @@ -19,6 +19,15 @@ namespace { std::function g_ApiAccessAllowed; std::function g_ForbiddenPage; + std::function 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"; @@ -37,6 +46,13 @@ RequireAuthMiddleware::RequireAuthMiddleware(uint8_t minGmLevel) : requiredLevel RequireAuthMiddleware::RequireAuthMiddleware(std::function requiredLevel) : requiredLevel(std::move(requiredLevel)) {} +RequireAuthMiddleware::RequireAuthMiddleware(std::function requiredLevel, std::string permission) + : requiredLevel(std::move(requiredLevel)), permission(std::move(permission)) {} + +void RequireAuthMiddleware::SetApiKeyDeniedHook(std::function 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()); @@ -105,5 +121,21 @@ bool RequireAuthMiddleware::Process(HTTPContext& context, HTTPReply& reply) { 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; } diff --git a/dDashboardServer/auth/RequireAuthMiddleware.h b/dDashboardServer/auth/RequireAuthMiddleware.h index 08f3403ad..dab1d96b6 100644 --- a/dDashboardServer/auth/RequireAuthMiddleware.h +++ b/dDashboardServer/auth/RequireAuthMiddleware.h @@ -4,6 +4,7 @@ #include #include #include +#include #include "IHTTPMiddleware.h" /** @@ -22,6 +23,9 @@ public: // The required level is looked up on every request (a permission that can be changed while running) explicit RequireAuthMiddleware(std::function requiredLevel); + + // A route guarded by a named permission: an API key must also have it in its scope + RequireAuthMiddleware(std::function requiredLevel, std::string permission); ~RequireAuthMiddleware() override = default; bool Process(HTTPContext& context, HTTPReply& reply) override; @@ -32,10 +36,18 @@ public: // Renders the page a signed-in account gets when it may not open a page (not /api/); unset replies with JSON static void SetForbiddenPage(std::function render); + // The route only reads, although it may be a POST (DataTables and lookups send their query as a body): read-only + // API keys may use it + void SetReadsOnly() { readsOnly = true; } + + // Told when an API key is refused a route for its scope or because it is read-only (for the audit log) + static void SetApiKeyDeniedHook(std::function hook); std::string GetName() const override { return "RequireAuthMiddleware"; } private: std::function requiredLevel; + std::string permission; + bool readsOnly{}; }; #endif // !__REQUIREAUTHMIDDLEWARE_H__ diff --git a/dDashboardServer/routes/APIRoutes.cpp b/dDashboardServer/routes/APIRoutes.cpp index 4aed3deaa..ac3a9e535 100644 --- a/dDashboardServer/routes/APIRoutes.cpp +++ b/dDashboardServer/routes/APIRoutes.cpp @@ -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 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 '. Create a token on your account page (POST /api/auth/token). " + {"authentication", "Send 'Authorization: Bearer '. 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 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(); }); diff --git a/dDashboardServer/routes/ApiKeyRoutes.cpp b/dDashboardServer/routes/ApiKeyRoutes.cpp new file mode 100644 index 000000000..1f153e413 --- /dev/null +++ b/dDashboardServer/routes/ApiKeyRoutes.cpp @@ -0,0 +1,287 @@ +#include "ApiKeyRoutes.h" + +#include +#include + +#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(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 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(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(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 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(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(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 KeyForAction(const HTTPContext& context, HTTPReply& reply, bool ownOnly) { + const auto id = PathId(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(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(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(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() == ApiKeys::ALL_PERMISSIONS) { + key.permissions = std::string(ApiKeys::ALL_PERMISSIONS); + } else if (requested.is_array()) { + std::set permissions; + for (const auto& permission : requested) if (permission.is_string()) permissions.insert(permission.get()); + 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(rate); + key.dailyQuota = static_cast(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); }); + } +} diff --git a/dDashboardServer/routes/ApiKeyRoutes.h b/dDashboardServer/routes/ApiKeyRoutes.h new file mode 100644 index 000000000..d6dfee13c --- /dev/null +++ b/dDashboardServer/routes/ApiKeyRoutes.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +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); +} diff --git a/dDashboardServer/routes/AuthRoutes.cpp b/dDashboardServer/routes/AuthRoutes.cpp index 6b06d664f..0c9c30046 100644 --- a/dDashboardServer/routes/AuthRoutes.cpp +++ b/dDashboardServer/routes/AuthRoutes.cpp @@ -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(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} }); } }); diff --git a/dDashboardServer/routes/CMakeLists.txt b/dDashboardServer/routes/CMakeLists.txt index c04fcee98..fccbfdea6 100644 --- a/dDashboardServer/routes/CMakeLists.txt +++ b/dDashboardServer/routes/CMakeLists.txt @@ -4,6 +4,7 @@ set(DASHBOARDROUTES_SOURCES "DashboardRoutes.cpp" "WSRoutes.cpp" "AuthRoutes.cpp" + "ApiKeyRoutes.cpp" "RouteUtils.cpp" "PlayerActions.cpp" "AccountRoutes.cpp" diff --git a/dDashboardServer/routes/ChatRoutes.cpp b/dDashboardServer/routes/ChatRoutes.cpp index 4743afe6c..83c5c94f8 100644 --- a/dDashboardServer/routes/ChatRoutes.cpp +++ b/dDashboardServer/routes/ChatRoutes.cpp @@ -75,7 +75,7 @@ namespace ChatRoutes { } void RegisterChatRoutes() { - Game::web.RegisterWSSubscription("chat_message", std::function([] { return Permissions::Level("chat_view"); })); + Game::web.RegisterWSSubscription("chat_message", std::function([] { 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), " diff --git a/dDashboardServer/routes/ClientAssets.cpp b/dDashboardServer/routes/ClientAssets.cpp index 5beb1551c..f9400a381 100644 --- a/dDashboardServer/routes/ClientAssets.cpp +++ b/dDashboardServer/routes/ClientAssets.cpp @@ -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"); diff --git a/dDashboardServer/routes/DashboardRoutes.cpp b/dDashboardServer/routes/DashboardRoutes.cpp index 76a4433be..f04ef5c2c 100644 --- a/dDashboardServer/routes/DashboardRoutes.cpp +++ b/dDashboardServer/routes/DashboardRoutes.cpp @@ -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(); diff --git a/dDashboardServer/routes/EventsCalendar.cpp b/dDashboardServer/routes/EventsCalendar.cpp index 9072fa169..ad55421dc 100644 --- a/dDashboardServer/routes/EventsCalendar.cpp +++ b/dDashboardServer/routes/EventsCalendar.cpp @@ -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"); diff --git a/dDashboardServer/routes/Inspector.cpp b/dDashboardServer/routes/Inspector.cpp index 3a2937648..624982989 100644 --- a/dDashboardServer/routes/Inspector.cpp +++ b/dDashboardServer/routes/Inspector.cpp @@ -480,7 +480,7 @@ namespace Inspector { } void RegisterRoutes() { - Game::web.RegisterWSSubscription(TOPIC, std::function([] { return Permissions::Level(PERMISSION); })); + Game::web.RegisterWSSubscription(TOPIC, std::function([] { return Permissions::Level(PERMISSION); }), PERMISSION); Route(eHTTPMethod::GET, "/inspector", Perm(PERMISSION), "The game message inspector", [](HTTPReply& reply, const HTTPContext& context) { diff --git a/dDashboardServer/routes/LiveWorld.cpp b/dDashboardServer/routes/LiveWorld.cpp index 865bfac3f..5243dff8a 100644 --- a/dDashboardServer/routes/LiveWorld.cpp +++ b/dDashboardServer/routes/LiveWorld.cpp @@ -211,7 +211,7 @@ namespace LiveWorld { } void RegisterRoutes() { - Game::web.RegisterWSSubscription("player_positions", std::function([] { return Permissions::Level("players_view"); })); + Game::web.RegisterWSSubscription("player_positions", std::function([] { 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&) { diff --git a/dDashboardServer/routes/PrometheusMetrics.cpp b/dDashboardServer/routes/PrometheusMetrics.cpp index b962c1e5e..cd60477b8 100644 --- a/dDashboardServer/routes/PrometheusMetrics.cpp +++ b/dDashboardServer/routes/PrometheusMetrics.cpp @@ -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()); } } diff --git a/dDashboardServer/routes/RouteUtils.cpp b/dDashboardServer/routes/RouteUtils.cpp index 87e9e9f6a..64a5daf72 100644 --- a/dDashboardServer/routes/RouteUtils.cpp +++ b/dDashboardServer/routes/RouteUtils.cpp @@ -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 g_RouteDocs; + int g_ReadRoutes = 0; + + std::shared_ptr MakeRequireAuth(std::shared_ptr 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 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 middleware; - if (minGmLevel >= 0) middleware.push_back(std::make_shared(static_cast(minGmLevel))); + if (minGmLevel >= 0) middleware.push_back(MakeRequireAuth(std::make_shared(static_cast(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 middleware; - middleware.push_back(std::make_shared(std::function([key = permission.key] { return Permissions::Level(key); }))); + middleware.push_back(MakeRequireAuth(std::make_shared(std::function([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 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& 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 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(); diff --git a/dDashboardServer/routes/RouteUtils.h b/dDashboardServer/routes/RouteUtils.h index 7d9acedd6..a13ac5f25 100644 --- a/dDashboardServer/routes/RouteUtils.h +++ b/dDashboardServer/routes/RouteUtils.h @@ -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 + 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. diff --git a/dDashboardServer/routes/SettingsCatalog.cpp b/dDashboardServer/routes/SettingsCatalog.cpp index bed4cdc76..91f5facbf 100644 --- a/dDashboardServer/routes/SettingsCatalog.cpp +++ b/dDashboardServer/routes/SettingsCatalog.cpp @@ -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" })); diff --git a/dDashboardServer/routes/SettingsRoutes.cpp b/dDashboardServer/routes/SettingsRoutes.cpp index 806cff0d8..b6aaed475 100644 --- a/dDashboardServer/routes/SettingsRoutes.cpp +++ b/dDashboardServer/routes/SettingsRoutes.cpp @@ -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", diff --git a/dDashboardServer/routes/WSRoutes.cpp b/dDashboardServer/routes/WSRoutes.cpp index c55c57a0f..1498de6e6 100644 --- a/dDashboardServer/routes/WSRoutes.cpp +++ b/dDashboardServer/routes/WSRoutes.cpp @@ -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([] { return Permissions::Level("moderate_names"); })); + Game::web.RegisterWSSubscription("moderation_counts", std::function([] { 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); } diff --git a/dWeb/HTTPContext.h b/dWeb/HTTPContext.h index 5283fd5a7..7fcf99545 100644 --- a/dWeb/HTTPContext.h +++ b/dWeb/HTTPContext.h @@ -6,6 +6,7 @@ #include #include "eHTTPStatusCode.h" #include "json.hpp" +#include "ApiKeyScope.h" /** * HTTP Request Context @@ -34,6 +35,9 @@ struct HTTPContext { std::string authenticatedUser{}; uint32_t accountId = 0; uint8_t gmLevel = 0; + // Set when an API key authenticated the request: the key's scope, on top of what the account may do (gmLevel). + // Every permission check must honour it (RouteUtils::Can and friends do). + std::shared_ptr apiKey{}; // Custom data for middleware to communicate std::map userData{}; diff --git a/dWeb/Web.cpp b/dWeb/Web.cpp index fbd81305e..9ef2dc369 100644 --- a/dWeb/Web.cpp +++ b/dWeb/Web.cpp @@ -29,6 +29,8 @@ namespace { std::vector g_WSSubscriptions; // Minimum permission level per subscription, parallel to g_WSSubscriptions std::vector> g_WSSubscriptionLevels; + // The permission guarding each subscription (empty: level only), parallel to g_WSSubscriptions + std::vector g_WSSubscriptionPermissions; // Authenticated WebSocket connections: their permission level, account and the token they connected with. // Entries are removed on MG_EV_CLOSE so a reused connection address is never treated as authenticated. struct WSClient { @@ -37,7 +39,16 @@ namespace { std::string token; // empty for trusted internal connections, which are never rechecked bool apiToken{}; // connected with Authorization: Bearer (subject to the API access rule) std::chrono::steady_clock::time_point nextCheck; + std::shared_ptr apiKey{}; // connected with an API key: its scope }; + + // Whether a connection may subscribe to (and receive) a subscription + bool MayReceive(const WSClient& client, size_t index, uint8_t minLevel) { + if (client.level < minLevel) return false; + if (!client.apiKey) return true; + const auto& permission = g_WSSubscriptionPermissions[index]; + return permission.empty() ? (minLevel == 0 || client.apiKey->allPermissions) : client.apiKey->Has(permission); + } std::map g_AuthenticatedWSConnections; constexpr uint8_t INTERNAL_WS_LEVEL = UINT8_MAX; constexpr auto WS_RECHECK_INTERVAL = std::chrono::seconds(60); @@ -66,6 +77,7 @@ namespace { continue; } client.level = auth->level; + client.apiKey = auth->apiKey; } for (auto* connection : expired) { LOG_DEBUG("Closing a WebSocket whose session is no longer valid"); @@ -357,7 +369,7 @@ void HandleHTTPMessage(mg_connection* connection, const mg_http_message* http_ms if (level) { mg_ws_upgrade(connection, const_cast(http_msg), NULL); g_AuthenticatedWSConnections[connection] = { level->level, level->accountId, connectToken, apiToken, - std::chrono::steady_clock::now() + WS_RECHECK_INTERVAL }; + std::chrono::steady_clock::now() + WS_RECHECK_INTERVAL, level->apiKey }; const char* connType = isInternal ? "internal" : "external"; LOG_DEBUG("Upgraded %s connection to websocket: %d.%d.%d.%d:%i", connType, MG_IPADDR_PARTS(&connection->rem.ip), connection->rem.port); } else { @@ -549,7 +561,7 @@ void HandleWSSubscribe(mg_connection* connection, json data) { // get index of subscription auto index = std::distance(g_WSSubscriptions.begin(), subItr); const auto connItr = g_AuthenticatedWSConnections.find(connection); - if (connItr == g_AuthenticatedWSConnections.end() || connItr->second.level < g_WSSubscriptionLevels[index]()) { + if (connItr == g_AuthenticatedWSConnections.end() || !MayReceive(connItr->second, index, g_WSSubscriptionLevels[index]())) { const std::string forbidden = "{\"error\":\"Forbidden\",\"subscription\":\"" + subscription + "\"}"; mg_ws_send(connection, forbidden.c_str(), forbidden.size(), WEBSOCKET_OP_TEXT); return; @@ -664,6 +676,10 @@ void Web::RegisterWSSubscription(const std::string& subscription, uint8_t minLev } void Web::RegisterWSSubscription(const std::string& subscription, std::function minLevel) { + RegisterWSSubscription(subscription, std::move(minLevel), ""); +} + +void Web::RegisterWSSubscription(const std::string& subscription, std::function minLevel, std::string permission) { if (!Game::web.enabled) { LOG_DEBUG("Failed to register WS subscription %s: web server not enabled", subscription.c_str()); return; @@ -679,6 +695,7 @@ void Web::RegisterWSSubscription(const std::string& subscription, std::function< LOG_DEBUG("Registered WS subscription %s", subscription.c_str()); g_WSSubscriptions.push_back(subscription); g_WSSubscriptionLevels.push_back(std::move(minLevel)); + g_WSSubscriptionPermissions.push_back(std::move(permission)); } } @@ -800,7 +817,7 @@ void Web::SendWSMessageToAccount(const std::string subscription, json& data, uin for (auto* wc = Game::web.GetManager().conns; wc != NULL; wc = wc->next) { if (!wc->is_websocket || wc->is_closing || wc->data[index] != SubscriptionStatus::SUBSCRIBED) continue; const auto connItr = g_AuthenticatedWSConnections.find(wc); - if (connItr == g_AuthenticatedWSConnections.end() || connItr->second.accountId != accountId || connItr->second.level < minLevel) continue; + if (connItr == g_AuthenticatedWSConnections.end() || connItr->second.accountId != accountId || !MayReceive(connItr->second, index, minLevel)) continue; mg_ws_send(wc, payload.c_str(), payload.size(), WEBSOCKET_OP_TEXT); } } @@ -823,7 +840,7 @@ void Web::SendWSMessage(const std::string subscription, json& data) { for (auto *wc = Game::web.GetManager().conns; wc != NULL; wc = wc->next) { if (!wc->is_websocket || wc->is_closing || wc->data[index] != SubscriptionStatus::SUBSCRIBED) continue; const auto connItr = g_AuthenticatedWSConnections.find(wc); - if (connItr == g_AuthenticatedWSConnections.end() || connItr->second.level < minLevel) continue; + if (connItr == g_AuthenticatedWSConnections.end() || !MayReceive(connItr->second, index, minLevel)) continue; mg_ws_send(wc, payload.c_str(), payload.size(), WEBSOCKET_OP_TEXT); } } diff --git a/dWeb/Web.h b/dWeb/Web.h index 5a7a1baec..b2bf47c33 100644 --- a/dWeb/Web.h +++ b/dWeb/Web.h @@ -56,6 +56,8 @@ enum SubscriptionStatus { struct WSAuth { uint8_t level{}; uint32_t accountId{}; + // Connected with an API key: subscriptions also need their permission in its scope + std::shared_ptr apiKey{}; }; // WebSocket authentication callback function type @@ -85,6 +87,9 @@ public: void RegisterWSSubscription(const std::string& subscription, uint8_t minLevel = 0); // The level is looked up each time (for permissions that can change while running) void RegisterWSSubscription(const std::string& subscription, std::function minLevel); + // Guarded by a named permission (at its level): connections made with an API key also need it in the key's scope. + // Level-only subscriptions above level 0 reach API keys only when they have all of their owner's permissions. + void RegisterWSSubscription(const std::string& subscription, std::function minLevel, std::string permission); /** * Answer this request later (from any thread) instead of when the handler returns, for slow work that would hold * up every other request: the handler hands the returned DeferredReply to a worker and returns; the web thread diff --git a/tests/dWebTests/ApiKeyTests.cpp b/tests/dWebTests/ApiKeyTests.cpp new file mode 100644 index 000000000..a7761a538 --- /dev/null +++ b/tests/dWebTests/ApiKeyTests.cpp @@ -0,0 +1,256 @@ +#include + +#include +#include + +#include "AccountRules.h" +#include "ApiKeyLimiter.h" +#include "ApiKeyScope.h" +#include "HTTPContext.h" +#include "HTTPReply.h" +#include "Permissions.h" +#include "RequireAuthMiddleware.h" + +using AccountRules::eAccountAction; +using AccountRules::eManageDenial; + +namespace { + // With no config every permission is at its default level (accounts_ban 4, characters_view 1, self_items 9 ...) + std::shared_ptr Scope(std::set permissions, bool readOnly = false) { + auto scope = std::make_shared(); + scope->keyId = 7; + scope->name = "bot"; + scope->permissions = std::move(permissions); + scope->readOnly = readOnly; + return scope; + } + + std::shared_ptr AllScope() { + auto scope = Scope({}); + scope->allPermissions = true; + return scope; + } +} + +TEST(ApiKeyScopeTests, PermissionListsRoundTrip) { + bool all = false; + std::set permissions; + ApiKeys::ParsePermissions("chat_view,players_view", all, permissions); + EXPECT_FALSE(all); + EXPECT_EQ(permissions, (std::set{ "chat_view", "players_view" })); + EXPECT_EQ(ApiKeys::JoinPermissions(false, permissions), "chat_view,players_view"); + ApiKeys::ParsePermissions("*", all, permissions); + EXPECT_TRUE(all); + EXPECT_TRUE(permissions.empty()); + EXPECT_EQ(ApiKeys::JoinPermissions(true, {}), "*"); + ApiKeys::ParsePermissions("", all, permissions); + EXPECT_FALSE(all); + EXPECT_TRUE(permissions.empty()); +} + +TEST(ApiKeyScopeTests, AddressAndPathRestrictions) { + const auto ips = ApiKeys::SplitList(" 10.0.0., 192.168.1.5 ,,::1"); + ASSERT_EQ(ips.size(), 3u); + EXPECT_TRUE(ApiKeys::AddressAllowed(ips, "10.0.0.44")); + EXPECT_TRUE(ApiKeys::AddressAllowed(ips, "192.168.1.5")); + EXPECT_TRUE(ApiKeys::AddressAllowed(ips, "::1")); + EXPECT_FALSE(ApiKeys::AddressAllowed(ips, "192.168.1.50")); // exact entries don't match as prefixes + EXPECT_FALSE(ApiKeys::AddressAllowed(ips, "10.0.1.1")); + EXPECT_TRUE(ApiKeys::AddressAllowed({}, "8.8.8.8")); + + const auto paths = ApiKeys::SplitList("/api/chat,/api/players"); + EXPECT_TRUE(ApiKeys::PathAllowed(paths, "/api/chat/send")); + EXPECT_FALSE(ApiKeys::PathAllowed(paths, "/api/accounts/2")); + EXPECT_TRUE(ApiKeys::PathAllowed({}, "/api/accounts/2")); +} + +TEST(ApiKeyPermissionTests, KeyIsOwnerPermissionsIntersectScope) { + const auto key = Scope({ "accounts_ban", "characters_view" }); + // The owner has both: the key has exactly its scope + EXPECT_TRUE(Permissions::Allowed(5, "accounts_ban", key.get())); + EXPECT_TRUE(Permissions::Allowed(5, "characters_view", key.get())); + EXPECT_FALSE(Permissions::Allowed(5, "accounts_mute", key.get())); // the owner may, the key may not + EXPECT_TRUE(Permissions::Allowed(5, "accounts_mute", nullptr)); // a browser session may + // The owner is demoted: the key loses what the owner lost, although its scope still names it + EXPECT_FALSE(Permissions::Allowed(3, "accounts_ban", key.get())); + EXPECT_TRUE(Permissions::Allowed(3, "characters_view", key.get())); + // Demoted to a player: nothing staff-only is left + EXPECT_FALSE(Permissions::Allowed(0, "characters_view", key.get())); + // Unknown permissions never pass, scope or not + EXPECT_FALSE(Permissions::Allowed(9, "no_such_permission", AllScope().get())); +} + +TEST(ApiKeyPermissionTests, AllPermissionsKeyFollowsOwner) { + const auto key = AllScope(); + EXPECT_TRUE(Permissions::Allowed(4, "accounts_ban", key.get())); + EXPECT_FALSE(Permissions::Allowed(3, "accounts_ban", key.get())); + const auto can = Permissions::ForLevel(4, key.get()); + EXPECT_EQ(can, Permissions::ForLevel(4)); +} + +TEST(ApiKeyPermissionTests, ForLevelIsMasked) { + const auto key = Scope({ "accounts_view" }); + const auto can = Permissions::ForLevel(9, key.get()); + EXPECT_TRUE(can["accounts_view"].get()); + EXPECT_FALSE(can["accounts_ban"].get()); + EXPECT_FALSE(can["own_characters"].get()); +} + +TEST(ApiKeyPermissionTests, CharacterViewNeedsScope) { + // Own characters with own_characters in scope, others' with characters_view + EXPECT_TRUE(Permissions::CanViewCharacter(0, 5, 5, Scope({ "own_characters" }).get())); + EXPECT_FALSE(Permissions::CanViewCharacter(0, 5, 5, Scope({ "leaderboards_view" }).get())); + EXPECT_FALSE(Permissions::CanViewCharacter(9, 5, 6, Scope({ "own_characters" }).get())); + EXPECT_TRUE(Permissions::CanViewCharacter(9, 5, 6, Scope({ "characters_view" }).get())); + EXPECT_FALSE(Permissions::CanViewCharacter(0, 5, 6, Scope({ "characters_view" }).get())); // the owner can't +} + +TEST(ApiKeyPermissionTests, CreatorCantGrantWhatTheyLack) { + EXPECT_TRUE(Permissions::NotGrantable(5, { "accounts_ban", "chat_view" }).empty()); + EXPECT_EQ(Permissions::NotGrantable(3, { "accounts_ban", "characters_view" }), (std::set{ "accounts_ban" })); + EXPECT_EQ(Permissions::NotGrantable(9, { "made_up" }), (std::set{ "made_up" })); + EXPECT_EQ(Permissions::NotGrantable(0, { "own_characters", "characters_view" }), (std::set{ "characters_view" })); +} + +TEST(ApiKeyPermissionTests, SelfAndRankRulesNarrowToo) { + constexpr uint32_t OWNER = 5, OTHER = 6; + // GM 9 needs neither self_* nor manage_equal_rank; a key of theirs needs them in its scope + EXPECT_EQ(AccountRules::ManageDenialNow(9, OWNER, 9, OWNER, eAccountAction::ITEMS, nullptr), eManageDenial::NONE); + EXPECT_EQ(AccountRules::ManageDenialNow(9, OWNER, 9, OWNER, eAccountAction::ITEMS, Scope({ "characters_edit" }).get()), eManageDenial::SELF); + EXPECT_EQ(AccountRules::ManageDenialNow(9, OWNER, 9, OWNER, eAccountAction::ITEMS, Scope({ "self_items" }).get()), eManageDenial::NONE); + EXPECT_EQ(AccountRules::ManageDenialNow(9, OWNER, 9, OTHER, eAccountAction::MODERATION, Scope({}).get()), eManageDenial::EQUAL_RANK); + EXPECT_EQ(AccountRules::ManageDenialNow(9, OWNER, 9, OTHER, eAccountAction::MODERATION, Scope({ "manage_equal_rank" }).get()), eManageDenial::NONE); + EXPECT_EQ(AccountRules::ManageDenialNow(9, OWNER, 3, OTHER, eAccountAction::MODERATION, Scope({}).get()), eManageDenial::NONE); + // The owner's own rules always come first: no scope lets a key act above its owner + EXPECT_EQ(AccountRules::ManageDenialNow(4, OWNER, 5, OTHER, eAccountAction::TOOLS, AllScope().get()), eManageDenial::HIGHER_RANK); + // self_tools defaults to GM 1, so a GM 4 may kick themselves; their key only with self_tools in scope + EXPECT_EQ(AccountRules::ManageDenialNow(4, OWNER, 4, OWNER, eAccountAction::TOOLS, nullptr), eManageDenial::NONE); + EXPECT_EQ(AccountRules::ManageDenialNow(4, OWNER, 4, OWNER, eAccountAction::TOOLS, Scope({ "accounts_kick" }).get()), eManageDenial::SELF); + // self_items defaults to GM 9: in a GM 4's scope it still doesn't let them + EXPECT_EQ(AccountRules::ManageDenialNow(4, OWNER, 4, OWNER, eAccountAction::ITEMS, Scope({ "self_items" }).get()), eManageDenial::SELF); + // An owner demoted below the target: the owner's rule refuses, whatever the scope + EXPECT_EQ(AccountRules::ManageDenialNow(2, OWNER, 3, OTHER, eAccountAction::TOOLS, AllScope().get()), eManageDenial::HIGHER_RANK); +} + +class ApiKeyMiddlewareTest : public ::testing::Test { +protected: + HTTPContext context; + HTTPReply reply; + + void WithKey(uint8_t ownerLevel, std::shared_ptr scope, const std::string& method = "GET") { + context.method = method; + context.path = "/api/something"; + context.isAuthenticated = true; + context.authenticatedUser = "owner"; + context.accountId = 5; + context.gmLevel = ownerLevel; + context.userData["auth_source"] = "header"; + context.apiKey = std::move(scope); + } + + static RequireAuthMiddleware PermRoute(const std::string& key) { + return RequireAuthMiddleware(std::function([key] { return Permissions::Level(key); }), key); + } +}; + +TEST_F(ApiKeyMiddlewareTest, PermissionRouteNeedsScope) { + WithKey(9, Scope({ "chat_view" })); + EXPECT_TRUE(PermRoute("chat_view").Process(context, reply)); + EXPECT_FALSE(PermRoute("accounts_ban").Process(context, reply)); + EXPECT_EQ(reply.status, eHTTPStatusCode::FORBIDDEN); + EXPECT_NE(reply.message.find("accounts_ban"), std::string::npos); +} + +TEST_F(ApiKeyMiddlewareTest, DemotedOwnerNarrowsKey) { + WithKey(4, Scope({ "accounts_ban" })); + EXPECT_TRUE(PermRoute("accounts_ban").Process(context, reply)); + context.gmLevel = 3; // looked up again on the next request + EXPECT_FALSE(PermRoute("accounts_ban").Process(context, reply)); + EXPECT_EQ(reply.status, eHTTPStatusCode::FORBIDDEN); +} + +TEST_F(ApiKeyMiddlewareTest, ReadOnlyKeysOnlyRead) { + WithKey(9, Scope({ "chat_view" }, true), "POST"); + EXPECT_FALSE(PermRoute("chat_view").Process(context, reply)); + EXPECT_EQ(reply.status, eHTTPStatusCode::FORBIDDEN); + // A POST that only reads (a DataTables query) is fine + auto reads = PermRoute("chat_view"); + reads.SetReadsOnly(); + reply = {}; + EXPECT_TRUE(reads.Process(context, reply)); + context.method = "GET"; + EXPECT_TRUE(PermRoute("chat_view").Process(context, reply)); +} + +TEST_F(ApiKeyMiddlewareTest, LevelOnlyRoutesNeedAllPermissions) { + WithKey(9, Scope({ "chat_view" })); + EXPECT_TRUE(RequireAuthMiddleware(0).Process(context, reply)); // level 0: the handler checks its own permissions + EXPECT_FALSE(RequireAuthMiddleware(1).Process(context, reply)); + WithKey(9, AllScope()); + EXPECT_TRUE(RequireAuthMiddleware(1).Process(context, reply)); + WithKey(0, AllScope()); + EXPECT_FALSE(RequireAuthMiddleware(1).Process(context, reply)); // never more than the owner +} + +TEST_F(ApiKeyMiddlewareTest, DeniedHookIsTold) { + std::string told; + RequireAuthMiddleware::SetApiKeyDeniedHook([&](const HTTPContext&, const std::string& reason) { told = reason; }); + WithKey(9, Scope({})); + EXPECT_FALSE(PermRoute("chat_view").Process(context, reply)); + EXPECT_NE(told.find("chat_view"), std::string::npos); + RequireAuthMiddleware::SetApiKeyDeniedHook(nullptr); +} + +TEST(ApiKeyLimiterTests, BurstThenSteadyRate) { + ApiKeyLimiter limiter; + const auto start = ApiKeyLimiter::Clock::now(); + for (int i = 0; i < 60; ++i) EXPECT_TRUE(limiter.Check(1, 60, 0, 100, 1000, 0, 0, start).allowed) << i; + const auto refused = limiter.Check(1, 60, 0, 100, 1000, 0, 0, start); + EXPECT_FALSE(refused.allowed); + EXPECT_FALSE(refused.quotaExceeded); + EXPECT_EQ(refused.limit, 60u); + EXPECT_EQ(refused.remaining, 0u); + EXPECT_GE(refused.retryAfterSeconds, 1u); + // One a second comes back at 60 a minute + EXPECT_TRUE(limiter.Check(1, 60, 0, 100, 1000, 0, 0, start + std::chrono::milliseconds(1001)).allowed); + EXPECT_FALSE(limiter.Check(1, 60, 0, 100, 1000, 0, 0, start + std::chrono::milliseconds(1002)).allowed); + // Keys are separate + EXPECT_TRUE(limiter.Check(2, 60, 0, 100, 1000, 0, 0, start).allowed); + // A full minute later the whole allowance is back, no more + const auto later = start + std::chrono::minutes(5); + for (int i = 0; i < 60; ++i) EXPECT_TRUE(limiter.Check(1, 60, 0, 100, 1000, 0, 0, later).allowed); + EXPECT_FALSE(limiter.Check(1, 60, 0, 100, 1000, 0, 0, later).allowed); +} + +TEST(ApiKeyLimiterTests, DailyQuota) { + ApiKeyLimiter limiter; + const auto now = ApiKeyLimiter::Clock::now(); + // 3 were already used today before a restart + auto decision = limiter.Check(1, 1000, 5, 100, 3600, 100, 3, now); + EXPECT_TRUE(decision.allowed); + EXPECT_EQ(decision.dayCount, 4u); + EXPECT_EQ(decision.quotaRemaining, 1u); + EXPECT_TRUE(limiter.Check(1, 1000, 5, 100, 3600, 100, 3, now).allowed); + decision = limiter.Check(1, 1000, 5, 100, 3600, 100, 3, now); + EXPECT_FALSE(decision.allowed); + EXPECT_TRUE(decision.quotaExceeded); + EXPECT_EQ(decision.retryAfterSeconds, 3600u); + // The next UTC day starts over + decision = limiter.Check(1, 1000, 5, 101, 86400, 100, 3, now); + EXPECT_TRUE(decision.allowed); + EXPECT_EQ(decision.dayCount, 1u); + // A stored count from an earlier day doesn't count + EXPECT_EQ(limiter.Check(2, 1000, 5, 101, 86400, 100, 5, now).dayCount, 1u); +} + +TEST(ApiKeyLimiterTests, ForgetAndPrune) { + ApiKeyLimiter limiter; + const auto now = ApiKeyLimiter::Clock::now(); + EXPECT_TRUE(limiter.Check(1, 1, 0, 100, 1000, 0, 0, now).allowed); + EXPECT_FALSE(limiter.Check(1, 1, 0, 100, 1000, 0, 0, now).allowed); + limiter.Forget(1); + EXPECT_TRUE(limiter.Check(1, 1, 0, 100, 1000, 0, 0, now).allowed); + limiter.Check(2, 1, 0, 100, 1000, 0, 0, now + std::chrono::hours(1)); + limiter.Prune(now + std::chrono::hours(1), std::chrono::minutes(30)); + EXPECT_EQ(limiter.Size(), 1u); +} diff --git a/tests/dWebTests/CMakeLists.txt b/tests/dWebTests/CMakeLists.txt index da6b9d8be..826feca64 100644 --- a/tests/dWebTests/CMakeLists.txt +++ b/tests/dWebTests/CMakeLists.txt @@ -14,6 +14,7 @@ set(DWEBTESTS_SOURCES "ItemTraceTests.cpp" "CronTests.cpp" "PermissionsTests.cpp" + "ApiKeyTests.cpp" "SettingsCatalogTests.cpp" "BehaviorXmlTests.cpp" "CharacterXmlTests.cpp"