Files
DarkflameServer/dDashboardServer/routes/AuthRoutes.cpp
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

241 lines
12 KiB
C++

#include "AuthRoutes.h"
#include "ApiKeyRoutes.h"
#include "Permissions.h"
#include "DashboardAuthService.h"
#include "AuthTokenHandler.h"
#include "JWTUtils.h"
#include "RequireAuthMiddleware.h"
#include "RouteUtils.h"
#include "WSRoutes.h"
#include "EmailService.h"
#include "AccountRoutes.h"
#include "eGameMasterLevel.h"
#include "json.hpp"
#include "Logger.h"
#include "Database.h"
#include "Web.h"
#include "eHTTPMethod.h"
#include "HTTPContext.h"
#include "Alerts.h"
#include "PasswordRecovery.h"
namespace {
constexpr int64_t MAX_API_TOKEN_DAYS = 365;
// Per-IP limits on unauthenticated endpoints, in addition to the per-account lockout
RouteUtils::RateLimiter g_LoginLimiter(10, std::chrono::seconds(60));
RouteUtils::RateLimiter g_RegisterLimiter(5, std::chrono::seconds(60 * 60));
bool RegistrationEnabled() { return RouteUtils::ConfigFlag("allow_registration", false); }
bool RegistrationRequiresPlayKey() { return RouteUtils::ConfigFlag("registration_requires_play_key", true); }
}
void RegisterAuthRoutes() {
// POST /api/auth/login
// Request body: { "username": "string", "password": "string", "rememberMe": boolean }
// Sets an HttpOnly session cookie for the browser; the token is also returned for API clients.
Game::web.RegisterHTTPRoute({
.path = "/api/auth/login",
.method = eHTTPMethod::POST,
.middleware = {},
.handle = [](HTTPReply& reply, const HTTPContext& context) {
if (!g_LoginLimiter.Allow(RouteUtils::ClientAddress(context))) {
return RouteUtils::JsonReply(reply, eHTTPStatusCode::TOO_MANY_REQUESTS, { {"success", false}, {"message", "Too many login attempts, try again in a minute"} });
}
const auto json = RouteUtils::ParseBody(context);
if (!json) return RouteUtils::JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const std::string username = json->value("username", "");
const std::string password = json->value("password", "");
const bool rememberMe = json->value("rememberMe", false);
if (username.empty() || password.empty()) {
return RouteUtils::JsonReply(reply, eHTTPStatusCode::BAD_REQUEST, { {"success", false}, {"message", "Username and password are required"} });
}
const auto result = DashboardAuthService::Login(username, password, rememberMe, RouteUtils::ClientAddress(context));
if (result.twoFactorRequired) {
return RouteUtils::JsonReply(reply, eHTTPStatusCode::OK, {
{"success", false}, {"twoFactorRequired", true}, {"challenge", result.challenge}, {"message", result.message}
});
}
nlohmann::json response{ {"success", result.success}, {"message", result.message} };
if (result.success) {
response["token"] = result.token;
response["gmLevel"] = result.gmLevel;
reply.headers.push_back(AuthTokenHandler::BuildSessionCookie(result.token, rememberMe, RouteUtils::UseSecureCookies()));
const auto info = Database::Get()->GetAccountInfo(username);
Database::Get()->InsertAuditLog(info ? info->id : 0, username, "dashboard_login", "Login from " + RouteUtils::ClientAddress(context), info ? info->id : 0, 0);
}
RouteUtils::JsonReply(reply, result.success ? eHTTPStatusCode::OK : eHTTPStatusCode::UNAUTHORIZED, response);
}
});
// POST /api/auth/login/2fa - Second step for accounts with two-factor login
// Request body: { "challenge": "from /api/auth/login", "code": "123456 or a recovery code" }
Game::web.RegisterHTTPRoute({
.path = "/api/auth/login/2fa",
.method = eHTTPMethod::POST,
.middleware = {},
.handle = [](HTTPReply& reply, const HTTPContext& context) {
if (!g_LoginLimiter.Allow(RouteUtils::ClientAddress(context))) {
return RouteUtils::JsonReply(reply, eHTTPStatusCode::TOO_MANY_REQUESTS, { {"success", false}, {"message", "Too many login attempts, try again in a minute"} });
}
const auto json = RouteUtils::ParseBody(context);
if (!json) return RouteUtils::JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const auto result = DashboardAuthService::CompleteTwoFactor(json->value("challenge", ""), json->value("code", ""));
nlohmann::json response{ {"success", result.success}, {"message", result.message} };
if (result.success) {
response["token"] = result.token;
response["gmLevel"] = result.gmLevel;
// The cookie lifetime follows the token's (remember me was chosen in the first step)
JWTUtils::JWTPayload payload;
const bool longLived = JWTUtils::ValidateToken(result.token, payload) && payload.rememberMe;
reply.headers.push_back(AuthTokenHandler::BuildSessionCookie(result.token, longLived, RouteUtils::UseSecureCookies()));
Database::Get()->InsertAuditLog(result.accountId, payload.username, "dashboard_login",
"Login with two-factor " + std::string(result.usedRecoveryCode ? "recovery code" : "code") + " from " + RouteUtils::ClientAddress(context), result.accountId, 0);
if (result.usedRecoveryCode) {
Alerts::Emit("security", "Recovery code used", payload.username + " signed in with a two-factor recovery code from " + RouteUtils::ClientAddress(context) + ".");
}
}
RouteUtils::JsonReply(reply, result.success ? eHTTPStatusCode::OK : eHTTPStatusCode::UNAUTHORIZED, response);
}
});
// GET /api/auth/config - Public settings the login page needs
RouteUtils::Route(eHTTPMethod::GET, "/api/auth/config", RouteUtils::PUBLIC, "Whether self-registration is enabled",
[](HTTPReply& reply, const HTTPContext& context) {
RouteUtils::JsonReply(reply, eHTTPStatusCode::OK, {
{"registration", RegistrationEnabled()},
{"playKeyRequired", RegistrationRequiresPlayKey()},
{"emailEnabled", EmailService::IsConfigured()},
{"emailRequired", EmailService::IsConfigured() && RouteUtils::ConfigFlag("registration_requires_email", false)},
{"recoveryReset", RecoveryResetEnabled()},
// Pages open without signing in (PublicRoutes.cpp, Showcase.cpp)
{"publicStatus", RouteUtils::ConfigFlag("public_status", false)},
{"publicShowcase", RouteUtils::ConfigFlag("showcase_public", false)}
});
});
// POST /api/auth/register - Self-registration (allow_registration=1)
RouteUtils::Route(eHTTPMethod::POST, "/api/auth/register", RouteUtils::PUBLIC, "Create an account. Body: {username, password, play_key, email}",
[](HTTPReply& reply, const HTTPContext& context) {
if (!RegistrationEnabled()) return RouteUtils::JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Registration is disabled");
if (!g_RegisterLimiter.Allow(RouteUtils::ClientAddress(context))) {
return RouteUtils::JsonError(reply, eHTTPStatusCode::TOO_MANY_REQUESTS, "Too many registrations from your address, try again later");
}
const auto json = RouteUtils::ParseBody(context);
if (!json) return RouteUtils::JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const std::string username = json->value("username", "");
const std::string password = json->value("password", "");
const std::string playKey = json->value("play_key", "");
const std::string email = json->value("email", "");
const bool emailRequired = EmailService::IsConfigured() && RouteUtils::ConfigFlag("registration_requires_email", false);
if (emailRequired && email.empty()) return RouteUtils::JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "An email address is required");
if (!email.empty() && !Smtp::IsValidAddress(email)) return RouteUtils::JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "That is not a valid email address");
if (!email.empty() && Database::Get()->GetAccountIdByConfirmedEmail(email)) return RouteUtils::JsonError(reply, eHTTPStatusCode::CONFLICT, "That email address is already used by another account");
if (const auto error = RouteUtils::ValidateUsername(username)) return RouteUtils::JsonError(reply, eHTTPStatusCode::BAD_REQUEST, *error);
if (const auto error = RouteUtils::ValidatePassword(password)) return RouteUtils::JsonError(reply, eHTTPStatusCode::BAD_REQUEST, *error);
std::optional<int32_t> keyId;
if (RegistrationRequiresPlayKey() || !playKey.empty()) {
keyId = Database::Get()->GetRedeemablePlayKeyId(playKey);
if (!keyId) return RouteUtils::JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "That play key is invalid, inactive or used up");
}
if (Database::Get()->GetAccountInfo(username)) return RouteUtils::JsonError(reply, eHTTPStatusCode::CONFLICT, "That username is taken");
Database::Get()->InsertNewAccount(username, RouteUtils::HashPassword(password), eGameMasterLevel::CIVILIAN);
const auto account = Database::Get()->GetAccountInfo(username);
if (!account) return RouteUtils::JsonError(reply, eHTTPStatusCode::INTERNAL_SERVER_ERROR, "Account creation failed");
if (keyId) Database::Get()->SetAccountPlayKey(account->id, *keyId);
Database::Get()->InsertAuditLog(account->id, username, "register_account",
"Registered from " + RouteUtils::ClientAddress(context) + (keyId ? " with play key ID " + std::to_string(*keyId) : ""), account->id, 0);
BroadcastTableChanged("accounts");
BroadcastTableChanged("play_keys");
std::string message = "Account created. You can now log in to the game and the dashboard.";
if (!email.empty() && EmailService::IsConfigured()) {
// Sent through the same path as a user changing their address
Database::Get()->SetAccountEmail(account->id, email, false);
SendVerificationEmail(account->id, username, email, 0);
message += " Check " + email + " to confirm your email address.";
}
RouteUtils::JsonSuccess(reply, { {"message", message} });
});
// POST /api/auth/logout - Clear the session cookie
Game::web.RegisterHTTPRoute({
.path = "/api/auth/logout",
.method = eHTTPMethod::POST,
.middleware = {},
.handle = [](HTTPReply& reply, const HTTPContext& context) {
reply.headers.push_back(AuthTokenHandler::BuildClearSessionCookie(RouteUtils::UseSecureCookies()));
RouteUtils::JsonReply(reply, eHTTPStatusCode::OK, { {"success", true} });
}
});
// GET /api/auth/me - Current session info
Game::web.RegisterHTTPRoute({
.path = "/api/auth/me",
.method = eHTTPMethod::GET,
.middleware = {},
.handle = [](HTTPReply& reply, const HTTPContext& context) {
if (!context.isAuthenticated) return RouteUtils::JsonReply(reply, eHTTPStatusCode::OK, { {"valid", false} });
RouteUtils::JsonReply(reply, eHTTPStatusCode::OK, {
{"valid", true},
{"username", context.authenticatedUser},
{"accountId", context.accountId},
{"gmLevel", context.gmLevel}
});
}
});
// POST /api/auth/verify
// Request body: { "token": "string" }
// Response: { "valid": boolean, "username": "string", "gmLevel": number }
Game::web.RegisterHTTPRoute({
.path = "/api/auth/verify",
.method = eHTTPMethod::POST,
.middleware = {},
.handle = [](HTTPReply& reply, const HTTPContext& context) {
const auto json = RouteUtils::ParseBody(context);
const std::string token = json ? json->value("token", "") : "";
const auto result = AuthTokenHandler::ValidateToken(token);
nlohmann::json response{ {"valid", result.isValid} };
if (result.isValid) {
response["username"] = result.username;
response["gmLevel"] = result.gmLevel;
}
RouteUtils::JsonReply(reply, eHTTPStatusCode::OK, response);
}
});
// POST /api/auth/token - Issue a bearer token for API clients (bots, scripts)
// Request body: { "days": number (1-365, default 30) }
// 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,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
if (!Permissions::Allowed(context.gmLevel, "api_access")) return RouteUtils::JsonError(reply, eHTTPStatusCode::FORBIDDEN, "API access isn't allowed for your account");
// Only a signed-in browser session may make tokens: a leaked token must not be able to renew itself for a year
const auto source = context.userData.find("auth_source");
if (source == context.userData.end() || source->second != "cookie") {
return RouteUtils::JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Make API tokens from your account page while signed in, not with another token");
}
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 = ApiKeyRoutes::CreateFullKey(context, "API token", days);
RouteUtils::JsonReply(reply, eHTTPStatusCode::OK, { {"token", token}, {"expiresInDays", days} });
}
});
}