mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
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:
@@ -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 <fstream>
|
||||
@@ -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<WSAuth> {
|
||||
// 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();
|
||||
|
||||
92
dDashboardServer/auth/ApiKeyLimiter.h
Normal file
92
dDashboardServer/auth/ApiKeyLimiter.h
Normal file
@@ -0,0 +1,92 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <unordered_map>
|
||||
|
||||
/**
|
||||
* 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<uint32_t>(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<double>(now - state.refilledAt).count();
|
||||
state.tokens = std::min<double>(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<uint32_t>(secondsToNextDay, 1);
|
||||
} else if (state.tokens < 1.0) {
|
||||
decision.retryAfterSeconds = static_cast<uint32_t>((1.0 - state.tokens) * 60.0 / perMinute) + 1;
|
||||
} else {
|
||||
state.tokens -= 1.0;
|
||||
state.dayCount++;
|
||||
decision.allowed = true;
|
||||
}
|
||||
decision.remaining = static_cast<uint32_t>(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<uint64_t, State> m_Keys;
|
||||
};
|
||||
284
dDashboardServer/auth/ApiKeyService.cpp
Normal file
284
dDashboardServer/auth/ApiKeyService.cpp
Normal file
@@ -0,0 +1,284 @@
|
||||
#include "ApiKeyService.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <ctime>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <unordered_map>
|
||||
|
||||
#include <openssl/rand.h>
|
||||
|
||||
#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<IApiKeys::ApiKey> key; // nullopt: no such key (so a stream of bad keys doesn't hit the database)
|
||||
std::shared_ptr<const ApiKeys::Scope> scope;
|
||||
std::vector<std::string> allowedIps;
|
||||
std::vector<std::string> allowedPaths;
|
||||
Clock::time_point loadedAt;
|
||||
};
|
||||
|
||||
struct State {
|
||||
std::recursive_mutex mutex;
|
||||
std::unordered_map<std::string, CachedKey> cache; // by key hash
|
||||
ApiKeyLimiter limiter;
|
||||
std::map<uint64_t, IApiKeys::ApiKeyUsage> pending;
|
||||
std::map<std::pair<uint64_t, std::string>, Clock::time_point> deniedAt;
|
||||
Clock::time_point nextFlush = Clock::now() + FLUSH_INTERVAL;
|
||||
std::function<std::string(const HTTPContext&)> clientAddress;
|
||||
std::function<void(const HTTPContext&, const std::string&)> 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<ApiKeys::Scope>();
|
||||
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<Owner> CheckKeyAndOwner(const IApiKeys::ApiKey& key) {
|
||||
const auto now = static_cast<int64_t>(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<uint8_t>(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<uint32_t>(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<int32_t>(now / 86400);
|
||||
const auto toNextDay = static_cast<uint32_t>(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<Verified> 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<IApiKeys::ApiKeyUsage> 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<IApiKeys::ApiKeyUsage> 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<std::string(const HTTPContext&)> resolve) {
|
||||
GetState().clientAddress = std::move(resolve);
|
||||
}
|
||||
|
||||
void SetDeniedHook(std::function<void(const HTTPContext&, const std::string& reason)> 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<unsigned long long>(context.apiKey->keyId), context.apiKey->name.c_str(),
|
||||
context.authenticatedUser.c_str(), reason.c_str());
|
||||
if (state.deniedHook) state.deniedHook(context, reason);
|
||||
}
|
||||
}
|
||||
77
dDashboardServer/auth/ApiKeyService.h
Normal file
77
dDashboardServer/auth/ApiKeyService.h
Normal file
@@ -0,0 +1,77 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#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<const ApiKeys::Scope> scope;
|
||||
};
|
||||
std::optional<Verified> 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<IApiKeys::ApiKeyUsage> 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<std::string(const HTTPContext&)> 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<void(const HTTPContext&, const std::string& reason)> hook);
|
||||
void NoteDenied(const HTTPContext& context, const std::string& reason);
|
||||
|
||||
uint32_t DefaultRateLimit();
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -4,6 +4,7 @@ set(DASHBOARDAUTH_SOURCES
|
||||
"DashboardAuthService.cpp"
|
||||
"AuthMiddleware.cpp"
|
||||
"AuthTokenHandler.cpp"
|
||||
"ApiKeyService.cpp"
|
||||
"RequireAuthMiddleware.cpp"
|
||||
)
|
||||
|
||||
|
||||
@@ -19,6 +19,15 @@ namespace {
|
||||
|
||||
std::function<bool(uint8_t)> g_ApiAccessAllowed;
|
||||
std::function<void(const HTTPContext&, HTTPReply&)> g_ForbiddenPage;
|
||||
std::function<void(const HTTPContext&, const std::string&)> g_ApiKeyDenied;
|
||||
|
||||
bool RefuseApiKey(const HTTPContext& context, HTTPReply& reply, const std::string& reason, const std::string& message) {
|
||||
if (g_ApiKeyDenied) g_ApiKeyDenied(context, reason);
|
||||
reply.status = eHTTPStatusCode::FORBIDDEN;
|
||||
reply.message = nlohmann::json{ {"success", false}, {"error", message}, {"code", "api_key_scope"} }.dump();
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool IsSafeMethod(const HTTPContext& context) {
|
||||
return context.method == "GET" || context.method == "HEAD" || context.method == "OPTIONS";
|
||||
@@ -37,6 +46,13 @@ RequireAuthMiddleware::RequireAuthMiddleware(uint8_t minGmLevel) : requiredLevel
|
||||
|
||||
RequireAuthMiddleware::RequireAuthMiddleware(std::function<uint8_t()> requiredLevel) : requiredLevel(std::move(requiredLevel)) {}
|
||||
|
||||
RequireAuthMiddleware::RequireAuthMiddleware(std::function<uint8_t()> requiredLevel, std::string permission)
|
||||
: requiredLevel(std::move(requiredLevel)), permission(std::move(permission)) {}
|
||||
|
||||
void RequireAuthMiddleware::SetApiKeyDeniedHook(std::function<void(const HTTPContext& context, const std::string& reason)> hook) {
|
||||
g_ApiKeyDenied = std::move(hook);
|
||||
}
|
||||
|
||||
bool RequireAuthMiddleware::Process(HTTPContext& context, HTTPReply& reply) {
|
||||
if (!context.isAuthenticated) {
|
||||
LOG_DEBUG("Unauthorized access attempt to %s from %s", context.path.c_str(), context.clientIP.c_str());
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <memory>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#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<uint8_t()> requiredLevel);
|
||||
|
||||
// A route guarded by a named permission: an API key must also have it in its scope
|
||||
RequireAuthMiddleware(std::function<uint8_t()> 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<void(const HTTPContext& context, HTTPReply& reply)> 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<void(const HTTPContext& context, const std::string& reason)> hook);
|
||||
std::string GetName() const override { return "RequireAuthMiddleware"; }
|
||||
|
||||
private:
|
||||
std::function<uint8_t()> requiredLevel;
|
||||
std::string permission;
|
||||
bool readsOnly{};
|
||||
};
|
||||
|
||||
#endif // !__REQUIREAUTHMIDDLEWARE_H__
|
||||
|
||||
@@ -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(); });
|
||||
|
||||
287
dDashboardServer/routes/ApiKeyRoutes.cpp
Normal file
287
dDashboardServer/routes/ApiKeyRoutes.cpp
Normal 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); });
|
||||
}
|
||||
}
|
||||
21
dDashboardServer/routes/ApiKeyRoutes.h
Normal file
21
dDashboardServer/routes/ApiKeyRoutes.h
Normal 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);
|
||||
}
|
||||
@@ -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} });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ set(DASHBOARDROUTES_SOURCES
|
||||
"DashboardRoutes.cpp"
|
||||
"WSRoutes.cpp"
|
||||
"AuthRoutes.cpp"
|
||||
"ApiKeyRoutes.cpp"
|
||||
"RouteUtils.cpp"
|
||||
"PlayerActions.cpp"
|
||||
"AccountRoutes.cpp"
|
||||
|
||||
@@ -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), "
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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&) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <algorithm>
|
||||
#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<const ApiKeys::Scope> apiKey{};
|
||||
|
||||
// Custom data for middleware to communicate
|
||||
std::map<std::string, std::string> userData{};
|
||||
|
||||
25
dWeb/Web.cpp
25
dWeb/Web.cpp
@@ -29,6 +29,8 @@ namespace {
|
||||
std::vector<std::string> g_WSSubscriptions;
|
||||
// Minimum permission level per subscription, parallel to g_WSSubscriptions
|
||||
std::vector<std::function<uint8_t()>> g_WSSubscriptionLevels;
|
||||
// The permission guarding each subscription (empty: level only), parallel to g_WSSubscriptions
|
||||
std::vector<std::string> 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<const ApiKeys::Scope> 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<mg_connection*, WSClient> 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<mg_http_message*>(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<uint8_t()> minLevel) {
|
||||
RegisterWSSubscription(subscription, std::move(minLevel), "");
|
||||
}
|
||||
|
||||
void Web::RegisterWSSubscription(const std::string& subscription, std::function<uint8_t()> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<const ApiKeys::Scope> 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<uint8_t()> 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<uint8_t()> 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
|
||||
|
||||
256
tests/dWebTests/ApiKeyTests.cpp
Normal file
256
tests/dWebTests/ApiKeyTests.cpp
Normal file
@@ -0,0 +1,256 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
|
||||
#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<ApiKeys::Scope> Scope(std::set<std::string> permissions, bool readOnly = false) {
|
||||
auto scope = std::make_shared<ApiKeys::Scope>();
|
||||
scope->keyId = 7;
|
||||
scope->name = "bot";
|
||||
scope->permissions = std::move(permissions);
|
||||
scope->readOnly = readOnly;
|
||||
return scope;
|
||||
}
|
||||
|
||||
std::shared_ptr<ApiKeys::Scope> AllScope() {
|
||||
auto scope = Scope({});
|
||||
scope->allPermissions = true;
|
||||
return scope;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(ApiKeyScopeTests, PermissionListsRoundTrip) {
|
||||
bool all = false;
|
||||
std::set<std::string> permissions;
|
||||
ApiKeys::ParsePermissions("chat_view,players_view", all, permissions);
|
||||
EXPECT_FALSE(all);
|
||||
EXPECT_EQ(permissions, (std::set<std::string>{ "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<bool>());
|
||||
EXPECT_FALSE(can["accounts_ban"].get<bool>());
|
||||
EXPECT_FALSE(can["own_characters"].get<bool>());
|
||||
}
|
||||
|
||||
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<std::string>{ "accounts_ban" }));
|
||||
EXPECT_EQ(Permissions::NotGrantable(9, { "made_up" }), (std::set<std::string>{ "made_up" }));
|
||||
EXPECT_EQ(Permissions::NotGrantable(0, { "own_characters", "characters_view" }), (std::set<std::string>{ "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<ApiKeys::Scope> 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<uint8_t()>([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);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ set(DWEBTESTS_SOURCES
|
||||
"ItemTraceTests.cpp"
|
||||
"CronTests.cpp"
|
||||
"PermissionsTests.cpp"
|
||||
"ApiKeyTests.cpp"
|
||||
"SettingsCatalogTests.cpp"
|
||||
"BehaviorXmlTests.cpp"
|
||||
"CharacterXmlTests.cpp"
|
||||
|
||||
Reference in New Issue
Block a user