mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
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>
285 lines
11 KiB
C++
285 lines
11 KiB
C++
#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);
|
|
}
|
|
}
|