mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-15 17:14:07 +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:
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__
|
||||
|
||||
Reference in New Issue
Block a user