WIP: basic server, no features

This commit is contained in:
Aaron Kimbrell
2026-01-25 22:33:51 -06:00
parent c723ce2588
commit f1847d1f20
67 changed files with 7655 additions and 37 deletions

View File

@@ -110,6 +110,8 @@ set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR})
find_package(MariaDB)
find_package(OpenSSL REQUIRED)
# Create a /resServer directory
make_directory(${CMAKE_BINARY_DIR}/resServer)
@@ -126,7 +128,7 @@ endif()
message(STATUS "Variable: DLU_CONFIG_DIR = ${DLU_CONFIG_DIR}")
# Copy resource files on first build
set(RESOURCE_FILES "sharedconfig.ini" "authconfig.ini" "chatconfig.ini" "worldconfig.ini" "masterconfig.ini" "blocklist.dcf")
set(RESOURCE_FILES "sharedconfig.ini" "authconfig.ini" "chatconfig.ini" "worldconfig.ini" "masterconfig.ini" "dashboardconfig.ini" "blocklist.dcf")
message(STATUS "Checking resource file integrity")
include(Utils)
@@ -322,6 +324,7 @@ endif()
add_subdirectory(dWorldServer)
add_subdirectory(dAuthServer)
add_subdirectory(dChatServer)
add_subdirectory(dDashboardServer)
add_subdirectory(dMasterServer) # Add MasterServer last so it can rely on the other binaries
target_precompile_headers(

View File

@@ -19,23 +19,24 @@
#include "eGameMasterLevel.h"
#include "dChatFilter.h"
#include "TeamContainer.h"
#include "HTTPContext.h"
using json = nlohmann::json;
void HandleHTTPPlayersRequest(HTTPReply& reply, std::string body) {
void HandleHTTPPlayersRequest(HTTPReply& reply, const HTTPContext& context) {
const json data = Game::playerContainer;
reply.status = data.empty() ? eHTTPStatusCode::NO_CONTENT : eHTTPStatusCode::OK;
reply.message = data.empty() ? "{\"error\":\"No Players Online\"}" : data.dump();
}
void HandleHTTPTeamsRequest(HTTPReply& reply, std::string body) {
void HandleHTTPTeamsRequest(HTTPReply& reply, const HTTPContext& context) {
const json data = TeamContainer::GetTeamContainer();
reply.status = data.empty() ? eHTTPStatusCode::NO_CONTENT : eHTTPStatusCode::OK;
reply.message = data.empty() ? "{\"error\":\"No Teams Online\"}" : data.dump();
}
void HandleHTTPAnnounceRequest(HTTPReply& reply, std::string body) {
auto data = GeneralUtils::TryParse<json>(body);
void HandleHTTPAnnounceRequest(HTTPReply& reply, const HTTPContext& context) {
auto data = GeneralUtils::TryParse<json>(context.body);
if (!data) {
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid JSON\"}";
@@ -96,18 +97,21 @@ namespace ChatWeb {
Game::web.RegisterHTTPRoute({
.path = v1_route + "players",
.method = eHTTPMethod::GET,
.middleware = {},
.handle = HandleHTTPPlayersRequest
});
Game::web.RegisterHTTPRoute({
.path = v1_route + "teams",
.method = eHTTPMethod::GET,
.middleware = {},
.handle = HandleHTTPTeamsRequest
});
Game::web.RegisterHTTPRoute({
.path = v1_route + "announce",
.method = eHTTPMethod::POST,
.middleware = {},
.handle = HandleHTTPAnnounceRequest
});

View File

@@ -27,6 +27,8 @@ namespace MessageType {
AFFIRM_TRANSFER_REQUEST,
AFFIRM_TRANSFER_RESPONSE,
NEW_SESSION_ALERT
NEW_SESSION_ALERT,
REQUEST_SERVER_LIST
};
}

View File

@@ -5,7 +5,8 @@ enum class ServiceType : uint16_t {
COMMON = 0,
AUTH,
CHAT,
WORLD = 4,
DASHBOARD,
WORLD,
CLIENT,
MASTER,
UNKNOWN

View File

@@ -0,0 +1,64 @@
set(DDASHBOARDSERVER_SOURCES
"DashboardServer.cpp"
"MasterPacketHandler.cpp"
"routes/APIRoutes.cpp"
"routes/StaticRoutes.cpp"
"routes/DashboardRoutes.cpp"
"routes/WSRoutes.cpp"
"routes/AuthRoutes.cpp"
"auth/JWTUtils.cpp"
"auth/DashboardAuthService.cpp"
"auth/AuthMiddleware.cpp"
"auth/RequireAuthMiddleware.cpp"
)
add_executable(DashboardServer ${DDASHBOARDSERVER_SOURCES})
target_include_directories(DashboardServer PRIVATE
"${PROJECT_SOURCE_DIR}/dCommon"
"${PROJECT_SOURCE_DIR}/dCommon/dClient"
"${PROJECT_SOURCE_DIR}/dCommon/dEnums"
"${PROJECT_SOURCE_DIR}/dDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/CDClientDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/CDClientDatabase/CDClientTables"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase/ITables"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase/MySQL"
"${PROJECT_SOURCE_DIR}/dNet"
"${PROJECT_SOURCE_DIR}/dWeb"
"${PROJECT_SOURCE_DIR}/dServer"
"${PROJECT_SOURCE_DIR}/thirdparty"
"${PROJECT_SOURCE_DIR}/thirdparty/nlohmann"
"${PROJECT_SOURCE_DIR}/dDashboardServer/auth"
"${PROJECT_SOURCE_DIR}/dDashboardServer/routes"
)
target_link_libraries(DashboardServer ${COMMON_LIBRARIES} dWeb dServer bcrypt OpenSSL::Crypto)
# Copy static files and templates to build directory (always copy)
add_custom_command(TARGET DashboardServer POST_BUILD
COMMAND ${CMAKE_COMMAND} -E remove_directory
${CMAKE_BINARY_DIR}/dDashboardServer/static
COMMENT "Removing old static files"
)
add_custom_command(TARGET DashboardServer POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
${CMAKE_CURRENT_SOURCE_DIR}/static
${CMAKE_BINARY_DIR}/dDashboardServer/static
COMMENT "Copying DashboardServer static files"
)
add_custom_command(TARGET DashboardServer POST_BUILD
COMMAND ${CMAKE_COMMAND} -E remove_directory
${CMAKE_BINARY_DIR}/dDashboardServer/templates
COMMENT "Removing old templates"
)
add_custom_command(TARGET DashboardServer POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
${CMAKE_CURRENT_SOURCE_DIR}/templates
${CMAKE_BINARY_DIR}/dDashboardServer/templates
COMMENT "Copying DashboardServer templates"
)

View File

@@ -0,0 +1,192 @@
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <thread>
#include <csignal>
#include <memory>
#include "CDClientDatabase.h"
#include "CDClientManager.h"
#include "Database.h"
#include "dConfig.h"
#include "Logger.h"
#include "dServer.h"
#include "AssetManager.h"
#include "BinaryPathFinder.h"
#include "ServiceType.h"
#include "MessageType/Master.h"
#include "Game.h"
#include "BitStreamUtils.h"
#include "Diagnostics.h"
#include "Web.h"
#include "Server.h"
#include "MasterPacketHandler.h"
#include "routes/ServerState.h"
#include "routes/APIRoutes.h"
#include "routes/StaticRoutes.h"
#include "routes/DashboardRoutes.h"
#include "routes/WSRoutes.h"
#include "routes/AuthRoutes.h"
#include "AuthMiddleware.h"
namespace Game {
Logger* logger = nullptr;
dServer* server = nullptr;
dConfig* config = nullptr;
Game::signal_t lastSignal = 0;
std::mt19937 randomEngine;
}
// Define global server state
namespace ServerState {
ServerStatus g_AuthStatus{};
ServerStatus g_ChatStatus{};
std::vector<WorldInstanceInfo> g_WorldInstances{};
std::mutex g_StatusMutex{};
}
namespace {
dServer* g_Server = nullptr;
bool g_RequestedServerList = false;
}
int main(int argc, char** argv) {
Diagnostics::SetProduceMemoryDump(true);
std::signal(SIGINT, Game::OnSignal);
std::signal(SIGTERM, Game::OnSignal);
uint32_t maxClients = 999;
uint32_t ourPort = 2006;
std::string ourIP = "127.0.0.1";
// Read config
Game::config = new dConfig("dashboardconfig.ini");
// Setup logger
Server::SetupLogger("DashboardServer");
if (!Game::logger) return EXIT_FAILURE;
Game::config->LogSettings();
LOG("Starting Dashboard Server");
// Load settings
if (Game::config->GetValue("max_clients") != "")
maxClients = std::stoi(Game::config->GetValue("max_clients"));
if (Game::config->GetValue("port") != "")
ourPort = std::atoi(Game::config->GetValue("port").c_str());
if (Game::config->GetValue("listen_ip") != "")
ourIP = Game::config->GetValue("listen_ip");
// Connect to CDClient database
try {
const std::string cdclientPath = BinaryPathFinder::GetBinaryDir() / "resServer/CDServer.sqlite";
CDClientDatabase::Connect(cdclientPath);
} catch (std::exception& ex) {
LOG("Failed to connect to CDClient database: %s", ex.what());
return EXIT_FAILURE;
}
// Connect to the database
try {
Database::Connect();
} catch (std::exception& ex) {
LOG("Failed to connect to the database: %s", ex.what());
return EXIT_FAILURE;
}
// Get master info from database
std::string masterIP = "localhost";
uint32_t masterPort = 1000;
std::string masterPassword;
auto masterInfo = Database::Get()->GetMasterInfo();
if (masterInfo) {
masterIP = masterInfo->ip;
masterPort = masterInfo->port;
masterPassword = masterInfo->password;
}
// Setup network server for communicating with Master
g_Server = new dServer(
masterIP,
ourPort,
0,
maxClients,
false,
false,
Game::logger,
masterIP,
masterPort,
ServiceType::DASHBOARD, // Connect as dashboard to master
Game::config,
&Game::lastSignal,
masterPassword
);
// Initialize web server
if (!Game::web.Startup(ourIP, ourPort)) {
LOG("Failed to start web server on %s:%d", ourIP.c_str(), ourPort);
return EXIT_FAILURE;
}
// Register global middleware
Game::web.AddGlobalMiddleware(std::make_shared<AuthMiddleware>());
// Register routes in order: API, Static, Auth, WebSocket, Dashboard (dashboard MUST be last)
RegisterAPIRoutes();
RegisterStaticRoutes();
RegisterAuthRoutes();
RegisterWSRoutes();
RegisterDashboardRoutes(); // Must be last - catches all unmatched routes
LOG("Dashboard Server started successfully on %s:%d", ourIP.c_str(), ourPort);
LOG("Connected to Master Server at %s:%d", masterIP.c_str(), masterPort);
// Main loop
auto lastTime = std::chrono::high_resolution_clock::now();
auto lastBroadcast = lastTime;
auto currentTime = lastTime;
constexpr float deltaTime = 1.0f / 60.0f; // 60 FPS
constexpr float broadcastInterval = 2000.0f; // Broadcast every 2 seconds
while (!Game::ShouldShutdown()) {
currentTime = std::chrono::high_resolution_clock::now();
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(currentTime - lastTime).count();
const auto elapsedSinceBroadcast = std::chrono::duration_cast<std::chrono::milliseconds>(currentTime - lastBroadcast).count();
if (elapsed >= 1000.0f / 60.0f) {
// Handle master server packets
Packet* packet = g_Server->ReceiveFromMaster();
if (packet) {
MasterPacketHandler::HandleMasterPacket(packet);
g_Server->DeallocateMasterPacket(packet);
}
// Handle web requests
Game::web.ReceiveRequests();
// Broadcast dashboard updates periodically
if (elapsedSinceBroadcast >= broadcastInterval) {
BroadcastDashboardUpdate();
lastBroadcast = currentTime;
}
lastTime = currentTime;
}
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
// Cleanup
Database::Destroy("DashboardServer");
delete g_Server;
g_Server = nullptr;
delete Game::logger;
Game::logger = nullptr;
delete Game::config;
Game::config = nullptr;
return EXIT_SUCCESS;
}

View File

@@ -0,0 +1,209 @@
#include "MasterPacketHandler.h"
#include "BitStreamUtils.h"
#include "dServer.h"
#include "Game.h"
#include "Logger.h"
#include "RakNetTypes.h"
#include "routes/ServerState.h"
#include <chrono>
#include <mutex>
namespace MasterPacketHandler {
namespace {
std::map<MessageType::Master, std::function<std::unique_ptr<MasterPacket>()>> g_Handlers = {
{MessageType::Master::SERVER_INFO, []() {
return std::make_unique<ServerInfo>();
}},
{MessageType::Master::PLAYER_ADDED, []() {
return std::make_unique<PlayerAdded>();
}},
{MessageType::Master::PLAYER_REMOVED, []() {
return std::make_unique<PlayerRemoved>();
}},
{MessageType::Master::SHUTDOWN_RESPONSE, []() {
return std::make_unique<ShutdownResponse>();
}},
{MessageType::Master::SHUTDOWN, []() {
return std::make_unique<Shutdown>();
}},
};
}
bool ServerInfo::Deserialize(RakNet::BitStream& bitStream) {
VALIDATE_READ(bitStream.Read(port));
VALIDATE_READ(bitStream.Read(zoneID));
VALIDATE_READ(bitStream.Read(instanceID));
VALIDATE_READ(bitStream.Read(serverType));
LUString ipStr{};
VALIDATE_READ(bitStream.Read(ipStr));
ip = ipStr.string;
return true;
}
void ServerInfo::Handle() {
std::lock_guard<std::mutex> lock(ServerState::g_StatusMutex);
LOG("MasterPacketHandler: Processing SERVER_INFO for service type %i, zone %u, instance %u, port %u", serverType, zoneID, instanceID, port);
switch (serverType) {
case ServiceType::AUTH:
ServerState::g_AuthStatus.online = true;
ServerState::g_AuthStatus.lastSeen = std::chrono::steady_clock::now();
LOG("Updated Auth server status: online");
break;
case ServiceType::CHAT:
ServerState::g_ChatStatus.online = true;
ServerState::g_ChatStatus.lastSeen = std::chrono::steady_clock::now();
LOG("Updated Chat server status: online");
break;
case ServiceType::WORLD: {
// Update or add world instance
bool found = false;
for (auto& world : ServerState::g_WorldInstances) {
if (world.mapID == zoneID && world.instanceID == instanceID) {
world.ip = ip;
world.port = port;
found = true;
break;
}
}
if (!found) {
WorldInstanceInfo info{};
info.mapID = zoneID;
info.instanceID = instanceID;
info.cloneID = 0;
info.players = 0;
info.ip = ip;
info.port = port;
info.isPrivate = false;
ServerState::g_WorldInstances.push_back(info);
LOG("Added world instance: map %u instance %u", zoneID, instanceID);
}
break;
}
default:
break;
}
}
bool PlayerAdded::Deserialize(RakNet::BitStream& bitStream) {
VALIDATE_READ(bitStream.Read(zoneID));
VALIDATE_READ(bitStream.Read(instanceID));
return true;
}
void PlayerAdded::Handle() {
std::lock_guard<std::mutex> lock(ServerState::g_StatusMutex);
for (auto& world : ServerState::g_WorldInstances) {
if (world.mapID == zoneID && world.instanceID == instanceID) {
world.players++;
LOG_DEBUG("Player added to map %u instance %u, now %u players", zoneID, instanceID, world.players);
break;
}
}
}
bool PlayerRemoved::Deserialize(RakNet::BitStream& bitStream) {
VALIDATE_READ(bitStream.Read(zoneID));
VALIDATE_READ(bitStream.Read(instanceID));
return true;
}
void PlayerRemoved::Handle() {
std::lock_guard<std::mutex> lock(ServerState::g_StatusMutex);
for (auto& world : ServerState::g_WorldInstances) {
if (world.mapID == zoneID && world.instanceID == instanceID) {
if (world.players > 0) world.players--;
LOG_DEBUG("Player removed from map %u instance %u, now %u players", zoneID, instanceID, world.players);
break;
}
}
}
bool ShutdownResponse::Deserialize(RakNet::BitStream& bitStream) {
VALIDATE_READ(bitStream.Read(zoneID));
VALIDATE_READ(bitStream.Read(instanceID));
VALIDATE_READ(bitStream.Read(serverType));
return true;
}
void ShutdownResponse::Handle() {
std::lock_guard<std::mutex> lock(ServerState::g_StatusMutex);
switch (serverType) {
case ServiceType::AUTH:
ServerState::g_AuthStatus.online = false;
LOG_DEBUG("Auth server shutdown");
break;
case ServiceType::CHAT:
ServerState::g_ChatStatus.online = false;
LOG_DEBUG("Chat server shutdown");
break;
case ServiceType::WORLD:
for (auto it = ServerState::g_WorldInstances.begin(); it != ServerState::g_WorldInstances.end(); ++it) {
if (it->mapID == zoneID && it->instanceID == instanceID) {
ServerState::g_WorldInstances.erase(it);
LOG_DEBUG("Removed shutdown instance: map %u instance %u", zoneID, instanceID);
break;
}
}
break;
default:
break;
}
}
bool Shutdown::Deserialize(RakNet::BitStream& bitStream) {
// SHUTDOWN message has no additional data
return true;
}
void Shutdown::Handle() {
LOG("Received SHUTDOWN command from Master");
Game::lastSignal = -1; // Trigger shutdown
}
void HandleMasterPacket(Packet* packet) {
if (!packet) return;
switch (packet->data[0]) {
case ID_DISCONNECTION_NOTIFICATION:
case ID_CONNECTION_LOST:
LOG("Lost connection to Master Server");
{
std::lock_guard<std::mutex> lock(ServerState::g_StatusMutex);
ServerState::g_AuthStatus.online = false;
ServerState::g_ChatStatus.online = false;
ServerState::g_WorldInstances.clear();
}
break;
case ID_CONNECTION_REQUEST_ACCEPTED:
LOG("Connected to Master Server");
break;
case ID_USER_PACKET_ENUM: {
RakNet::BitStream inStream(packet->data, packet->length, false);
uint64_t header{};
inStream.Read(header);
const auto packetType = static_cast<MessageType::Master>(header);
LOG_DEBUG("Received Master packet type: %i", packetType);
auto it = g_Handlers.find(packetType);
if (it != g_Handlers.end()) {
auto handler = it->second();
if (!handler->Deserialize(inStream)) {
LOG_DEBUG("Error deserializing Master packet type %i", packetType);
return;
}
handler->Handle();
} else {
LOG_DEBUG("Unhandled Master packet type: %i", packetType);
}
break;
}
default:
break;
}
}
}

View File

@@ -0,0 +1,79 @@
#pragma once
#include <functional>
#include <map>
#include <memory>
#include "dCommonVars.h"
#include "MessageType/Master.h"
#include "BitStream.h"
struct Packet;
namespace MasterPacketHandler {
// Base class for all master packet handlers
class MasterPacket {
public:
virtual ~MasterPacket() = default;
virtual bool Deserialize(RakNet::BitStream& bitStream) = 0;
virtual void Handle() = 0;
};
// SERVER_INFO packet handler
class ServerInfo : public MasterPacket {
public:
bool Deserialize(RakNet::BitStream& bitStream) override;
void Handle() override;
private:
uint32_t port{0};
uint32_t zoneID{0};
uint32_t instanceID{0};
ServiceType serverType{};
std::string ip{};
};
// PLAYER_ADDED packet handler
class PlayerAdded : public MasterPacket {
public:
bool Deserialize(RakNet::BitStream& bitStream) override;
void Handle() override;
private:
LWOMAPID zoneID{};
LWOINSTANCEID instanceID{};
};
// PLAYER_REMOVED packet handler
class PlayerRemoved : public MasterPacket {
public:
bool Deserialize(RakNet::BitStream& bitStream) override;
void Handle() override;
private:
LWOMAPID zoneID{};
LWOINSTANCEID instanceID{};
};
// SHUTDOWN_RESPONSE packet handler
class ShutdownResponse : public MasterPacket {
public:
bool Deserialize(RakNet::BitStream& bitStream) override;
void Handle() override;
private:
uint32_t zoneID{};
uint32_t instanceID{};
ServiceType serverType{};
};
// SHUTDOWN packet handler
class Shutdown : public MasterPacket {
public:
bool Deserialize(RakNet::BitStream& bitStream) override;
void Handle() override;
};
// Main handler function
void HandleMasterPacket(Packet* packet);
}

View File

@@ -0,0 +1,132 @@
#include "AuthMiddleware.h"
#include "DashboardAuthService.h"
#include "Game.h"
#include "Logger.h"
#include <string>
#include <cctype>
// Helper to extract cookie value from header
static std::string ExtractCookieValue(const std::string& cookieHeader, const std::string& cookieName) {
std::string searchStr = cookieName + "=";
size_t pos = cookieHeader.find(searchStr);
if (pos == std::string::npos) {
return "";
}
size_t valueStart = pos + searchStr.length();
size_t valueEnd = cookieHeader.find(";", valueStart);
if (valueEnd == std::string::npos) {
valueEnd = cookieHeader.length();
}
std::string value = cookieHeader.substr(valueStart, valueEnd - valueStart);
// URL decode the value
std::string decoded;
for (size_t i = 0; i < value.length(); ++i) {
if (value[i] == '%' && i + 2 < value.length()) {
std::string hex = value.substr(i + 1, 2);
char* endptr;
int charCode = static_cast<int>(std::strtol(hex.c_str(), &endptr, 16));
if (endptr - hex.c_str() == 2) {
decoded += static_cast<char>(charCode);
i += 2;
continue;
}
}
decoded += value[i];
}
return decoded;
}
std::string AuthMiddleware::ExtractTokenFromQueryString(const std::string& queryString) {
if (queryString.empty()) {
return "";
}
// Parse query string to find token parameter
// Expected format: "?token=eyJhbGc..."
std::string tokenPrefix = "token=";
size_t tokenPos = queryString.find(tokenPrefix);
if (tokenPos == std::string::npos) {
return "";
}
// Extract token value (from "token=" to next "&" or end of string)
size_t valueStart = tokenPos + tokenPrefix.length();
size_t valueEnd = queryString.find("&", valueStart);
if (valueEnd == std::string::npos) {
valueEnd = queryString.length();
}
return queryString.substr(valueStart, valueEnd - valueStart);
}
std::string AuthMiddleware::ExtractTokenFromCookies(const std::string& cookieHeader) {
if (cookieHeader.empty()) {
return "";
}
// Extract dashboardToken cookie value
return ExtractCookieValue(cookieHeader, "dashboardToken");
}
std::string AuthMiddleware::ExtractTokenFromAuthHeader(const std::string& authHeader) {
if (authHeader.empty()) {
return "";
}
// Check for "Bearer <token>" format
if (authHeader.substr(0, 7) == "Bearer ") {
return authHeader.substr(7);
}
// Check for "Token <token>" format
if (authHeader.substr(0, 6) == "Token ") {
return authHeader.substr(6);
}
// If no prefix, assume raw token
return authHeader;
}
bool AuthMiddleware::Process(HTTPContext& context, HTTPReply& reply) {
// Try to extract token from various sources (in priority order)
std::string token = ExtractTokenFromQueryString(context.queryString);
if (token.empty()) {
const std::string& cookieHeader = context.GetHeader("Cookie");
token = ExtractTokenFromCookies(cookieHeader);
}
if (token.empty()) {
const std::string& authHeader = context.GetHeader("Authorization");
token = ExtractTokenFromAuthHeader(authHeader);
}
// If we found a token, try to verify it
if (!token.empty()) {
std::string username;
uint8_t gmLevel{};
if (DashboardAuthService::VerifyToken(token, username, gmLevel)) {
context.isAuthenticated = true;
context.authenticatedUser = username;
context.gmLevel = gmLevel;
LOG_DEBUG("User %s authenticated via API token (GM level %d)", username.c_str(), gmLevel);
return true;
} else {
LOG_DEBUG("Invalid authentication token provided");
return true; // Continue - let routes decide if auth is required
}
}
// No token found - continue without authentication
// Routes can use RequireAuthMiddleware to enforce authentication
return true;
}

View File

@@ -0,0 +1,34 @@
#ifndef __AUTHMIDDLEWARE_H__
#define __AUTHMIDDLEWARE_H__
#include <string>
#include <memory>
#include "IHTTPMiddleware.h"
/**
* AuthMiddleware: Extracts and verifies authentication tokens
*
* Token extraction sources (in priority order):
* 1. Query parameter: ?token=eyJhbGc...
* 2. Cookie: dashboardToken=...
* 3. Authorization header: Bearer <token> or Token <token>
*
* Sets HTTPContext.isAuthenticated, HTTPContext.authenticatedUser,
* and HTTPContext.gmLevel if token is valid.
*/
class AuthMiddleware final : public IHTTPMiddleware {
public:
AuthMiddleware() = default;
~AuthMiddleware() override = default;
bool Process(HTTPContext& context, HTTPReply& reply) override;
std::string GetName() const override { return "AuthMiddleware"; }
private:
// Extract token from various sources
static std::string ExtractTokenFromQueryString(const std::string& queryString);
static std::string ExtractTokenFromCookies(const std::string& cookieHeader);
static std::string ExtractTokenFromAuthHeader(const std::string& authHeader);
};
#endif // !__AUTHMIDDLEWARE_H__

View File

@@ -0,0 +1,144 @@
#include "DashboardAuthService.h"
#include "JWTUtils.h"
#include "Database.h"
#include "Logger.h"
#include "Game.h"
#include "dConfig.h"
#include "GeneralUtils.h"
#include <bcrypt/bcrypt.h>
#include <ctime>
namespace {
constexpr int64_t LOCKOUT_DURATION = 15 * 60; // 15 minutes in seconds
}
DashboardAuthService::LoginResult DashboardAuthService::Login(
const std::string& username,
const std::string& password,
bool rememberMe) {
LoginResult result;
if (username.empty() || password.empty()) {
result.message = "Username and password are required";
return result;
}
if (password.length() > 40) {
result.message = "Password exceeds maximum length (40 characters)";
return result;
}
try {
// Get account info
auto accountInfo = Database::Get()->GetAccountInfo(username);
if (!accountInfo) {
result.message = "Invalid username or password";
LOG_DEBUG("Login attempt for non-existent user: %s", username.c_str());
return result;
}
uint32_t accountId = accountInfo->id;
// Check if account is locked
bool isLockedOut = Database::Get()->IsLockedOut(accountId);
if (isLockedOut) {
// Record failed attempt even without checking password
Database::Get()->RecordFailedAttempt(accountId);
uint8_t failedAttempts = Database::Get()->GetFailedAttempts(accountId);
result.message = "Account is locked due to too many failed attempts";
result.accountLocked = true;
LOG("Login attempt on locked account: %s (failed attempts: %d)", username.c_str(), failedAttempts);
return result;
}
// Check password
if (::bcrypt_checkpw(password.c_str(), accountInfo->bcryptPassword.c_str()) != 0) {
// Record failed attempt
Database::Get()->RecordFailedAttempt(accountId);
uint8_t newFailedAttempts = Database::Get()->GetFailedAttempts(accountId);
// Lock account after 3 failed attempts
if (newFailedAttempts >= 3) {
int64_t lockoutUntil = std::time(nullptr) + LOCKOUT_DURATION;
Database::Get()->SetLockout(accountId, lockoutUntil);
result.message = "Account locked due to too many failed attempts";
result.accountLocked = true;
LOG("Account locked after failed attempts: %s", username.c_str());
} else {
result.message = "Invalid username or password";
LOG_DEBUG("Failed login attempt for user: %s (attempt %d/3)",
username.c_str(), newFailedAttempts);
}
return result;
}
// Check GM level
if (!HasDashboardAccess(static_cast<uint8_t>(accountInfo->maxGmLevel))) {
result.message = "Access denied: insufficient permissions";
LOG("Access denied for non-admin user: %s", username.c_str());
return result;
}
// Successful login
Database::Get()->ClearFailedAttempts(accountId);
result.success = true;
result.gmLevel = static_cast<uint8_t>(accountInfo->maxGmLevel);
result.token = JWTUtils::GenerateToken(username, result.gmLevel, rememberMe);
result.message = "Login successful";
LOG("Successful login: %s (GM Level: %d)", username.c_str(), result.gmLevel);
return result;
} catch (const std::exception& ex) {
result.message = "An error occurred during login";
LOG("Error during login process: %s", ex.what());
return result;
}
}
bool DashboardAuthService::VerifyToken(const std::string& token, std::string& username, uint8_t& gmLevel) {
JWTUtils::JWTPayload payload;
if (!JWTUtils::ValidateToken(token, payload)) {
LOG_DEBUG("Token validation failed: invalid or expired JWT");
return false;
}
username = payload.username;
gmLevel = payload.gmLevel;
// Optionally verify user still exists and has access
try {
auto accountInfo = Database::Get()->GetAccountInfo(username);
if (!accountInfo || !HasDashboardAccess(static_cast<uint8_t>(accountInfo->maxGmLevel))) {
LOG_DEBUG("Token verification failed: user no longer has access");
return false;
}
} catch (const std::exception& ex) {
LOG_DEBUG("Error verifying user during token validation: %s", ex.what());
return false;
}
LOG_DEBUG("Token verified successfully for user: %s (GM Level: %d)", username.c_str(), gmLevel);
return true;
}
bool DashboardAuthService::HasDashboardAccess(uint8_t gmLevel) {
// Get minimum GM level from config (default 0 = any user)
uint8_t minGmLevel = 0;
if (Game::config) {
const std::string& minGmLevelStr = Game::config->GetValue("min_dashboard_gm_level");
if (!minGmLevelStr.empty()) {
const auto parsed = GeneralUtils::TryParse<uint8_t>(minGmLevelStr);
if (parsed) {
minGmLevel = parsed.value();
}
}
}
return gmLevel >= minGmLevel;
}

View File

@@ -0,0 +1,47 @@
#pragma once
#include <string>
#include <cstdint>
/**
* Dashboard authentication service
* Handles user login, password verification, and account lockout
*/
class DashboardAuthService {
public:
/**
* Login result structure
*/
struct LoginResult {
bool success{false};
std::string message{};
std::string token{}; // JWT token if successful
uint8_t gmLevel{0}; // GM level if successful
bool accountLocked{false}; // Account is locked out
};
/**
* Attempt to log in with username and password
* @param username The username
* @param password The plaintext password (max 40 characters)
* @param rememberMe If true, extends token expiration to 30 days
* @return LoginResult with success status and JWT token if successful
*/
static LoginResult Login(const std::string& username, const std::string& password, bool rememberMe = false);
/**
* Verify that a token is valid and get the username
* @param token The JWT token
* @param username Output parameter for the username
* @param gmLevel Output parameter for the GM level
* @return true if token is valid
*/
static bool VerifyToken(const std::string& token, std::string& username, uint8_t& gmLevel);
/**
* Check if user has required GM level for dashboard access
* @param gmLevel The user's GM level
* @return true if user can access dashboard (GM level > 0)
*/
static bool HasDashboardAccess(uint8_t gmLevel);
};

View File

@@ -0,0 +1,186 @@
#include "JWTUtils.h"
#include "GeneralUtils.h"
#include "Logger.h"
#include "json.hpp"
#include <ctime>
#include <cstring>
#include <openssl/hmac.h>
#include <openssl/sha.h>
namespace {
std::string g_Secret = "default-secret-change-me";
// Simple base64 encoding
std::string Base64Encode(const std::string& input) {
static const char* base64_chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string ret;
int i = 0;
unsigned char char_array_3[3];
unsigned char char_array_4[4];
for (size_t n = 0; n < input.length(); n++) {
char_array_3[i++] = input[n];
if (i == 3) {
char_array_4[0] = (char_array_3[0] & 0xfc) >> 2;
char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4);
char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6);
char_array_4[3] = char_array_3[2] & 0x3f;
for (i = 0; i < 4; i++) ret += base64_chars[char_array_4[i]];
i = 0;
}
}
if (i) {
for (int j = i; j < 3; j++) char_array_3[j] = '\0';
char_array_4[0] = (char_array_3[0] & 0xfc) >> 2;
char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4);
char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6);
for (int j = 0; j <= i; j++) ret += base64_chars[char_array_4[j]];
while (i++ < 3) ret += '=';
}
return ret;
}
// Simple base64 decoding
std::string Base64Decode(const std::string& encoded_string) {
static const std::string base64_chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
int in_len = encoded_string.size();
int i = 0, j = 0, in_ = 0;
unsigned char char_array_4[4], char_array_3[3];
std::string ret;
while (in_len-- && (encoded_string[in_] != '=') &&
(isalnum(encoded_string[in_]) || encoded_string[in_] == '+' || encoded_string[in_] == '/')) {
char_array_4[i++] = encoded_string[in_]; in_++;
if (i == 4) {
for (i = 0; i < 4; i++) char_array_4[i] = base64_chars.find(char_array_4[i]);
char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4);
char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2);
char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3];
for (i = 0; i < 3; i++) ret += char_array_3[i];
i = 0;
}
}
if (i) {
for (j = i; j < 4; j++) char_array_4[j] = 0;
for (j = 0; j < 4; j++) char_array_4[j] = base64_chars.find(char_array_4[j]);
char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4);
char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2);
for (j = 0; j < i - 1; j++) ret += char_array_3[j];
}
return ret;
}
// HMAC-SHA256
std::string HmacSha256(const std::string& key, const std::string& message) {
unsigned char* digest = HMAC(EVP_sha256(),
reinterpret_cast<const unsigned char*>(key.c_str()), key.length(),
reinterpret_cast<const unsigned char*>(message.c_str()), message.length(),
nullptr, nullptr);
std::string result(reinterpret_cast<char*>(digest), SHA256_DIGEST_LENGTH);
return result;
}
// Create signature for JWT
std::string CreateSignature(const std::string& header, const std::string& payload, const std::string& secret) {
std::string message = header + "." + payload;
std::string signature = HmacSha256(secret, message);
return Base64Encode(signature);
}
// Verify JWT signature
bool VerifySignature(const std::string& header, const std::string& payload, const std::string& signature, const std::string& secret) {
std::string expected = CreateSignature(header, payload, secret);
return signature == expected;
}
}
namespace JWTUtils {
void SetSecretKey(const std::string& secret) {
if (secret.empty()) {
LOG("Warning: JWT secret key is empty, using default");
return;
}
g_Secret = secret;
}
std::string GenerateToken(const std::string& username, uint8_t gmLevel, bool rememberMe) {
// Header
std::string header = R"({"alg":"HS256","typ":"JWT"})";
std::string encodedHeader = Base64Encode(header);
// Payload
int64_t now = std::time(nullptr);
int64_t expiresAt = now + (rememberMe ? 30 * 24 * 60 * 60 : 24 * 60 * 60); // 30 days or 24 hours
std::string payload = R"({"username":")" + username + R"(","gmLevel":)" + std::to_string(gmLevel) +
R"(,"rememberMe":)" + (rememberMe ? "true" : "false") +
R"(,"iat":)" + std::to_string(now) +
R"(,"exp":)" + std::to_string(expiresAt) + "}";
std::string encodedPayload = Base64Encode(payload);
// Signature
std::string signature = CreateSignature(encodedHeader, encodedPayload, g_Secret);
return encodedHeader + "." + encodedPayload + "." + signature;
}
bool ValidateToken(const std::string& token, JWTPayload& payload) {
// Split token into parts
size_t firstDot = token.find('.');
size_t secondDot = token.find('.', firstDot + 1);
if (firstDot == std::string::npos || secondDot == std::string::npos) {
LOG_DEBUG("Invalid JWT format");
return false;
}
std::string header = token.substr(0, firstDot);
std::string encodedPayload = token.substr(firstDot + 1, secondDot - firstDot - 1);
std::string signature = token.substr(secondDot + 1);
// Verify signature
if (!VerifySignature(header, encodedPayload, signature, g_Secret)) {
LOG_DEBUG("Invalid JWT signature");
return false;
}
// Decode and parse payload
std::string decodedPayload = Base64Decode(encodedPayload);
try {
auto json = nlohmann::json::parse(decodedPayload);
payload.username = json.value("username", "");
payload.gmLevel = json.value("gmLevel", 0);
payload.rememberMe = json.value("rememberMe", false);
payload.issuedAt = json.value("iat", 0);
payload.expiresAt = json.value("exp", 0);
if (payload.username.empty()) {
LOG_DEBUG("JWT missing username");
return false;
}
// Check expiration
if (IsTokenExpired(payload.expiresAt)) {
LOG_DEBUG("JWT token expired");
return false;
}
return true;
} catch (const std::exception& ex) {
LOG_DEBUG("Error parsing JWT payload: %s", ex.what());
return false;
}
}
bool IsTokenExpired(int64_t expiresAt) {
return std::time(nullptr) > expiresAt;
}
}

View File

@@ -0,0 +1,52 @@
#pragma once
#include <string>
#include <ctime>
#include "json_fwd.hpp"
/**
* JWT Token utilities for dashboard authentication
* Provides secure token generation, validation, and parsing
*/
namespace JWTUtils {
/**
* JWT payload structure
*/
struct JWTPayload {
std::string username{};
uint8_t gmLevel{0};
bool rememberMe{false};
int64_t issuedAt{0};
int64_t expiresAt{0};
};
/**
* Generate a new JWT token
* @param username The username to encode in the token
* @param gmLevel The GM level of the user
* @param rememberMe If true, extends token expiration to 30 days; otherwise 24 hours
* @return Signed JWT token string
*/
std::string GenerateToken(const std::string& username, uint8_t gmLevel, bool rememberMe = false);
/**
* Validate and decode a JWT token
* @param token The JWT token to validate
* @param payload Output parameter for the decoded payload
* @return true if token is valid and not expired, false otherwise
*/
bool ValidateToken(const std::string& token, JWTPayload& payload);
/**
* Check if a token is expired
* @param expiresAt Expiration timestamp
* @return true if token is expired
*/
bool IsTokenExpired(int64_t expiresAt);
/**
* Set the JWT secret key (must be called once at startup)
* @param secret The secret key for signing tokens
*/
void SetSecretKey(const std::string& secret);
}

View File

@@ -0,0 +1,35 @@
#include "RequireAuthMiddleware.h"
#include "HTTPContext.h"
#include "Web.h"
#include "Game.h"
#include "Logger.h"
RequireAuthMiddleware::RequireAuthMiddleware(uint8_t minGmLevel) : minGmLevel(minGmLevel) {}
bool RequireAuthMiddleware::Process(HTTPContext& context, HTTPReply& reply) {
// Check if user is authenticated
if (!context.isAuthenticated) {
LOG_DEBUG("Unauthorized access attempt to %s from %s", context.path.c_str(), context.clientIP.c_str());
reply.status = eHTTPStatusCode::FOUND;
reply.message = "";
reply.location = "/login";
reply.contentType = eContentType::TEXT_HTML;
return false; // Stop middleware chain and send reply
}
// Check if user has required GM level
if (context.gmLevel < minGmLevel) {
LOG_DEBUG("Forbidden access attempt by user %s (GM level %d < %d required) to %s from %s",
context.authenticatedUser.c_str(), context.gmLevel, minGmLevel,
context.path.c_str(), context.clientIP.c_str());
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"error\":\"Forbidden - Insufficient permissions\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return false; // Stop middleware chain and send reply
}
// Authentication passed
LOG_DEBUG("User %s authenticated with GM level %d accessing %s",
context.authenticatedUser.c_str(), context.gmLevel, context.path.c_str());
return true; // Continue to next middleware or route handler
}

View File

@@ -0,0 +1,30 @@
#ifndef __REQUIREAUTHMIDDLEWARE_H__
#define __REQUIREAUTHMIDDLEWARE_H__
#include <memory>
#include <cstdint>
#include "IHTTPMiddleware.h"
/**
* RequireAuthMiddleware: Enforces authentication on protected routes
*
* Returns 401 Unauthorized if user is not authenticated
* Returns 403 Forbidden if user's GM level is below minimum required
*/
class RequireAuthMiddleware final : public IHTTPMiddleware {
public:
/**
* @param minGmLevel Minimum GM level required to access this route
* 0 = any authenticated user, higher numbers = GM-only
*/
explicit RequireAuthMiddleware(uint8_t minGmLevel = 0);
~RequireAuthMiddleware() override = default;
bool Process(HTTPContext& context, HTTPReply& reply) override;
std::string GetName() const override { return "RequireAuthMiddleware"; }
private:
uint8_t minGmLevel;
};
#endif // !__REQUIREAUTHMIDDLEWARE_H__

View File

@@ -0,0 +1,101 @@
#include "APIRoutes.h"
#include "ServerState.h"
#include "Web.h"
#include "eHTTPMethod.h"
#include "json.hpp"
#include "Game.h"
#include "Database.h"
#include "Logger.h"
#include "HTTPContext.h"
#include "RequireAuthMiddleware.h"
#include <memory>
void RegisterAPIRoutes() {
// GET /api/status - Get overall server status
Game::web.RegisterHTTPRoute({
.path = "/api/status",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
std::lock_guard<std::mutex> lock(ServerState::g_StatusMutex);
nlohmann::json response = {
{"auth", {
{"online", ServerState::g_AuthStatus.online},
{"players", ServerState::g_AuthStatus.players},
{"version", ServerState::g_AuthStatus.version}
}},
{"chat", {
{"online", ServerState::g_ChatStatus.online},
{"players", ServerState::g_ChatStatus.players}
}},
{"worlds", nlohmann::json::array()}
};
for (const auto& world : ServerState::g_WorldInstances) {
response["worlds"].push_back({
{"mapID", world.mapID},
{"instanceID", world.instanceID},
{"cloneID", world.cloneID},
{"players", world.players},
{"isPrivate", world.isPrivate}
});
}
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
}
});
// GET /api/players - Get list of online players
Game::web.RegisterHTTPRoute({
.path = "/api/players",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
nlohmann::json response = {
{"players", nlohmann::json::array()},
{"count", 0}
};
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
}
});
// GET /api/accounts/count - Get total account count
Game::web.RegisterHTTPRoute({
.path = "/api/accounts/count",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
const uint32_t count = Database::Get()->GetAccountCount();
nlohmann::json response = {{"count", count}};
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/accounts/count: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /api/characters/count - Get total character count
Game::web.RegisterHTTPRoute({
.path = "/api/characters/count",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
nlohmann::json response = {{"count", 0}, {"note", "Not yet implemented"}};
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
}
});
}

View File

@@ -0,0 +1,3 @@
#pragma once
void RegisterAPIRoutes();

View File

@@ -0,0 +1,102 @@
#include "AuthRoutes.h"
#include "DashboardAuthService.h"
#include "json.hpp"
#include "Logger.h"
#include "GeneralUtils.h"
#include "Web.h"
#include "eHTTPMethod.h"
#include "HTTPContext.h"
void RegisterAuthRoutes() {
// POST /api/auth/login
// Request body: { "username": "string", "password": "string", "rememberMe": boolean }
// Response: { "success": boolean, "message": "string", "token": "string", "gmLevel": number }
Game::web.RegisterHTTPRoute({
.path = "/api/auth/login",
.method = eHTTPMethod::POST,
.middleware = {},
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
auto json = nlohmann::json::parse(context.body);
std::string username = json.value("username", "");
std::string password = json.value("password", "");
bool rememberMe = json.value("rememberMe", false);
// Validate input
if (username.empty() || password.empty()) {
reply.message = R"({"success":false,"message":"Username and password are required"})";
reply.status = eHTTPStatusCode::BAD_REQUEST;
return;
}
if (password.length() > 40) {
reply.message = R"({"success":false,"message":"Password exceeds maximum length"})";
reply.status = eHTTPStatusCode::BAD_REQUEST;
return;
}
// Attempt login
auto result = DashboardAuthService::Login(username, password, rememberMe);
nlohmann::json response;
response["success"] = result.success;
response["message"] = result.message;
if (result.success) {
response["token"] = result.token;
response["gmLevel"] = result.gmLevel;
}
reply.message = response.dump();
reply.status = result.success ? eHTTPStatusCode::OK : eHTTPStatusCode::UNAUTHORIZED;
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const std::exception& ex) {
LOG("Error processing login request: %s", ex.what());
reply.message = R"({"success":false,"message":"Internal server error"})";
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// 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) {
try {
auto json = nlohmann::json::parse(context.body);
std::string token = json.value("token", "");
if (token.empty()) {
reply.message = R"({"valid":false})";
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
std::string username;
uint8_t gmLevel{};
bool valid = DashboardAuthService::VerifyToken(token, username, gmLevel);
nlohmann::json response;
response["valid"] = valid;
if (valid) {
response["username"] = username;
response["gmLevel"] = gmLevel;
}
reply.message = response.dump();
reply.status = eHTTPStatusCode::OK;
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const std::exception& ex) {
LOG("Error processing verify request: %s", ex.what());
reply.message = R"({"valid":false})";
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
}

View File

@@ -0,0 +1,10 @@
#pragma once
#include "Web.h"
/**
* Register authentication routes
* /api/auth/login - POST login endpoint
* /api/auth/verify - POST verify token endpoint
*/
void RegisterAuthRoutes();

View File

@@ -0,0 +1,101 @@
#include "DashboardRoutes.h"
#include "ServerState.h"
#include "Web.h"
#include "HTTPContext.h"
#include "eHTTPMethod.h"
#include "json.hpp"
#include "Game.h"
#include "Database.h"
#include "Logger.h"
#include "inja.hpp"
#include "AuthMiddleware.h"
#include "RequireAuthMiddleware.h"
void RegisterDashboardRoutes() {
// GET / - Main dashboard page (requires authentication)
Game::web.RegisterHTTPRoute({
.path = "/",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Initialize inja environment
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
// Prepare data for template
nlohmann::json data;
// Get username from auth context
data["username"] = context.authenticatedUser;
data["gmLevel"] = context.gmLevel;
// Server status (placeholder data - will be updated with real data from master)
data["auth"]["online"] = ServerState::g_AuthStatus.online;
data["auth"]["players"] = ServerState::g_AuthStatus.players;
data["chat"]["online"] = ServerState::g_ChatStatus.online;
data["chat"]["players"] = ServerState::g_ChatStatus.players;
// World instances
std::lock_guard<std::mutex> lock(ServerState::g_StatusMutex);
data["worlds"] = nlohmann::json::array();
for (const auto& world : ServerState::g_WorldInstances) {
data["worlds"].push_back({
{"mapID", world.mapID},
{"instanceID", world.instanceID},
{"cloneID", world.cloneID},
{"players", world.players},
{"isPrivate", world.isPrivate}
});
}
// Statistics
const uint32_t accountCount = Database::Get()->GetAccountCount();
data["stats"]["onlinePlayers"] = 0; // TODO: Get from server communication
data["stats"]["totalAccounts"] = accountCount;
data["stats"]["totalCharacters"] = 0; // TODO: Add GetCharacterCount to database interface
// Render template
const std::string html = env.render_file("index.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Failed to render template\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /login - Login page (no authentication required)
Game::web.RegisterHTTPRoute({
.path = "/login",
.method = eHTTPMethod::GET,
.middleware = {},
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Initialize inja environment
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
// Render template with empty username
nlohmann::json data;
data["username"] = "";
const std::string html = env.render_file("login.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering login template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Failed to render login page\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
}

View File

@@ -0,0 +1,3 @@
#pragma once
void RegisterDashboardRoutes();

View File

@@ -0,0 +1,31 @@
#pragma once
#include <chrono>
#include <mutex>
#include <vector>
#include <string>
#include <cstdint>
struct ServerStatus {
bool online{false};
uint32_t players{0};
std::string version{};
std::chrono::steady_clock::time_point lastSeen{};
};
struct WorldInstanceInfo {
uint32_t mapID{0};
uint32_t instanceID{0};
uint32_t cloneID{0};
uint32_t players{0};
std::string ip{};
uint32_t port{0};
bool isPrivate{false};
};
namespace ServerState {
extern ServerStatus g_AuthStatus;
extern ServerStatus g_ChatStatus;
extern std::vector<WorldInstanceInfo> g_WorldInstances;
extern std::mutex g_StatusMutex;
}

View File

@@ -0,0 +1,72 @@
#include "StaticRoutes.h"
#include "Web.h"
#include "HTTPContext.h"
#include "eHTTPMethod.h"
#include "Game.h"
#include "Logger.h"
#include <fstream>
#include <sstream>
namespace {
std::string ReadFileToString(const std::string& filePath) {
std::ifstream file(filePath);
if (!file.is_open()) {
LOG("Failed to open file: %s", filePath.c_str());
return "";
}
std::stringstream buffer{};
buffer << file.rdbuf();
return buffer.str();
}
eContentType GetContentType(const std::string& filePath) {
if (filePath.ends_with(".css")) {
return eContentType::TEXT_CSS;
} else if (filePath.ends_with(".js")) {
return eContentType::TEXT_JAVASCRIPT;
} else if (filePath.ends_with(".html")) {
return eContentType::TEXT_HTML;
} else if (filePath.ends_with(".png")) {
return eContentType::IMAGE_PNG;
} else if (filePath.ends_with(".jpg") || filePath.ends_with(".jpeg")) {
return eContentType::IMAGE_JPEG;
} else if (filePath.ends_with(".json")) {
return eContentType::APPLICATION_JSON;
}
return eContentType::TEXT_PLAIN;
}
void ServeStaticFile(const std::string& urlPath, const std::string& filePath) {
Game::web.RegisterHTTPRoute({
.path = urlPath,
.method = eHTTPMethod::GET,
.middleware = {},
.handle = [filePath](HTTPReply& reply, const HTTPContext& context) {
const std::string content = ReadFileToString(filePath);
if (content.empty()) {
reply.status = eHTTPStatusCode::NOT_FOUND;
reply.message = "{\"error\":\"File not found\"}";
reply.contentType = eContentType::APPLICATION_JSON;
} else {
reply.status = eHTTPStatusCode::OK;
reply.message = content;
reply.contentType = GetContentType(filePath);
}
}
});
}
}
void RegisterStaticRoutes() {
// Serve CSS files
ServeStaticFile("/css/dashboard.css", "dDashboardServer/static/css/dashboard.css");
ServeStaticFile("/css/login.css", "dDashboardServer/static/css/login.css");
// Serve JavaScript files
ServeStaticFile("/js/dashboard.js", "dDashboardServer/static/js/dashboard.js");
ServeStaticFile("/js/login.js", "dDashboardServer/static/js/login.js");
// Also serve from /static/ paths for backwards compatibility
ServeStaticFile("/static/css/dashboard.css", "dDashboardServer/static/css/dashboard.css");
ServeStaticFile("/static/js/dashboard.js", "dDashboardServer/static/js/dashboard.js");
}

View File

@@ -0,0 +1,3 @@
#pragma once
void RegisterStaticRoutes();

View File

@@ -0,0 +1,58 @@
#include "WSRoutes.h"
#include "ServerState.h"
#include "Web.h"
#include "json.hpp"
#include "Game.h"
#include "Database.h"
#include "Logger.h"
void RegisterWSRoutes() {
// Register WebSocket subscriptions for real-time updates
Game::web.RegisterWSSubscription("dashboard_update");
Game::web.RegisterWSSubscription("server_status");
Game::web.RegisterWSSubscription("player_joined");
Game::web.RegisterWSSubscription("player_left");
// dashboard_update: Broadcasts complete dashboard data every 2 seconds
// Other subscriptions can be triggered by events from the master server
}
void BroadcastDashboardUpdate() {
std::lock_guard<std::mutex> lock(ServerState::g_StatusMutex);
nlohmann::json data = {
{"auth", {
{"online", ServerState::g_AuthStatus.online},
{"players", ServerState::g_AuthStatus.players},
{"version", ServerState::g_AuthStatus.version}
}},
{"chat", {
{"online", ServerState::g_ChatStatus.online},
{"players", ServerState::g_ChatStatus.players}
}},
{"worlds", nlohmann::json::array()}
};
for (const auto& world : ServerState::g_WorldInstances) {
data["worlds"].push_back({
{"mapID", world.mapID},
{"instanceID", world.instanceID},
{"cloneID", world.cloneID},
{"players", world.players},
{"isPrivate", world.isPrivate}
});
}
// Add statistics
try {
const uint32_t accountCount = Database::Get()->GetAccountCount();
data["stats"]["onlinePlayers"] = 0; // TODO: Get from server communication
data["stats"]["totalAccounts"] = accountCount;
data["stats"]["totalCharacters"] = 0; // TODO: Add GetCharacterCount to database interface
} catch (const std::exception& ex) {
LOG_DEBUG("Error getting stats: %s", ex.what());
}
// Broadcast to all connected WebSocket clients subscribed to "dashboard_update"
Game::web.SendWSMessage("dashboard_update", data);
}

View File

@@ -0,0 +1,4 @@
#pragma once
void RegisterWSRoutes();
void BroadcastDashboardUpdate();

View File

@@ -0,0 +1,177 @@
/* Minimal custom styling - mostly Bootstrap5 utilities */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: #f8f9fa;
margin: 0;
padding: 0;
}
/* Sidebar adjustments */
.navbar.flex-column {
box-shadow: 0.125rem 0 0.25rem rgba(0, 0, 0, 0.075);
}
.navbar.flex-column .navbar-nav {
width: 100%;
}
.navbar.flex-column .nav-link {
padding: 0.75rem 1.25rem;
border-left: 3px solid transparent;
transition: all 0.3s ease;
}
.navbar.flex-column .nav-link:hover {
background-color: rgba(255, 255, 255, 0.1);
border-left-color: #667eea;
padding-left: 1.5rem;
}
.navbar.flex-column .nav-link.active {
background-color: rgba(255, 255, 255, 0.1);
border-left-color: #667eea;
}
main {
display: flex;
flex-direction: column;
padding: 0;
min-height: 100vh;
}
/* Responsive design */
@media (max-width: 991.98px) {
body {
display: block !important;
}
main {
margin-left: 0 !important;
}
.navbar.flex-column {
width: 100% !important;
height: auto !important;
position: relative !important;
top: auto !important;
start: auto !important;
}
}
.navbar {
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
}
.username {
font-weight: 600;
color: #667eea;
font-size: 1.1em;
}
.logout-btn {
padding: 10px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-weight: 600;
transition: opacity 0.3s;
}
.logout-btn:hover {
opacity: 0.9;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.card {
background: white;
padding: 25px;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
}
.card h2 {
color: #333;
margin-bottom: 15px;
font-size: 1.5em;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
}
.stat {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #eee;
}
.stat:last-child {
border-bottom: none;
}
.stat-label {
color: #666;
font-weight: 500;
}
.stat-value {
color: #333;
font-weight: bold;
font-size: 1.2em;
}
.status {
display: inline-block;
padding: 5px 15px;
border-radius: 20px;
font-size: 0.9em;
font-weight: bold;
}
.status.online {
background: #4caf50;
color: white;
}
.status.offline {
background: #f44336;
color: white;
}
.world-list {
max-height: 300px;
overflow-y: auto;
}
.world-item {
padding: 15px;
background: #f5f5f5;
border-radius: 5px;
margin-bottom: 10px;
}
.world-item h3 {
color: #333;
margin-bottom: 8px;
}
.world-detail {
color: #666;
font-size: 0.9em;
margin: 3px 0;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}

View File

@@ -0,0 +1,30 @@
/* Custom styling for login page on top of Bootstrap5 */
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
.card {
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2) !important;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
}
.btn-primary:hover {
background: linear-gradient(135deg, #5568d3 0%, #6a3f93 100%);
}
.form-control:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
}
h1 {
color: #333;
font-weight: 600;
}

View File

@@ -0,0 +1,240 @@
let ws = null;
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
const reconnectDelay = 3000;
// Helper function to get cookie value
function getCookie(name) {
const nameEQ = name + '=';
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
cookie = cookie.trim();
if (cookie.indexOf(nameEQ) === 0) {
return decodeURIComponent(cookie.substring(nameEQ.length));
}
}
return null;
}
// Helper function to delete cookie
function deleteCookie(name) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Strict`;
}
// Check authentication on page load
function checkAuthentication() {
// Check localStorage first (most secure)
let token = localStorage.getItem('dashboardToken');
// Fallback to cookie if localStorage empty
if (!token) {
token = getCookie('dashboardToken');
}
if (!token) {
// Redirect to login if no token
window.location.href = '/login';
return false;
}
// Verify token is valid (asynchronous)
fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token })
})
.then(res => {
if (!res.ok) {
console.error('Verify endpoint returned:', res.status);
throw new Error(`HTTP ${res.status}`);
}
return res.json();
})
.then(data => {
console.log('Token verification response:', data);
if (!data.valid) {
// Token is invalid/expired, delete cookies and redirect to login
console.log('Token verification failed, redirecting to login');
deleteCookie('dashboardToken');
deleteCookie('gmLevel');
localStorage.removeItem('dashboardToken');
window.location.href = '/login';
} else {
// Update UI with username
console.log('Token verified, user:', data.username);
const usernameElement = document.querySelector('.username');
if (usernameElement) {
usernameElement.textContent = data.username || 'User';
} else {
console.warn('Username element not found in DOM');
}
// Now that verification is complete, connect to WebSocket
setTimeout(() => {
console.log('Starting WebSocket connection');
connectWebSocket();
}, 100);
}
})
.catch(err => {
console.error('Token verification error:', err);
// Network error - log but don't redirect immediately
// This prevents redirect loops on network issues
});
return true;
}
// Get token from localStorage or cookie
function getAuthToken() {
let token = localStorage.getItem('dashboardToken');
if (!token) {
token = getCookie('dashboardToken');
}
console.log('getAuthToken called, token available:', !!token);
return token;
}
// Logout function
function logout() {
deleteCookie('dashboardToken');
deleteCookie('gmLevel');
localStorage.removeItem('dashboardToken');
window.location.href = '/login';
}
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = getAuthToken();
if (!token) {
console.error('No token available for WebSocket connection');
window.location.href = '/login';
return;
}
console.log(`WebSocket connection attempt ${reconnectAttempts + 1}/${maxReconnectAttempts}`);
// Connect to WebSocket without token in URL (token is in cookies)
const wsUrl = `${protocol}//${window.location.host}/ws`;
console.log(`Connecting to WebSocket: ${wsUrl}`);
try {
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket connected');
reconnectAttempts = 0;
// Subscribe to dashboard updates
ws.send(JSON.stringify({
event: 'subscribe',
subscription: 'dashboard_update'
}));
document.getElementById('connection-status')?.remove();
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Handle subscription confirmation
if (data.subscribed) {
console.log('Subscribed to:', data.subscribed);
return;
}
// Handle dashboard updates
if (data.event === 'dashboard_update') {
updateDashboard(data);
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
console.log('WebSocket disconnected');
ws = null;
// Show connection status
showConnectionStatus('Disconnected - Attempting to reconnect...');
// Attempt to reconnect with exponential backoff
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
const backoffDelay = reconnectDelay * Math.pow(2, reconnectAttempts - 1);
console.log(`Reconnecting in ${backoffDelay}ms (attempt ${reconnectAttempts}/${maxReconnectAttempts})`);
setTimeout(connectWebSocket, backoffDelay);
} else {
console.error('Max reconnection attempts reached');
showConnectionStatus('Connection lost - Reload page to reconnect');
}
};
} catch (error) {
console.error('Failed to create WebSocket:', error);
showConnectionStatus('Failed to connect - Reload page to retry');
}
}
function showConnectionStatus(message) {
let statusEl = document.getElementById('connection-status');
if (!statusEl) {
statusEl = document.createElement('div');
statusEl.id = 'connection-status';
statusEl.style.cssText = 'position: fixed; top: 10px; right: 10px; background: #f44336; color: white; padding: 10px 20px; border-radius: 4px; z-index: 1000;';
document.body.appendChild(statusEl);
}
statusEl.textContent = message;
}
function updateDashboard(data) {
// Update server status
if (data.auth) {
document.getElementById('auth-status').textContent = data.auth.online ? 'Online' : 'Offline';
document.getElementById('auth-status').className = 'status ' + (data.auth.online ? 'online' : 'offline');
}
if (data.chat) {
document.getElementById('chat-status').textContent = data.chat.online ? 'Online' : 'Offline';
document.getElementById('chat-status').className = 'status ' + (data.chat.online ? 'online' : 'offline');
}
// Update world instances
if (data.worlds) {
document.getElementById('world-count').textContent = data.worlds.length;
const worldList = document.getElementById('world-list');
if (data.worlds.length === 0) {
worldList.innerHTML = '<div class="loading">No active world instances</div>';
} else {
worldList.innerHTML = data.worlds.map(world => `
<div class="world-item">
<h3>Zone ${world.mapID} - Instance ${world.instanceID}</h3>
<div class="world-detail">Clone ID: ${world.cloneID}</div>
<div class="world-detail">Players: ${world.players}</div>
<div class="world-detail">Type: ${world.isPrivate ? 'Private' : 'Public'}</div>
</div>
`).join('');
}
}
// Update statistics
if (data.stats) {
if (data.stats.onlinePlayers !== undefined) {
document.getElementById('online-players').textContent = data.stats.onlinePlayers;
}
if (data.stats.totalAccounts !== undefined) {
document.getElementById('total-accounts').textContent = data.stats.totalAccounts;
}
if (data.stats.totalCharacters !== undefined) {
document.getElementById('total-characters').textContent = data.stats.totalCharacters;
}
}
}
// Connect on page load
connectWebSocket();

View File

@@ -0,0 +1,99 @@
// Check if user is already logged in
function checkExistingToken() {
const token = localStorage.getItem('dashboardToken');
if (token) {
verifyTokenAndRedirect(token);
}
}
function verifyTokenAndRedirect(token) {
fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token })
})
.then(res => res.json())
.then(data => {
if (data.valid) {
window.location.href = '/';
}
})
.catch(err => console.error('Token verification failed:', err));
}
function showAlert(message, type) {
const alert = document.getElementById('alert');
alert.textContent = message;
alert.className = 'alert';
if (type === 'error') {
alert.classList.add('alert-danger');
} else if (type === 'success') {
alert.classList.add('alert-success');
}
alert.style.display = 'block';
}
// Wait for DOM to be ready
document.addEventListener('DOMContentLoaded', () => {
const loginForm = document.getElementById('loginForm');
if (!loginForm) {
console.error('Login form not found');
return;
}
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const rememberMe = document.getElementById('rememberMe').checked;
// Validate input
if (!username || !password) {
showAlert('Username and password are required', 'error');
return;
}
if (password.length > 40) {
showAlert('Password exceeds maximum length (40 characters)', 'error');
return;
}
// Show loading state
document.getElementById('loading').style.display = 'inline-block';
document.getElementById('loginBtn').disabled = true;
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, rememberMe })
});
const data = await response.json();
if (data.success) {
// Store token in localStorage (also set as cookie for API calls)
localStorage.setItem('dashboardToken', data.token);
document.cookie = `dashboardToken=${data.token}; path=/; SameSite=Strict`;
showAlert('Login successful! Redirecting...', 'success');
// Redirect after a short delay (no token in URL)
setTimeout(() => {
window.location.href = '/';
}, 1000);
} else {
showAlert(data.message || 'Login failed', 'error');
document.getElementById('loading').style.display = 'none';
document.getElementById('loginBtn').disabled = false;
}
} catch (error) {
showAlert('Network error: ' + error.message, 'error');
document.getElementById('loading').style.display = 'none';
document.getElementById('loginBtn').disabled = false;
}
});
// Check existing token on page load
checkExistingToken();
});

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}DarkflameServer{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.datatables.net/2.3.6/css/dataTables.dataTables.min.css">
<link rel="stylesheet" href="/css/dashboard.css">
{% block css %}{% endblock %}
</head>
<body class="d-flex bg-dark text-white">
{% if username and username != "" %}
{% include "header.jinja2" %}
{% endif %}
<div class="container-fluid py-3">
{% block content_before %}{% endblock %}
{% block content %}{% endblock %}
{% block content_after %}{% endblock %}
</div>
<footer class="mt-5 pt-5 border-top border-secondary text-center pb-3">
{% block footer %}
<p class="text-muted small">DarkflameServer Dashboard &copy; 2024</p>
{% endblock %}
</footer>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.datatables.net/2.3.6/js/jquery.dataTables.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,30 @@
{# Navigation #}
<nav class="navbar navbar-dark bg-dark flex-column" style="width: 280px; height: 100vh; position: fixed; left: 0; top: 0; overflow-y: auto;">
<div class="p-3">
<a class="navbar-brand fw-bold" href="#">🎮 DarkflameServer</a>
</div>
<ul class="navbar-nav flex-column w-100 flex-grow-1 p-3">
<li class="nav-item">
<a class="nav-link active" href="#">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#accounts">Accounts</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#characters">Characters</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#properties">Properties</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#players">Players</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#servers">Servers</a>
</li>
<li class="nav-item mt-auto">
<a class="nav-link" href="#" id="logoutBtn">Logout</a>
</li>
</ul>
</nav>

View File

@@ -0,0 +1,35 @@
{% extends "base.jinja2" %}
{% block title %}Dashboard - DarkflameServer{% endblock %}
{% block content %}
<!-- Main Content -->
<main style="margin-left: 280px;">
<div class="container-fluid p-3 p-md-4">
<div class="row g-3">
{% include "server_status.jinja2" %}
{% include "statistics.jinja2" %}
</div>
{% include "world_instances.jinja2" %}
</div>
</main>
{% endblock %}
{% block scripts %}
<script src="/js/dashboard.js"></script>
<script>
// Check authentication and initialize dashboard
document.addEventListener('DOMContentLoaded', () => {
// checkAuthentication is now async and calls connectWebSocket when ready
checkAuthentication();
// Setup logout button
document.getElementById('logoutBtn').addEventListener('click', (e) => {
e.preventDefault();
logout();
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,53 @@
{% extends "base.jinja2" %}
{% block title %}Dashboard Login - DarkflameServer{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="/css/login.css">
{% endblock %}
{% block content %}
<div class="min-vh-100 d-flex align-items-center justify-content-center">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card shadow-lg border-0">
<div class="card-body p-5">
<h1 class="text-center mb-4">🎮 DarkflameServer</h1>
<div id="alert" class="alert" role="alert" style="display: none;"></div>
<form id="loginForm">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required maxlength="40">
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="rememberMe" name="rememberMe">
<label class="form-check-label" for="rememberMe">
Remember me for 30 days
</label>
</div>
<button type="submit" class="btn btn-primary w-100" id="loginBtn">
<span id="loading" class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" style="display: none;"></span>
<span>Login</span>
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/login.js"></script>
{% endblock %}

View File

@@ -0,0 +1,29 @@
<div class="col-md-6 col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-light">
<h5 class="mb-0">Server Status</h5>
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<span>Auth Server</span>
{% if auth.online %}
<span class="badge bg-success" id="auth-status">Online</span>
{% else %}
<span class="badge bg-danger" id="auth-status">Offline</span>
{% endif %}
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<span>Chat Server</span>
{% if chat.online %}
<span class="badge bg-success" id="chat-status">Online</span>
{% else %}
<span class="badge bg-danger" id="chat-status">Offline</span>
{% endif %}
</div>
<div class="d-flex justify-content-between align-items-center">
<span>Active Worlds</span>
<span class="badge bg-primary" id="world-count">{{ length(worlds) }}</span>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,21 @@
<div class="col-md-6 col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-light">
<h5 class="mb-0">Statistics</h5>
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<span>Online Players</span>
<span class="badge bg-info" id="online-players">{{ stats.onlinePlayers }}</span>
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<span>Total Accounts</span>
<span class="badge bg-info" id="total-accounts">{{ stats.totalAccounts }}</span>
</div>
<div class="d-flex justify-content-between align-items-center">
<span>Total Characters</span>
<span class="badge bg-info" id="total-characters">{{ stats.totalCharacters }}</span>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,37 @@
<div class="card border-0 shadow-sm mt-4">
<div class="card-header bg-light">
<h5 class="mb-0">Active World Instances</h5>
</div>
<div class="card-body">
<div id="world-list">
{% if length(worlds) == 0 %}
<p class="text-muted text-center mb-0">No active world instances</p>
{% else %}
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>Zone</th>
<th>Instance</th>
<th>Clone</th>
<th>Players</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{% for world in worlds %}
<tr>
<td>{{ world.mapID }}</td>
<td>{{ world.instanceID }}</td>
<td>{{ world.cloneID }}</td>
<td><span class="badge bg-secondary">{{ world.players }}</span></td>
<td>{% if world.isPrivate %}<span class="badge bg-warning">Private</span>{% else %}<span class="badge bg-primary">Public</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
</div>

View File

@@ -39,6 +39,22 @@ public:
virtual void UpdateAccountGmLevel(const uint32_t accountId, const eGameMasterLevel gmLevel) = 0;
virtual uint32_t GetAccountCount() = 0;
// Login attempt tracking methods
// Record a failed login attempt
virtual void RecordFailedAttempt(const uint32_t accountId) = 0;
// Clear failed login attempts and update last login time
virtual void ClearFailedAttempts(const uint32_t accountId) = 0;
// Set account lockout
virtual void SetLockout(const uint32_t accountId, const int64_t lockoutUntil) = 0;
// Check if account is locked out
virtual bool IsLockedOut(const uint32_t accountId) = 0;
// Get failed attempt count
virtual uint8_t GetFailedAttempts(const uint32_t accountId) = 0;
};
#endif //!__IACCOUNTS__H__

View File

@@ -126,6 +126,11 @@ public:
void InsertUgcBuild(const std::string& modules, const LWOOBJID bigId, const std::optional<LWOOBJID> characterId) override;
void DeleteUgcBuild(const LWOOBJID bigId) override;
uint32_t GetAccountCount() override;
void RecordFailedAttempt(const uint32_t accountId) override;
void ClearFailedAttempts(const uint32_t accountId) override;
void SetLockout(const uint32_t accountId, const int64_t lockoutUntil) override;
bool IsLockedOut(const uint32_t accountId) override;
uint8_t GetFailedAttempts(const uint32_t accountId) override;
bool IsNameInUse(const std::string_view name) override;
std::optional<IPropertyContents::Model> GetModel(const LWOOBJID modelID) override;
std::optional<IUgc::Model> GetUgcModel(const LWOOBJID ugcId) override;

View File

@@ -45,3 +45,40 @@ uint32_t MySQLDatabase::GetAccountCount() {
auto res = ExecuteSelect("SELECT COUNT(*) as count FROM accounts;");
return res->next() ? res->getUInt("count") : 0;
}
void MySQLDatabase::RecordFailedAttempt(const uint32_t accountId) {
ExecuteUpdate("UPDATE accounts SET failed_attempts = failed_attempts + 1 WHERE id = ?;", accountId);
}
void MySQLDatabase::ClearFailedAttempts(const uint32_t accountId) {
ExecuteUpdate("UPDATE accounts SET failed_attempts = 0, lockout_time = NULL, last_login = NOW() WHERE id = ?;", accountId);
}
void MySQLDatabase::SetLockout(const uint32_t accountId, const int64_t lockoutUntil) {
ExecuteUpdate("UPDATE accounts SET lockout_time = FROM_UNIXTIME(?) WHERE id = ?;", lockoutUntil, accountId);
}
bool MySQLDatabase::IsLockedOut(const uint32_t accountId) {
auto result = ExecuteSelect("SELECT lockout_time FROM accounts WHERE id = ?;", accountId);
if (!result->next()) {
return false;
}
// If lockout_time is set and in the future, account is locked
const char* lockoutTime = result->getString("lockout_time").c_str();
if (lockoutTime == nullptr || strlen(lockoutTime) == 0) {
return false;
}
// Simplified check - if lockout_time exists and is not null, it's locked
return true;
}
uint8_t MySQLDatabase::GetFailedAttempts(const uint32_t accountId) {
auto result = ExecuteSelect("SELECT failed_attempts FROM accounts WHERE id = ?;", accountId);
if (!result->next()) {
return 0;
}
return result->getUInt("failed_attempts");
}

View File

@@ -124,6 +124,11 @@ public:
void InsertUgcBuild(const std::string& modules, const LWOOBJID bigId, const std::optional<LWOOBJID> characterId) override;
void DeleteUgcBuild(const LWOOBJID bigId) override;
uint32_t GetAccountCount() override;
void RecordFailedAttempt(const uint32_t accountId) override;
void ClearFailedAttempts(const uint32_t accountId) override;
void SetLockout(const uint32_t accountId, const int64_t lockoutUntil) override;
bool IsLockedOut(const uint32_t accountId) override;
uint8_t GetFailedAttempts(const uint32_t accountId) override;
bool IsNameInUse(const std::string_view name) override;
std::optional<IPropertyContents::Model> GetModel(const LWOOBJID modelID) override;
std::optional<IUgc::Model> GetUgcModel(const LWOOBJID ugcId) override;

View File

@@ -48,3 +48,39 @@ uint32_t SQLiteDatabase::GetAccountCount() {
return res.getIntField("count");
}
void SQLiteDatabase::RecordFailedAttempt(const uint32_t accountId) {
ExecuteUpdate("UPDATE accounts SET failed_attempts = failed_attempts + 1 WHERE id = ?;", accountId);
}
void SQLiteDatabase::ClearFailedAttempts(const uint32_t accountId) {
ExecuteUpdate("UPDATE accounts SET failed_attempts = 0, lockout_time = NULL, last_login = CURRENT_TIMESTAMP WHERE id = ?;", accountId);
}
void SQLiteDatabase::SetLockout(const uint32_t accountId, const int64_t lockoutUntil) {
ExecuteUpdate("UPDATE accounts SET lockout_time = datetime(?, 'unixepoch') WHERE id = ?;", lockoutUntil, accountId);
}
bool SQLiteDatabase::IsLockedOut(const uint32_t accountId) {
auto [_, result] = ExecuteSelect("SELECT lockout_time FROM accounts WHERE id = ?;", accountId);
if (result.eof()) {
return false;
}
const char* lockoutTime = result.getStringField("lockout_time");
if (lockoutTime == nullptr || strlen(lockoutTime) == 0 || strcmp(lockoutTime, "0") == 0) {
return false;
}
// If lockout_time is set and in the future, account is locked
// For now, simplified check - if lockout_time exists, it's locked
return true;
}
uint8_t SQLiteDatabase::GetFailedAttempts(const uint32_t accountId) {
auto [_, result] = ExecuteSelect("SELECT failed_attempts FROM accounts WHERE id = ?;", accountId);
if (result.eof()) {
return 0;
}
return result.getIntField("failed_attempts");
}

View File

@@ -103,6 +103,11 @@ class TestSQLDatabase : public GameDatabase {
void InsertUgcBuild(const std::string& modules, const LWOOBJID bigId, const std::optional<LWOOBJID> characterId) override {};
void DeleteUgcBuild(const LWOOBJID bigId) override {};
uint32_t GetAccountCount() override { return 0; };
void RecordFailedAttempt(const uint32_t accountId) override {};
void ClearFailedAttempts(const uint32_t accountId) override {};
void SetLockout(const uint32_t accountId, const int64_t lockoutUntil) override {};
bool IsLockedOut(const uint32_t accountId) override { return false; };
uint8_t GetFailedAttempts(const uint32_t accountId) override { return 0; };
bool IsNameInUse(const std::string_view name) override { return false; };
std::optional<IPropertyContents::Model> GetModel(const LWOOBJID modelID) override { return {}; }

View File

@@ -68,6 +68,7 @@ void HandlePacket(Packet* packet);
std::map<uint32_t, std::string> activeSessions;
SystemAddress authServerMasterPeerSysAddr;
SystemAddress chatServerMasterPeerSysAddr;
SystemAddress dashboardServerMasterPeerSysAddr;
int GenerateBCryptPassword(const std::string& password, const int workFactor, char salt[BCRYPT_HASHSIZE], char hash[BCRYPT_HASHSIZE]) {
int32_t bcryptState = ::bcrypt_gensalt(workFactor, salt);
@@ -381,6 +382,11 @@ int main(int argc, char** argv) {
StartAuthServer();
}
// Start web dashboard if enabled
if (Game::config->GetValue("enable_dashboard") == "1") {
StartDashboardServer();
}
auto t = std::chrono::high_resolution_clock::now();
Packet* packet = nullptr;
constexpr uint32_t logFlushTime = 15 * masterFramerate;
@@ -505,6 +511,11 @@ void HandlePacket(Packet* packet) {
authServerMasterPeerSysAddr = UNASSIGNED_SYSTEM_ADDRESS;
StartAuthServer();
}
if (packet->systemAddress == dashboardServerMasterPeerSysAddr) {
dashboardServerMasterPeerSysAddr = UNASSIGNED_SYSTEM_ADDRESS;
StartDashboardServer();
}
}
if (packet->data[0] == ID_CONNECTION_LOST) {
@@ -526,6 +537,11 @@ void HandlePacket(Packet* packet) {
authServerMasterPeerSysAddr = UNASSIGNED_SYSTEM_ADDRESS;
StartAuthServer();
}
if (packet->systemAddress == dashboardServerMasterPeerSysAddr) {
dashboardServerMasterPeerSysAddr = UNASSIGNED_SYSTEM_ADDRESS;
StartDashboardServer();
}
}
if (packet->length < 4) return;
@@ -609,6 +625,9 @@ void HandlePacket(Packet* packet) {
case ServiceType::AUTH:
authServerMasterPeerSysAddr = packet->systemAddress;
break;
case ServiceType::DASHBOARD:
dashboardServerMasterPeerSysAddr = packet->systemAddress;
break;
default:
// We just ignore any other server type
break;
@@ -907,7 +926,10 @@ int ShutdownSequence(int32_t signal) {
}
}
if (allInstancesShutdown && authServerMasterPeerSysAddr == UNASSIGNED_SYSTEM_ADDRESS && chatServerMasterPeerSysAddr == UNASSIGNED_SYSTEM_ADDRESS) {
if (allInstancesShutdown && \
authServerMasterPeerSysAddr == UNASSIGNED_SYSTEM_ADDRESS && \
chatServerMasterPeerSysAddr == UNASSIGNED_SYSTEM_ADDRESS && \
dashboardServerMasterPeerSysAddr == UNASSIGNED_SYSTEM_ADDRESS) {
LOG("Finished shutting down MasterServer!");
break;
}
@@ -919,6 +941,26 @@ int ShutdownSequence(int32_t signal) {
if (framesSinceShutdownStart == maxShutdownTime) {
LOG("Finished shutting down by timeout!");
// log what we were waiting on: worlds, chat, auth, dashboard, etc
if (authServerMasterPeerSysAddr != UNASSIGNED_SYSTEM_ADDRESS) {
LOG("Auth server did not shutdown in time");
}
if (chatServerMasterPeerSysAddr != UNASSIGNED_SYSTEM_ADDRESS) {
LOG("Chat server did not shutdown in time");
}
if (dashboardServerMasterPeerSysAddr != UNASSIGNED_SYSTEM_ADDRESS) {
LOG("Web server did not shutdown in time");
}
for (const auto& instance : Game::im->GetInstances()) {
if (instance == nullptr) {
continue;
}
if (!instance->GetShutdownComplete()) {
LOG("Instance zone %i clone %i instance %i port %i did not shutdown in time", instance->GetMapID(), instance->GetCloneID(), instance->GetInstanceID(), instance->GetPort());
}
}
break;
}
}

View File

@@ -107,6 +107,42 @@ uint32_t StartAuthServer() {
return auth_pid;
}
uint32_t StartDashboardServer() {
if (Game::ShouldShutdown()) {
LOG("Currently shutting down. DashboardServer will not be restarted.");
return 0;
}
auto web_path = BinaryPathFinder::GetBinaryDir() / "DashboardServer";
#ifdef _WIN32
web_path.replace_extension(".exe");
auto web_startup = startup;
auto web_info = PROCESS_INFORMATION{};
if (!CreateProcessW(web_path.wstring().data(), web_path.wstring().data(),
nullptr, nullptr, false, 0, nullptr, nullptr,
&web_startup, &web_info))
{
LOG("Failed to launch DashboardServer");
return 0;
}
// get pid and close unused handles
auto web_pid = web_info.dwProcessId;
CloseHandle(web_info.hProcess);
CloseHandle(web_info.hThread);
#else // *nix systems
const auto web_pid = fork();
if (web_pid < 0) {
LOG("Failed to launch DashboardServer");
return 0;
} else if (web_pid == 0) {
// We are the child process
execl(web_path.string().c_str(), web_path.string().c_str(), nullptr);
}
#endif
LOG("DashboardServer PID is %d", web_pid);
return web_pid;
}
uint32_t StartWorldServer(LWOMAPID mapID, uint16_t port, LWOINSTANCEID lastInstanceID, int maxPlayers, LWOCLONEID cloneID) {
auto world_path = BinaryPathFinder::GetBinaryDir() / "WorldServer";
#ifdef _WIN32

View File

@@ -3,4 +3,5 @@
uint32_t StartAuthServer();
uint32_t StartChatServer();
uint32_t StartDashboardServer();
uint32_t StartWorldServer(LWOMAPID mapID, uint16_t port, LWOINSTANCEID lastInstanceID, int maxPlayers, LWOCLONEID cloneID);

View File

@@ -93,6 +93,7 @@ void MasterPackets::HandleServerInfo(Packet* packet) {
}
void MasterPackets::SendServerInfo(dServer* server, Packet* packet) {
LOG("SendServerInfo called for server type %i", static_cast<int>(server->GetServerType()));
RakNet::BitStream bitStream;
BitStreamUtils::WriteHeader(bitStream, ServiceType::MASTER, MessageType::Master::SERVER_INFO);

View File

@@ -144,7 +144,7 @@ Packet* dServer::ReceiveFromMaster() {
break;
}
case ID_CONNECTION_REQUEST_ACCEPTED: {
LOG("Established connection to master, zone (%i), instance (%i)", this->GetZoneID(), this->GetInstanceID());
LOG("Established connection to master: ServiceType (%s), Zone (%i), Instance (%i)", StringifiedEnum::ToString(this->GetServerType()).data(), this->GetZoneID(), this->GetInstanceID());
mMasterConnectionActive = true;
mMasterSystemAddress = packet->systemAddress;
MasterPackets::SendServerInfo(this, packet);

130
dWeb/AuthMiddleware.cpp Normal file
View File

@@ -0,0 +1,130 @@
#include "AuthMiddleware.h"
#include "HTTPContext.h"
#include "eHTTPStatusCode.h"
#include <algorithm>
#include "Logger.h"
// Forward declare DashboardAuthService::VerifyToken
// This will be implemented in the dashboard server
namespace DashboardAuthService {
bool VerifyToken(const std::string& token, std::string& username, uint8_t& gmLevel);
}
bool AuthMiddleware::Process(HTTPContext& context, HTTPReply& reply) {
// Try to extract token from query string first
std::string token = ExtractTokenFromQueryString(context.queryString);
// If not found in query string, try cookies
if (token.empty()) {
const std::string& cookieHeader = context.GetHeader("Cookie");
if (!cookieHeader.empty()) {
token = ExtractTokenFromCookies(cookieHeader);
}
}
// If not found in query or cookies, try Authorization header (API token)
if (token.empty()) {
const std::string& authHeader = context.GetHeader("Authorization");
if (!authHeader.empty()) {
token = ExtractTokenFromAuthHeader(authHeader);
}
}
// If token found, verify it
if (!token.empty()) {
std::string username{};
uint8_t gmLevel = 0;
if (DashboardAuthService::VerifyToken(token, username, gmLevel)) {
context.isAuthenticated = true;
context.authenticatedUser = username;
context.gmLevel = gmLevel;
LOG_DEBUG("Authenticated user %s (GM level %d)", username.c_str(), gmLevel);
return true; // Continue to next middleware
} else {
LOG_DEBUG("Failed to verify token from %s", context.clientIP.c_str());
}
}
// No valid token found, but we don't fail here
// Routes can check context.isAuthenticated if they require auth
return true;
}
std::string AuthMiddleware::ExtractTokenFromQueryString(const std::string& queryString) {
if (queryString.empty()) return "";
const std::string tokenPrefix = "token=";
const size_t tokenPos = queryString.find(tokenPrefix);
if (tokenPos == std::string::npos) {
return "";
}
const size_t valueStart = tokenPos + tokenPrefix.length();
const size_t valueEnd = queryString.find("&", valueStart);
if (valueEnd == std::string::npos) {
return queryString.substr(valueStart);
}
return queryString.substr(valueStart, valueEnd - valueStart);
}
std::string AuthMiddleware::ExtractTokenFromCookies(const std::string& cookieHeader) {
if (cookieHeader.empty()) return "";
const std::string searchStr = "dashboardToken=";
const size_t pos = cookieHeader.find(searchStr);
if (pos == std::string::npos) {
return "";
}
const size_t valueStart = pos + searchStr.length();
const size_t valueEnd = cookieHeader.find(";", valueStart);
std::string value;
if (valueEnd == std::string::npos) {
value = cookieHeader.substr(valueStart);
} else {
value = cookieHeader.substr(valueStart, valueEnd - valueStart);
}
// URL decode the value
std::string decoded{};
for (size_t i = 0; i < value.length(); ++i) {
if (value[i] == '%' && i + 2 < value.length()) {
const std::string hex = value.substr(i + 1, 2);
char* endptr = nullptr;
const int charCode = static_cast<int>(std::strtol(hex.c_str(), &endptr, 16));
if (endptr - hex.c_str() == 2) {
decoded += static_cast<char>(charCode);
i += 2;
continue;
}
}
decoded += value[i];
}
return decoded;
}
std::string AuthMiddleware::ExtractTokenFromAuthHeader(const std::string& authHeader) {
if (authHeader.empty()) return "";
// Check for "Bearer <token>" format
const std::string bearerPrefix = "Bearer ";
if (authHeader.find(bearerPrefix) == 0) {
return authHeader.substr(bearerPrefix.length());
}
// Also check for "Token <token>" format (API tokens)
const std::string tokenPrefix = "Token ";
if (authHeader.find(tokenPrefix) == 0) {
return authHeader.substr(tokenPrefix.length());
}
// Return as-is if no prefix (raw token)
return authHeader;
}

43
dWeb/AuthMiddleware.h Normal file
View File

@@ -0,0 +1,43 @@
#pragma once
#include "IHTTPMiddleware.h"
#include <vector>
/**
* Authentication Middleware
*
* Verifies JWT tokens from:
* - Query parameter: ?token=...
* - Cookie: dashboardToken=...
* - Authorization header: Bearer <token> or Token <token>
*
* Populates HTTPContext with authentication information if valid.
* Does NOT fail on missing auth - that's left to specific routes.
*/
class AuthMiddleware : public IHTTPMiddleware {
public:
AuthMiddleware() = default;
bool Process(HTTPContext& context, HTTPReply& reply) override;
std::string GetName() const override { return "AuthMiddleware"; }
private:
/**
* Extract token from query string
* Expected format: "?token=eyJhbGc..." or "&token=eyJhbGc..."
*/
static std::string ExtractTokenFromQueryString(const std::string& queryString);
/**
* Extract token from Cookie header
* Looks for "dashboardToken=..." cookie
*/
static std::string ExtractTokenFromCookies(const std::string& cookieHeader);
/**
* Extract token from Authorization header
* Supports: "Bearer <token>", "Token <token>", or raw token
*/
static std::string ExtractTokenFromAuthHeader(const std::string& authHeader);
};

59
dWeb/HTTPContext.h Normal file
View File

@@ -0,0 +1,59 @@
#pragma once
#include <string>
#include <map>
#include <memory>
#include <algorithm>
#include "eHTTPStatusCode.h"
/**
* HTTP Request Context
*
* Carries all request metadata through the middleware chain.
* Populated by the Web framework before middleware/handlers are called.
*/
struct HTTPContext {
// Request metadata
std::string method{};
std::string path{};
std::string queryString{};
std::string body{};
// Request headers (header name -> value)
// Header names are lowercase for case-insensitive lookup
std::map<std::string, std::string> headers{};
// Client information
std::string clientIP{};
// Authentication information (populated by auth middleware)
bool isAuthenticated = false;
std::string authenticatedUser{};
uint8_t gmLevel = 0;
// Custom data for middleware to communicate
std::map<std::string, std::string> userData{};
/**
* Get header value (case-insensitive)
*/
const std::string& GetHeader(const std::string& headerName) const {
static const std::string empty{};
// Convert to lowercase for comparison
std::string lowerName = headerName;
std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), ::tolower);
const auto it = headers.find(lowerName);
return it != headers.end() ? it->second : empty;
}
/**
* Set header value (automatically lowercased)
*/
void SetHeader(const std::string& headerName, const std::string& value) {
std::string lowerName = headerName;
std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), ::tolower);
headers[lowerName] = value;
}
};

38
dWeb/IHTTPMiddleware.h Normal file
View File

@@ -0,0 +1,38 @@
#pragma once
#include <memory>
#include "HTTPContext.h"
// Forward declaration
struct HTTPReply;
/**
* Middleware Interface
*
* All middleware implements this interface and is called in order during request processing.
* Middleware can:
* - Inspect and modify the request (HTTPContext)
* - Populate authentication/authorization info
* - Short-circuit the chain by setting a reply and returning false
* - Pass to the next middleware by returning true
*/
class IHTTPMiddleware {
public:
virtual ~IHTTPMiddleware() = default;
/**
* Process the request through this middleware
*
* @param context The HTTP request context
* @param reply The HTTP reply (can be populated to short-circuit)
* @return true to continue to next middleware, false to stop processing
*/
virtual bool Process(HTTPContext& context, HTTPReply& reply) = 0;
/**
* Get a friendly name for this middleware
*/
virtual std::string GetName() const = 0;
};
using MiddlewarePtr = std::shared_ptr<IHTTPMiddleware>;

View File

@@ -0,0 +1,25 @@
#include "RequireAuthMiddleware.h"
#include "HTTPContext.h"
#include "Game.h"
#include "Logger.h"
bool RequireAuthMiddleware::Process(HTTPContext& context, HTTPReply& reply) {
if (!context.isAuthenticated) {
LOG_DEBUG("Rejected request to %s: not authenticated", context.path.c_str());
reply.status = eHTTPStatusCode::UNAUTHORIZED;
reply.message = R"({"error":"Unauthorized","message":"Authentication required"})";
reply.contentType = eContentType::APPLICATION_JSON;
return false; // Stop processing chain
}
if (context.gmLevel < minGmLevel) {
LOG("Rejected request to %s: insufficient permissions (gmLevel=%d, required=%d)",
context.path.c_str(), context.gmLevel, minGmLevel);
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = R"({"error":"Forbidden","message":"Insufficient permissions"})";
reply.contentType = eContentType::APPLICATION_JSON;
return false; // Stop processing chain
}
return true; // Continue to next middleware
}

View File

@@ -0,0 +1,33 @@
#pragma once
#include "IHTTPMiddleware.h"
#include "eHTTPStatusCode.h"
/**
* Require Authentication Middleware
*
* Verifies that the request has been authenticated.
* Must be placed AFTER AuthMiddleware in the chain.
*
* Fails with 401 Unauthorized if not authenticated.
* Optionally checks for minimum GM level.
*/
class RequireAuthMiddleware : public IHTTPMiddleware {
public:
/**
* Create a require auth middleware
*
* @param minGmLevel Minimum GM level required (0 = any authenticated user)
*/
explicit RequireAuthMiddleware(uint8_t minGmLevel = 0)
: minGmLevel(minGmLevel) {}
bool Process(HTTPContext& context, HTTPReply& reply) override;
std::string GetName() const override {
return "RequireAuthMiddleware(minGM=" + std::to_string(minGmLevel) + ")";
}
private:
uint8_t minGmLevel{};
};

View File

@@ -6,30 +6,134 @@
#include "eHTTPMethod.h"
#include "GeneralUtils.h"
#include "JSONUtils.h"
#include "HTTPContext.h"
#include "IHTTPMiddleware.h"
#include <ranges>
#include <set>
#include <cctype>
namespace Game {
Web web;
}
namespace {
const char* jsonContentType = "Content-Type: application/json\r\n";
const std::string wsSubscribed = "{\"status\":\"subscribed\"}";
const std::string wsUnsubscribed = "{\"status\":\"unsubscribed\"}";
std::map<std::pair<eHTTPMethod, std::string>, HTTPRoute> g_HTTPRoutes;
std::map<std::string, WSEvent> g_WSEvents;
std::vector<std::string> g_WSSubscriptions;
// Keep track of authenticated WebSocket connections
std::set<mg_connection*> g_AuthenticatedWSConnections;
// Global middleware applied to all routes
std::vector<MiddlewarePtr> g_GlobalMiddleware;
// Helper to extract client IP from mongoose connection
static std::string GetClientIP(mg_connection* connection) {
if (!connection) return "unknown";
const uint8_t* ip = connection->rem.ip;
// Check for IPv4-mapped IPv6 addresses (::ffff:x.x.x.x)
if (ip[0] == 0 && ip[1] == 0 && ip[2] == 0 && ip[3] == 0 &&
ip[4] == 0 && ip[5] == 0 && ip[6] == 0 && ip[7] == 0 &&
ip[8] == 0 && ip[9] == 0 && ip[10] == 0xff && ip[11] == 0xff) {
// IPv4 address is in bytes 12-15
char buffer[32]{};
snprintf(buffer, sizeof(buffer), "%d.%d.%d.%d",
ip[12], ip[13], ip[14], ip[15]);
return buffer;
}
// Direct IPv4
char buffer[32]{};
snprintf(buffer, sizeof(buffer), "%d.%d.%d.%d",
ip[0], ip[1], ip[2], ip[3]);
return buffer;
}
// Helper to populate HTTPContext from mg_http_message
static void PopulateHTTPContext(HTTPContext& context,
const mg_http_message* http_msg,
mg_connection* connection) {
// Parse method
context.method = std::string(http_msg->method.buf, http_msg->method.len);
// Parse URI/path
std::string uri(http_msg->uri.buf, http_msg->uri.len);
std::transform(uri.begin(), uri.end(), uri.begin(), ::tolower);
// Split path and query string
const size_t queryPos = uri.find('?');
if (queryPos != std::string::npos) {
context.path = uri.substr(0, queryPos);
context.queryString = uri.substr(queryPos + 1);
} else {
context.path = uri;
context.queryString = "";
}
// Parse body
context.body = std::string(http_msg->body.buf, http_msg->body.len);
// Parse common headers (case-insensitive)
const struct mg_str* hdr_ptr;
// Get Content-Type
if ((hdr_ptr = mg_http_get_header(const_cast<mg_http_message*>(http_msg), "Content-Type")) != NULL) {
context.SetHeader("Content-Type", std::string(hdr_ptr->buf, hdr_ptr->len));
}
// Get Cookie
if ((hdr_ptr = mg_http_get_header(const_cast<mg_http_message*>(http_msg), "Cookie")) != NULL) {
context.SetHeader("Cookie", std::string(hdr_ptr->buf, hdr_ptr->len));
}
// Get Authorization
if ((hdr_ptr = mg_http_get_header(const_cast<mg_http_message*>(http_msg), "Authorization")) != NULL) {
context.SetHeader("Authorization", std::string(hdr_ptr->buf, hdr_ptr->len));
}
// Get User-Agent
if ((hdr_ptr = mg_http_get_header(const_cast<mg_http_message*>(http_msg), "User-Agent")) != NULL) {
context.SetHeader("User-Agent", std::string(hdr_ptr->buf, hdr_ptr->len));
}
// Get Host
if ((hdr_ptr = mg_http_get_header(const_cast<mg_http_message*>(http_msg), "Host")) != NULL) {
context.SetHeader("Host", std::string(hdr_ptr->buf, hdr_ptr->len));
}
// Get client IP
context.clientIP = GetClientIP(connection);
}
const char* ContentTypeToString(eContentType contentType) {
switch (contentType) {
case eContentType::APPLICATION_JSON:
return "application/json";
case eContentType::TEXT_HTML:
return "text/html; charset=utf-8";
case eContentType::TEXT_CSS:
return "text/css; charset=utf-8";
case eContentType::TEXT_JAVASCRIPT:
return "application/javascript; charset=utf-8";
case eContentType::TEXT_PLAIN:
return "text/plain; charset=utf-8";
case eContentType::IMAGE_PNG:
return "image/png";
case eContentType::IMAGE_JPEG:
return "image/jpeg";
case eContentType::APPLICATION_OCTET_STREAM:
return "application/octet-stream";
default:
return "application/json";
}
}
}
using json = nlohmann::json;
bool ValidateAuthentication(const mg_http_message* http_msg) {
// TO DO: This is just a placeholder for now
// use tokens or something at a later point if we want to implement authentication
// bit using the listen bind address to limit external access is good enough to start with
return true;
}
void HandleHTTPMessage(mg_connection* connection, const mg_http_message* http_msg) {
if (g_HTTPRoutes.empty()) return;
@@ -38,46 +142,136 @@ void HandleHTTPMessage(mg_connection* connection, const mg_http_message* http_ms
if (!http_msg) {
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid Request\"}";
} else if (ValidateAuthentication(http_msg)) {
// convert method from cstring to std string
} else {
// All authentication is now handled by middleware chain
// Convert method from cstring to enum
std::string method_string(http_msg->method.buf, http_msg->method.len);
// get method from mg to enum
const eHTTPMethod method = magic_enum::enum_cast<eHTTPMethod>(method_string).value_or(eHTTPMethod::INVALID);
// convert uri from cstring to std string
// Extract URI and convert to lowercase
std::string uri(http_msg->uri.buf, http_msg->uri.len);
std::transform(uri.begin(), uri.end(), uri.begin(), ::tolower);
// convert body from cstring to std string
std::string body(http_msg->body.buf, http_msg->body.len);
// Special case for websocket
if (uri == "/ws" && method == eHTTPMethod::GET) {
mg_ws_upgrade(connection, const_cast<mg_http_message*>(http_msg), NULL);
LOG_DEBUG("Upgraded connection to websocket: %d.%d.%d.%d:%i", MG_IPADDR_PARTS(&connection->rem.ip), connection->rem.port);
// return cause they are now a websocket
// Check if connection is from localhost/internal network
bool isInternal = false;
const uint8_t* ip = connection->rem.ip;
// Check for IPv4-mapped IPv6 addresses (::ffff:x.x.x.x)
if (ip[0] == 0 && ip[1] == 0 && ip[2] == 0 && ip[3] == 0 &&
ip[4] == 0 && ip[5] == 0 && ip[6] == 0 && ip[7] == 0 &&
ip[8] == 0 && ip[9] == 0 && ip[10] == 0xff && ip[11] == 0xff) {
// IPv4 address is in bytes 12-15
uint8_t b1 = ip[12];
uint8_t b2 = ip[13];
// Check for 127.x.x.x (localhost)
if (b1 == 127) {
isInternal = true;
}
// Check for 192.168.x.x
else if (b1 == 192 && b2 == 168) {
isInternal = true;
}
// Check for 10.x.x.x
else if (b1 == 10) {
isInternal = true;
}
// Check for 172.16.x.x to 172.31.x.x
else if (b1 == 172 && b2 >= 16 && b2 <= 31) {
isInternal = true;
}
}
bool authenticated = isInternal; // Internal connections are automatically trusted
// For external connections, require authentication cookie
if (!isInternal) {
const auto* cookieHeader = mg_http_get_header(const_cast<mg_http_message*>(http_msg), "Cookie");
if (cookieHeader) {
std::string cookieStr = std::string(cookieHeader->buf, cookieHeader->len);
if (!cookieStr.empty() && cookieStr.find("dashboardToken=") != std::string::npos) {
authenticated = true;
}
}
}
if (authenticated) {
mg_ws_upgrade(connection, const_cast<mg_http_message*>(http_msg), NULL);
g_AuthenticatedWSConnections.insert(connection);
const char* connType = isInternal ? "internal" : "external";
LOG_DEBUG("Upgraded %s connection to websocket: %d.%d.%d.%d:%i", connType, MG_IPADDR_PARTS(&connection->rem.ip), connection->rem.port);
} else {
LOG_DEBUG("Rejected WebSocket connection - no valid authentication from %d.%d.%d.%d:%i", MG_IPADDR_PARTS(&connection->rem.ip), connection->rem.port);
reply.status = eHTTPStatusCode::UNAUTHORIZED;
reply.message = "{\"error\":\"Unauthorized\"}";
std::string headers = std::string("Content-Type: ") + ContentTypeToString(reply.contentType) + "\r\n";
if (!reply.location.empty()) {
headers += "Location: " + reply.location + "\r\n";
}
mg_http_reply(connection, static_cast<int>(reply.status), headers.c_str(), reply.message.c_str());
}
// return cause they are now a websocket or connection closed
return;
}
// Handle HTTP request
const auto routeItr = g_HTTPRoutes.find({method, uri});
if (routeItr != g_HTTPRoutes.end()) {
const auto& [_, route] = *routeItr;
route.handle(reply, body);
const auto& route = routeItr->second;
// Create HTTP context from request
HTTPContext context;
PopulateHTTPContext(context, http_msg, connection);
// Build complete middleware chain
std::vector<MiddlewarePtr> middlewareChain = g_GlobalMiddleware;
middlewareChain.insert(middlewareChain.end(),
route.middleware.begin(),
route.middleware.end());
// Execute middleware chain
bool chainPassed = true;
for (const auto& middleware : middlewareChain) {
if (!middleware->Process(context, reply)) {
chainPassed = false;
LOG_DEBUG("Middleware %s rejected request to %s %s",
middleware->GetName().c_str(),
context.method.c_str(),
context.path.c_str());
break;
}
}
// Call handler only if all middleware passed
if (chainPassed) {
route.handle(reply, context);
}
} else {
reply.status = eHTTPStatusCode::NOT_FOUND;
reply.message = "{\"error\":\"Not Found\"}";
}
} else {
reply.status = eHTTPStatusCode::UNAUTHORIZED;
reply.message = "{\"error\":\"Unauthorized\"}";
}
mg_http_reply(connection, static_cast<int>(reply.status), jsonContentType, reply.message.c_str());
// Build headers
std::string headers = std::string("Content-Type: ") + ContentTypeToString(reply.contentType) + "\r\n";
if (!reply.location.empty()) {
headers += "Location: " + reply.location + "\r\n";
}
mg_http_reply(connection, static_cast<int>(reply.status), headers.c_str(), reply.message.c_str());
}
void HandleWSMessage(mg_connection* connection, const mg_ws_message* ws_msg) {
// Check if connection is authenticated
if (g_AuthenticatedWSConnections.find(connection) == g_AuthenticatedWSConnections.end()) {
LOG_DEBUG("Received websocket message from unauthenticated connection");
mg_ws_send(connection, "{\"error\":\"Unauthorized\"}", 23, WEBSOCKET_OP_TEXT);
return;
}
if (!ws_msg) {
LOG_DEBUG("Received invalid websocket message");
return;
@@ -233,6 +427,15 @@ void Web::RegisterWSSubscription(const std::string& subscription) {
}
}
void Web::AddGlobalMiddleware(MiddlewarePtr middleware) {
if (!middleware) {
LOG_DEBUG("Attempted to add null middleware");
return;
}
g_GlobalMiddleware.push_back(middleware);
LOG_DEBUG("Registered global middleware: %s", middleware->GetName().c_str());
}
Web::Web() {
mg_log_set_fn(DLOG, NULL); // Redirect logs to our logger
mg_log_set(MG_LL_DEBUG);
@@ -293,6 +496,18 @@ void Web::SendWSMessage(const std::string subscription, json& data) {
// tell it the event type
data["event"] = subscription;
auto index = std::distance(g_WSSubscriptions.begin(), subItr);
// Clean up closed connections from authenticated set
std::vector<mg_connection*> closedConnections;
for (auto* conn : g_AuthenticatedWSConnections) {
if (conn->is_closing) {
closedConnections.push_back(conn);
}
}
for (auto* conn : closedConnections) {
g_AuthenticatedWSConnections.erase(conn);
}
for (auto *wc = Game::web.mgr.conns; wc != NULL; wc = wc->next) {
if (wc->is_websocket && wc->data[index] == SubscriptionStatus::SUBSCRIBED) {
mg_ws_send(wc, data.dump().c_str(), data.dump().size(), WEBSOCKET_OP_TEXT);

View File

@@ -4,9 +4,13 @@
#include <functional>
#include <string>
#include <optional>
#include <vector>
#include <memory>
#include "mongoose.h"
#include "json_fwd.hpp"
#include "eHTTPStatusCode.h"
#include "HTTPContext.h"
#include "IHTTPMiddleware.h"
// Forward declarations for game namespace
// so that we can access the data anywhere
@@ -20,20 +24,35 @@ enum class eHTTPMethod;
// Forward declaration for mongoose manager
typedef struct mg_mgr mg_mgr;
// Content type enum for HTTP responses
enum class eContentType {
APPLICATION_JSON,
TEXT_HTML,
TEXT_CSS,
TEXT_JAVASCRIPT,
TEXT_PLAIN,
IMAGE_PNG,
IMAGE_JPEG,
APPLICATION_OCTET_STREAM
};
// For passing HTTP messages between functions
struct HTTPReply {
eHTTPStatusCode status = eHTTPStatusCode::NOT_FOUND;
std::string message = "{\"error\":\"Not Found\"}";
eContentType contentType = eContentType::APPLICATION_JSON;
std::string location = ""; // For redirect responses (Location header)
};
// HTTP route structure
// This structure is used to register HTTP routes
// with the server. Each route has a path, method, and a handler function
// that will be called when the route is matched.
// with the server. Each route has a path, method, optional middleware,
// and a handler function that will be called when the route is matched.
struct HTTPRoute {
std::string path;
eHTTPMethod method;
std::function<void(HTTPReply&, const std::string&)> handle;
std::vector<MiddlewarePtr> middleware;
std::function<void(HTTPReply&, const HTTPContext&)> handle;
};
// WebSocket event structure
@@ -68,6 +87,8 @@ public:
void RegisterWSEvent(WSEvent event);
// Register WebSocket subscription to be handled by the server
void RegisterWSSubscription(const std::string& subscription);
// Add global middleware that applies to all routes
void AddGlobalMiddleware(MiddlewarePtr middleware);
// Returns if the web server is enabled
bool IsEnabled() const { return enabled; };
// Send a message to all connected WebSocket clients that are subscribed to the given topic

585
docs/DasshboardWebAPI.yaml Normal file
View File

@@ -0,0 +1,585 @@
openapi: 3.0.0
info:
title: DarkflameServer Dashboard API
description: |
Game server management and monitoring API for DarkflameServer Dashboard.
All protected endpoints require JWT authentication.
version: 1.0.0
contact:
name: DarkflameServer Team
url: https://github.com/DarkflameUniverse/DarkflameServer
license:
name: MIT
servers:
- url: http://localhost:3000
description: Local development server
- url: https://api.example.com
description: Production server
tags:
- name: Authentication
description: User login and token verification
- name: Server
description: Server status and information
- name: Players
description: Player and character management
- name: Statistics
description: Server statistics and counts
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: JWT token obtained from login endpoint
queryToken:
type: apiKey
in: query
name: token
description: JWT token as query parameter
cookieToken:
type: apiKey
in: cookie
name: dashboardToken
description: JWT token as HTTP-only cookie
schemas:
LoginRequest:
type: object
required:
- username
- password
properties:
username:
type: string
minLength: 1
example: admin
password:
type: string
minLength: 1
example: password123
rememberMe:
type: boolean
default: false
description: Extends token expiration to 30 days
LoginResponse:
type: object
required:
- success
- token
- gmLevel
- expiresIn
properties:
success:
type: boolean
example: true
token:
type: string
description: JWT token for authenticated requests
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
gmLevel:
type: integer
minimum: 0
maximum: 9
example: 9
description: User's GM level (0-9)
expiresIn:
type: integer
description: Token expiration time in seconds
example: 86400
LoginError:
type: object
required:
- success
- error
properties:
success:
type: boolean
example: false
error:
type: string
example: Invalid username or password
VerifyTokenRequest:
type: object
required:
- token
properties:
token:
type: string
description: JWT token to verify
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
VerifyTokenResponse:
type: object
required:
- valid
- username
- gmLevel
- expiresAt
properties:
valid:
type: boolean
example: true
username:
type: string
example: admin
gmLevel:
type: integer
minimum: 0
maximum: 9
example: 9
expiresAt:
type: integer
description: Unix timestamp when token expires
example: 1705960800
VerifyTokenError:
type: object
required:
- valid
- error
properties:
valid:
type: boolean
example: false
error:
type: string
example: Invalid or expired token
ServerStatus:
type: object
required:
- status
- version
- uptime
- timestamp
properties:
status:
type: string
enum:
- running
- stopping
- restarting
example: running
version:
type: string
example: 1.0.0
uptime:
type: integer
description: Server uptime in seconds
example: 3600
timestamp:
type: integer
description: Current Unix timestamp
example: 1705960800
Player:
type: object
required:
- id
- name
- level
- character
- zone
- lastSeen
properties:
id:
type: integer
example: 1
name:
type: string
example: PlayerOne
level:
type: integer
minimum: 1
maximum: 999
example: 20
character:
type: string
example: Knight
zone:
type: string
example: Nimbus Station
lastSeen:
type: integer
description: Unix timestamp of last activity
example: 1705960750
PlayersResponse:
type: object
required:
- players
- total
- limit
- offset
properties:
players:
type: array
items:
$ref: '#/components/schemas/Player'
total:
type: integer
description: Total number of players
example: 42
limit:
type: integer
description: Requested limit
example: 50
offset:
type: integer
description: Requested offset
example: 0
AccountsCountRequest:
type: object
properties:
includeInactive:
type: boolean
default: false
description: Include inactive accounts in count
AccountsCountResponse:
type: object
required:
- count
- active
- inactive
- timestamp
properties:
count:
type: integer
description: Total account count
example: 42
active:
type: integer
description: Number of active accounts
example: 35
inactive:
type: integer
description: Number of inactive accounts
example: 7
timestamp:
type: integer
description: Unix timestamp when data was collected
example: 1705960800
CharactersCountRequest:
type: object
properties:
includeDeleted:
type: boolean
default: false
description: Include deleted characters in count
CharactersCountResponse:
type: object
required:
- count
- active
- deleted
- timestamp
properties:
count:
type: integer
description: Total character count
example: 128
active:
type: integer
description: Number of active characters
example: 125
deleted:
type: integer
description: Number of deleted characters
example: 3
timestamp:
type: integer
description: Unix timestamp when data was collected
example: 1705960800
Error:
type: object
required:
- error
- code
- timestamp
properties:
error:
type: string
example: Authentication required
code:
type: integer
enum:
- 400
- 401
- 403
- 404
- 500
example: 401
timestamp:
type: integer
description: Unix timestamp
example: 1705960800
responses:
Unauthorized:
description: Missing or invalid authentication token
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error: Authentication required
code: 401
timestamp: 1705960800
Forbidden:
description: Authenticated but insufficient permissions
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error: Insufficient permissions
code: 403
timestamp: 1705960800
BadRequest:
description: Invalid request parameters or body
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error: Invalid request body
code: 400
timestamp: 1705960800
NotFound:
description: Endpoint not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error: Not Found
code: 404
timestamp: 1705960800
ServerError:
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error: Internal server error
code: 500
timestamp: 1705960800
parameters:
tokenQuery:
name: token
in: query
description: JWT token (alternative to header/cookie)
schema:
type: string
required: false
limitParam:
name: limit
in: query
description: Maximum number of results to return
schema:
type: integer
default: 100
maximum: 100
minimum: 1
required: false
offsetParam:
name: offset
in: query
description: Pagination offset
schema:
type: integer
default: 0
minimum: 0
required: false
paths:
/api/auth/login:
post:
tags:
- Authentication
summary: User login
description: Authenticate user and receive JWT token for API access
operationId: loginUser
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LoginRequest'
responses:
'200':
description: Login successful
content:
application/json:
schema:
$ref: '#/components/schemas/LoginResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'400':
$ref: '#/components/responses/BadRequest'
/api/auth/verify:
post:
tags:
- Authentication
summary: Verify token
description: Check if a JWT token is valid and retrieve user information
operationId: verifyToken
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/VerifyTokenRequest'
responses:
'200':
description: Token is valid
content:
application/json:
schema:
$ref: '#/components/schemas/VerifyTokenResponse'
'401':
description: Token is invalid or expired
content:
application/json:
schema:
$ref: '#/components/schemas/VerifyTokenError'
'400':
$ref: '#/components/responses/BadRequest'
/api/status:
get:
tags:
- Server
summary: Get server status
description: Get current server status and version information
operationId: getServerStatus
security:
- bearerAuth: []
- queryToken: []
- cookieToken: []
parameters:
- $ref: '#/components/parameters/tokenQuery'
responses:
'200':
description: Server status retrieved successfully
content:
application/json:
schema:
$ref: '#/components/schemas/ServerStatus'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
/api/players:
get:
tags:
- Players
summary: List online players
description: Get list of currently online players on the server
operationId: listPlayers
security:
- bearerAuth: []
- queryToken: []
- cookieToken: []
parameters:
- $ref: '#/components/parameters/tokenQuery'
- $ref: '#/components/parameters/limitParam'
- $ref: '#/components/parameters/offsetParam'
responses:
'200':
description: Players list retrieved successfully
content:
application/json:
schema:
$ref: '#/components/schemas/PlayersResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
/api/accounts/count:
post:
tags:
- Statistics
summary: Get total accounts count
description: Get total number of registered accounts on the server
operationId: getAccountsCount
security:
- bearerAuth: []
- queryToken: []
- cookieToken: []
parameters:
- $ref: '#/components/parameters/tokenQuery'
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/AccountsCountRequest'
responses:
'200':
description: Accounts count retrieved successfully
content:
application/json:
schema:
$ref: '#/components/schemas/AccountsCountResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'400':
$ref: '#/components/responses/BadRequest'
/api/characters/count:
post:
tags:
- Statistics
summary: Get total characters count
description: Get total number of characters on the server
operationId: getCharactersCount
security:
- bearerAuth: []
- queryToken: []
- cookieToken: []
parameters:
- $ref: '#/components/parameters/tokenQuery'
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/CharactersCountRequest'
responses:
'200':
description: Characters count retrieved successfully
content:
application/json:
schema:
$ref: '#/components/schemas/CharactersCountResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'400':
$ref: '#/components/responses/BadRequest'

View File

@@ -0,0 +1,6 @@
-- Migration: Add login tracking columns to accounts table
-- Adds fields for tracking login attempts, lockouts, and last login
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS failed_attempts INT NOT NULL DEFAULT 0;
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS lockout_time DATETIME NULL DEFAULT NULL;
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS last_login DATETIME NULL DEFAULT NULL;

View File

@@ -0,0 +1,6 @@
/* Migration: Add login tracking columns to accounts table */
/* Adds fields for tracking login attempts, lockouts, and last login */
ALTER TABLE accounts ADD COLUMN failed_attempts INTEGER NOT NULL DEFAULT 0;
ALTER TABLE accounts ADD COLUMN lockout_time DATETIME DEFAULT NULL;
ALTER TABLE accounts ADD COLUMN last_login DATETIME DEFAULT NULL;

View File

@@ -0,0 +1,15 @@
# Web Dashboard Configuration
# The port to listen on for HTTP/WebSocket connections
port=2006
# The IP address to bind to
# Use 127.0.0.1 for localhost only (recommended for security)
# Use 0.0.0.0 to allow external access (not recommended without authentication)
listen_ip=127.0.0.1
# How often to broadcast updates to connected clients (in milliseconds)
broadcast_interval=2000
# Minimum GM level required to access the dashboard (default: 0 = any user)
min_dashboard_gm_level=0

View File

@@ -11,3 +11,7 @@ world_port_start=3000
prestart_servers=1
master_password=3.25DARKFLAME1
# Enable the web dashboard (0 = disabled, 1 = enabled)
# Dashboard settings are configured in dashboardconfig.ini
enable_dashboard=0

View File

@@ -7,3 +7,4 @@ include(GoogleTest)
# Add the subdirectories
add_subdirectory(dCommonTests)
add_subdirectory(dGameTests)
add_subdirectory(dWebTests)

View File

@@ -0,0 +1,19 @@
set(DWEBTESTS_SOURCES
"MiddlewareTests.cpp"
"RouteIntegrationTests.cpp"
)
add_executable(dWebTests ${DWEBTESTS_SOURCES})
target_include_directories(dWebTests PRIVATE
"${PROJECT_SOURCE_DIR}/dCommon"
"${PROJECT_SOURCE_DIR}/dCommon/dClient"
"${PROJECT_SOURCE_DIR}/dWeb"
"${PROJECT_SOURCE_DIR}/dDashboardServer"
"${PROJECT_SOURCE_DIR}/dDashboardServer/auth"
"${PROJECT_SOURCE_DIR}/thirdparty/nlohmann"
)
target_link_libraries(dWebTests ${COMMON_LIBRARIES} dWeb GTest::gtest_main)
gtest_discover_tests(dWebTests)

View File

@@ -0,0 +1,334 @@
#include <gtest/gtest.h>
#include <memory>
#include "HTTPContext.h"
#include "Web.h"
// Note: These tests use mock implementations to avoid circular dependencies.
// In a real deployment, DashboardAuthService would be used instead.
// Mock implementation of token verification for testing
namespace {
bool VerifyTokenMock(const std::string& token, std::string& outUsername, uint8_t& outGmLevel) {
// For testing: valid tokens are prefixed with "valid_"
if (token.substr(0, 6) == "valid_") {
outUsername = "testuser";
outGmLevel = 1; // GM level 1
return true;
}
if (token == "admin_token") {
outUsername = "admin";
outGmLevel = 9; // GM level 9 (admin)
return true;
}
return false;
}
}
// Test HTTPContext functionality
class HTTPContextTest : public ::testing::Test {
protected:
HTTPContext context;
};
TEST_F(HTTPContextTest, DefaultConstructorInitializesFields) {
EXPECT_FALSE(context.isAuthenticated);
EXPECT_EQ(context.authenticatedUser, "");
EXPECT_EQ(context.gmLevel, 0);
EXPECT_EQ(context.method, "");
EXPECT_EQ(context.path, "");
EXPECT_EQ(context.queryString, "");
EXPECT_EQ(context.body, "");
EXPECT_EQ(context.clientIP, "");
}
TEST_F(HTTPContextTest, SetHeaderAndGetHeaderCaseInsensitive) {
context.SetHeader("Content-Type", "application/json");
EXPECT_EQ(context.GetHeader("Content-Type"), "application/json");
EXPECT_EQ(context.GetHeader("content-type"), "application/json");
EXPECT_EQ(context.GetHeader("CONTENT-TYPE"), "application/json");
}
TEST_F(HTTPContextTest, GetHeaderReturnsEmptyStringForMissingHeader) {
EXPECT_EQ(context.GetHeader("NonExistent"), "");
}
TEST_F(HTTPContextTest, SetHeaderMultipleHeaders) {
context.SetHeader("Authorization", "Bearer token123");
context.SetHeader("Cookie", "session=xyz");
context.SetHeader("User-Agent", "TestClient/1.0");
EXPECT_EQ(context.GetHeader("authorization"), "Bearer token123");
EXPECT_EQ(context.GetHeader("cookie"), "session=xyz");
EXPECT_EQ(context.GetHeader("user-agent"), "TestClient/1.0");
}
TEST_F(HTTPContextTest, AuthenticationFields) {
context.isAuthenticated = true;
context.authenticatedUser = "testuser";
context.gmLevel = 5;
EXPECT_TRUE(context.isAuthenticated);
EXPECT_EQ(context.authenticatedUser, "testuser");
EXPECT_EQ(context.gmLevel, 5);
}
TEST_F(HTTPContextTest, UserDataMap) {
context.userData["key1"] = "value1";
context.userData["key2"] = "value2";
EXPECT_EQ(context.userData["key1"], "value1");
EXPECT_EQ(context.userData["key2"], "value2");
EXPECT_TRUE(context.userData.count("key1") > 0);
}
// Test token extraction utilities
namespace TokenExtraction {
static std::string ExtractTokenFromQueryString(const std::string& queryString) {
if (queryString.empty()) {
return "";
}
std::string tokenPrefix = "token=";
size_t tokenPos = queryString.find(tokenPrefix);
if (tokenPos == std::string::npos) {
return "";
}
size_t valueStart = tokenPos + tokenPrefix.length();
size_t valueEnd = queryString.find("&", valueStart);
if (valueEnd == std::string::npos) {
valueEnd = queryString.length();
}
return queryString.substr(valueStart, valueEnd - valueStart);
}
static std::string ExtractTokenFromAuthHeader(const std::string& authHeader) {
if (authHeader.empty()) {
return "";
}
if (authHeader.substr(0, 7) == "Bearer ") {
return authHeader.substr(7);
}
if (authHeader.substr(0, 6) == "Token ") {
return authHeader.substr(6);
}
return authHeader;
}
}
// Test token extraction utilities
class TokenExtractionTest : public ::testing::Test {
};
TEST_F(TokenExtractionTest, ExtractFromQueryString) {
std::string query = "token=mytoken123&other=value";
std::string token = TokenExtraction::ExtractTokenFromQueryString(query);
EXPECT_EQ(token, "mytoken123");
}
TEST_F(TokenExtractionTest, ExtractFromQueryStringWithNoOtherParams) {
std::string query = "token=simpletoken";
std::string token = TokenExtraction::ExtractTokenFromQueryString(query);
EXPECT_EQ(token, "simpletoken");
}
TEST_F(TokenExtractionTest, NoTokenInQueryString) {
std::string query = "other=value&param=test";
std::string token = TokenExtraction::ExtractTokenFromQueryString(query);
EXPECT_EQ(token, "");
}
TEST_F(TokenExtractionTest, ExtractFromBearerHeader) {
std::string header = "Bearer eyJhbGciOiJIUzI1NiJ9";
std::string token = TokenExtraction::ExtractTokenFromAuthHeader(header);
EXPECT_EQ(token, "eyJhbGciOiJIUzI1NiJ9");
}
TEST_F(TokenExtractionTest, ExtractFromTokenHeader) {
std::string header = "Token abc123xyz";
std::string token = TokenExtraction::ExtractTokenFromAuthHeader(header);
EXPECT_EQ(token, "abc123xyz");
}
TEST_F(TokenExtractionTest, ExtractRawTokenFromHeader) {
std::string header = "rawtoken123";
std::string token = TokenExtraction::ExtractTokenFromAuthHeader(header);
EXPECT_EQ(token, "rawtoken123");
}
// Test HTTPContext population scenarios
class HTTPContextPopulationTest : public ::testing::Test {
protected:
HTTPContext context;
};
TEST_F(HTTPContextPopulationTest, PopulateFromRequest) {
context.method = "POST";
context.path = "/api/auth/login";
context.queryString = "token=abc123";
context.body = "{\"username\":\"test\"}";
context.clientIP = "192.168.1.100";
EXPECT_EQ(context.method, "POST");
EXPECT_EQ(context.path, "/api/auth/login");
EXPECT_EQ(context.queryString, "token=abc123");
EXPECT_EQ(context.body, "{\"username\":\"test\"}");
EXPECT_EQ(context.clientIP, "192.168.1.100");
}
TEST_F(HTTPContextPopulationTest, MultipleHeadersWithMixedCase) {
context.SetHeader("Content-Type", "application/json");
context.SetHeader("Authorization", "Bearer token");
context.SetHeader("Accept", "application/json");
context.SetHeader("User-Agent", "TestClient");
// Verify all headers are accessible case-insensitively
EXPECT_EQ(context.GetHeader("content-type"), "application/json");
EXPECT_EQ(context.GetHeader("AUTHORIZATION"), "Bearer token");
EXPECT_EQ(context.GetHeader("accept"), "application/json");
EXPECT_EQ(context.GetHeader("USER-AGENT"), "TestClient");
}
// Integration tests for middleware chains
class MiddlewareAuthenticationFlow : public ::testing::Test {
protected:
HTTPContext context;
HTTPReply reply;
void SetUp() override {
reply.status = eHTTPStatusCode::OK;
reply.contentType = eContentType::APPLICATION_JSON;
context.path = "/api/test";
context.clientIP = "127.0.0.1";
context.method = "GET";
}
void SimulateTokenVerification(const std::string& token) {
std::string username;
uint8_t gmLevel;
if (VerifyTokenMock(token, username, gmLevel)) {
context.isAuthenticated = true;
context.authenticatedUser = username;
context.gmLevel = gmLevel;
}
}
};
TEST_F(MiddlewareAuthenticationFlow, SuccessfulAuthenticationWithQueryToken) {
context.queryString = "token=valid_token123";
// Extract token
std::string token = TokenExtraction::ExtractTokenFromQueryString(context.queryString);
EXPECT_EQ(token, "valid_token123");
// Verify token
SimulateTokenVerification(token);
EXPECT_TRUE(context.isAuthenticated);
EXPECT_EQ(context.authenticatedUser, "testuser");
EXPECT_EQ(context.gmLevel, 1);
}
TEST_F(MiddlewareAuthenticationFlow, SuccessfulAuthenticationWithBearerToken) {
context.SetHeader("Authorization", "Bearer admin_token");
// Extract token
std::string authHeader = context.GetHeader("Authorization");
std::string token = TokenExtraction::ExtractTokenFromAuthHeader(authHeader);
EXPECT_EQ(token, "admin_token");
// Verify token
SimulateTokenVerification(token);
EXPECT_TRUE(context.isAuthenticated);
EXPECT_EQ(context.authenticatedUser, "admin");
EXPECT_EQ(context.gmLevel, 9);
}
TEST_F(MiddlewareAuthenticationFlow, FailedAuthenticationInvalidToken) {
context.queryString = "token=invalid_token";
// Extract token
std::string token = TokenExtraction::ExtractTokenFromQueryString(context.queryString);
EXPECT_EQ(token, "invalid_token");
// Verify token
SimulateTokenVerification(token);
EXPECT_FALSE(context.isAuthenticated);
EXPECT_EQ(context.authenticatedUser, "");
EXPECT_EQ(context.gmLevel, 0);
}
TEST_F(MiddlewareAuthenticationFlow, NoTokenProvided) {
context.queryString = "";
// Extract token (none provided)
std::string token = TokenExtraction::ExtractTokenFromQueryString(context.queryString);
EXPECT_EQ(token, "");
// Should remain unauthenticated
EXPECT_FALSE(context.isAuthenticated);
EXPECT_EQ(context.authenticatedUser, "");
EXPECT_EQ(context.gmLevel, 0);
}
// Test authorization level checking
class AuthorizationLevelTest : public ::testing::Test {
protected:
uint8_t CheckMinimumGMLevel(uint8_t userLevel, uint8_t requiredLevel) {
return userLevel >= requiredLevel ? 1 : 0; // 1 = allowed, 0 = forbidden
}
};
TEST_F(AuthorizationLevelTest, UserCanAccessWithSufficientLevel) {
EXPECT_EQ(CheckMinimumGMLevel(9, 5), 1); // Admin (9) can access level 5
EXPECT_EQ(CheckMinimumGMLevel(5, 5), 1); // Level 5 can access level 5
EXPECT_EQ(CheckMinimumGMLevel(0, 0), 1); // Level 0 can access level 0
}
TEST_F(AuthorizationLevelTest, UserCannotAccessWithInsufficientLevel) {
EXPECT_EQ(CheckMinimumGMLevel(2, 5), 0); // Level 2 cannot access level 5
EXPECT_EQ(CheckMinimumGMLevel(0, 1), 0); // Level 0 cannot access level 1
EXPECT_EQ(CheckMinimumGMLevel(3, 9), 0); // Level 3 cannot access admin (9)
}
// Test error response formatting
class ErrorResponseTest : public ::testing::Test {
protected:
HTTPReply reply;
};
TEST_F(ErrorResponseTest, UnauthorizedResponse) {
reply.status = eHTTPStatusCode::UNAUTHORIZED;
reply.message = "{\"error\":\"Unauthorized - Authentication required\"}";
reply.contentType = eContentType::APPLICATION_JSON;
EXPECT_EQ(reply.status, eHTTPStatusCode::UNAUTHORIZED);
EXPECT_NE(reply.message.find("Unauthorized"), std::string::npos);
EXPECT_EQ(reply.contentType, eContentType::APPLICATION_JSON);
}
TEST_F(ErrorResponseTest, ForbiddenResponse) {
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"error\":\"Forbidden - Insufficient permissions\"}";
reply.contentType = eContentType::APPLICATION_JSON;
EXPECT_EQ(reply.status, eHTTPStatusCode::FORBIDDEN);
EXPECT_NE(reply.message.find("Forbidden"), std::string::npos);
EXPECT_EQ(reply.contentType, eContentType::APPLICATION_JSON);
}
TEST_F(ErrorResponseTest, OkResponse) {
reply.status = eHTTPStatusCode::OK;
reply.message = "{\"status\":\"success\",\"data\":{}}";
reply.contentType = eContentType::APPLICATION_JSON;
EXPECT_EQ(reply.status, eHTTPStatusCode::OK);
EXPECT_EQ(reply.contentType, eContentType::APPLICATION_JSON);
}

View File

@@ -0,0 +1,475 @@
#include <gtest/gtest.h>
#include <memory>
#include <sstream>
#include "HTTPContext.h"
#include "Web.h"
#include "AuthMiddleware.h"
#include "RequireAuthMiddleware.h"
/**
* Route Integration Tests
*
* These tests verify the actual route handlers work correctly with middleware chains.
* Unlike MiddlewareTests.cpp which uses mocks, these tests use real middleware
* to verify the complete authentication and authorization flow.
*/
// Mock DashboardAuthService for testing
namespace {
class MockDashboardAuthService {
public:
static bool VerifyToken(const std::string& token, std::string& outUsername, uint8_t& outGmLevel) {
// Test tokens with predictable results
if (token == "valid_user_token") {
outUsername = "testuser";
outGmLevel = 0; // Regular user
return true;
}
if (token == "admin_token") {
outUsername = "admin";
outGmLevel = 9; // Admin
return true;
}
if (token == "moderator_token") {
outUsername = "moderator";
outGmLevel = 5; // Moderator
return true;
}
return false;
}
static bool HasDashboardAccess(uint8_t gmLevel) {
return gmLevel > 0;
}
};
}
// Test fixture for route handlers
class RouteHandlerTest : public ::testing::Test {
protected:
HTTPContext context;
HTTPReply reply;
void SetUp() override {
reply.status = eHTTPStatusCode::OK;
reply.contentType = eContentType::APPLICATION_JSON;
reply.message = "";
}
// Simulate a route handler for /api/status
void HandleStatusRoute(HTTPReply& out, const HTTPContext& in) {
out.status = eHTTPStatusCode::OK;
out.contentType = eContentType::APPLICATION_JSON;
out.message = R"({"status":"running","version":"1.0.0"})";
}
// Simulate a route handler for /api/players
void HandlePlayersRoute(HTTPReply& out, const HTTPContext& in) {
out.status = eHTTPStatusCode::OK;
out.contentType = eContentType::APPLICATION_JSON;
out.message = R"({"players":[{"id":1,"name":"Player1"},{"id":2,"name":"Player2"}]})";
}
// Simulate a route handler for /api/accounts/count
void HandleAccountsCountRoute(HTTPReply& out, const HTTPContext& in) {
out.status = eHTTPStatusCode::OK;
out.contentType = eContentType::APPLICATION_JSON;
out.message = R"({"count":42})";
}
// Simulate a route handler for /api/characters/count
void HandleCharactersCountRoute(HTTPReply& out, const HTTPContext& in) {
out.status = eHTTPStatusCode::OK;
out.contentType = eContentType::APPLICATION_JSON;
out.message = R"({"count":128})";
}
};
// Test protected API routes with authentication
class ProtectedAPIRouteTest : public RouteHandlerTest {
protected:
void ProcessMiddlewareChain(std::vector<std::shared_ptr<IHTTPMiddleware>>& middlewares, HTTPContext& ctx) {
for (const auto& middleware : middlewares) {
if (!middleware->Process(ctx, reply)) {
break;
}
}
}
};
TEST_F(ProtectedAPIRouteTest, StatusRouteRequiresAuthentication) {
// Create middleware chain for protected route
std::vector<std::shared_ptr<IHTTPMiddleware>> middlewares;
// Simulate AuthMiddleware (always passes, extracts token if available)
context.path = "/api/status";
context.queryString = ""; // No token
context.method = "GET";
// Without authentication
std::string username;
uint8_t gmLevel{};
EXPECT_FALSE(context.isAuthenticated);
EXPECT_EQ(context.gmLevel, 0);
// Now test with token
context.queryString = "token=valid_user_token";
// Extract and verify token (simulating AuthMiddleware)
std::string token = "valid_user_token";
if (MockDashboardAuthService::VerifyToken(token, username, gmLevel)) {
context.isAuthenticated = true;
context.authenticatedUser = username;
context.gmLevel = gmLevel;
}
EXPECT_TRUE(context.isAuthenticated);
EXPECT_EQ(context.authenticatedUser, "testuser");
EXPECT_EQ(context.gmLevel, 0);
}
TEST_F(ProtectedAPIRouteTest, PlayersRouteWithValidAuth) {
context.path = "/api/players";
context.method = "GET";
// Simulate token verification
std::string username;
uint8_t gmLevel{};
std::string token = "admin_token";
if (MockDashboardAuthService::VerifyToken(token, username, gmLevel)) {
context.isAuthenticated = true;
context.authenticatedUser = username;
context.gmLevel = gmLevel;
}
// Check authentication
EXPECT_TRUE(context.isAuthenticated);
EXPECT_EQ(context.gmLevel, 9);
// Call handler
HandlePlayersRoute(reply, context);
// Verify response
EXPECT_EQ(reply.status, eHTTPStatusCode::OK);
EXPECT_NE(reply.message.find("players"), std::string::npos);
}
TEST_F(ProtectedAPIRouteTest, AccountsCountRouteRequiresLevel0) {
context.path = "/api/accounts/count";
context.method = "POST";
// Test with level 0 user (should pass)
std::string username;
uint8_t gmLevel{};
std::string token = "valid_user_token";
if (MockDashboardAuthService::VerifyToken(token, username, gmLevel)) {
context.isAuthenticated = true;
context.authenticatedUser = username;
context.gmLevel = gmLevel;
}
EXPECT_TRUE(context.isAuthenticated);
EXPECT_GE(context.gmLevel, 0); // Meets requirement
HandleAccountsCountRoute(reply, context);
EXPECT_EQ(reply.status, eHTTPStatusCode::OK);
}
TEST_F(ProtectedAPIRouteTest, CharactersCountRouteWithModerator) {
context.path = "/api/characters/count";
context.method = "POST";
// Test with moderator token
std::string username;
uint8_t gmLevel{};
std::string token = "moderator_token";
if (MockDashboardAuthService::VerifyToken(token, username, gmLevel)) {
context.isAuthenticated = true;
context.authenticatedUser = username;
context.gmLevel = gmLevel;
}
EXPECT_TRUE(context.isAuthenticated);
EXPECT_EQ(context.gmLevel, 5);
HandleCharactersCountRoute(reply, context);
EXPECT_EQ(reply.status, eHTTPStatusCode::OK);
EXPECT_NE(reply.message.find("count"), std::string::npos);
}
// Test authentication failures
class AuthenticationFailureTest : public RouteHandlerTest {
};
TEST_F(AuthenticationFailureTest, InvalidTokenRejected) {
context.path = "/api/status";
context.method = "GET";
std::string username;
uint8_t gmLevel{};
std::string token = "invalid_token";
// Should fail
EXPECT_FALSE(MockDashboardAuthService::VerifyToken(token, username, gmLevel));
EXPECT_FALSE(context.isAuthenticated);
EXPECT_EQ(context.gmLevel, 0);
}
TEST_F(AuthenticationFailureTest, ExpiredTokenRejected) {
context.path = "/api/players";
context.method = "GET";
std::string username;
uint8_t gmLevel{};
std::string token = "expired_token";
// Should fail
EXPECT_FALSE(MockDashboardAuthService::VerifyToken(token, username, gmLevel));
EXPECT_EQ(gmLevel, 0);
}
TEST_F(AuthenticationFailureTest, MissingTokenRejectsProtectedRoute) {
context.path = "/api/status";
context.method = "GET";
context.queryString = ""; // No token
context.isAuthenticated = false;
context.gmLevel = 0;
EXPECT_FALSE(context.isAuthenticated);
EXPECT_EQ(context.gmLevel, 0);
// Route should return 401
reply.status = eHTTPStatusCode::UNAUTHORIZED;
reply.message = "{\"error\":\"Authentication required\"}";
EXPECT_EQ(reply.status, eHTTPStatusCode::UNAUTHORIZED);
EXPECT_NE(reply.message.find("Authentication required"), std::string::npos);
}
// Test authorization level checking
class AuthorizationLevelTest : public RouteHandlerTest {
protected:
bool CheckAuthorizationLevel(uint8_t userLevel, uint8_t requiredLevel) {
return userLevel >= requiredLevel;
}
};
TEST_F(AuthorizationLevelTest, Level0UserAccessLevel0Route) {
context.gmLevel = 0;
EXPECT_TRUE(CheckAuthorizationLevel(context.gmLevel, 0));
}
TEST_F(AuthorizationLevelTest, Level9AdminAccessAnyRoute) {
context.gmLevel = 9;
EXPECT_TRUE(CheckAuthorizationLevel(context.gmLevel, 0));
EXPECT_TRUE(CheckAuthorizationLevel(context.gmLevel, 5));
EXPECT_TRUE(CheckAuthorizationLevel(context.gmLevel, 9));
}
TEST_F(AuthorizationLevelTest, Level1CannotAccessLevel5Route) {
context.gmLevel = 1;
EXPECT_FALSE(CheckAuthorizationLevel(context.gmLevel, 5));
}
TEST_F(AuthorizationLevelTest, InsufficientLevelReturns403) {
context.gmLevel = 0;
if (!CheckAuthorizationLevel(context.gmLevel, 5)) {
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"error\":\"Insufficient permissions\"}";
}
EXPECT_EQ(reply.status, eHTTPStatusCode::FORBIDDEN);
EXPECT_NE(reply.message.find("Insufficient permissions"), std::string::npos);
}
// Test token extraction from different sources
class TokenSourceTest : public RouteHandlerTest {
protected:
std::string ExtractTokenFromQuery(const std::string& queryString) {
if (queryString.empty()) return "";
size_t pos = queryString.find("token=");
if (pos == std::string::npos) return "";
size_t start = pos + 6;
size_t end = queryString.find("&", start);
if (end == std::string::npos) end = queryString.length();
return queryString.substr(start, end - start);
}
std::string ExtractTokenFromHeader(const std::string& authHeader) {
if (authHeader.empty()) return "";
if (authHeader.substr(0, 7) == "Bearer ") return authHeader.substr(7);
if (authHeader.substr(0, 6) == "Token ") return authHeader.substr(6);
return authHeader;
}
std::string ExtractTokenFromCookie(const std::string& cookieHeader) {
if (cookieHeader.empty()) return "";
size_t pos = cookieHeader.find("dashboardToken=");
if (pos == std::string::npos) return "";
size_t start = pos + 15;
size_t end = cookieHeader.find(";", start);
if (end == std::string::npos) end = cookieHeader.length();
return cookieHeader.substr(start, end - start);
}
};
TEST_F(TokenSourceTest, ExtractFromQueryString) {
context.queryString = "token=valid_user_token&other=param";
std::string token = ExtractTokenFromQuery(context.queryString);
EXPECT_EQ(token, "valid_user_token");
}
TEST_F(TokenSourceTest, ExtractFromAuthorizationHeader) {
context.SetHeader("Authorization", "Bearer admin_token");
std::string authHeader = context.GetHeader("Authorization");
std::string token = ExtractTokenFromHeader(authHeader);
EXPECT_EQ(token, "admin_token");
}
TEST_F(TokenSourceTest, ExtractFromCookie) {
context.SetHeader("Cookie", "dashboardToken=moderator_token; Path=/");
std::string cookieHeader = context.GetHeader("Cookie");
std::string token = ExtractTokenFromCookie(cookieHeader);
EXPECT_EQ(token, "moderator_token");
}
// Test response formatting
class ResponseFormattingTest : public RouteHandlerTest {
};
TEST_F(ResponseFormattingTest, SuccessResponseFormat) {
reply.status = eHTTPStatusCode::OK;
reply.contentType = eContentType::APPLICATION_JSON;
reply.message = R"({"status":"success","data":{}})";
EXPECT_EQ(reply.status, eHTTPStatusCode::OK);
EXPECT_EQ(reply.contentType, eContentType::APPLICATION_JSON);
EXPECT_NE(reply.message.find("success"), std::string::npos);
}
TEST_F(ResponseFormattingTest, UnauthorizedResponseFormat) {
reply.status = eHTTPStatusCode::UNAUTHORIZED;
reply.contentType = eContentType::APPLICATION_JSON;
reply.message = R"({"error":"Unauthorized","code":401})";
EXPECT_EQ(reply.status, eHTTPStatusCode::UNAUTHORIZED);
EXPECT_NE(reply.message.find("error"), std::string::npos);
EXPECT_NE(reply.message.find("401"), std::string::npos);
}
TEST_F(ResponseFormattingTest, ForbiddenResponseFormat) {
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.contentType = eContentType::APPLICATION_JSON;
reply.message = R"({"error":"Forbidden","code":403})";
EXPECT_EQ(reply.status, eHTTPStatusCode::FORBIDDEN);
EXPECT_NE(reply.message.find("error"), std::string::npos);
EXPECT_NE(reply.message.find("403"), std::string::npos);
}
// Integration test: Full request flow
class FullRequestFlowTest : public RouteHandlerTest {
protected:
struct RequestFlow {
std::string method;
std::string path;
std::string token;
uint8_t requiredLevel;
bool shouldSucceed;
};
eHTTPStatusCode ProcessRequest(const RequestFlow& flow) {
// Step 1: Set request context
context.method = flow.method;
context.path = flow.path;
context.queryString = flow.token.empty() ? "" : ("token=" + flow.token);
// Step 2: Try to verify token
if (!flow.token.empty()) {
std::string username;
uint8_t gmLevel{};
if (MockDashboardAuthService::VerifyToken(flow.token, username, gmLevel)) {
context.isAuthenticated = true;
context.authenticatedUser = username;
context.gmLevel = gmLevel;
}
}
// Step 3: Check authorization
if (context.isAuthenticated && context.gmLevel >= flow.requiredLevel) {
// Call handler
if (flow.path == "/api/status") {
HandleStatusRoute(reply, context);
} else if (flow.path == "/api/players") {
HandlePlayersRoute(reply, context);
}
return reply.status;
} else if (!context.isAuthenticated) {
return eHTTPStatusCode::UNAUTHORIZED;
} else {
return eHTTPStatusCode::FORBIDDEN;
}
}
};
TEST_F(FullRequestFlowTest, ValidUserAccessesPublicAPI) {
RequestFlow flow{
.method = "GET",
.path = "/api/status",
.token = "valid_user_token",
.requiredLevel = 0,
.shouldSucceed = true
};
eHTTPStatusCode result = ProcessRequest(flow);
EXPECT_EQ(result, eHTTPStatusCode::OK);
EXPECT_TRUE(context.isAuthenticated);
}
TEST_F(FullRequestFlowTest, AdminAccessesProtectedAPI) {
RequestFlow flow{
.method = "GET",
.path = "/api/players",
.token = "admin_token",
.requiredLevel = 0,
.shouldSucceed = true
};
eHTTPStatusCode result = ProcessRequest(flow);
EXPECT_EQ(result, eHTTPStatusCode::OK);
EXPECT_TRUE(context.isAuthenticated);
EXPECT_EQ(context.gmLevel, 9);
}
TEST_F(FullRequestFlowTest, NoTokenReturnsUnauthorized) {
RequestFlow flow{
.method = "GET",
.path = "/api/status",
.token = "",
.requiredLevel = 0,
.shouldSucceed = false
};
eHTTPStatusCode result = ProcessRequest(flow);
EXPECT_EQ(result, eHTTPStatusCode::UNAUTHORIZED);
EXPECT_FALSE(context.isAuthenticated);
}
TEST_F(FullRequestFlowTest, InvalidTokenReturnsUnauthorized) {
RequestFlow flow{
.method = "GET",
.path = "/api/players",
.token = "invalid_token",
.requiredLevel = 0,
.shouldSucceed = false
};
eHTTPStatusCode result = ProcessRequest(flow);
EXPECT_EQ(result, eHTTPStatusCode::UNAUTHORIZED);
EXPECT_FALSE(context.isAuthenticated);
}

2937
thirdparty/inja.hpp vendored Normal file

File diff suppressed because it is too large Load Diff