2025-08-03 21:53:15 -04:00
|
|
|
#include "AuthService.h"
|
|
|
|
|
#include "DatabaseService.h"
|
|
|
|
|
#include "RedisHelper.h"
|
|
|
|
|
#include <drogon/utils/Utilities.h>
|
|
|
|
|
#include <regex>
|
|
|
|
|
#include <random>
|
2025-08-10 07:55:39 -04:00
|
|
|
#include <functional>
|
|
|
|
|
#include <memory>
|
2025-08-13 00:10:25 -04:00
|
|
|
#include <fstream>
|
|
|
|
|
#include <sstream>
|
|
|
|
|
#include <cstdlib>
|
2025-08-03 21:53:15 -04:00
|
|
|
|
|
|
|
|
using namespace drogon;
|
|
|
|
|
using namespace drogon::orm;
|
|
|
|
|
|
|
|
|
|
bool AuthService::validatePassword(const std::string& password, std::string& error) {
|
|
|
|
|
if (password.length() < 8) {
|
|
|
|
|
error = "Password must be at least 8 characters long";
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!std::regex_search(password, std::regex("[0-9]"))) {
|
|
|
|
|
error = "Password must contain at least one number";
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!std::regex_search(password, std::regex("[!@#$%^&*(),.?\":{}|<>]"))) {
|
|
|
|
|
error = "Password must contain at least one symbol";
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-13 00:10:25 -04:00
|
|
|
// Helper function to execute GPG commands
|
|
|
|
|
std::string executeGpgCommand(const std::string& command) {
|
|
|
|
|
std::array<char, 128> buffer;
|
|
|
|
|
std::string result;
|
|
|
|
|
|
|
|
|
|
FILE* pipe = popen(command.c_str(), "r");
|
|
|
|
|
if (!pipe) {
|
|
|
|
|
LOG_ERROR << "Failed to execute GPG command: " << command;
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
while (fgets(buffer.data(), buffer.size(), pipe) != nullptr) {
|
|
|
|
|
result += buffer.data();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int exitCode = pclose(pipe);
|
|
|
|
|
|
|
|
|
|
// Exit code is returned as status << 8, so we need to extract the actual exit code
|
|
|
|
|
int actualExitCode = WEXITSTATUS(exitCode);
|
|
|
|
|
|
|
|
|
|
if (actualExitCode != 0) {
|
|
|
|
|
LOG_ERROR << "GPG command failed with exit code: " << actualExitCode
|
|
|
|
|
<< " for command: " << command
|
|
|
|
|
<< " output: " << result;
|
|
|
|
|
// Don't return empty string immediately - sometimes GPG returns non-zero but still works
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Server-side PGP signature verification
|
|
|
|
|
bool verifyPgpSignature(const std::string& message, const std::string& signature, const std::string& publicKey) {
|
|
|
|
|
try {
|
|
|
|
|
// Create temporary directory for GPG operations
|
|
|
|
|
std::string tmpDir = "/tmp/pgp_verify_" + drogon::utils::genRandomString(8);
|
|
|
|
|
std::string mkdirCmd = "mkdir -p " + tmpDir;
|
|
|
|
|
if (system(mkdirCmd.c_str()) != 0) {
|
|
|
|
|
LOG_ERROR << "Failed to create temporary directory: " << tmpDir;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create GPG home directory
|
|
|
|
|
std::string keyringDir = tmpDir + "/gnupg";
|
|
|
|
|
std::string mkdirGpgCmd = "mkdir -p " + keyringDir + " && chmod 700 " + keyringDir;
|
|
|
|
|
if (system(mkdirGpgCmd.c_str()) != 0) {
|
|
|
|
|
LOG_ERROR << "Failed to create GPG home directory: " << keyringDir;
|
|
|
|
|
std::string cleanupCmd = "rm -rf " + tmpDir;
|
|
|
|
|
system(cleanupCmd.c_str());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Write files
|
|
|
|
|
std::string messageFile = tmpDir + "/message.txt";
|
|
|
|
|
std::string sigFile = tmpDir + "/signature.asc";
|
|
|
|
|
std::string pubkeyFile = tmpDir + "/pubkey.asc";
|
|
|
|
|
|
|
|
|
|
// Write message file
|
|
|
|
|
std::ofstream msgOut(messageFile);
|
|
|
|
|
if (!msgOut) {
|
|
|
|
|
LOG_ERROR << "Failed to create message file: " << messageFile;
|
|
|
|
|
std::string cleanupCmd = "rm -rf " + tmpDir;
|
|
|
|
|
system(cleanupCmd.c_str());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
msgOut << message;
|
|
|
|
|
msgOut.close();
|
|
|
|
|
|
|
|
|
|
// Write signature file
|
|
|
|
|
std::ofstream sigOut(sigFile);
|
|
|
|
|
if (!sigOut) {
|
|
|
|
|
LOG_ERROR << "Failed to create signature file: " << sigFile;
|
|
|
|
|
std::string cleanupCmd = "rm -rf " + tmpDir;
|
|
|
|
|
system(cleanupCmd.c_str());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
sigOut << signature;
|
|
|
|
|
sigOut.close();
|
|
|
|
|
|
|
|
|
|
// Write public key file
|
|
|
|
|
std::ofstream keyOut(pubkeyFile);
|
|
|
|
|
if (!keyOut) {
|
|
|
|
|
LOG_ERROR << "Failed to create public key file: " << pubkeyFile;
|
|
|
|
|
std::string cleanupCmd = "rm -rf " + tmpDir;
|
|
|
|
|
system(cleanupCmd.c_str());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
keyOut << publicKey;
|
|
|
|
|
keyOut.close();
|
|
|
|
|
|
|
|
|
|
// Initialize GPG (create trustdb if needed)
|
|
|
|
|
std::string initCmd = "GNUPGHOME=" + keyringDir + " gpg --batch --yes --list-keys 2>&1";
|
|
|
|
|
executeGpgCommand(initCmd); // This will create the trustdb if it doesn't exist
|
|
|
|
|
|
|
|
|
|
// Import the public key to the temporary keyring
|
|
|
|
|
// Use --trust-model always to avoid trust issues
|
|
|
|
|
std::string importCmd = "GNUPGHOME=" + keyringDir +
|
|
|
|
|
" gpg --batch --yes --trust-model always --import " + pubkeyFile + " 2>&1";
|
|
|
|
|
std::string importResult = executeGpgCommand(importCmd);
|
|
|
|
|
|
|
|
|
|
LOG_DEBUG << "GPG import result: " << importResult;
|
|
|
|
|
|
|
|
|
|
// Check if import was successful (be more lenient with the check)
|
|
|
|
|
bool importSuccess = (importResult.find("imported") != std::string::npos) ||
|
|
|
|
|
(importResult.find("unchanged") != std::string::npos) ||
|
|
|
|
|
(importResult.find("processed: 1") != std::string::npos) ||
|
|
|
|
|
(importResult.find("public key") != std::string::npos);
|
|
|
|
|
|
|
|
|
|
if (!importSuccess) {
|
|
|
|
|
LOG_ERROR << "Failed to import public key. Import output: " << importResult;
|
|
|
|
|
// Try to get more information about what went wrong
|
|
|
|
|
std::string debugCmd = "GNUPGHOME=" + keyringDir + " gpg --list-keys 2>&1";
|
|
|
|
|
std::string debugResult = executeGpgCommand(debugCmd);
|
|
|
|
|
LOG_ERROR << "GPG keyring state: " << debugResult;
|
|
|
|
|
|
|
|
|
|
// Cleanup
|
|
|
|
|
std::string cleanupCmd = "rm -rf " + tmpDir;
|
|
|
|
|
system(cleanupCmd.c_str());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify the signature
|
|
|
|
|
// Use --trust-model always to avoid trust issues
|
|
|
|
|
std::string verifyCmd = "GNUPGHOME=" + keyringDir +
|
|
|
|
|
" gpg --batch --yes --trust-model always --verify " +
|
|
|
|
|
sigFile + " " + messageFile + " 2>&1";
|
|
|
|
|
std::string verifyResult = executeGpgCommand(verifyCmd);
|
|
|
|
|
|
|
|
|
|
LOG_DEBUG << "GPG verify result: " << verifyResult;
|
|
|
|
|
|
|
|
|
|
// Check if verification succeeded (check both English and potential localized messages)
|
|
|
|
|
bool verified = (verifyResult.find("Good signature") != std::string::npos) ||
|
|
|
|
|
(verifyResult.find("gpg: Good signature") != std::string::npos) ||
|
|
|
|
|
(verifyResult.find("Signature made") != std::string::npos &&
|
|
|
|
|
verifyResult.find("BAD signature") == std::string::npos);
|
|
|
|
|
|
|
|
|
|
if (!verified) {
|
|
|
|
|
LOG_WARN << "Signature verification failed. Verify output: " << verifyResult;
|
|
|
|
|
} else {
|
|
|
|
|
LOG_INFO << "Signature verification successful for challenge";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cleanup temporary files
|
|
|
|
|
std::string cleanupCmd = "rm -rf " + tmpDir;
|
|
|
|
|
system(cleanupCmd.c_str());
|
|
|
|
|
|
|
|
|
|
return verified;
|
|
|
|
|
|
|
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_ERROR << "Exception during signature verification: " << e.what();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-10 07:55:39 -04:00
|
|
|
void AuthService::generateUniqueColor(std::function<void(const std::string& color)> callback) {
|
|
|
|
|
try {
|
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
|
|
|
|
|
|
// Create a structure to hold the state for recursive attempts
|
|
|
|
|
struct ColorGenerator : public std::enable_shared_from_this<ColorGenerator> {
|
|
|
|
|
std::mt19937 gen;
|
|
|
|
|
std::uniform_int_distribution<> dis;
|
|
|
|
|
std::function<void(const std::string&)> callback;
|
|
|
|
|
DbClientPtr dbClient;
|
|
|
|
|
int attempts;
|
|
|
|
|
|
|
|
|
|
ColorGenerator(std::function<void(const std::string&)> cb, DbClientPtr db)
|
|
|
|
|
: gen(std::random_device{}()),
|
|
|
|
|
dis(0, 0xFFFFFF),
|
|
|
|
|
callback(cb),
|
|
|
|
|
dbClient(db),
|
|
|
|
|
attempts(0) {}
|
|
|
|
|
|
|
|
|
|
void tryGenerate() {
|
|
|
|
|
auto self = shared_from_this();
|
|
|
|
|
|
|
|
|
|
// Limit attempts to prevent infinite recursion
|
|
|
|
|
if (++attempts > 100) {
|
|
|
|
|
LOG_ERROR << "Failed to generate unique color after 100 attempts";
|
|
|
|
|
callback("#561D5E"); // Fallback to default
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate random color
|
|
|
|
|
int colorValue = dis(gen);
|
|
|
|
|
char colorHex[8];
|
|
|
|
|
snprintf(colorHex, sizeof(colorHex), "#%06X", colorValue);
|
|
|
|
|
std::string color(colorHex);
|
|
|
|
|
|
|
|
|
|
// Check if color exists
|
|
|
|
|
*dbClient << "SELECT id FROM users WHERE user_color = $1 LIMIT 1"
|
|
|
|
|
<< color
|
|
|
|
|
>> [self, color](const Result& r) {
|
|
|
|
|
if (r.empty()) {
|
|
|
|
|
// Color is unique, use it
|
|
|
|
|
self->callback(color);
|
|
|
|
|
} else {
|
|
|
|
|
// Color exists, try again
|
|
|
|
|
self->tryGenerate();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
>> [self](const DrogonDbException& e) {
|
|
|
|
|
LOG_ERROR << "Database error checking color: " << e.base().what();
|
|
|
|
|
// Fallback to a default color
|
|
|
|
|
self->callback("#561D5E");
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
auto generator = std::make_shared<ColorGenerator>(callback, dbClient);
|
|
|
|
|
generator->tryGenerate();
|
|
|
|
|
|
|
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_ERROR << "Exception in generateUniqueColor: " << e.what();
|
|
|
|
|
callback("#561D5E"); // Fallback to default
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void AuthService::updateUserColor(int64_t userId, const std::string& newColor,
|
|
|
|
|
std::function<void(bool, const std::string&, const std::string&)> callback) {
|
|
|
|
|
try {
|
|
|
|
|
// Validate color format
|
|
|
|
|
if (newColor.length() != 7 || newColor[0] != '#') {
|
|
|
|
|
callback(false, "Invalid color format. Use #RRGGBB", "");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if color is valid hex
|
|
|
|
|
for (size_t i = 1; i < 7; i++) {
|
|
|
|
|
if (!std::isxdigit(newColor[i])) {
|
|
|
|
|
callback(false, "Invalid color format. Use #RRGGBB", "");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
|
|
|
|
|
|
// Check if color is already taken
|
|
|
|
|
*dbClient << "SELECT id FROM users WHERE user_color = $1 AND id != $2 LIMIT 1"
|
|
|
|
|
<< newColor << userId
|
|
|
|
|
>> [dbClient, userId, newColor, callback](const Result& r) {
|
|
|
|
|
if (!r.empty()) {
|
|
|
|
|
callback(false, "This color is already taken", "");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update the color
|
|
|
|
|
*dbClient << "UPDATE users SET user_color = $1 WHERE id = $2 RETURNING user_color"
|
|
|
|
|
<< newColor << userId
|
|
|
|
|
>> [callback, newColor](const Result& r2) {
|
|
|
|
|
if (!r2.empty()) {
|
|
|
|
|
callback(true, "", newColor);
|
|
|
|
|
} else {
|
|
|
|
|
callback(false, "Failed to update color", "");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
>> [callback](const DrogonDbException& e) {
|
|
|
|
|
LOG_ERROR << "Failed to update color: " << e.base().what();
|
|
|
|
|
callback(false, "Database error", "");
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
>> [callback](const DrogonDbException& e) {
|
|
|
|
|
LOG_ERROR << "Database error: " << e.base().what();
|
|
|
|
|
callback(false, "Database error", "");
|
|
|
|
|
};
|
|
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_ERROR << "Exception in updateUserColor: " << e.what();
|
|
|
|
|
callback(false, "Internal server error", "");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
void AuthService::registerUser(const std::string& username, const std::string& password,
|
|
|
|
|
const std::string& publicKey, const std::string& fingerprint,
|
|
|
|
|
std::function<void(bool, const std::string&, int64_t)> callback) {
|
2025-08-10 07:55:39 -04:00
|
|
|
try {
|
|
|
|
|
LOG_DEBUG << "Starting user registration for: " << username;
|
|
|
|
|
|
|
|
|
|
// Validate username
|
|
|
|
|
if (username.length() < 3 || username.length() > 30) {
|
|
|
|
|
callback(false, "Username must be between 3 and 30 characters", 0);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!std::regex_match(username, std::regex("^[a-zA-Z0-9_]+$"))) {
|
|
|
|
|
callback(false, "Username can only contain letters, numbers, and underscores", 0);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate password
|
|
|
|
|
std::string error;
|
|
|
|
|
if (!validatePassword(password, error)) {
|
|
|
|
|
callback(false, error, 0);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
LOG_DEBUG << "Validation passed, generating unique color";
|
|
|
|
|
|
|
|
|
|
// Generate unique color first
|
|
|
|
|
generateUniqueColor([this, username, password, publicKey, fingerprint, callback](const std::string& color) {
|
|
|
|
|
try {
|
|
|
|
|
LOG_DEBUG << "Got unique color: " << color << ", checking username availability";
|
|
|
|
|
|
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
|
if (!dbClient) {
|
|
|
|
|
LOG_ERROR << "Database client is null";
|
|
|
|
|
callback(false, "Database connection error", 0);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if username exists
|
|
|
|
|
*dbClient << "SELECT id FROM users WHERE username = $1 LIMIT 1"
|
|
|
|
|
<< username
|
|
|
|
|
>> [dbClient, username, password, publicKey, fingerprint, color, callback](const Result& r) {
|
|
|
|
|
try {
|
|
|
|
|
if (!r.empty()) {
|
|
|
|
|
LOG_WARN << "Username already exists: " << username;
|
|
|
|
|
callback(false, "Username already exists", 0);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
LOG_DEBUG << "Username available, checking fingerprint";
|
|
|
|
|
|
|
|
|
|
// Check if fingerprint exists
|
|
|
|
|
*dbClient << "SELECT id FROM pgp_keys WHERE fingerprint = $1 LIMIT 1"
|
|
|
|
|
<< fingerprint
|
|
|
|
|
>> [dbClient, username, password, publicKey, fingerprint, color, callback](const Result& r2) {
|
|
|
|
|
try {
|
|
|
|
|
if (!r2.empty()) {
|
|
|
|
|
LOG_WARN << "Fingerprint already exists";
|
|
|
|
|
callback(false, "This PGP key is already registered", 0);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
LOG_DEBUG << "Fingerprint available, hashing password";
|
|
|
|
|
|
|
|
|
|
// Hash password
|
|
|
|
|
std::string hash;
|
|
|
|
|
try {
|
|
|
|
|
hash = BCrypt::generateHash(password);
|
|
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_ERROR << "Failed to hash password: " << e.what();
|
|
|
|
|
callback(false, "Failed to process password", 0);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
LOG_DEBUG << "Password hashed, inserting user";
|
|
|
|
|
|
|
|
|
|
// Insert user first (without transaction for simplicity)
|
|
|
|
|
*dbClient << "INSERT INTO users (username, password_hash, is_admin, is_streamer, is_pgp_only, user_color) "
|
|
|
|
|
"VALUES ($1, $2, false, false, false, $3) RETURNING id"
|
|
|
|
|
<< username << hash << color
|
|
|
|
|
>> [dbClient, publicKey, fingerprint, callback, username](const Result& r3) {
|
|
|
|
|
try {
|
|
|
|
|
if (r3.empty()) {
|
|
|
|
|
LOG_ERROR << "Failed to insert user";
|
|
|
|
|
callback(false, "Failed to create user", 0);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int64_t userId = r3[0]["id"].as<int64_t>();
|
|
|
|
|
LOG_INFO << "User created with ID: " << userId;
|
|
|
|
|
|
|
|
|
|
// Insert PGP key
|
|
|
|
|
*dbClient << "INSERT INTO pgp_keys (user_id, public_key, fingerprint) VALUES ($1, $2, $3)"
|
|
|
|
|
<< userId << publicKey << fingerprint
|
|
|
|
|
>> [callback, userId, username](const Result&) {
|
|
|
|
|
LOG_INFO << "User registration complete for: " << username << " (ID: " << userId << ")";
|
|
|
|
|
callback(true, "", userId);
|
|
|
|
|
}
|
|
|
|
|
>> [dbClient, userId, callback](const DrogonDbException& e) {
|
|
|
|
|
LOG_ERROR << "Failed to insert PGP key: " << e.base().what();
|
|
|
|
|
// Try to clean up the user
|
|
|
|
|
*dbClient << "DELETE FROM users WHERE id = $1" << userId >> [](const Result&) {} >> [](const DrogonDbException&) {};
|
|
|
|
|
callback(false, "Failed to save PGP key", 0);
|
|
|
|
|
};
|
|
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_ERROR << "Exception processing user insert result: " << e.what();
|
|
|
|
|
callback(false, "Registration failed", 0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
>> [callback](const DrogonDbException& e) {
|
|
|
|
|
LOG_ERROR << "Failed to insert user: " << e.base().what();
|
|
|
|
|
callback(false, "Registration failed", 0);
|
|
|
|
|
};
|
|
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_ERROR << "Exception in fingerprint check callback: " << e.what();
|
|
|
|
|
callback(false, "Registration failed", 0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
>> [callback](const DrogonDbException& e) {
|
|
|
|
|
LOG_ERROR << "Database error checking fingerprint: " << e.base().what();
|
|
|
|
|
callback(false, "Database error", 0);
|
|
|
|
|
};
|
|
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_ERROR << "Exception in username check callback: " << e.what();
|
|
|
|
|
callback(false, "Registration failed", 0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
>> [callback](const DrogonDbException& e) {
|
|
|
|
|
LOG_ERROR << "Database error checking username: " << e.base().what();
|
|
|
|
|
callback(false, "Database error", 0);
|
|
|
|
|
};
|
|
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_ERROR << "Exception in color generation callback: " << e.what();
|
|
|
|
|
callback(false, "Registration failed", 0);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_ERROR << "Exception in registerUser: " << e.what();
|
|
|
|
|
callback(false, "Registration failed", 0);
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void AuthService::loginUser(const std::string& username, const std::string& password,
|
|
|
|
|
std::function<void(bool, const std::string&, const UserInfo&)> callback) {
|
2025-08-10 07:55:39 -04:00
|
|
|
try {
|
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
|
if (!dbClient) {
|
|
|
|
|
LOG_ERROR << "Database client is null";
|
|
|
|
|
callback(false, "", UserInfo{});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
*dbClient << "SELECT id, username, password_hash, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at, user_color "
|
|
|
|
|
"FROM users WHERE username = $1 LIMIT 1"
|
|
|
|
|
<< username
|
|
|
|
|
>> [password, callback, this](const Result& r) {
|
|
|
|
|
try {
|
2025-08-03 21:53:15 -04:00
|
|
|
if (r.empty()) {
|
2025-08-10 07:55:39 -04:00
|
|
|
callback(false, "", UserInfo{});
|
2025-08-03 21:53:15 -04:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-10 07:55:39 -04:00
|
|
|
// Check if PGP-only is enabled BEFORE password validation
|
|
|
|
|
bool isPgpOnly = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
|
|
|
|
|
|
|
|
|
|
if (isPgpOnly) {
|
|
|
|
|
// Return a specific error for PGP-only accounts
|
|
|
|
|
callback(false, "PGP-only login enabled for this account", UserInfo{});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string hash = r[0]["password_hash"].as<std::string>();
|
|
|
|
|
|
|
|
|
|
bool valid = false;
|
|
|
|
|
try {
|
|
|
|
|
valid = BCrypt::validatePassword(password, hash);
|
|
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_ERROR << "Failed to validate password: " << e.what();
|
|
|
|
|
callback(false, "", UserInfo{});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!valid) {
|
2025-08-03 21:53:15 -04:00
|
|
|
callback(false, "", UserInfo{});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
UserInfo user;
|
|
|
|
|
user.id = r[0]["id"].as<int64_t>();
|
|
|
|
|
user.username = r[0]["username"].as<std::string>();
|
|
|
|
|
user.isAdmin = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as<bool>();
|
|
|
|
|
user.isStreamer = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
|
2025-08-10 07:55:39 -04:00
|
|
|
user.isPgpOnly = isPgpOnly;
|
2025-08-03 21:53:15 -04:00
|
|
|
user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
|
|
|
|
|
user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
|
|
|
|
|
user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
|
2025-08-10 07:55:39 -04:00
|
|
|
user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
|
2025-08-03 21:53:15 -04:00
|
|
|
|
|
|
|
|
std::string token = generateToken(user);
|
|
|
|
|
callback(true, token, user);
|
2025-08-10 07:55:39 -04:00
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_ERROR << "Exception in login callback: " << e.what();
|
2025-08-03 21:53:15 -04:00
|
|
|
callback(false, "", UserInfo{});
|
2025-08-10 07:55:39 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
>> [callback](const DrogonDbException& e) {
|
|
|
|
|
LOG_ERROR << "Database error: " << e.base().what();
|
|
|
|
|
callback(false, "", UserInfo{});
|
|
|
|
|
};
|
|
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_ERROR << "Exception in loginUser: " << e.what();
|
|
|
|
|
callback(false, "", UserInfo{});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void AuthService::initiatePgpLogin(const std::string& username,
|
|
|
|
|
std::function<void(bool, const std::string&, const std::string&)> callback) {
|
|
|
|
|
try {
|
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
|
if (!dbClient) {
|
|
|
|
|
LOG_ERROR << "Database client is null";
|
|
|
|
|
callback(false, "", "");
|
|
|
|
|
return;
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
2025-08-10 07:55:39 -04:00
|
|
|
|
|
|
|
|
// Generate random challenge
|
|
|
|
|
auto bytes = drogon::utils::genRandomString(32);
|
|
|
|
|
std::string challenge = drogon::utils::base64Encode(
|
|
|
|
|
reinterpret_cast<const unsigned char*>(bytes.data()), bytes.length()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Store challenge in Redis with 5 minute TTL
|
|
|
|
|
RedisHelper::storeKeyAsync("pgp_challenge:" + username, challenge, 300,
|
|
|
|
|
[dbClient, username, challenge, callback](bool stored) {
|
|
|
|
|
if (!stored) {
|
|
|
|
|
callback(false, "", "");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get user's latest public key
|
|
|
|
|
*dbClient << "SELECT pk.public_key FROM pgp_keys pk "
|
|
|
|
|
"JOIN users u ON pk.user_id = u.id "
|
|
|
|
|
"WHERE u.username = $1 "
|
|
|
|
|
"ORDER BY pk.created_at DESC LIMIT 1"
|
|
|
|
|
<< username
|
|
|
|
|
>> [callback, challenge](const Result& r) {
|
|
|
|
|
if (r.empty()) {
|
|
|
|
|
callback(false, "", "");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string publicKey = r[0]["public_key"].as<std::string>();
|
|
|
|
|
callback(true, challenge, publicKey);
|
|
|
|
|
}
|
|
|
|
|
>> [callback](const DrogonDbException& e) {
|
|
|
|
|
LOG_ERROR << "Database error: " << e.base().what();
|
|
|
|
|
callback(false, "", "");
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_ERROR << "Exception in initiatePgpLogin: " << e.what();
|
|
|
|
|
callback(false, "", "");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void AuthService::verifyPgpLogin(const std::string& username, const std::string& signature,
|
|
|
|
|
const std::string& challenge,
|
|
|
|
|
std::function<void(bool, const std::string&, const UserInfo&)> callback) {
|
|
|
|
|
try {
|
|
|
|
|
// Get stored challenge from Redis
|
|
|
|
|
RedisHelper::getKeyAsync("pgp_challenge:" + username,
|
|
|
|
|
[username, signature, challenge, callback, this](const std::string& storedChallenge) {
|
|
|
|
|
try {
|
|
|
|
|
if (storedChallenge.empty() || storedChallenge != challenge) {
|
2025-08-13 00:10:25 -04:00
|
|
|
LOG_WARN << "Challenge mismatch for user: " << username;
|
2025-08-10 07:55:39 -04:00
|
|
|
callback(false, "", UserInfo{});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Delete challenge after use
|
|
|
|
|
RedisHelper::deleteKeyAsync("pgp_challenge:" + username, [](bool) {});
|
|
|
|
|
|
2025-08-13 00:10:25 -04:00
|
|
|
// Get user's public key and verify signature
|
2025-08-10 07:55:39 -04:00
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
|
if (!dbClient) {
|
|
|
|
|
LOG_ERROR << "Database client is null";
|
|
|
|
|
callback(false, "", UserInfo{});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-13 00:10:25 -04:00
|
|
|
*dbClient << "SELECT pk.public_key, u.id, u.username, u.is_admin, u.is_streamer, "
|
|
|
|
|
"u.is_pgp_only, u.bio, u.avatar_url, u.pgp_only_enabled_at, u.user_color "
|
|
|
|
|
"FROM pgp_keys pk JOIN users u ON pk.user_id = u.id "
|
|
|
|
|
"WHERE u.username = $1 ORDER BY pk.created_at DESC LIMIT 1"
|
2025-08-10 07:55:39 -04:00
|
|
|
<< username
|
2025-08-13 00:10:25 -04:00
|
|
|
>> [callback, signature, challenge, this](const Result& r) {
|
2025-08-10 07:55:39 -04:00
|
|
|
try {
|
|
|
|
|
if (r.empty()) {
|
2025-08-13 00:10:25 -04:00
|
|
|
LOG_WARN << "No PGP key found for user";
|
2025-08-10 07:55:39 -04:00
|
|
|
callback(false, "", UserInfo{});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-13 00:10:25 -04:00
|
|
|
std::string publicKey = r[0]["public_key"].as<std::string>();
|
|
|
|
|
|
|
|
|
|
// CRITICAL: Server-side signature verification
|
|
|
|
|
bool signatureValid = verifyPgpSignature(challenge, signature, publicKey);
|
|
|
|
|
|
|
|
|
|
if (!signatureValid) {
|
|
|
|
|
LOG_WARN << "Invalid PGP signature for user";
|
|
|
|
|
callback(false, "Invalid signature", UserInfo{});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
LOG_INFO << "PGP signature verified successfully for user";
|
|
|
|
|
|
2025-08-10 07:55:39 -04:00
|
|
|
UserInfo user;
|
|
|
|
|
user.id = r[0]["id"].as<int64_t>();
|
|
|
|
|
user.username = r[0]["username"].as<std::string>();
|
|
|
|
|
user.isAdmin = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as<bool>();
|
|
|
|
|
user.isStreamer = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
|
|
|
|
|
user.isPgpOnly = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
|
|
|
|
|
user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
|
|
|
|
|
user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
|
|
|
|
|
user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
|
|
|
|
|
user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
|
|
|
|
|
|
|
|
|
|
std::string token = generateToken(user);
|
|
|
|
|
callback(true, token, user);
|
|
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_ERROR << "Exception processing user data: " << e.what();
|
|
|
|
|
callback(false, "", UserInfo{});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
>> [callback](const DrogonDbException& e) {
|
|
|
|
|
LOG_ERROR << "Database error: " << e.base().what();
|
|
|
|
|
callback(false, "", UserInfo{});
|
|
|
|
|
};
|
|
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_ERROR << "Exception in Redis callback: " << e.what();
|
|
|
|
|
callback(false, "", UserInfo{});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_ERROR << "Exception in verifyPgpLogin: " << e.what();
|
|
|
|
|
callback(false, "", UserInfo{});
|
|
|
|
|
}
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string AuthService::generateToken(const UserInfo& user) {
|
2025-08-10 07:55:39 -04:00
|
|
|
try {
|
|
|
|
|
if (jwtSecret_.empty()) {
|
|
|
|
|
const char* envSecret = std::getenv("JWT_SECRET");
|
|
|
|
|
jwtSecret_ = envSecret ? std::string(envSecret) : "your-jwt-secret";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto token = jwt::create()
|
|
|
|
|
.set_issuer("streaming-app")
|
|
|
|
|
.set_type("JWS")
|
|
|
|
|
.set_issued_at(std::chrono::system_clock::now())
|
|
|
|
|
.set_expires_at(std::chrono::system_clock::now() + std::chrono::hours(24))
|
|
|
|
|
.set_payload_claim("user_id", jwt::claim(std::to_string(user.id)))
|
|
|
|
|
.set_payload_claim("username", jwt::claim(user.username))
|
|
|
|
|
.set_payload_claim("is_admin", jwt::claim(std::to_string(user.isAdmin)))
|
|
|
|
|
.set_payload_claim("is_streamer", jwt::claim(std::to_string(user.isStreamer)))
|
|
|
|
|
.set_payload_claim("color_code", jwt::claim(
|
|
|
|
|
user.colorCode.empty() ? "#561D5E" : user.colorCode
|
|
|
|
|
)) // Ensure color is never empty
|
|
|
|
|
.sign(jwt::algorithm::hs256{jwtSecret_});
|
|
|
|
|
|
|
|
|
|
return token;
|
|
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_ERROR << "Failed to generate token: " << e.what();
|
|
|
|
|
return "";
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) {
|
|
|
|
|
try {
|
2025-08-10 07:55:39 -04:00
|
|
|
if (jwtSecret_.empty()) {
|
|
|
|
|
const char* envSecret = std::getenv("JWT_SECRET");
|
|
|
|
|
jwtSecret_ = envSecret ? std::string(envSecret) : "your-jwt-secret";
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
auto decoded = jwt::decode(token);
|
|
|
|
|
|
|
|
|
|
auto verifier = jwt::verify()
|
|
|
|
|
.allow_algorithm(jwt::algorithm::hs256{jwtSecret_})
|
|
|
|
|
.with_issuer("streaming-app");
|
|
|
|
|
|
|
|
|
|
verifier.verify(decoded);
|
|
|
|
|
|
|
|
|
|
userInfo.id = std::stoll(decoded.get_payload_claim("user_id").as_string());
|
|
|
|
|
userInfo.username = decoded.get_payload_claim("username").as_string();
|
|
|
|
|
userInfo.isAdmin = decoded.get_payload_claim("is_admin").as_string() == "1";
|
|
|
|
|
userInfo.isStreamer = decoded.has_payload_claim("is_streamer") ?
|
|
|
|
|
decoded.get_payload_claim("is_streamer").as_string() == "1" : false;
|
|
|
|
|
|
2025-08-10 07:55:39 -04:00
|
|
|
// Get color from token if available, otherwise will need to fetch from DB
|
|
|
|
|
if (decoded.has_payload_claim("color_code")) {
|
|
|
|
|
userInfo.colorCode = decoded.get_payload_claim("color_code").as_string();
|
|
|
|
|
} else {
|
|
|
|
|
// For older tokens without color, default value
|
|
|
|
|
userInfo.colorCode = "#561D5E";
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
return true;
|
|
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_DEBUG << "Token validation failed: " << e.what();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void AuthService::updatePassword(int64_t userId, const std::string& oldPassword,
|
|
|
|
|
const std::string& newPassword,
|
|
|
|
|
std::function<void(bool, const std::string&)> callback) {
|
2025-08-10 07:55:39 -04:00
|
|
|
try {
|
|
|
|
|
// Validate new password
|
|
|
|
|
std::string error;
|
|
|
|
|
if (!validatePassword(newPassword, error)) {
|
|
|
|
|
callback(false, error);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
|
if (!dbClient) {
|
|
|
|
|
LOG_ERROR << "Database client is null";
|
|
|
|
|
callback(false, "Database connection error");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify old password
|
|
|
|
|
*dbClient << "SELECT password_hash FROM users WHERE id = $1 LIMIT 1"
|
|
|
|
|
<< userId
|
|
|
|
|
>> [oldPassword, newPassword, userId, dbClient, callback](const Result& r) {
|
|
|
|
|
try {
|
|
|
|
|
if (r.empty()) {
|
|
|
|
|
callback(false, "User not found");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string hash = r[0]["password_hash"].as<std::string>();
|
|
|
|
|
|
|
|
|
|
bool valid = false;
|
|
|
|
|
try {
|
|
|
|
|
valid = BCrypt::validatePassword(oldPassword, hash);
|
|
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_ERROR << "Failed to validate password: " << e.what();
|
|
|
|
|
callback(false, "Password validation error");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!valid) {
|
|
|
|
|
callback(false, "Incorrect password");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update password
|
|
|
|
|
std::string newHash;
|
|
|
|
|
try {
|
|
|
|
|
newHash = BCrypt::generateHash(newPassword);
|
|
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_ERROR << "Failed to hash new password: " << e.what();
|
|
|
|
|
callback(false, "Failed to process new password");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
*dbClient << "UPDATE users SET password_hash = $1 WHERE id = $2"
|
|
|
|
|
<< newHash << userId
|
|
|
|
|
>> [callback](const Result&) {
|
|
|
|
|
callback(true, "");
|
|
|
|
|
}
|
|
|
|
|
>> [callback](const DrogonDbException& e) {
|
|
|
|
|
LOG_ERROR << "Failed to update password: " << e.base().what();
|
|
|
|
|
callback(false, "Failed to update password");
|
|
|
|
|
};
|
|
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_ERROR << "Exception in password update callback: " << e.what();
|
|
|
|
|
callback(false, "Failed to update password");
|
|
|
|
|
}
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
2025-08-10 07:55:39 -04:00
|
|
|
>> [callback](const DrogonDbException& e) {
|
|
|
|
|
LOG_ERROR << "Database error: " << e.base().what();
|
|
|
|
|
callback(false, "Database error");
|
|
|
|
|
};
|
|
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_ERROR << "Exception in updatePassword: " << e.what();
|
|
|
|
|
callback(false, "Failed to update password");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void AuthService::fetchUserInfo(int64_t userId, std::function<void(bool, const UserInfo&)> callback) {
|
|
|
|
|
try {
|
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
|
if (!dbClient) {
|
|
|
|
|
LOG_ERROR << "Database client is null";
|
|
|
|
|
callback(false, UserInfo{});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
*dbClient << "SELECT id, username, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at, user_color "
|
|
|
|
|
"FROM users WHERE id = $1 LIMIT 1"
|
|
|
|
|
<< userId
|
|
|
|
|
>> [callback](const Result& r) {
|
|
|
|
|
try {
|
|
|
|
|
if (r.empty()) {
|
|
|
|
|
callback(false, UserInfo{});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
UserInfo user;
|
|
|
|
|
user.id = r[0]["id"].as<int64_t>();
|
|
|
|
|
user.username = r[0]["username"].as<std::string>();
|
|
|
|
|
user.isAdmin = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as<bool>();
|
|
|
|
|
user.isStreamer = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
|
|
|
|
|
user.isPgpOnly = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
|
|
|
|
|
user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
|
|
|
|
|
user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
|
|
|
|
|
user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
|
|
|
|
|
user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
|
|
|
|
|
|
|
|
|
|
callback(true, user);
|
|
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_ERROR << "Exception processing user data: " << e.what();
|
|
|
|
|
callback(false, UserInfo{});
|
|
|
|
|
}
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
2025-08-10 07:55:39 -04:00
|
|
|
>> [callback](const DrogonDbException& e) {
|
|
|
|
|
LOG_ERROR << "Database error: " << e.base().what();
|
|
|
|
|
callback(false, UserInfo{});
|
|
|
|
|
};
|
|
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_ERROR << "Exception in fetchUserInfo: " << e.what();
|
|
|
|
|
callback(false, UserInfo{});
|
|
|
|
|
}
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|