Files
DarkflameServer/dDashboardServer/auth/ApiKeyLimiter.h
Aaron Kimbrell 3f2a9cc5b0 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>
2026-09-28 22:31:03 -05:00

93 lines
3.3 KiB
C++

#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;
};