mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-06 04:43:43 +00:00
The NexusDashboard-parity dashboard (dDashboardServer) and everything built on it on the experimental branch: accounts, characters, properties and moderation tools, permissions shared with in-game slash commands, economy reports, World 3D and property 3D views with client scenery, scheduled events (features, vanity changes, live events, announcements, restarts), vanity files and events, the CDClient browser, the message inspector with saved captures, chat filter tools, community challenges, live ops, the AI moderator helper, and the server-side changes they need. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
241 lines
13 KiB
C++
241 lines
13 KiB
C++
#include "AuthRoutes.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) }
|
|
// The token carries the caller's identity; its permissions always follow the account's current GM level.
|
|
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 = JWTUtils::GenerateSessionToken(context.accountId, context.authenticatedUser, context.gmLevel, false, days * 24 * 60 * 60);
|
|
if (token.empty()) return RouteUtils::JsonError(reply, eHTTPStatusCode::INTERNAL_SERVER_ERROR, "Token generation failed");
|
|
|
|
RouteUtils::Audit(context, "create_api_token", "Valid for " + std::to_string(days) + " days");
|
|
RouteUtils::JsonReply(reply, eHTTPStatusCode::OK, { {"token", token}, {"expiresInDays", days} });
|
|
}
|
|
});
|
|
}
|