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>
241 lines
12 KiB
C++
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} });
|
|
}
|
|
});
|
|
}
|