mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43: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>
93 lines
3.3 KiB
C++
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;
|
|
};
|