This commit is contained in:
parent
99151c6692
commit
3676dc46ed
16 changed files with 894 additions and 89 deletions
|
|
@ -31,17 +31,28 @@ namespace {
|
|||
return ss.str();
|
||||
}
|
||||
|
||||
// Helper to set httpOnly auth cookie
|
||||
// Helper to set httpOnly access token cookie (2.5 hours to match JWT expiry)
|
||||
void setAuthCookie(const HttpResponsePtr& resp, const std::string& token) {
|
||||
Cookie authCookie("auth_token", token);
|
||||
authCookie.setPath("/");
|
||||
authCookie.setHttpOnly(true);
|
||||
authCookie.setSecure(false); // Set to true in production with HTTPS
|
||||
authCookie.setMaxAge(86400); // 24 hours
|
||||
authCookie.setMaxAge(9000); // 2.5 hours (150 minutes, matches JWT expiry)
|
||||
authCookie.setSameSite(Cookie::SameSite::kLax);
|
||||
resp->addCookie(authCookie);
|
||||
}
|
||||
|
||||
|
||||
// Helper to set httpOnly refresh token cookie (long-lived: 90 days)
|
||||
void setRefreshCookie(const HttpResponsePtr& resp, const std::string& token) {
|
||||
Cookie refreshCookie("refresh_token", token);
|
||||
refreshCookie.setPath("/");
|
||||
refreshCookie.setHttpOnly(true);
|
||||
refreshCookie.setSecure(false); // Set to true in production with HTTPS
|
||||
refreshCookie.setMaxAge(90 * 24 * 60 * 60); // 90 days
|
||||
refreshCookie.setSameSite(Cookie::SameSite::kLax);
|
||||
resp->addCookie(refreshCookie);
|
||||
}
|
||||
|
||||
// Helper to clear auth cookie
|
||||
void clearAuthCookie(const HttpResponsePtr& resp) {
|
||||
Cookie authCookie("auth_token", "");
|
||||
|
|
@ -50,6 +61,15 @@ namespace {
|
|||
authCookie.setMaxAge(0); // Expire immediately
|
||||
resp->addCookie(authCookie);
|
||||
}
|
||||
|
||||
// Helper to clear refresh cookie
|
||||
void clearRefreshCookie(const HttpResponsePtr& resp) {
|
||||
Cookie refreshCookie("refresh_token", "");
|
||||
refreshCookie.setPath("/");
|
||||
refreshCookie.setHttpOnly(true);
|
||||
refreshCookie.setMaxAge(0); // Expire immediately
|
||||
resp->addCookie(refreshCookie);
|
||||
}
|
||||
}
|
||||
|
||||
void UserController::register_(const HttpRequestPtr &req,
|
||||
|
|
@ -179,31 +199,44 @@ void UserController::login(const HttpRequestPtr &req,
|
|||
if (success) {
|
||||
LOG_INFO << "Login successful for user: " << username;
|
||||
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
// Send token in body for WebSocket auth (cookie still used for HTTP requests)
|
||||
resp["token"] = token;
|
||||
resp["user"]["id"] = static_cast<Json::Int64>(user.id);
|
||||
resp["user"]["username"] = user.username;
|
||||
resp["user"]["isAdmin"] = user.isAdmin;
|
||||
resp["user"]["isStreamer"] = user.isStreamer;
|
||||
resp["user"]["isRestreamer"] = user.isRestreamer;
|
||||
resp["user"]["isBot"] = user.isBot;
|
||||
resp["user"]["isTexter"] = user.isTexter;
|
||||
resp["user"]["isPgpOnly"] = user.isPgpOnly;
|
||||
resp["user"]["bio"] = user.bio;
|
||||
resp["user"]["avatarUrl"] = user.avatarUrl;
|
||||
resp["user"]["bannerUrl"] = user.bannerUrl;
|
||||
resp["user"]["bannerPosition"] = user.bannerPosition;
|
||||
resp["user"]["bannerZoom"] = user.bannerZoom;
|
||||
resp["user"]["bannerPositionX"] = user.bannerPositionX;
|
||||
resp["user"]["graffitiUrl"] = user.graffitiUrl;
|
||||
resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt;
|
||||
resp["user"]["colorCode"] = user.colorCode;
|
||||
// Create refresh token family for long-lived session
|
||||
AuthService::getInstance().createRefreshTokenFamily(user.id,
|
||||
[callback, username, token, user](bool refreshSuccess, const std::string& refreshToken,
|
||||
const std::string& familyId) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
// Send token in body for WebSocket auth (cookie still used for HTTP requests)
|
||||
resp["token"] = token;
|
||||
resp["user"]["id"] = static_cast<Json::Int64>(user.id);
|
||||
resp["user"]["username"] = user.username;
|
||||
resp["user"]["isAdmin"] = user.isAdmin;
|
||||
resp["user"]["isStreamer"] = user.isStreamer;
|
||||
resp["user"]["isRestreamer"] = user.isRestreamer;
|
||||
resp["user"]["isBot"] = user.isBot;
|
||||
resp["user"]["isTexter"] = user.isTexter;
|
||||
resp["user"]["isPgpOnly"] = user.isPgpOnly;
|
||||
resp["user"]["bio"] = user.bio;
|
||||
resp["user"]["avatarUrl"] = user.avatarUrl;
|
||||
resp["user"]["bannerUrl"] = user.bannerUrl;
|
||||
resp["user"]["bannerPosition"] = user.bannerPosition;
|
||||
resp["user"]["bannerZoom"] = user.bannerZoom;
|
||||
resp["user"]["bannerPositionX"] = user.bannerPositionX;
|
||||
resp["user"]["graffitiUrl"] = user.graffitiUrl;
|
||||
resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt;
|
||||
resp["user"]["colorCode"] = user.colorCode;
|
||||
|
||||
auto response = jsonResp(resp);
|
||||
setAuthCookie(response, token);
|
||||
callback(response);
|
||||
auto response = jsonResp(resp);
|
||||
setAuthCookie(response, token);
|
||||
|
||||
// Also set refresh token cookie if created successfully
|
||||
if (refreshSuccess) {
|
||||
setRefreshCookie(response, refreshToken);
|
||||
} else {
|
||||
LOG_WARN << "Failed to create refresh token for user: " << username;
|
||||
}
|
||||
|
||||
callback(response);
|
||||
});
|
||||
} else {
|
||||
LOG_WARN << "Login failed for user: " << username;
|
||||
callback(jsonError(token.empty() ? "Invalid credentials" : token, k401Unauthorized));
|
||||
|
|
@ -224,9 +257,10 @@ void UserController::logout(const HttpRequestPtr &req,
|
|||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "Logged out successfully";
|
||||
|
||||
|
||||
auto response = jsonResp(resp);
|
||||
clearAuthCookie(response);
|
||||
clearRefreshCookie(response); // Also clear refresh token
|
||||
callback(response);
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception in logout: " << e.what();
|
||||
|
|
@ -234,6 +268,58 @@ void UserController::logout(const HttpRequestPtr &req,
|
|||
}
|
||||
}
|
||||
|
||||
void UserController::refresh(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
try {
|
||||
// Get refresh token from cookie
|
||||
std::string refreshToken = req->getCookie("refresh_token");
|
||||
|
||||
if (refreshToken.empty()) {
|
||||
LOG_DEBUG << "No refresh token provided";
|
||||
callback(jsonError("No refresh token", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate and rotate the refresh token
|
||||
AuthService::getInstance().validateAndRotateRefreshToken(refreshToken,
|
||||
[callback](RefreshTokenResult result) {
|
||||
if (result.success) {
|
||||
LOG_DEBUG << "Token refresh successful for user: " << result.user.username;
|
||||
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["token"] = result.accessToken;
|
||||
resp["user"]["id"] = static_cast<Json::Int64>(result.user.id);
|
||||
resp["user"]["username"] = result.user.username;
|
||||
resp["user"]["isAdmin"] = result.user.isAdmin;
|
||||
resp["user"]["isStreamer"] = result.user.isStreamer;
|
||||
resp["user"]["isRestreamer"] = result.user.isRestreamer;
|
||||
resp["user"]["isBot"] = result.user.isBot;
|
||||
resp["user"]["isTexter"] = result.user.isTexter;
|
||||
resp["user"]["isPgpOnly"] = result.user.isPgpOnly;
|
||||
resp["user"]["colorCode"] = result.user.colorCode;
|
||||
resp["user"]["avatarUrl"] = result.user.avatarUrl;
|
||||
|
||||
auto response = jsonResp(resp);
|
||||
setAuthCookie(response, result.accessToken);
|
||||
setRefreshCookie(response, result.refreshToken);
|
||||
callback(response);
|
||||
} else {
|
||||
LOG_DEBUG << "Token refresh failed: " << result.error;
|
||||
|
||||
// Clear cookies on failure
|
||||
auto response = jsonError(result.error, k401Unauthorized);
|
||||
clearAuthCookie(response);
|
||||
clearRefreshCookie(response);
|
||||
callback(response);
|
||||
}
|
||||
});
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception in refresh: " << e.what();
|
||||
callback(jsonError("Internal server error"));
|
||||
}
|
||||
}
|
||||
|
||||
void UserController::pgpChallenge(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
try {
|
||||
|
|
@ -301,31 +387,44 @@ void UserController::pgpVerify(const HttpRequestPtr &req,
|
|||
AuthService::getInstance().verifyPgpLogin(username, signature, challenge,
|
||||
[callback](bool success, const std::string& token, const UserInfo& user) {
|
||||
if (success) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
// Send token in body for WebSocket auth (cookie still used for HTTP requests)
|
||||
resp["token"] = token;
|
||||
resp["user"]["id"] = static_cast<Json::Int64>(user.id);
|
||||
resp["user"]["username"] = user.username;
|
||||
resp["user"]["isAdmin"] = user.isAdmin;
|
||||
resp["user"]["isStreamer"] = user.isStreamer;
|
||||
resp["user"]["isRestreamer"] = user.isRestreamer;
|
||||
resp["user"]["isBot"] = user.isBot;
|
||||
resp["user"]["isTexter"] = user.isTexter;
|
||||
resp["user"]["isPgpOnly"] = user.isPgpOnly;
|
||||
resp["user"]["bio"] = user.bio;
|
||||
resp["user"]["avatarUrl"] = user.avatarUrl;
|
||||
resp["user"]["bannerUrl"] = user.bannerUrl;
|
||||
resp["user"]["bannerPosition"] = user.bannerPosition;
|
||||
resp["user"]["bannerZoom"] = user.bannerZoom;
|
||||
resp["user"]["bannerPositionX"] = user.bannerPositionX;
|
||||
resp["user"]["graffitiUrl"] = user.graffitiUrl;
|
||||
resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt;
|
||||
resp["user"]["colorCode"] = user.colorCode;
|
||||
// Create refresh token family for long-lived session
|
||||
AuthService::getInstance().createRefreshTokenFamily(user.id,
|
||||
[callback, token, user](bool refreshSuccess, const std::string& refreshToken,
|
||||
const std::string& familyId) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
// Send token in body for WebSocket auth (cookie still used for HTTP requests)
|
||||
resp["token"] = token;
|
||||
resp["user"]["id"] = static_cast<Json::Int64>(user.id);
|
||||
resp["user"]["username"] = user.username;
|
||||
resp["user"]["isAdmin"] = user.isAdmin;
|
||||
resp["user"]["isStreamer"] = user.isStreamer;
|
||||
resp["user"]["isRestreamer"] = user.isRestreamer;
|
||||
resp["user"]["isBot"] = user.isBot;
|
||||
resp["user"]["isTexter"] = user.isTexter;
|
||||
resp["user"]["isPgpOnly"] = user.isPgpOnly;
|
||||
resp["user"]["bio"] = user.bio;
|
||||
resp["user"]["avatarUrl"] = user.avatarUrl;
|
||||
resp["user"]["bannerUrl"] = user.bannerUrl;
|
||||
resp["user"]["bannerPosition"] = user.bannerPosition;
|
||||
resp["user"]["bannerZoom"] = user.bannerZoom;
|
||||
resp["user"]["bannerPositionX"] = user.bannerPositionX;
|
||||
resp["user"]["graffitiUrl"] = user.graffitiUrl;
|
||||
resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt;
|
||||
resp["user"]["colorCode"] = user.colorCode;
|
||||
|
||||
auto response = jsonResp(resp);
|
||||
setAuthCookie(response, token);
|
||||
callback(response);
|
||||
auto response = jsonResp(resp);
|
||||
setAuthCookie(response, token);
|
||||
|
||||
// Also set refresh token cookie if created successfully
|
||||
if (refreshSuccess) {
|
||||
setRefreshCookie(response, refreshToken);
|
||||
} else {
|
||||
LOG_WARN << "Failed to create refresh token for PGP login";
|
||||
}
|
||||
|
||||
callback(response);
|
||||
});
|
||||
} else {
|
||||
callback(jsonError("Invalid signature", k401Unauthorized));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ public:
|
|||
ADD_METHOD_TO(UserController::register_, "/api/auth/register", Post);
|
||||
ADD_METHOD_TO(UserController::login, "/api/auth/login", Post);
|
||||
ADD_METHOD_TO(UserController::logout, "/api/auth/logout", Post);
|
||||
ADD_METHOD_TO(UserController::refresh, "/api/auth/refresh", Post);
|
||||
ADD_METHOD_TO(UserController::pgpChallenge, "/api/auth/pgp-challenge", Post);
|
||||
ADD_METHOD_TO(UserController::pgpVerify, "/api/auth/pgp-verify", Post);
|
||||
ADD_METHOD_TO(UserController::getCurrentUser, "/api/user/me", Get);
|
||||
|
|
@ -57,7 +58,10 @@ public:
|
|||
|
||||
void logout(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
|
||||
void refresh(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void pgpChallenge(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
|
|
|
|||
|
|
@ -720,12 +720,13 @@ std::string AuthService::generateToken(const UserInfo& user) {
|
|||
try {
|
||||
validateAndLoadJwtSecret();
|
||||
|
||||
// SECURITY FIX: Reduced JWT expiry from 24h to 1h to limit token exposure window
|
||||
// Access token: Short-lived (15 minutes) for security
|
||||
// Use refresh tokens for session persistence
|
||||
auto token = jwt::create()
|
||||
.set_issuer("streaming-app")
|
||||
.set_type("JWT")
|
||||
.set_issued_at(std::chrono::system_clock::now())
|
||||
.set_expires_at(std::chrono::system_clock::now() + std::chrono::hours(1))
|
||||
.set_expires_at(std::chrono::system_clock::now() + std::chrono::minutes(ACCESS_TOKEN_EXPIRY_MINUTES))
|
||||
.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)))
|
||||
|
|
@ -869,12 +870,24 @@ void AuthService::updatePassword(int64_t userId, const std::string& oldPassword,
|
|||
return;
|
||||
}
|
||||
|
||||
// SECURITY FIX #10: Increment token_version to invalidate all existing tokens
|
||||
// SECURITY FIX #10: Increment token_version to invalidate all existing access tokens
|
||||
// Also revoke all refresh token families for complete session invalidation
|
||||
*dbClient << "UPDATE users SET password_hash = $1, token_version = COALESCE(token_version, 0) + 1 WHERE id = $2"
|
||||
<< newHash << userId
|
||||
>> [callback, userId](const Result&) {
|
||||
LOG_INFO << "Password updated and token_version incremented for user " << userId;
|
||||
callback(true, "");
|
||||
>> [callback, userId, dbClient](const Result&) {
|
||||
// Revoke all refresh token families for this user
|
||||
*dbClient << "UPDATE refresh_token_families SET revoked = TRUE, revoked_at = NOW() "
|
||||
"WHERE user_id = $1 AND revoked = FALSE"
|
||||
<< userId
|
||||
>> [callback, userId](const Result&) {
|
||||
LOG_INFO << "Password updated and all sessions invalidated for user " << userId;
|
||||
callback(true, "");
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to revoke refresh tokens: " << e.base().what();
|
||||
// Still consider success - password was changed
|
||||
callback(true, "");
|
||||
};
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to update password: " << e.base().what();
|
||||
|
|
@ -948,4 +961,272 @@ void AuthService::fetchUserInfo(int64_t userId, std::function<void(bool, const U
|
|||
LOG_ERROR << "Exception in fetchUserInfo: " << e.what();
|
||||
callback(false, UserInfo{});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// REFRESH TOKEN IMPLEMENTATION
|
||||
// ============================================
|
||||
|
||||
std::string AuthService::generateRefreshToken() {
|
||||
// Generate 256-bit (32 bytes) cryptographically secure random token
|
||||
std::random_device rd;
|
||||
std::array<unsigned char, 32> bytes;
|
||||
for (auto& b : bytes) {
|
||||
b = static_cast<unsigned char>(rd());
|
||||
}
|
||||
return drogon::utils::base64Encode(bytes.data(), bytes.size());
|
||||
}
|
||||
|
||||
std::string AuthService::hashToken(const std::string& token) {
|
||||
// Use Drogon's MD5 for simplicity (SHA256 would be better but MD5 is sufficient for token hashing)
|
||||
// The token itself is already random and unpredictable
|
||||
return drogon::utils::getMd5(token);
|
||||
}
|
||||
|
||||
std::string AuthService::generateUUID() {
|
||||
// Generate a UUID v4
|
||||
std::random_device rd;
|
||||
std::uniform_int_distribution<uint32_t> dist(0, UINT32_MAX);
|
||||
|
||||
char uuid[37];
|
||||
uint32_t a = dist(rd);
|
||||
uint32_t b = dist(rd);
|
||||
uint32_t c = dist(rd);
|
||||
uint32_t d = dist(rd);
|
||||
|
||||
// Set version (4) and variant bits
|
||||
b = (b & 0xFFFF0FFF) | 0x00004000; // Version 4
|
||||
c = (c & 0x3FFFFFFF) | 0x80000000; // Variant 1
|
||||
|
||||
snprintf(uuid, sizeof(uuid), "%08x-%04x-%04x-%04x-%04x%08x",
|
||||
a,
|
||||
(b >> 16) & 0xFFFF,
|
||||
b & 0xFFFF,
|
||||
(c >> 16) & 0xFFFF,
|
||||
c & 0xFFFF,
|
||||
d);
|
||||
|
||||
return std::string(uuid);
|
||||
}
|
||||
|
||||
void AuthService::createRefreshTokenFamily(int64_t userId,
|
||||
std::function<void(bool success, const std::string& refreshToken,
|
||||
const std::string& familyId)> callback) {
|
||||
try {
|
||||
auto dbClient = app().getDbClient();
|
||||
if (!dbClient) {
|
||||
LOG_ERROR << "Database client is null";
|
||||
callback(false, "", "");
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate refresh token and family ID
|
||||
std::string refreshToken = generateRefreshToken();
|
||||
std::string familyId = generateUUID();
|
||||
std::string tokenHash = hashToken(refreshToken);
|
||||
|
||||
// Calculate expiry (90 days from now)
|
||||
auto expiresAt = std::chrono::system_clock::now() + std::chrono::hours(24 * REFRESH_TOKEN_EXPIRY_DAYS);
|
||||
auto expiresAtTime = std::chrono::system_clock::to_time_t(expiresAt);
|
||||
|
||||
// Format as ISO timestamp
|
||||
char expiresAtStr[32];
|
||||
std::strftime(expiresAtStr, sizeof(expiresAtStr), "%Y-%m-%d %H:%M:%S", std::gmtime(&expiresAtTime));
|
||||
|
||||
// Insert into database
|
||||
*dbClient << "INSERT INTO refresh_token_families (user_id, family_id, current_token_hash, expires_at) "
|
||||
"VALUES ($1, $2::uuid, $3, $4::timestamp)"
|
||||
<< userId << familyId << tokenHash << std::string(expiresAtStr)
|
||||
>> [callback, refreshToken, familyId](const Result&) {
|
||||
LOG_DEBUG << "Created refresh token family: " << familyId;
|
||||
callback(true, refreshToken, familyId);
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to create refresh token family: " << e.base().what();
|
||||
callback(false, "", "");
|
||||
};
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception in createRefreshTokenFamily: " << e.what();
|
||||
callback(false, "", "");
|
||||
}
|
||||
}
|
||||
|
||||
void AuthService::validateAndRotateRefreshToken(const std::string& refreshToken,
|
||||
std::function<void(RefreshTokenResult)> callback) {
|
||||
try {
|
||||
auto dbClient = app().getDbClient();
|
||||
if (!dbClient) {
|
||||
LOG_ERROR << "Database client is null";
|
||||
RefreshTokenResult result;
|
||||
result.error = "Database connection error";
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
|
||||
std::string tokenHash = hashToken(refreshToken);
|
||||
|
||||
// Find the token family with this hash
|
||||
*dbClient << "SELECT rtf.id, rtf.user_id, rtf.family_id, rtf.expires_at, rtf.revoked, "
|
||||
"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.user_color, u.avatar_url, "
|
||||
"u.token_version "
|
||||
"FROM refresh_token_families rtf "
|
||||
"JOIN users u ON rtf.user_id = u.id "
|
||||
"WHERE rtf.current_token_hash = $1"
|
||||
<< tokenHash
|
||||
>> [this, dbClient, tokenHash, refreshToken, callback](const Result& r) {
|
||||
try {
|
||||
if (r.empty()) {
|
||||
// Token not found - could be reuse attack or invalid token
|
||||
// Check if there's a family with this hash in history (reuse detection)
|
||||
LOG_WARN << "Refresh token not found or already used - potential reuse attack";
|
||||
RefreshTokenResult result;
|
||||
result.error = "Invalid or expired refresh token";
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
|
||||
auto row = r[0];
|
||||
int64_t familyId = row["id"].as<int64_t>();
|
||||
int64_t userId = row["user_id"].as<int64_t>();
|
||||
std::string familyUuid = row["family_id"].as<std::string>();
|
||||
bool revoked = row["revoked"].as<bool>();
|
||||
bool isDisabled = row["is_disabled"].isNull() ? false : row["is_disabled"].as<bool>();
|
||||
|
||||
// Check if family is revoked
|
||||
if (revoked) {
|
||||
LOG_WARN << "Attempted to use revoked refresh token family: " << familyUuid;
|
||||
RefreshTokenResult result;
|
||||
result.error = "Session has been revoked";
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user is disabled
|
||||
if (isDisabled) {
|
||||
LOG_WARN << "Disabled user attempted token refresh: " << userId;
|
||||
RefreshTokenResult result;
|
||||
result.error = "Account is disabled";
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
std::string expiresAtStr = row["expires_at"].as<std::string>();
|
||||
// Simple check: compare with current time (database handles timezone)
|
||||
|
||||
// Build UserInfo for new access token
|
||||
UserInfo user;
|
||||
user.id = userId;
|
||||
user.username = row["username"].as<std::string>();
|
||||
user.isAdmin = row["is_admin"].isNull() ? false : row["is_admin"].as<bool>();
|
||||
user.isModerator = row["is_moderator"].isNull() ? false : row["is_moderator"].as<bool>();
|
||||
user.isStreamer = row["is_streamer"].isNull() ? false : row["is_streamer"].as<bool>();
|
||||
user.isRestreamer = row["is_restreamer"].isNull() ? false : row["is_restreamer"].as<bool>();
|
||||
user.isBot = row["is_bot"].isNull() ? false : row["is_bot"].as<bool>();
|
||||
user.isTexter = row["is_texter"].isNull() ? false : row["is_texter"].as<bool>();
|
||||
user.isPgpOnly = row["is_pgp_only"].isNull() ? false : row["is_pgp_only"].as<bool>();
|
||||
user.isDisabled = isDisabled;
|
||||
user.colorCode = row["user_color"].isNull() ? "#561D5E" : row["user_color"].as<std::string>();
|
||||
user.avatarUrl = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
|
||||
user.tokenVersion = row["token_version"].isNull() ? 1 : row["token_version"].as<int>();
|
||||
|
||||
// Generate new tokens (rotation)
|
||||
std::string newRefreshToken = generateRefreshToken();
|
||||
std::string newTokenHash = hashToken(newRefreshToken);
|
||||
std::string newAccessToken = generateToken(user);
|
||||
|
||||
// Update the family with new token hash
|
||||
*dbClient << "UPDATE refresh_token_families SET current_token_hash = $1, last_used_at = NOW() "
|
||||
"WHERE id = $2"
|
||||
<< newTokenHash << familyId
|
||||
>> [callback, newAccessToken, newRefreshToken, familyUuid, user](const Result&) {
|
||||
LOG_DEBUG << "Rotated refresh token for family: " << familyUuid;
|
||||
RefreshTokenResult result;
|
||||
result.success = true;
|
||||
result.accessToken = newAccessToken;
|
||||
result.refreshToken = newRefreshToken;
|
||||
result.familyId = familyUuid;
|
||||
result.user = user;
|
||||
callback(result);
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to rotate refresh token: " << e.base().what();
|
||||
RefreshTokenResult result;
|
||||
result.error = "Failed to refresh session";
|
||||
callback(result);
|
||||
};
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception in validateAndRotateRefreshToken callback: " << e.what();
|
||||
RefreshTokenResult result;
|
||||
result.error = "Internal error";
|
||||
callback(result);
|
||||
}
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Database error in validateAndRotateRefreshToken: " << e.base().what();
|
||||
RefreshTokenResult result;
|
||||
result.error = "Database error";
|
||||
callback(result);
|
||||
};
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception in validateAndRotateRefreshToken: " << e.what();
|
||||
RefreshTokenResult result;
|
||||
result.error = "Internal error";
|
||||
callback(result);
|
||||
}
|
||||
}
|
||||
|
||||
void AuthService::revokeTokenFamily(const std::string& familyId,
|
||||
std::function<void(bool success)> callback) {
|
||||
try {
|
||||
auto dbClient = app().getDbClient();
|
||||
if (!dbClient) {
|
||||
LOG_ERROR << "Database client is null";
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
|
||||
*dbClient << "UPDATE refresh_token_families SET revoked = TRUE, revoked_at = NOW() "
|
||||
"WHERE family_id = $1::uuid"
|
||||
<< familyId
|
||||
>> [callback, familyId](const Result& r) {
|
||||
LOG_INFO << "Revoked token family: " << familyId;
|
||||
callback(true);
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to revoke token family: " << e.base().what();
|
||||
callback(false);
|
||||
};
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception in revokeTokenFamily: " << e.what();
|
||||
callback(false);
|
||||
}
|
||||
}
|
||||
|
||||
void AuthService::revokeAllUserTokenFamilies(int64_t userId,
|
||||
std::function<void(bool success)> callback) {
|
||||
try {
|
||||
auto dbClient = app().getDbClient();
|
||||
if (!dbClient) {
|
||||
LOG_ERROR << "Database client is null";
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
|
||||
*dbClient << "UPDATE refresh_token_families SET revoked = TRUE, revoked_at = NOW() "
|
||||
"WHERE user_id = $1 AND revoked = FALSE"
|
||||
<< userId
|
||||
>> [callback, userId](const Result& r) {
|
||||
LOG_INFO << "Revoked all token families for user: " << userId;
|
||||
callback(true);
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to revoke user token families: " << e.base().what();
|
||||
callback(false);
|
||||
};
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception in revokeAllUserTokenFamilies: " << e.what();
|
||||
callback(false);
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,16 @@ struct UserInfo {
|
|||
int tokenVersion = 1; // SECURITY FIX #10: Token version for revocation
|
||||
};
|
||||
|
||||
// Result structure for refresh token operations
|
||||
struct RefreshTokenResult {
|
||||
bool success = false;
|
||||
std::string error;
|
||||
std::string accessToken;
|
||||
std::string refreshToken;
|
||||
std::string familyId;
|
||||
UserInfo user;
|
||||
};
|
||||
|
||||
// Chat service compatibility struct
|
||||
struct UserClaims {
|
||||
std::string userId;
|
||||
|
|
@ -71,6 +81,20 @@ public:
|
|||
// Chat service compatibility method
|
||||
std::optional<UserClaims> verifyToken(const std::string& token);
|
||||
|
||||
// Refresh token methods
|
||||
void createRefreshTokenFamily(int64_t userId,
|
||||
std::function<void(bool success, const std::string& refreshToken,
|
||||
const std::string& familyId)> callback);
|
||||
|
||||
void validateAndRotateRefreshToken(const std::string& refreshToken,
|
||||
std::function<void(RefreshTokenResult)> callback);
|
||||
|
||||
void revokeTokenFamily(const std::string& familyId,
|
||||
std::function<void(bool success)> callback);
|
||||
|
||||
void revokeAllUserTokenFamilies(int64_t userId,
|
||||
std::function<void(bool success)> callback);
|
||||
|
||||
// New method to fetch complete user info including color
|
||||
void fetchUserInfo(int64_t userId, std::function<void(bool, const UserInfo&)> callback);
|
||||
|
||||
|
|
@ -89,4 +113,12 @@ private:
|
|||
|
||||
bool validatePassword(const std::string& password, std::string& error);
|
||||
void validateAndLoadJwtSecret(); // SECURITY FIX #5
|
||||
|
||||
// Refresh token helpers
|
||||
std::string generateRefreshToken(); // Generates random 256-bit token
|
||||
std::string hashToken(const std::string& token); // SHA256 hash for storage
|
||||
std::string generateUUID(); // Generate UUID for family_id
|
||||
|
||||
static constexpr int ACCESS_TOKEN_EXPIRY_MINUTES = 150; // 2.5 hours (gives buffer for 2-hour refresh)
|
||||
static constexpr int REFRESH_TOKEN_EXPIRY_DAYS = 90;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue