mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-16 01:24:08 +00:00
feat: web dashboard and playground work
The NexusDashboard-parity dashboard (dDashboardServer) and everything built on it on the experimental branch: accounts, characters, properties and moderation tools, permissions shared with in-game slash commands, economy reports, World 3D and property 3D views with client scenery, scheduled events (features, vanity changes, live events, announcements, restarts), vanity files and events, the CDClient browser, the message inspector with saved captures, chat filter tools, community challenges, live ops, the AI moderator helper, and the server-side changes they need. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
22
dDashboardServer/auth/AuthMiddleware.cpp
Normal file
22
dDashboardServer/auth/AuthMiddleware.cpp
Normal file
@@ -0,0 +1,22 @@
|
||||
#include "AuthMiddleware.h"
|
||||
#include "AuthTokenHandler.h"
|
||||
#include "Game.h"
|
||||
#include "Logger.h"
|
||||
#include "HTTPContext.h"
|
||||
#include "Web.h"
|
||||
|
||||
bool AuthMiddleware::Process(HTTPContext& context, HTTPReply& reply) {
|
||||
// Any state-changing request that doesn't carry its own Authorization header could have been sent by another
|
||||
// site through the visitor's browser (a form or a text/plain fetch). Those can't set custom headers, so require
|
||||
// one. This covers requests before sign-in too (login, registration, password reset); signed-in cookie requests
|
||||
// are checked again in RequireAuthMiddleware.
|
||||
const bool safeMethod = context.method == "GET" || context.method == "HEAD" || context.method == "OPTIONS";
|
||||
if (!safeMethod && context.GetHeader("Authorization").empty() && context.GetHeader("X-Requested-With").empty()) {
|
||||
LOG("Rejected %s %s without X-Requested-With (possible CSRF) from %s", context.method.c_str(), context.path.c_str(), context.clientIP.c_str());
|
||||
reply.status = eHTTPStatusCode::FORBIDDEN;
|
||||
reply.message = "{\"success\":false,\"error\":\"Missing X-Requested-With header\"}";
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
return false;
|
||||
}
|
||||
return AuthTokenHandler::ProcessHTTPContext(context, reply);
|
||||
}
|
||||
28
dDashboardServer/auth/AuthMiddleware.h
Normal file
28
dDashboardServer/auth/AuthMiddleware.h
Normal file
@@ -0,0 +1,28 @@
|
||||
#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"; }
|
||||
};
|
||||
|
||||
#endif // !__AUTHMIDDLEWARE_H__
|
||||
106
dDashboardServer/auth/AuthTokenHandler.cpp
Normal file
106
dDashboardServer/auth/AuthTokenHandler.cpp
Normal file
@@ -0,0 +1,106 @@
|
||||
#include "AuthTokenHandler.h"
|
||||
#include "DashboardAuthService.h"
|
||||
#include "Game.h"
|
||||
#include "Logger.h"
|
||||
#include "HTTPContext.h"
|
||||
#include "Web.h"
|
||||
|
||||
std::string AuthTokenHandler::ExtractCookie(const std::string& cookieHeader, const std::string& name) {
|
||||
// Match whole cookie names only so "xdashboardToken" does not satisfy "dashboardToken"
|
||||
size_t pos = 0;
|
||||
while (pos < cookieHeader.size()) {
|
||||
while (pos < cookieHeader.size() && (cookieHeader[pos] == ' ' || cookieHeader[pos] == ';')) pos++;
|
||||
const size_t end = std::min(cookieHeader.find(';', pos), cookieHeader.size());
|
||||
const std::string_view pair(cookieHeader.data() + pos, end - pos);
|
||||
const size_t eq = pair.find('=');
|
||||
if (eq != std::string_view::npos && pair.substr(0, eq) == name) {
|
||||
const auto value = pair.substr(eq + 1);
|
||||
std::string decoded;
|
||||
decoded.reserve(value.size());
|
||||
for (size_t i = 0; i < value.size(); ++i) {
|
||||
if (value[i] == '%' && i + 2 < value.size()) {
|
||||
char* endptr = nullptr;
|
||||
const std::string hex(value.substr(i + 1, 2));
|
||||
const auto code = std::strtol(hex.c_str(), &endptr, 16);
|
||||
if (endptr == hex.c_str() + 2) {
|
||||
decoded += static_cast<char>(code);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
decoded += value[i];
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
pos = end + 1;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string AuthTokenHandler::ExtractTokenFromAuthHeader(const std::string& authHeader) {
|
||||
if (authHeader.starts_with("Bearer ")) return authHeader.substr(7);
|
||||
if (authHeader.starts_with("Token ")) return authHeader.substr(6);
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string AuthTokenHandler::ExtractToken(const std::string& cookieHeader, const std::string& authHeader, eTokenSource& source) {
|
||||
auto token = ExtractTokenFromAuthHeader(authHeader);
|
||||
if (!token.empty()) {
|
||||
source = eTokenSource::HEADER;
|
||||
return token;
|
||||
}
|
||||
|
||||
token = ExtractCookie(cookieHeader, COOKIE_NAME);
|
||||
source = token.empty() ? eTokenSource::NONE : eTokenSource::COOKIE;
|
||||
return token;
|
||||
}
|
||||
|
||||
AuthTokenHandler::TokenValidationResult AuthTokenHandler::ValidateToken(const std::string& token) {
|
||||
TokenValidationResult result;
|
||||
|
||||
if (token.empty()) {
|
||||
result.errorMessage = "No token provided";
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!DashboardAuthService::VerifyToken(token, result.username, result.gmLevel, result.accountId)) {
|
||||
result.errorMessage = "Invalid or expired token";
|
||||
return result;
|
||||
}
|
||||
|
||||
result.isValid = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
bool AuthTokenHandler::ProcessHTTPContext(HTTPContext& context, HTTPReply& reply) {
|
||||
eTokenSource source = eTokenSource::NONE;
|
||||
const auto token = ExtractToken(context.GetHeader("Cookie"), context.GetHeader("Authorization"), source);
|
||||
if (token.empty()) return true;
|
||||
|
||||
const auto result = ValidateToken(token);
|
||||
if (!result.isValid) {
|
||||
LOG_DEBUG("Authentication token validation failed: %s", result.errorMessage.c_str());
|
||||
return true; // Let routes decide if auth is required
|
||||
}
|
||||
|
||||
context.isAuthenticated = true;
|
||||
context.authenticatedUser = result.username;
|
||||
context.accountId = result.accountId;
|
||||
context.gmLevel = result.gmLevel;
|
||||
context.userData["auth_source"] = source == eTokenSource::COOKIE ? "cookie" : "header";
|
||||
if (DashboardAuthService::NeedsTwoFactorSetup(result.accountId, result.gmLevel)) context.userData["needs_2fa"] = "1";
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string AuthTokenHandler::BuildSessionCookie(const std::string& token, bool rememberMe, bool secure) {
|
||||
std::string cookie = std::string("Set-Cookie: ") + COOKIE_NAME + "=" + token + "; Path=/; HttpOnly; SameSite=Strict";
|
||||
if (rememberMe) cookie += "; Max-Age=" + std::to_string(30 * 24 * 60 * 60);
|
||||
if (secure) cookie += "; Secure";
|
||||
return cookie;
|
||||
}
|
||||
|
||||
std::string AuthTokenHandler::BuildClearSessionCookie(bool secure) {
|
||||
std::string cookie = std::string("Set-Cookie: ") + COOKIE_NAME + "=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0";
|
||||
if (secure) cookie += "; Secure";
|
||||
return cookie;
|
||||
}
|
||||
62
dDashboardServer/auth/AuthTokenHandler.h
Normal file
62
dDashboardServer/auth/AuthTokenHandler.h
Normal file
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
|
||||
/**
|
||||
* Centralized authentication token handler
|
||||
* Consolidates token extraction and validation for HTTP routes and WebSocket connections
|
||||
*/
|
||||
class AuthTokenHandler {
|
||||
public:
|
||||
enum class eTokenSource : uint8_t {
|
||||
NONE,
|
||||
HEADER, // Authorization header, used by API clients
|
||||
COOKIE // HttpOnly session cookie, used by the browser dashboard
|
||||
};
|
||||
|
||||
struct TokenValidationResult {
|
||||
bool isValid{false};
|
||||
std::string username{};
|
||||
uint32_t accountId{0};
|
||||
uint8_t gmLevel{0};
|
||||
std::string errorMessage{};
|
||||
};
|
||||
|
||||
static constexpr const char* COOKIE_NAME = "dashboardToken";
|
||||
|
||||
/**
|
||||
* Extract a named cookie value from a Cookie header
|
||||
* @return The URL-decoded value, or an empty string if not present
|
||||
*/
|
||||
static std::string ExtractCookie(const std::string& cookieHeader, const std::string& name);
|
||||
|
||||
/**
|
||||
* Extract token from Authorization header
|
||||
* Supports "Bearer <token>" and "Token <token>"
|
||||
*/
|
||||
static std::string ExtractTokenFromAuthHeader(const std::string& authHeader);
|
||||
|
||||
/**
|
||||
* Extract token from the Authorization header, falling back to the session cookie.
|
||||
* Tokens are never read from the query string since URLs end up in logs and browser history.
|
||||
*/
|
||||
static std::string ExtractToken(const std::string& cookieHeader, const std::string& authHeader, eTokenSource& source);
|
||||
|
||||
/**
|
||||
* Validate a token against its signature and the current state of the account in the database
|
||||
*/
|
||||
static TokenValidationResult ValidateToken(const std::string& token);
|
||||
|
||||
/**
|
||||
* Process authentication for HTTP middleware use
|
||||
* Populates the HTTPContext auth fields if a valid token is present; never rejects on its own
|
||||
*/
|
||||
static bool ProcessHTTPContext(class HTTPContext& context, class HTTPReply& reply);
|
||||
|
||||
/**
|
||||
* Build Set-Cookie header values for the session cookie
|
||||
*/
|
||||
static std::string BuildSessionCookie(const std::string& token, bool rememberMe, bool secure);
|
||||
static std::string BuildClearSessionCookie(bool secure);
|
||||
};
|
||||
30
dDashboardServer/auth/CMakeLists.txt
Normal file
30
dDashboardServer/auth/CMakeLists.txt
Normal file
@@ -0,0 +1,30 @@
|
||||
set(DASHBOARDAUTH_SOURCES
|
||||
"JWTUtils.cpp"
|
||||
"Totp.cpp"
|
||||
"DashboardAuthService.cpp"
|
||||
"AuthMiddleware.cpp"
|
||||
"AuthTokenHandler.cpp"
|
||||
"RequireAuthMiddleware.cpp"
|
||||
)
|
||||
|
||||
add_library(DashboardAuth STATIC ${DASHBOARDAUTH_SOURCES})
|
||||
|
||||
target_include_directories(DashboardAuth 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"
|
||||
)
|
||||
|
||||
target_link_libraries(DashboardAuth PRIVATE ${COMMON_LIBRARIES} dWeb dServer bcrypt OpenSSL::Crypto)
|
||||
359
dDashboardServer/auth/DashboardAuthService.cpp
Normal file
359
dDashboardServer/auth/DashboardAuthService.cpp
Normal file
@@ -0,0 +1,359 @@
|
||||
#include "DashboardAuthService.h"
|
||||
#include "JWTUtils.h"
|
||||
#include "Database.h"
|
||||
#include "Logger.h"
|
||||
#include "Game.h"
|
||||
#include "dConfig.h"
|
||||
#include "GeneralUtils.h"
|
||||
#include "Totp.h"
|
||||
#include "LoginThrottle.h"
|
||||
#include <bcrypt/bcrypt.h>
|
||||
#include <openssl/rand.h>
|
||||
#include <openssl/sha.h>
|
||||
#include <algorithm>
|
||||
#include <ctime>
|
||||
#include <map>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
// Failed sign-ins are throttled per (address, account): a few wrong guesses from one address block only that
|
||||
// address from that account for a while, so nobody can lock the owner out of their own account.
|
||||
constexpr uint32_t ADDRESS_MAX_FAILURES = 5;
|
||||
constexpr int64_t ADDRESS_WINDOW = 15 * 60;
|
||||
constexpr int64_t ADDRESS_BLOCK = 15 * 60;
|
||||
// Backstop against guessing from many addresses: the account-wide lockout (accounts.lockout_time) is short and
|
||||
// needs many failures without a successful sign-in in between.
|
||||
constexpr uint8_t ACCOUNT_MAX_FAILURES = 25;
|
||||
constexpr int64_t ACCOUNT_LOCKOUT_DURATION = 10 * 60;
|
||||
constexpr int64_t CHALLENGE_LIFETIME = 5 * 60;
|
||||
constexpr int CHALLENGE_ATTEMPTS = 5;
|
||||
|
||||
// A real bcrypt hash (same work factor as accounts) to check against when the username doesn't exist
|
||||
const std::string& TimingDummyHash() {
|
||||
static const std::string hash = [] {
|
||||
char salt[BCRYPT_HASHSIZE];
|
||||
char out[BCRYPT_HASHSIZE];
|
||||
if (::bcrypt_gensalt(12, salt) != 0 || ::bcrypt_hashpw("dashboard timing dummy", salt, out) != 0) return std::string();
|
||||
return std::string(out);
|
||||
}();
|
||||
return hash;
|
||||
}
|
||||
|
||||
struct Challenge {
|
||||
uint32_t accountId{};
|
||||
std::string username;
|
||||
bool rememberMe{};
|
||||
int64_t expires{};
|
||||
int attempts{};
|
||||
std::string address;
|
||||
};
|
||||
std::map<std::string, Challenge> g_Challenges; // by hash of the challenge token
|
||||
|
||||
std::string RandomHex(size_t bytes) {
|
||||
std::vector<unsigned char> data(bytes);
|
||||
if (RAND_bytes(data.data(), static_cast<int>(data.size())) != 1) return "";
|
||||
static constexpr char digits[] = "0123456789abcdef";
|
||||
std::string out;
|
||||
for (const auto b : data) { out += digits[b >> 4]; out += digits[b & 15]; }
|
||||
return out;
|
||||
}
|
||||
|
||||
LoginThrottle g_Throttle(ADDRESS_MAX_FAILURES, ADDRESS_WINDOW, ADDRESS_BLOCK);
|
||||
|
||||
int64_t Now() { return static_cast<int64_t>(std::time(nullptr)); }
|
||||
|
||||
std::string BlockedMessage(int64_t seconds) {
|
||||
const auto minutes = std::max<int64_t>(1, (seconds + 59) / 60);
|
||||
return "Too many failed attempts, try again in " + std::to_string(minutes) + " minute" + (minutes == 1 ? "" : "s");
|
||||
}
|
||||
|
||||
// Wrong passwords, two-factor codes and recovery attempts all count here. Returns whether the caller is now blocked.
|
||||
bool RecordFailure(uint32_t accountId, const std::string& username, const std::string& address) {
|
||||
const bool addressBlocked = g_Throttle.RecordFailure(address, accountId, Now());
|
||||
if (addressBlocked) LOG("Sign-in to %s blocked for %s after failed attempts", username.c_str(), address.c_str());
|
||||
Database::Get()->RecordFailedAttempt(accountId);
|
||||
if (Database::Get()->GetFailedAttempts(accountId) >= ACCOUNT_MAX_FAILURES) {
|
||||
// SetLockout also starts a new count, so this can't turn into a permanent lock
|
||||
Database::Get()->SetLockout(accountId, Now() + ACCOUNT_LOCKOUT_DURATION);
|
||||
LOG("Account %s temporarily locked after %d failed attempts from all addresses", username.c_str(), ACCOUNT_MAX_FAILURES);
|
||||
return true;
|
||||
}
|
||||
return addressBlocked;
|
||||
}
|
||||
|
||||
// Seconds the caller must still wait before trying this account, 0 when it may try
|
||||
int64_t Throttled(uint32_t accountId, const std::string& address) {
|
||||
if (const auto wait = g_Throttle.BlockedFor(address, accountId, Now())) return wait;
|
||||
return Database::Get()->IsLockedOut(accountId) ? ACCOUNT_LOCKOUT_DURATION : 0;
|
||||
}
|
||||
}
|
||||
|
||||
DashboardAuthService::LoginResult DashboardAuthService::Login(
|
||||
const std::string& username,
|
||||
const std::string& password,
|
||||
bool rememberMe,
|
||||
const std::string& address) {
|
||||
|
||||
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) {
|
||||
// Take as long as a wrong password would, so response times don't reveal which usernames exist
|
||||
::bcrypt_checkpw(password.c_str(), TimingDummyHash().c_str());
|
||||
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;
|
||||
|
||||
// While throttled the password isn't checked at all (and the wait isn't extended)
|
||||
if (const auto wait = Throttled(accountId, address)) {
|
||||
result.message = BlockedMessage(wait);
|
||||
result.accountLocked = true;
|
||||
LOG_DEBUG("Throttled login attempt for %s from %s", username.c_str(), address.c_str());
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check password
|
||||
if (::bcrypt_checkpw(password.c_str(), accountInfo->bcryptPassword.c_str()) != 0) {
|
||||
if (RecordFailure(accountId, username, address)) {
|
||||
result.message = BlockedMessage(ADDRESS_BLOCK);
|
||||
result.accountLocked = true;
|
||||
} else {
|
||||
result.message = "Invalid username or password";
|
||||
LOG_DEBUG("Failed login attempt for user: %s from %s", username.c_str(), address.c_str());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (accountInfo->banned) {
|
||||
result.message = "Account is banned";
|
||||
return result;
|
||||
}
|
||||
|
||||
if (accountInfo->locked) {
|
||||
result.message = "Account is locked";
|
||||
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;
|
||||
}
|
||||
|
||||
// With two-factor login on, the password alone is not enough: hand out a short-lived challenge
|
||||
if (Database::Get()->GetTotp(accountId).enabledAt != 0) {
|
||||
const auto now = static_cast<int64_t>(std::time(nullptr));
|
||||
std::erase_if(g_Challenges, [now](const auto& entry) { return entry.second.expires < now; });
|
||||
result.challenge = RandomHex(32);
|
||||
if (result.challenge.empty()) {
|
||||
result.message = "An error occurred during login";
|
||||
return result;
|
||||
}
|
||||
g_Challenges[Sha256Hex(result.challenge)] = { accountId, username, rememberMe, now + CHALLENGE_LIFETIME, 0, address };
|
||||
result.twoFactorRequired = true;
|
||||
result.accountId = accountId;
|
||||
result.message = "Enter the code from your authenticator app";
|
||||
return result;
|
||||
}
|
||||
|
||||
// Successful login
|
||||
Database::Get()->ClearFailedAttempts(accountId);
|
||||
g_Throttle.Clear(address, accountId);
|
||||
result.accountId = accountId;
|
||||
result.success = true;
|
||||
result.gmLevel = static_cast<uint8_t>(accountInfo->maxGmLevel);
|
||||
result.token = JWTUtils::GenerateSessionToken(accountId, username, result.gmLevel, rememberMe);
|
||||
if (result.token.empty()) {
|
||||
result.success = false;
|
||||
result.message = "Server authentication is not configured";
|
||||
return result;
|
||||
}
|
||||
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, uint32_t& accountId) {
|
||||
JWTUtils::JWTPayload payload;
|
||||
if (!JWTUtils::ValidateToken(token, payload)) {
|
||||
LOG_DEBUG("Token validation failed: invalid or expired JWT");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
auto accountInfo = Database::Get()->GetAccountInfo(payload.username);
|
||||
if (!accountInfo || accountInfo->banned || accountInfo->locked || !HasDashboardAccess(static_cast<uint8_t>(accountInfo->maxGmLevel))) {
|
||||
LOG_DEBUG("Token verification failed: user no longer has access");
|
||||
return false;
|
||||
}
|
||||
// A token only works for the account it was issued to, not a later one with the same name
|
||||
if (payload.accountId != accountInfo->id) {
|
||||
LOG_DEBUG("Token verification failed: token belongs to a different account");
|
||||
return false;
|
||||
}
|
||||
// Sessions issued before a password reset or change are no longer valid
|
||||
if (payload.issuedAt < Database::Get()->GetSessionsValidAfter(accountInfo->id)) {
|
||||
LOG_DEBUG("Token verification failed: session was revoked");
|
||||
return false;
|
||||
}
|
||||
username = payload.username;
|
||||
gmLevel = static_cast<uint8_t>(accountInfo->maxGmLevel);
|
||||
accountId = accountInfo->id;
|
||||
} 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;
|
||||
}
|
||||
|
||||
DashboardAuthService::LoginResult DashboardAuthService::CompleteTwoFactor(const std::string& challenge, const std::string& code) {
|
||||
LoginResult result;
|
||||
const auto now = static_cast<int64_t>(std::time(nullptr));
|
||||
const auto it = g_Challenges.find(Sha256Hex(challenge));
|
||||
if (challenge.empty() || it == g_Challenges.end() || it->second.expires < now) {
|
||||
if (it != g_Challenges.end()) g_Challenges.erase(it);
|
||||
result.message = "This login has expired, please sign in again";
|
||||
return result;
|
||||
}
|
||||
auto& pending = it->second;
|
||||
|
||||
try {
|
||||
const auto account = Database::Get()->GetAccountInfo(pending.username);
|
||||
if (!account || account->id != pending.accountId || account->banned || account->locked || !HasDashboardAccess(static_cast<uint8_t>(account->maxGmLevel))) {
|
||||
g_Challenges.erase(it);
|
||||
result.message = "Access denied";
|
||||
return result;
|
||||
}
|
||||
if (const auto wait = Throttled(account->id, pending.address)) {
|
||||
g_Challenges.erase(it);
|
||||
result.message = BlockedMessage(wait);
|
||||
result.accountLocked = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
bool usedRecovery = false;
|
||||
if (!CheckTwoFactorCode(account->id, code, true, &usedRecovery)) {
|
||||
result.accountLocked = RecordFailure(account->id, pending.username, pending.address);
|
||||
if (result.accountLocked || ++pending.attempts >= CHALLENGE_ATTEMPTS) g_Challenges.erase(it);
|
||||
result.message = result.accountLocked ? BlockedMessage(ADDRESS_BLOCK) : "That code is not right";
|
||||
return result;
|
||||
}
|
||||
|
||||
const auto username = pending.username;
|
||||
const auto rememberMe = pending.rememberMe;
|
||||
const auto address = pending.address;
|
||||
g_Challenges.erase(it);
|
||||
Database::Get()->ClearFailedAttempts(account->id);
|
||||
g_Throttle.Clear(address, account->id);
|
||||
result.gmLevel = static_cast<uint8_t>(account->maxGmLevel);
|
||||
result.token = JWTUtils::GenerateSessionToken(account->id, username, result.gmLevel, rememberMe);
|
||||
if (result.token.empty()) {
|
||||
result.message = "Server authentication is not configured";
|
||||
return result;
|
||||
}
|
||||
result.success = true;
|
||||
result.usedRecoveryCode = usedRecovery;
|
||||
result.accountId = account->id;
|
||||
result.message = "Login successful";
|
||||
LOG("Successful two-factor login: %s (GM Level: %d)%s", username.c_str(), result.gmLevel, usedRecovery ? " using a recovery code" : "");
|
||||
} catch (const std::exception& ex) {
|
||||
result.message = "An error occurred during login";
|
||||
LOG("Error during two-factor login: %s", ex.what());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool DashboardAuthService::CheckTwoFactorCode(uint32_t accountId, const std::string& code, bool allowRecovery, bool* usedRecoveryCode) {
|
||||
if (usedRecoveryCode) *usedRecoveryCode = false;
|
||||
const auto totp = Database::Get()->GetTotp(accountId);
|
||||
if (totp.enabledAt == 0 || totp.encryptedSecret.empty()) return false;
|
||||
|
||||
const auto secret = Totp::DecryptSecret(totp.encryptedSecret);
|
||||
if (!secret) {
|
||||
LOG("Could not decrypt the two-factor secret of account %u (was dashboard_totp_key replaced?)", accountId);
|
||||
} else if (const auto step = Totp::Verify(*secret, code, static_cast<int64_t>(std::time(nullptr)))) {
|
||||
// A code seen once can't be replayed, even inside its 90 second window
|
||||
return Database::Get()->UseTotpStep(accountId, *step);
|
||||
}
|
||||
|
||||
if (!allowRecovery) return false;
|
||||
const auto normalized = Totp::NormalizeRecoveryCode(code);
|
||||
if (normalized.size() != 12) return false;
|
||||
if (!Database::Get()->UseRecoveryCode(accountId, Sha256Hex(normalized))) return false;
|
||||
if (usedRecoveryCode) *usedRecoveryCode = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DashboardAuthService::RecordFailedAttempt(uint32_t accountId, const std::string& username, const std::string& address) {
|
||||
return RecordFailure(accountId, username, address);
|
||||
}
|
||||
|
||||
bool DashboardAuthService::IsThrottled(uint32_t accountId, const std::string& address) {
|
||||
return Throttled(accountId, address) != 0;
|
||||
}
|
||||
|
||||
void DashboardAuthService::ClearThrottle(uint32_t accountId) {
|
||||
g_Throttle.ClearAccount(accountId);
|
||||
}
|
||||
|
||||
uint8_t DashboardAuthService::RequiredTwoFactorLevel() {
|
||||
if (!Game::config) return 0;
|
||||
return GeneralUtils::TryParse<uint8_t>(Game::config->GetValue("require_2fa_gm_level")).value_or(0);
|
||||
}
|
||||
|
||||
bool DashboardAuthService::NeedsTwoFactorSetup(uint32_t accountId, uint8_t gmLevel) {
|
||||
const auto required = RequiredTwoFactorLevel();
|
||||
if (required == 0 || gmLevel < required) return false;
|
||||
return Database::Get()->GetTotp(accountId).enabledAt == 0;
|
||||
}
|
||||
|
||||
std::string DashboardAuthService::Sha256Hex(const std::string& text) {
|
||||
unsigned char digest[SHA256_DIGEST_LENGTH];
|
||||
SHA256(reinterpret_cast<const unsigned char*>(text.data()), text.size(), digest);
|
||||
static constexpr char digits[] = "0123456789abcdef";
|
||||
std::string out;
|
||||
for (const auto b : digest) { out += digits[b >> 4]; out += digits[b & 15]; }
|
||||
return out;
|
||||
}
|
||||
84
dDashboardServer/auth/DashboardAuthService.h
Normal file
84
dDashboardServer/auth/DashboardAuthService.h
Normal file
@@ -0,0 +1,84 @@
|
||||
#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
|
||||
bool twoFactorRequired{false}; // Password was right; finish with CompleteTwoFactor(challenge, code)
|
||||
std::string challenge{};
|
||||
bool usedRecoveryCode{false};
|
||||
uint32_t accountId{0};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param address The client's network address; failed attempts are throttled per (address, account)
|
||||
* @return LoginResult with success status and JWT token if successful
|
||||
*/
|
||||
static LoginResult Login(const std::string& username, const std::string& password, bool rememberMe, const std::string& address);
|
||||
|
||||
/**
|
||||
* Second step of logging in to an account with two-factor login: a code from the authenticator app, or one of
|
||||
* the account's recovery codes. A challenge is valid for 5 minutes and a few attempts.
|
||||
*/
|
||||
static LoginResult CompleteTwoFactor(const std::string& challenge, const std::string& code);
|
||||
|
||||
/**
|
||||
* Check a two-factor code for an account (authenticator code, or a recovery code when allowRecovery).
|
||||
* Each authenticator code and recovery code is accepted only once.
|
||||
*/
|
||||
static bool CheckTwoFactorCode(uint32_t accountId, const std::string& code, bool allowRecovery, bool* usedRecoveryCode = nullptr);
|
||||
|
||||
// Count a failed attempt (wrong password or code) from an address. Returns whether that address is now blocked
|
||||
// from the account (or the whole account is briefly locked after many failures from everywhere).
|
||||
static bool RecordFailedAttempt(uint32_t accountId, const std::string& username, const std::string& address);
|
||||
|
||||
// Whether sign-in attempts to the account from this address are currently refused
|
||||
static bool IsThrottled(uint32_t accountId, const std::string& address);
|
||||
|
||||
// Forget failed attempts against an account (staff unlocked it)
|
||||
static void ClearThrottle(uint32_t accountId);
|
||||
|
||||
// GM level from which two-factor login is mandatory (config require_2fa_gm_level), 0 when it is not required
|
||||
static uint8_t RequiredTwoFactorLevel();
|
||||
|
||||
// Whether this account must set up two-factor login before using the dashboard
|
||||
static bool NeedsTwoFactorSetup(uint32_t accountId, uint8_t gmLevel);
|
||||
|
||||
static std::string Sha256Hex(const std::string& text);
|
||||
|
||||
/**
|
||||
* Verify that a token is valid and resolve the account it belongs to.
|
||||
* The GM level comes from the database, not the token, so demotions and bans apply immediately.
|
||||
* @param token The JWT token
|
||||
* @param username Output parameter for the username
|
||||
* @param gmLevel Output parameter for the account's current GM level
|
||||
* @param accountId Output parameter for the account ID
|
||||
* @return true if token is valid and the account may still use the dashboard
|
||||
*/
|
||||
static bool VerifyToken(const std::string& token, std::string& username, uint8_t& gmLevel, uint32_t& accountId);
|
||||
|
||||
/**
|
||||
* 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);
|
||||
};
|
||||
230
dDashboardServer/auth/JWTUtils.cpp
Normal file
230
dDashboardServer/auth/JWTUtils.cpp
Normal file
@@ -0,0 +1,230 @@
|
||||
#include "JWTUtils.h"
|
||||
#include "GeneralUtils.h"
|
||||
#include "Logger.h"
|
||||
#include "json.hpp"
|
||||
#include <ctime>
|
||||
#include <cstring>
|
||||
#include <openssl/hmac.h>
|
||||
#include <openssl/sha.h>
|
||||
#include <openssl/crypto.h>
|
||||
#include <openssl/rand.h>
|
||||
|
||||
namespace {
|
||||
std::string g_Secret{};
|
||||
|
||||
// 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.size() == expected.size() && CRYPTO_memcmp(signature.data(), expected.data(), expected.size()) == 0;
|
||||
}
|
||||
}
|
||||
|
||||
namespace JWTUtils {
|
||||
std::string BuildToken(const std::string& username, uint8_t gmLevel, int64_t lifetimeSeconds, bool rememberMe, uint32_t accountId);
|
||||
|
||||
void SetSecretKey(const std::string& secret) {
|
||||
if (secret.size() < 32) {
|
||||
LOG("Refusing JWT secret shorter than 32 bytes");
|
||||
return;
|
||||
}
|
||||
g_Secret = secret;
|
||||
}
|
||||
|
||||
bool HasSecretKey() {
|
||||
return !g_Secret.empty();
|
||||
}
|
||||
|
||||
std::string GenerateSecret() {
|
||||
unsigned char bytes[48];
|
||||
if (RAND_bytes(bytes, sizeof(bytes)) != 1) return "";
|
||||
std::string hex;
|
||||
hex.reserve(sizeof(bytes) * 2);
|
||||
constexpr const char* digits = "0123456789abcdef";
|
||||
for (const auto b : bytes) {
|
||||
hex += digits[b >> 4];
|
||||
hex += digits[b & 0xf];
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
std::string GenerateToken(const std::string& username, uint8_t gmLevel, bool rememberMe) {
|
||||
return GenerateToken(username, gmLevel, rememberMe ? 30 * 24 * 60 * 60 : 24 * 60 * 60, rememberMe);
|
||||
}
|
||||
|
||||
std::string GenerateToken(const std::string& username, uint8_t gmLevel, int64_t lifetimeSeconds, bool rememberMe) {
|
||||
return BuildToken(username, gmLevel, lifetimeSeconds, rememberMe, 0);
|
||||
}
|
||||
|
||||
std::string GenerateSessionToken(uint32_t accountId, const std::string& username, uint8_t gmLevel, bool rememberMe, int64_t lifetimeSeconds) {
|
||||
if (lifetimeSeconds <= 0) lifetimeSeconds = rememberMe ? 30 * 24 * 60 * 60 : 24 * 60 * 60;
|
||||
return BuildToken(username, gmLevel, lifetimeSeconds, rememberMe, accountId);
|
||||
}
|
||||
|
||||
std::string BuildToken(const std::string& username, uint8_t gmLevel, int64_t lifetimeSeconds, bool rememberMe, uint32_t accountId) {
|
||||
if (g_Secret.empty()) return "";
|
||||
// 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 + lifetimeSeconds;
|
||||
|
||||
// Built with the JSON library so a username can never inject extra claims
|
||||
auto claims = nlohmann::json{
|
||||
{"username", username},
|
||||
{"gmLevel", gmLevel},
|
||||
{"rememberMe", rememberMe},
|
||||
{"iat", now},
|
||||
{"exp", expiresAt}
|
||||
};
|
||||
if (accountId != 0) claims["aid"] = accountId;
|
||||
const std::string payload = claims.dump();
|
||||
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) {
|
||||
if (g_Secret.empty()) return false;
|
||||
|
||||
// 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);
|
||||
payload.accountId = json.value("aid", 0u);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
73
dDashboardServer/auth/JWTUtils.h
Normal file
73
dDashboardServer/auth/JWTUtils.h
Normal file
@@ -0,0 +1,73 @@
|
||||
#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};
|
||||
uint32_t accountId{0}; // 0 in tokens made without one (GenerateToken)
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
/**
|
||||
* Generate a new JWT token with an explicit lifetime, e.g. for API clients
|
||||
*/
|
||||
std::string GenerateToken(const std::string& username, uint8_t gmLevel, int64_t lifetimeSeconds, bool rememberMe);
|
||||
|
||||
// Session and API tokens. They also carry the account ID, so a token can never sign in to a different account
|
||||
// that later gets the same username. lifetimeSeconds 0: 30 days with rememberMe, else 1 day.
|
||||
std::string GenerateSessionToken(uint32_t accountId, const std::string& username, uint8_t gmLevel, bool rememberMe, int64_t lifetimeSeconds = 0);
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
/**
|
||||
* @return true once a secret has been configured; no tokens are issued or accepted before that
|
||||
*/
|
||||
bool HasSecretKey();
|
||||
|
||||
/**
|
||||
* Generate a random hex secret suitable for SetSecretKey
|
||||
* @return The secret, or an empty string if the system RNG failed
|
||||
*/
|
||||
std::string GenerateSecret();
|
||||
}
|
||||
74
dDashboardServer/auth/LoginThrottle.h
Normal file
74
dDashboardServer/auth/LoginThrottle.h
Normal file
@@ -0,0 +1,74 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
/**
|
||||
* Failed sign-in throttling keyed to (network address, account).
|
||||
*
|
||||
* Wrong passwords or codes from one address only block that address from that account for a while, so a stranger
|
||||
* can't lock the real owner out by guessing. The account-wide lockout in the database (SetLockout/IsLockedOut) is a
|
||||
* separate, much higher backstop against guessing from many addresses, and is also time-limited.
|
||||
*
|
||||
* Not thread-safe; used from the dashboard's web thread only. Times are unix seconds, passed in so it can be tested.
|
||||
*/
|
||||
class LoginThrottle {
|
||||
public:
|
||||
LoginThrottle(uint32_t maxFailures, int64_t windowSeconds, int64_t blockSeconds)
|
||||
: m_MaxFailures(maxFailures), m_Window(windowSeconds), m_Block(blockSeconds) {}
|
||||
|
||||
// Seconds this address is still blocked from this account, 0 when it may try
|
||||
int64_t BlockedFor(const std::string& address, uint32_t accountId, int64_t now) const {
|
||||
const auto it = m_Entries.find({ address, accountId });
|
||||
if (it == m_Entries.end() || it->second.blockedUntil <= now) return 0;
|
||||
return it->second.blockedUntil - now;
|
||||
}
|
||||
|
||||
// Count a failure; returns whether the address is now blocked from the account
|
||||
bool RecordFailure(const std::string& address, uint32_t accountId, int64_t now) {
|
||||
Prune(now);
|
||||
auto& entry = m_Entries[{ address, accountId }];
|
||||
entry.failures.push_back(now);
|
||||
while (!entry.failures.empty() && entry.failures.front() <= now - m_Window) entry.failures.pop_front();
|
||||
if (entry.failures.size() >= m_MaxFailures) {
|
||||
entry.blockedUntil = now + m_Block;
|
||||
entry.failures.clear();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// A successful sign-in from this address forgets its failures
|
||||
void Clear(const std::string& address, uint32_t accountId) { m_Entries.erase({ address, accountId }); }
|
||||
|
||||
// Staff unlocking an account forgets every address's failures for it
|
||||
void ClearAccount(uint32_t accountId) {
|
||||
std::erase_if(m_Entries, [accountId](const auto& entry) { return entry.first.second == accountId; });
|
||||
}
|
||||
|
||||
size_t Size() const { return m_Entries.size(); }
|
||||
|
||||
private:
|
||||
struct Entry {
|
||||
std::deque<int64_t> failures;
|
||||
int64_t blockedUntil{};
|
||||
};
|
||||
|
||||
// Drop entries with nothing left to remember so memory stays bounded
|
||||
void Prune(int64_t now) {
|
||||
if (++m_Calls % 256 != 0) return;
|
||||
std::erase_if(m_Entries, [this, now](const auto& entry) {
|
||||
const auto& e = entry.second;
|
||||
return e.blockedUntil <= now && (e.failures.empty() || e.failures.back() <= now - m_Window);
|
||||
});
|
||||
}
|
||||
|
||||
uint32_t m_MaxFailures;
|
||||
int64_t m_Window;
|
||||
int64_t m_Block;
|
||||
uint32_t m_Calls{};
|
||||
std::map<std::pair<std::string, uint32_t>, Entry> m_Entries;
|
||||
};
|
||||
109
dDashboardServer/auth/RequireAuthMiddleware.cpp
Normal file
109
dDashboardServer/auth/RequireAuthMiddleware.cpp
Normal file
@@ -0,0 +1,109 @@
|
||||
#include "RequireAuthMiddleware.h"
|
||||
#include "HTTPContext.h"
|
||||
#include "Web.h"
|
||||
#include "Game.h"
|
||||
#include "Logger.h"
|
||||
|
||||
namespace {
|
||||
bool IsApiRequest(const HTTPContext& context) {
|
||||
return context.path.starts_with("/api/");
|
||||
}
|
||||
|
||||
// What an account that still has to set up two-factor login may reach: signing in and out, its own account
|
||||
bool AllowedBeforeTwoFactor(const HTTPContext& context) {
|
||||
const auto& path = context.path;
|
||||
return path.starts_with("/api/auth/") || path.starts_with("/api/account/") || path == "/account" || path == "/api/status" ||
|
||||
path == "/accounts/" + std::to_string(context.accountId) || path == "/api/accounts/" + std::to_string(context.accountId) ||
|
||||
path.starts_with("/js/") || path.starts_with("/css/") || path == "/favicon.ico";
|
||||
}
|
||||
|
||||
std::function<bool(uint8_t)> g_ApiAccessAllowed;
|
||||
std::function<void(const HTTPContext&, HTTPReply&)> g_ForbiddenPage;
|
||||
|
||||
bool IsSafeMethod(const HTTPContext& context) {
|
||||
return context.method == "GET" || context.method == "HEAD" || context.method == "OPTIONS";
|
||||
}
|
||||
}
|
||||
|
||||
void RequireAuthMiddleware::SetApiAccessCheck(std::function<bool(uint8_t gmLevel)> check) {
|
||||
g_ApiAccessAllowed = std::move(check);
|
||||
}
|
||||
|
||||
void RequireAuthMiddleware::SetForbiddenPage(std::function<void(const HTTPContext& context, HTTPReply& reply)> render) {
|
||||
g_ForbiddenPage = std::move(render);
|
||||
}
|
||||
|
||||
RequireAuthMiddleware::RequireAuthMiddleware(uint8_t minGmLevel) : requiredLevel([minGmLevel] { return minGmLevel; }) {}
|
||||
|
||||
RequireAuthMiddleware::RequireAuthMiddleware(std::function<uint8_t()> requiredLevel) : requiredLevel(std::move(requiredLevel)) {}
|
||||
|
||||
bool RequireAuthMiddleware::Process(HTTPContext& context, HTTPReply& reply) {
|
||||
if (!context.isAuthenticated) {
|
||||
LOG_DEBUG("Unauthorized access attempt to %s from %s", context.path.c_str(), context.clientIP.c_str());
|
||||
if (IsApiRequest(context)) {
|
||||
reply.status = eHTTPStatusCode::UNAUTHORIZED;
|
||||
reply.message = "{\"success\":false,\"error\":\"Authentication required\"}";
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
} else {
|
||||
reply.status = eHTTPStatusCode::FOUND;
|
||||
reply.message = "";
|
||||
reply.location = "/login";
|
||||
reply.contentType = eContentType::TEXT_HTML;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cookies are sent automatically by the browser, so state-changing requests authenticated by
|
||||
// cookie must also carry a header a cross-site form cannot set. Bearer-token clients are exempt.
|
||||
const auto authSource = context.userData.find("auth_source");
|
||||
if (!IsSafeMethod(context) && authSource != context.userData.end() && authSource->second == "cookie" &&
|
||||
context.GetHeader("X-Requested-With").empty()) {
|
||||
LOG("Rejected cookie-authenticated %s %s without X-Requested-With (possible CSRF) from %s",
|
||||
context.method.c_str(), context.path.c_str(), context.clientIP.c_str());
|
||||
reply.status = eHTTPStatusCode::FORBIDDEN;
|
||||
reply.message = "{\"success\":false,\"error\":\"Missing X-Requested-With header\"}";
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
return false;
|
||||
}
|
||||
|
||||
// A token in the Authorization header is API use, which a GM level may not be allowed
|
||||
if (authSource != context.userData.end() && authSource->second == "header" && g_ApiAccessAllowed && !g_ApiAccessAllowed(context.gmLevel)) {
|
||||
reply.status = eHTTPStatusCode::FORBIDDEN;
|
||||
reply.message = "{\"success\":false,\"error\":\"API access isn't allowed for your account\"}";
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context.userData.contains("needs_2fa") && !AllowedBeforeTwoFactor(context)) {
|
||||
if (IsApiRequest(context)) {
|
||||
reply.status = eHTTPStatusCode::FORBIDDEN;
|
||||
reply.message = "{\"success\":false,\"error\":\"Set up two-factor login on your account page first\",\"code\":\"2fa_required\"}";
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
} else {
|
||||
reply.status = eHTTPStatusCode::FOUND;
|
||||
reply.message = "";
|
||||
reply.location = "/accounts/" + std::to_string(context.accountId) + "#two-factor";
|
||||
reply.contentType = eContentType::TEXT_HTML;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto minGmLevel = requiredLevel();
|
||||
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());
|
||||
// A page opened in the browser gets the dashboard's error page rather than raw JSON
|
||||
if (!IsApiRequest(context) && g_ForbiddenPage) {
|
||||
g_ForbiddenPage(context, reply);
|
||||
reply.status = eHTTPStatusCode::FORBIDDEN;
|
||||
return false;
|
||||
}
|
||||
reply.status = eHTTPStatusCode::FORBIDDEN;
|
||||
reply.message = "{\"success\":false,\"error\":\"Insufficient permissions\"}";
|
||||
reply.contentType = eContentType::APPLICATION_JSON;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
41
dDashboardServer/auth/RequireAuthMiddleware.h
Normal file
41
dDashboardServer/auth/RequireAuthMiddleware.h
Normal file
@@ -0,0 +1,41 @@
|
||||
#ifndef __REQUIREAUTHMIDDLEWARE_H__
|
||||
#define __REQUIREAUTHMIDDLEWARE_H__
|
||||
|
||||
#include <memory>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#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);
|
||||
|
||||
// The required level is looked up on every request (a permission that can be changed while running)
|
||||
explicit RequireAuthMiddleware(std::function<uint8_t()> requiredLevel);
|
||||
~RequireAuthMiddleware() override = default;
|
||||
|
||||
bool Process(HTTPContext& context, HTTPReply& reply) override;
|
||||
|
||||
// Whether a GM level may use the API (requests signed in with a token in the Authorization header rather than
|
||||
// the browser's cookie). Set by the dashboard from its api_access permission; unset allows everyone.
|
||||
static void SetApiAccessCheck(std::function<bool(uint8_t gmLevel)> check);
|
||||
|
||||
// Renders the page a signed-in account gets when it may not open a page (not /api/); unset replies with JSON
|
||||
static void SetForbiddenPage(std::function<void(const HTTPContext& context, HTTPReply& reply)> render);
|
||||
std::string GetName() const override { return "RequireAuthMiddleware"; }
|
||||
|
||||
private:
|
||||
std::function<uint8_t()> requiredLevel;
|
||||
};
|
||||
|
||||
#endif // !__REQUIREAUTHMIDDLEWARE_H__
|
||||
247
dDashboardServer/auth/Totp.cpp
Normal file
247
dDashboardServer/auth/Totp.cpp
Normal file
@@ -0,0 +1,247 @@
|
||||
#include "Totp.h"
|
||||
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/hmac.h>
|
||||
#include <openssl/rand.h>
|
||||
|
||||
#include "BinaryPathFinder.h"
|
||||
#include "Game.h"
|
||||
#include "Logger.h"
|
||||
#include "dConfig.h"
|
||||
|
||||
namespace {
|
||||
constexpr std::string_view BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
constexpr std::string_view RECOVERY_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; // no look-alikes (i/l/1, o/0)
|
||||
constexpr size_t KEY_SIZE = 32;
|
||||
constexpr size_t NONCE_SIZE = 12;
|
||||
constexpr size_t TAG_SIZE = 16;
|
||||
|
||||
std::vector<uint8_t> g_Key;
|
||||
|
||||
std::string ToHex(const std::vector<uint8_t>& bytes) {
|
||||
static constexpr char digits[] = "0123456789abcdef";
|
||||
std::string out;
|
||||
for (const auto b : bytes) { out += digits[b >> 4]; out += digits[b & 15]; }
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<std::vector<uint8_t>> FromHex(std::string_view hex) {
|
||||
if (hex.size() % 2 != 0) return std::nullopt;
|
||||
std::vector<uint8_t> out;
|
||||
for (size_t i = 0; i < hex.size(); i += 2) {
|
||||
const auto nibble = [](char c) -> int {
|
||||
if (c >= '0' && c <= '9') return c - '0';
|
||||
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||
return -1;
|
||||
};
|
||||
const int hi = nibble(hex[i]), lo = nibble(hex[i + 1]);
|
||||
if (hi < 0 || lo < 0) return std::nullopt;
|
||||
out.push_back(static_cast<uint8_t>(hi << 4 | lo));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Compare without leaking where the first difference is
|
||||
bool ConstantTimeEquals(std::string_view a, std::string_view b) {
|
||||
if (a.size() != b.size()) return false;
|
||||
unsigned char diff = 0;
|
||||
for (size_t i = 0; i < a.size(); i++) diff |= static_cast<unsigned char>(a[i] ^ b[i]);
|
||||
return diff == 0;
|
||||
}
|
||||
|
||||
std::string PercentEncode(std::string_view text) {
|
||||
static constexpr char digits[] = "0123456789ABCDEF";
|
||||
std::string out;
|
||||
for (const unsigned char c : text) {
|
||||
if (std::isalnum(c) || c == '-' || c == '.' || c == '_' || c == '~') out += static_cast<char>(c);
|
||||
else { out += '%'; out += digits[c >> 4]; out += digits[c & 15]; }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
namespace Totp {
|
||||
std::string Base32Encode(const std::vector<uint8_t>& data) {
|
||||
std::string out;
|
||||
uint32_t buffer = 0;
|
||||
int bits = 0;
|
||||
for (const auto byte : data) {
|
||||
buffer = (buffer << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
out += BASE32_ALPHABET[(buffer >> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) out += BASE32_ALPHABET[(buffer << (5 - bits)) & 31];
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<std::vector<uint8_t>> Base32Decode(std::string_view text) {
|
||||
std::vector<uint8_t> out;
|
||||
uint32_t buffer = 0;
|
||||
int bits = 0;
|
||||
for (char c : text) {
|
||||
if (c == ' ' || c == '-' || c == '=') continue;
|
||||
if (c >= 'a' && c <= 'z') c = static_cast<char>(c - 'a' + 'A');
|
||||
const auto index = BASE32_ALPHABET.find(c);
|
||||
if (index == std::string_view::npos) return std::nullopt;
|
||||
buffer = (buffer << 5) | static_cast<uint32_t>(index);
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
out.push_back(static_cast<uint8_t>((buffer >> (bits - 8)) & 0xFF));
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string GenerateSecret() {
|
||||
std::vector<uint8_t> bytes(20);
|
||||
if (RAND_bytes(bytes.data(), static_cast<int>(bytes.size())) != 1) return "";
|
||||
return Base32Encode(bytes);
|
||||
}
|
||||
|
||||
std::string CodeAt(const std::vector<uint8_t>& secret, int64_t step) {
|
||||
std::array<uint8_t, 8> counter{};
|
||||
for (int i = 7; i >= 0; i--) {
|
||||
counter[static_cast<size_t>(i)] = static_cast<uint8_t>(step & 0xFF);
|
||||
step >>= 8;
|
||||
}
|
||||
unsigned char digest[EVP_MAX_MD_SIZE];
|
||||
unsigned int length = 0;
|
||||
HMAC(EVP_sha1(), secret.data(), static_cast<int>(secret.size()), counter.data(), counter.size(), digest, &length);
|
||||
const int offset = digest[length - 1] & 0x0F;
|
||||
const uint32_t binary = (static_cast<uint32_t>(digest[offset] & 0x7F) << 24) | (static_cast<uint32_t>(digest[offset + 1]) << 16) |
|
||||
(static_cast<uint32_t>(digest[offset + 2]) << 8) | digest[offset + 3];
|
||||
std::string code = std::to_string(binary % 1000000);
|
||||
return std::string(DIGITS - code.size(), '0') + code;
|
||||
}
|
||||
|
||||
std::optional<int64_t> Verify(const std::string& base32Secret, std::string_view code, int64_t unixTime) {
|
||||
std::string digits;
|
||||
for (const char c : code) if (c >= '0' && c <= '9') digits += c;
|
||||
if (digits.size() != DIGITS) return std::nullopt;
|
||||
const auto secret = Base32Decode(base32Secret);
|
||||
if (!secret || secret->empty()) return std::nullopt;
|
||||
const int64_t current = unixTime / STEP_SECONDS;
|
||||
for (int64_t step = current - 1; step <= current + 1; step++) {
|
||||
if (ConstantTimeEquals(CodeAt(*secret, step), digits)) return step;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::string ProvisioningUri(const std::string& issuer, const std::string& account, const std::string& base32Secret) {
|
||||
return "otpauth://totp/" + PercentEncode(issuer) + ":" + PercentEncode(account) + "?secret=" + base32Secret +
|
||||
"&issuer=" + PercentEncode(issuer) + "&algorithm=SHA1&digits=6&period=30";
|
||||
}
|
||||
|
||||
std::vector<std::string> GenerateRecoveryCodes(size_t count) {
|
||||
std::vector<std::string> codes;
|
||||
for (size_t n = 0; n < count; n++) {
|
||||
std::array<uint8_t, 12> bytes{};
|
||||
if (RAND_bytes(bytes.data(), static_cast<int>(bytes.size())) != 1) return {};
|
||||
std::string code;
|
||||
for (size_t i = 0; i < bytes.size(); i++) {
|
||||
if (i > 0 && i % 4 == 0) code += '-';
|
||||
code += RECOVERY_ALPHABET[bytes[i] % RECOVERY_ALPHABET.size()];
|
||||
}
|
||||
codes.push_back(code);
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
std::string NormalizeRecoveryCode(std::string_view code) {
|
||||
std::string out;
|
||||
for (char c : code) {
|
||||
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
|
||||
if (RECOVERY_ALPHABET.find(c) != std::string_view::npos) out += c;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool LoadKey() {
|
||||
const auto configured = Game::config ? Game::config->GetValue("totp_key") : "";
|
||||
if (!configured.empty()) {
|
||||
const auto key = FromHex(configured);
|
||||
if (!key || key->size() != KEY_SIZE) {
|
||||
LOG("totp_key must be 64 hex characters; two-factor login is unavailable");
|
||||
return false;
|
||||
}
|
||||
g_Key = *key;
|
||||
return true;
|
||||
}
|
||||
|
||||
const auto keyPath = BinaryPathFinder::GetBinaryDir() / "dashboard_totp_key";
|
||||
std::string stored;
|
||||
if (std::ifstream in(keyPath); in) std::getline(in, stored);
|
||||
if (const auto key = FromHex(stored); key && key->size() == KEY_SIZE) {
|
||||
g_Key = *key;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> key(KEY_SIZE);
|
||||
if (RAND_bytes(key.data(), static_cast<int>(key.size())) != 1) return false;
|
||||
std::ofstream out(keyPath, std::ios::trunc);
|
||||
out << ToHex(key);
|
||||
out.close();
|
||||
std::error_code ec;
|
||||
std::filesystem::permissions(keyPath, std::filesystem::perms::owner_read | std::filesystem::perms::owner_write, std::filesystem::perm_options::replace, ec);
|
||||
LOG("Generated a new two-factor login key at %s (back it up with the database, or everyone's 2FA stops working)", keyPath.string().c_str());
|
||||
g_Key = key;
|
||||
return true;
|
||||
}
|
||||
|
||||
void SetKeyForTesting(const std::vector<uint8_t>& key) {
|
||||
g_Key = key;
|
||||
}
|
||||
|
||||
// Stored as hex(nonce | ciphertext | tag)
|
||||
std::optional<std::string> EncryptSecret(const std::string& base32Secret) {
|
||||
if (g_Key.size() != KEY_SIZE) return std::nullopt;
|
||||
std::vector<uint8_t> nonce(NONCE_SIZE), ciphertext(base32Secret.size()), tag(TAG_SIZE);
|
||||
if (RAND_bytes(nonce.data(), static_cast<int>(nonce.size())) != 1) return std::nullopt;
|
||||
|
||||
auto* ctx = EVP_CIPHER_CTX_new();
|
||||
int length = 0;
|
||||
bool ok = ctx && EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, g_Key.data(), nonce.data()) == 1 &&
|
||||
EVP_EncryptUpdate(ctx, ciphertext.data(), &length, reinterpret_cast<const unsigned char*>(base32Secret.data()), static_cast<int>(base32Secret.size())) == 1 &&
|
||||
EVP_EncryptFinal_ex(ctx, ciphertext.data() + length, &length) == 1 &&
|
||||
EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, static_cast<int>(TAG_SIZE), tag.data()) == 1;
|
||||
EVP_CIPHER_CTX_free(ctx);
|
||||
if (!ok) return std::nullopt;
|
||||
|
||||
std::vector<uint8_t> out(nonce);
|
||||
out.insert(out.end(), ciphertext.begin(), ciphertext.end());
|
||||
out.insert(out.end(), tag.begin(), tag.end());
|
||||
return ToHex(out);
|
||||
}
|
||||
|
||||
std::optional<std::string> DecryptSecret(const std::string& stored) {
|
||||
if (g_Key.size() != KEY_SIZE) return std::nullopt;
|
||||
const auto bytes = FromHex(stored);
|
||||
if (!bytes || bytes->size() < NONCE_SIZE + TAG_SIZE) return std::nullopt;
|
||||
const std::vector<uint8_t> nonce(bytes->begin(), bytes->begin() + NONCE_SIZE);
|
||||
const std::vector<uint8_t> ciphertext(bytes->begin() + NONCE_SIZE, bytes->end() - TAG_SIZE);
|
||||
std::vector<uint8_t> tag(bytes->end() - TAG_SIZE, bytes->end());
|
||||
std::vector<uint8_t> plain(ciphertext.size() + 1);
|
||||
|
||||
auto* ctx = EVP_CIPHER_CTX_new();
|
||||
int length = 0, total = 0;
|
||||
bool ok = ctx && EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, g_Key.data(), nonce.data()) == 1 &&
|
||||
EVP_DecryptUpdate(ctx, plain.data(), &length, ciphertext.data(), static_cast<int>(ciphertext.size())) == 1;
|
||||
total = length;
|
||||
ok = ok && EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, static_cast<int>(TAG_SIZE), tag.data()) == 1 &&
|
||||
EVP_DecryptFinal_ex(ctx, plain.data() + total, &length) == 1; // fails if the data or key is wrong
|
||||
EVP_CIPHER_CTX_free(ctx);
|
||||
if (!ok) return std::nullopt;
|
||||
total += length;
|
||||
return std::string(reinterpret_cast<const char*>(plain.data()), static_cast<size_t>(total));
|
||||
}
|
||||
}
|
||||
49
dDashboardServer/auth/Totp.h
Normal file
49
dDashboardServer/auth/Totp.h
Normal file
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* Time-based one-time passwords (RFC 6238, the codes authenticator apps show): 6 digits, 30 second steps,
|
||||
* HMAC-SHA1, which is what every common authenticator app expects.
|
||||
*/
|
||||
namespace Totp {
|
||||
constexpr int64_t STEP_SECONDS = 30;
|
||||
constexpr int DIGITS = 6;
|
||||
|
||||
std::string Base32Encode(const std::vector<uint8_t>& data);
|
||||
std::optional<std::vector<uint8_t>> Base32Decode(std::string_view text); // ignores spaces and case
|
||||
|
||||
// A new random 160-bit secret, base32 encoded
|
||||
std::string GenerateSecret();
|
||||
|
||||
// The code for a time step (RFC 4226 HOTP with the step as counter)
|
||||
std::string CodeAt(const std::vector<uint8_t>& secret, int64_t step);
|
||||
|
||||
/**
|
||||
* Check a code against the current step and one step either side, for clock drift.
|
||||
* @return the matching step, so the caller can refuse to accept the same step twice
|
||||
*/
|
||||
std::optional<int64_t> Verify(const std::string& base32Secret, std::string_view code, int64_t unixTime);
|
||||
|
||||
// otpauth:// link for authenticator apps (usually shown as a QR code)
|
||||
std::string ProvisioningUri(const std::string& issuer, const std::string& account, const std::string& base32Secret);
|
||||
|
||||
// Single-use recovery codes like "k7qd-9xw2-mz4p", and the normalized form their hash is taken from
|
||||
std::vector<std::string> GenerateRecoveryCodes(size_t count);
|
||||
std::string NormalizeRecoveryCode(std::string_view code);
|
||||
|
||||
/**
|
||||
* Secrets are stored encrypted (AES-256-GCM) so a database copy alone cannot generate codes.
|
||||
* The key is `totp_key` from the dashboard config (64 hex characters), or a generated `dashboard_totp_key` file.
|
||||
*/
|
||||
bool LoadKey();
|
||||
std::optional<std::string> EncryptSecret(const std::string& base32Secret);
|
||||
std::optional<std::string> DecryptSecret(const std::string& stored);
|
||||
|
||||
// For tests: use this key instead of loading one
|
||||
void SetKeyForTesting(const std::vector<uint8_t>& key);
|
||||
}
|
||||
Reference in New Issue
Block a user