Initial commit - realms platform
This commit is contained in:
parent
c590ab6d18
commit
c717c3751c
234 changed files with 74103 additions and 15231 deletions
|
|
@ -9,6 +9,8 @@
|
|||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <cstdlib>
|
||||
#include <gpgme.h>
|
||||
#include <filesystem>
|
||||
|
||||
using namespace drogon;
|
||||
using namespace drogon::orm;
|
||||
|
|
@ -32,153 +34,140 @@ bool AuthService::validatePassword(const std::string& password, std::string& err
|
|||
return true;
|
||||
}
|
||||
|
||||
// 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;
|
||||
// GPGME-based PGP signature verification (no shell commands for security)
|
||||
namespace {
|
||||
// RAII wrapper for gpgme_data_t (named to avoid conflict with deprecated GpgmeData typedef)
|
||||
class GpgmeDataWrapper {
|
||||
public:
|
||||
GpgmeDataWrapper() : data_(nullptr) {}
|
||||
~GpgmeDataWrapper() { if (data_) gpgme_data_release(data_); }
|
||||
gpgme_data_t* ptr() { return &data_; }
|
||||
gpgme_data_t get() { return data_; }
|
||||
bool valid() const { return data_ != nullptr; }
|
||||
private:
|
||||
gpgme_data_t data_;
|
||||
};
|
||||
|
||||
// RAII wrapper for gpgme_ctx_t
|
||||
class GpgmeContextWrapper {
|
||||
public:
|
||||
GpgmeContextWrapper() : ctx_(nullptr) {}
|
||||
~GpgmeContextWrapper() { if (ctx_) gpgme_release(ctx_); }
|
||||
gpgme_ctx_t* ptr() { return &ctx_; }
|
||||
gpgme_ctx_t get() { return ctx_; }
|
||||
bool valid() const { return ctx_ != nullptr; }
|
||||
private:
|
||||
gpgme_ctx_t ctx_;
|
||||
};
|
||||
}
|
||||
|
||||
// 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;
|
||||
// Initialize GPGME
|
||||
gpgme_check_version(nullptr);
|
||||
|
||||
// Create temporary directory for isolated keyring using filesystem
|
||||
std::string tmpDir = "/tmp/pgp_verify_" + drogon::utils::genRandomString(16);
|
||||
std::filesystem::create_directories(tmpDir);
|
||||
std::filesystem::permissions(tmpDir, std::filesystem::perms::owner_all);
|
||||
|
||||
// Set GNUPGHOME environment for this context
|
||||
std::string gnupgHome = tmpDir;
|
||||
|
||||
// Create GPGME context
|
||||
GpgmeContextWrapper ctx;
|
||||
gpgme_error_t err = gpgme_new(ctx.ptr());
|
||||
if (err) {
|
||||
LOG_ERROR << "Failed to create GPGME context: " << gpgme_strerror(err);
|
||||
std::filesystem::remove_all(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());
|
||||
|
||||
// Set the engine info to use our temporary directory
|
||||
err = gpgme_ctx_set_engine_info(ctx.get(), GPGME_PROTOCOL_OpenPGP, nullptr, gnupgHome.c_str());
|
||||
if (err) {
|
||||
LOG_ERROR << "Failed to set GPGME engine info: " << gpgme_strerror(err);
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
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());
|
||||
|
||||
// Set protocol
|
||||
gpgme_set_protocol(ctx.get(), GPGME_PROTOCOL_OpenPGP);
|
||||
|
||||
// Import the public key
|
||||
GpgmeDataWrapper keyData;
|
||||
err = gpgme_data_new_from_mem(keyData.ptr(), publicKey.c_str(), publicKey.size(), 1);
|
||||
if (err) {
|
||||
LOG_ERROR << "Failed to create key data: " << gpgme_strerror(err);
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
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());
|
||||
|
||||
err = gpgme_op_import(ctx.get(), keyData.get());
|
||||
if (err) {
|
||||
LOG_ERROR << "Failed to import public key: " << gpgme_strerror(err);
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
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());
|
||||
|
||||
gpgme_import_result_t importResult = gpgme_op_import_result(ctx.get());
|
||||
if (!importResult || (importResult->imported == 0 && importResult->unchanged == 0)) {
|
||||
LOG_ERROR << "No keys were imported";
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
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());
|
||||
|
||||
LOG_DEBUG << "GPGME imported " << importResult->imported << " keys, "
|
||||
<< importResult->unchanged << " unchanged";
|
||||
|
||||
// Create data objects for signature and message
|
||||
GpgmeDataWrapper sigData;
|
||||
err = gpgme_data_new_from_mem(sigData.ptr(), signature.c_str(), signature.size(), 1);
|
||||
if (err) {
|
||||
LOG_ERROR << "Failed to create signature data: " << gpgme_strerror(err);
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
GpgmeDataWrapper msgData;
|
||||
err = gpgme_data_new_from_mem(msgData.ptr(), message.c_str(), message.size(), 1);
|
||||
if (err) {
|
||||
LOG_ERROR << "Failed to create message data: " << gpgme_strerror(err);
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
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";
|
||||
err = gpgme_op_verify(ctx.get(), sigData.get(), msgData.get(), nullptr);
|
||||
if (err) {
|
||||
LOG_WARN << "Signature verification failed: " << gpgme_strerror(err);
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cleanup temporary files
|
||||
std::string cleanupCmd = "rm -rf " + tmpDir;
|
||||
system(cleanupCmd.c_str());
|
||||
|
||||
|
||||
// Check verification result
|
||||
gpgme_verify_result_t verifyResult = gpgme_op_verify_result(ctx.get());
|
||||
if (!verifyResult || !verifyResult->signatures) {
|
||||
LOG_WARN << "No signatures found in verification result";
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if signature is valid
|
||||
gpgme_signature_t sig = verifyResult->signatures;
|
||||
bool verified = (sig->status == GPG_ERR_NO_ERROR);
|
||||
|
||||
if (verified) {
|
||||
LOG_INFO << "Signature verification successful for challenge";
|
||||
} else {
|
||||
LOG_WARN << "Signature verification failed: " << gpgme_strerror(sig->status);
|
||||
}
|
||||
|
||||
// Cleanup temporary directory
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
|
||||
return verified;
|
||||
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception during signature verification: " << e.what();
|
||||
return false;
|
||||
|
|
@ -314,8 +303,8 @@ void AuthService::registerUser(const std::string& username, const std::string& p
|
|||
return;
|
||||
}
|
||||
|
||||
if (!std::regex_match(username, std::regex("^[a-zA-Z0-9_]+$"))) {
|
||||
callback(false, "Username can only contain letters, numbers, and underscores", 0);
|
||||
if (!std::regex_match(username, std::regex("^[a-zA-Z][a-zA-Z0-9_]*$"))) {
|
||||
callback(false, "Username must start with a letter and contain only letters, numbers, and underscores", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -454,7 +443,7 @@ void AuthService::loginUser(const std::string& username, const std::string& pass
|
|||
return;
|
||||
}
|
||||
|
||||
*dbClient << "SELECT id, username, password_hash, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at, user_color "
|
||||
*dbClient << "SELECT id, username, password_hash, is_admin, is_moderator, is_streamer, is_restreamer, is_bot, is_texter, is_pgp_only, is_disabled, bio, avatar_url, banner_url, banner_position, banner_zoom, banner_position_x, graffiti_url, pgp_only_enabled_at, user_color "
|
||||
"FROM users WHERE username = $1 LIMIT 1"
|
||||
<< username
|
||||
>> [password, callback, this](const Result& r) {
|
||||
|
|
@ -463,18 +452,25 @@ void AuthService::loginUser(const std::string& username, const std::string& pass
|
|||
callback(false, "", UserInfo{});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Check if account is disabled
|
||||
bool isDisabled = r[0]["is_disabled"].isNull() ? false : r[0]["is_disabled"].as<bool>();
|
||||
if (isDisabled) {
|
||||
callback(false, "Account disabled", UserInfo{});
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
|
@ -483,23 +479,32 @@ void AuthService::loginUser(const std::string& username, const std::string& pass
|
|||
callback(false, "", UserInfo{});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!valid) {
|
||||
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.isModerator = r[0]["is_moderator"].isNull() ? false : r[0]["is_moderator"].as<bool>();
|
||||
user.isStreamer = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
|
||||
user.isRestreamer = r[0]["is_restreamer"].isNull() ? false : r[0]["is_restreamer"].as<bool>();
|
||||
user.isBot = r[0]["is_bot"].isNull() ? false : r[0]["is_bot"].as<bool>();
|
||||
user.isTexter = r[0]["is_texter"].isNull() ? false : r[0]["is_texter"].as<bool>();
|
||||
user.isPgpOnly = isPgpOnly;
|
||||
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.bannerUrl = r[0]["banner_url"].isNull() ? "" : r[0]["banner_url"].as<std::string>();
|
||||
user.bannerPosition = r[0]["banner_position"].isNull() ? 50 : r[0]["banner_position"].as<int>();
|
||||
user.bannerZoom = r[0]["banner_zoom"].isNull() ? 100 : r[0]["banner_zoom"].as<int>();
|
||||
user.bannerPositionX = r[0]["banner_position_x"].isNull() ? 50 : r[0]["banner_position_x"].as<int>();
|
||||
user.graffitiUrl = r[0]["graffiti_url"].isNull() ? "" : r[0]["graffiti_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) {
|
||||
|
|
@ -593,8 +598,8 @@ void AuthService::verifyPgpLogin(const std::string& username, const std::string&
|
|||
return;
|
||||
}
|
||||
|
||||
*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 "
|
||||
*dbClient << "SELECT pk.public_key, u.id, u.username, u.is_admin, u.is_moderator, u.is_streamer, u.is_restreamer, u.is_bot, u.is_texter, "
|
||||
"u.is_pgp_only, u.is_disabled, u.bio, u.avatar_url, u.banner_url, u.banner_position, u.banner_zoom, u.banner_position_x, u.graffiti_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"
|
||||
<< username
|
||||
|
|
@ -605,31 +610,47 @@ void AuthService::verifyPgpLogin(const std::string& username, const std::string&
|
|||
callback(false, "", UserInfo{});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Check if account is disabled
|
||||
bool isDisabled = r[0]["is_disabled"].isNull() ? false : r[0]["is_disabled"].as<bool>();
|
||||
if (isDisabled) {
|
||||
callback(false, "Account disabled", UserInfo{});
|
||||
return;
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
|
||||
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.isModerator = r[0]["is_moderator"].isNull() ? false : r[0]["is_moderator"].as<bool>();
|
||||
user.isStreamer = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
|
||||
user.isRestreamer = r[0]["is_restreamer"].isNull() ? false : r[0]["is_restreamer"].as<bool>();
|
||||
user.isBot = r[0]["is_bot"].isNull() ? false : r[0]["is_bot"].as<bool>();
|
||||
user.isTexter = r[0]["is_texter"].isNull() ? false : r[0]["is_texter"].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.bannerUrl = r[0]["banner_url"].isNull() ? "" : r[0]["banner_url"].as<std::string>();
|
||||
user.bannerPosition = r[0]["banner_position"].isNull() ? 50 : r[0]["banner_position"].as<int>();
|
||||
user.bannerZoom = r[0]["banner_zoom"].isNull() ? 100 : r[0]["banner_zoom"].as<int>();
|
||||
user.bannerPositionX = r[0]["banner_position_x"].isNull() ? 50 : r[0]["banner_position_x"].as<int>();
|
||||
user.graffitiUrl = r[0]["graffiti_url"].isNull() ? "" : r[0]["graffiti_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) {
|
||||
|
|
@ -653,25 +674,70 @@ void AuthService::verifyPgpLogin(const std::string& username, const std::string&
|
|||
}
|
||||
}
|
||||
|
||||
// SECURITY FIX #5: Validate JWT secret has minimum length and entropy
|
||||
void AuthService::validateAndLoadJwtSecret() {
|
||||
if (!jwtSecret_.empty()) {
|
||||
return; // Already loaded and validated
|
||||
}
|
||||
|
||||
const char* envSecret = std::getenv("JWT_SECRET");
|
||||
if (!envSecret || strlen(envSecret) == 0) {
|
||||
throw std::runtime_error("JWT_SECRET environment variable is not set");
|
||||
}
|
||||
|
||||
size_t secretLen = strlen(envSecret);
|
||||
|
||||
// Require at least 32 characters (256 bits) for HS256
|
||||
if (secretLen < 32) {
|
||||
throw std::runtime_error("JWT_SECRET must be at least 32 characters (256 bits) for security");
|
||||
}
|
||||
|
||||
// Basic entropy check - ensure not all same character
|
||||
bool hasVariety = false;
|
||||
for (size_t i = 1; i < secretLen && !hasVariety; ++i) {
|
||||
if (envSecret[i] != envSecret[0]) {
|
||||
hasVariety = true;
|
||||
}
|
||||
}
|
||||
if (!hasVariety) {
|
||||
throw std::runtime_error("JWT_SECRET has insufficient entropy - all characters are the same");
|
||||
}
|
||||
|
||||
// Check for common weak secrets
|
||||
std::string secretLower = envSecret;
|
||||
std::transform(secretLower.begin(), secretLower.end(), secretLower.begin(), ::tolower);
|
||||
if (secretLower.find("secret") != std::string::npos ||
|
||||
secretLower.find("password") != std::string::npos ||
|
||||
secretLower.find("123456") != std::string::npos) {
|
||||
LOG_WARN << "JWT_SECRET appears to contain common weak patterns - consider using a stronger secret";
|
||||
}
|
||||
|
||||
jwtSecret_ = std::string(envSecret);
|
||||
LOG_INFO << "JWT secret loaded and validated (" << secretLen << " characters)";
|
||||
}
|
||||
|
||||
std::string AuthService::generateToken(const UserInfo& user) {
|
||||
try {
|
||||
if (jwtSecret_.empty()) {
|
||||
const char* envSecret = std::getenv("JWT_SECRET");
|
||||
jwtSecret_ = envSecret ? std::string(envSecret) : "your-jwt-secret";
|
||||
}
|
||||
|
||||
validateAndLoadJwtSecret();
|
||||
|
||||
// SECURITY FIX: Reduced JWT expiry from 24h to 1h to limit token exposure window
|
||||
auto token = jwt::create()
|
||||
.set_issuer("streaming-app")
|
||||
.set_type("JWS")
|
||||
.set_type("JWT")
|
||||
.set_issued_at(std::chrono::system_clock::now())
|
||||
.set_expires_at(std::chrono::system_clock::now() + std::chrono::hours(24))
|
||||
.set_expires_at(std::chrono::system_clock::now() + std::chrono::hours(1))
|
||||
.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_moderator", jwt::claim(std::to_string(user.isModerator)))
|
||||
.set_payload_claim("is_streamer", jwt::claim(std::to_string(user.isStreamer)))
|
||||
.set_payload_claim("is_restreamer", jwt::claim(std::to_string(user.isRestreamer)))
|
||||
.set_payload_claim("is_disabled", jwt::claim(std::to_string(user.isDisabled))) // SECURITY FIX #26
|
||||
.set_payload_claim("token_version", jwt::claim(std::to_string(user.tokenVersion))) // SECURITY FIX #10
|
||||
.set_payload_claim("color_code", jwt::claim(
|
||||
user.colorCode.empty() ? "#561D5E" : user.colorCode
|
||||
)) // Ensure color is never empty
|
||||
.set_payload_claim("avatar_url", jwt::claim(user.avatarUrl))
|
||||
.sign(jwt::algorithm::hs256{jwtSecret_});
|
||||
|
||||
return token;
|
||||
|
|
@ -683,11 +749,8 @@ std::string AuthService::generateToken(const UserInfo& user) {
|
|||
|
||||
bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) {
|
||||
try {
|
||||
if (jwtSecret_.empty()) {
|
||||
const char* envSecret = std::getenv("JWT_SECRET");
|
||||
jwtSecret_ = envSecret ? std::string(envSecret) : "your-jwt-secret";
|
||||
}
|
||||
|
||||
validateAndLoadJwtSecret();
|
||||
|
||||
auto decoded = jwt::decode(token);
|
||||
|
||||
auto verifier = jwt::verify()
|
||||
|
|
@ -699,9 +762,21 @@ bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) {
|
|||
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") ?
|
||||
userInfo.isModerator = decoded.has_payload_claim("is_moderator") ?
|
||||
decoded.get_payload_claim("is_moderator").as_string() == "1" : false;
|
||||
userInfo.isStreamer = decoded.has_payload_claim("is_streamer") ?
|
||||
decoded.get_payload_claim("is_streamer").as_string() == "1" : false;
|
||||
|
||||
userInfo.isRestreamer = decoded.has_payload_claim("is_restreamer") ?
|
||||
decoded.get_payload_claim("is_restreamer").as_string() == "1" : false;
|
||||
|
||||
// SECURITY FIX #26: Extract disabled status
|
||||
userInfo.isDisabled = decoded.has_payload_claim("is_disabled") ?
|
||||
decoded.get_payload_claim("is_disabled").as_string() == "1" : false;
|
||||
|
||||
// SECURITY FIX #10: Extract token version for revocation check
|
||||
userInfo.tokenVersion = decoded.has_payload_claim("token_version") ?
|
||||
std::stoi(decoded.get_payload_claim("token_version").as_string()) : 1;
|
||||
|
||||
// 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();
|
||||
|
|
@ -709,7 +784,13 @@ bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) {
|
|||
// For older tokens without color, default value
|
||||
userInfo.colorCode = "#561D5E";
|
||||
}
|
||||
|
||||
|
||||
// SECURITY FIX #26: Reject tokens from disabled accounts
|
||||
if (userInfo.isDisabled) {
|
||||
LOG_DEBUG << "Token rejected - user account is disabled: " << userInfo.username;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
LOG_DEBUG << "Token validation failed: " << e.what();
|
||||
|
|
@ -717,6 +798,23 @@ bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) {
|
|||
}
|
||||
}
|
||||
|
||||
// Chat service compatibility method
|
||||
std::optional<UserClaims> AuthService::verifyToken(const std::string& token) {
|
||||
UserInfo userInfo;
|
||||
if (validateToken(token, userInfo)) {
|
||||
UserClaims claims;
|
||||
claims.userId = std::to_string(userInfo.id);
|
||||
claims.username = userInfo.username;
|
||||
claims.userColor = userInfo.colorCode;
|
||||
claims.isAdmin = userInfo.isAdmin;
|
||||
claims.isModerator = userInfo.isModerator;
|
||||
claims.isStreamer = userInfo.isStreamer;
|
||||
claims.isRestreamer = userInfo.isRestreamer;
|
||||
return claims;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
void AuthService::updatePassword(int64_t userId, const std::string& oldPassword,
|
||||
const std::string& newPassword,
|
||||
std::function<void(bool, const std::string&)> callback) {
|
||||
|
|
@ -771,9 +869,11 @@ void AuthService::updatePassword(int64_t userId, const std::string& oldPassword,
|
|||
return;
|
||||
}
|
||||
|
||||
*dbClient << "UPDATE users SET password_hash = $1 WHERE id = $2"
|
||||
// SECURITY FIX #10: Increment token_version to invalidate all existing tokens
|
||||
*dbClient << "UPDATE users SET password_hash = $1, token_version = COALESCE(token_version, 0) + 1 WHERE id = $2"
|
||||
<< newHash << userId
|
||||
>> [callback](const Result&) {
|
||||
>> [callback, userId](const Result&) {
|
||||
LOG_INFO << "Password updated and token_version incremented for user " << userId;
|
||||
callback(true, "");
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
|
|
@ -804,7 +904,7 @@ void AuthService::fetchUserInfo(int64_t userId, std::function<void(bool, const U
|
|||
return;
|
||||
}
|
||||
|
||||
*dbClient << "SELECT id, username, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at, user_color "
|
||||
*dbClient << "SELECT id, username, is_admin, is_moderator, is_streamer, is_restreamer, is_bot, is_texter, is_pgp_only, bio, avatar_url, banner_url, banner_position, banner_zoom, banner_position_x, graffiti_url, pgp_only_enabled_at, user_color "
|
||||
"FROM users WHERE id = $1 LIMIT 1"
|
||||
<< userId
|
||||
>> [callback](const Result& r) {
|
||||
|
|
@ -813,18 +913,27 @@ void AuthService::fetchUserInfo(int64_t userId, std::function<void(bool, const U
|
|||
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.isModerator = r[0]["is_moderator"].isNull() ? false : r[0]["is_moderator"].as<bool>();
|
||||
user.isStreamer = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
|
||||
user.isRestreamer = r[0]["is_restreamer"].isNull() ? false : r[0]["is_restreamer"].as<bool>();
|
||||
user.isBot = r[0]["is_bot"].isNull() ? false : r[0]["is_bot"].as<bool>();
|
||||
user.isTexter = r[0]["is_texter"].isNull() ? false : r[0]["is_texter"].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.bannerUrl = r[0]["banner_url"].isNull() ? "" : r[0]["banner_url"].as<std::string>();
|
||||
user.bannerPosition = r[0]["banner_position"].isNull() ? 50 : r[0]["banner_position"].as<int>();
|
||||
user.bannerZoom = r[0]["banner_zoom"].isNull() ? 100 : r[0]["banner_zoom"].as<int>();
|
||||
user.bannerPositionX = r[0]["banner_position_x"].isNull() ? 50 : r[0]["banner_position_x"].as<int>();
|
||||
user.graffitiUrl = r[0]["graffiti_url"].isNull() ? "" : r[0]["graffiti_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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue