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();
|
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) {
|
void setAuthCookie(const HttpResponsePtr& resp, const std::string& token) {
|
||||||
Cookie authCookie("auth_token", token);
|
Cookie authCookie("auth_token", token);
|
||||||
authCookie.setPath("/");
|
authCookie.setPath("/");
|
||||||
authCookie.setHttpOnly(true);
|
authCookie.setHttpOnly(true);
|
||||||
authCookie.setSecure(false); // Set to true in production with HTTPS
|
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);
|
authCookie.setSameSite(Cookie::SameSite::kLax);
|
||||||
resp->addCookie(authCookie);
|
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
|
// Helper to clear auth cookie
|
||||||
void clearAuthCookie(const HttpResponsePtr& resp) {
|
void clearAuthCookie(const HttpResponsePtr& resp) {
|
||||||
Cookie authCookie("auth_token", "");
|
Cookie authCookie("auth_token", "");
|
||||||
|
|
@ -50,6 +61,15 @@ namespace {
|
||||||
authCookie.setMaxAge(0); // Expire immediately
|
authCookie.setMaxAge(0); // Expire immediately
|
||||||
resp->addCookie(authCookie);
|
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,
|
void UserController::register_(const HttpRequestPtr &req,
|
||||||
|
|
@ -179,31 +199,44 @@ void UserController::login(const HttpRequestPtr &req,
|
||||||
if (success) {
|
if (success) {
|
||||||
LOG_INFO << "Login successful for user: " << username;
|
LOG_INFO << "Login successful for user: " << username;
|
||||||
|
|
||||||
Json::Value resp;
|
// Create refresh token family for long-lived session
|
||||||
resp["success"] = true;
|
AuthService::getInstance().createRefreshTokenFamily(user.id,
|
||||||
// Send token in body for WebSocket auth (cookie still used for HTTP requests)
|
[callback, username, token, user](bool refreshSuccess, const std::string& refreshToken,
|
||||||
resp["token"] = token;
|
const std::string& familyId) {
|
||||||
resp["user"]["id"] = static_cast<Json::Int64>(user.id);
|
Json::Value resp;
|
||||||
resp["user"]["username"] = user.username;
|
resp["success"] = true;
|
||||||
resp["user"]["isAdmin"] = user.isAdmin;
|
// Send token in body for WebSocket auth (cookie still used for HTTP requests)
|
||||||
resp["user"]["isStreamer"] = user.isStreamer;
|
resp["token"] = token;
|
||||||
resp["user"]["isRestreamer"] = user.isRestreamer;
|
resp["user"]["id"] = static_cast<Json::Int64>(user.id);
|
||||||
resp["user"]["isBot"] = user.isBot;
|
resp["user"]["username"] = user.username;
|
||||||
resp["user"]["isTexter"] = user.isTexter;
|
resp["user"]["isAdmin"] = user.isAdmin;
|
||||||
resp["user"]["isPgpOnly"] = user.isPgpOnly;
|
resp["user"]["isStreamer"] = user.isStreamer;
|
||||||
resp["user"]["bio"] = user.bio;
|
resp["user"]["isRestreamer"] = user.isRestreamer;
|
||||||
resp["user"]["avatarUrl"] = user.avatarUrl;
|
resp["user"]["isBot"] = user.isBot;
|
||||||
resp["user"]["bannerUrl"] = user.bannerUrl;
|
resp["user"]["isTexter"] = user.isTexter;
|
||||||
resp["user"]["bannerPosition"] = user.bannerPosition;
|
resp["user"]["isPgpOnly"] = user.isPgpOnly;
|
||||||
resp["user"]["bannerZoom"] = user.bannerZoom;
|
resp["user"]["bio"] = user.bio;
|
||||||
resp["user"]["bannerPositionX"] = user.bannerPositionX;
|
resp["user"]["avatarUrl"] = user.avatarUrl;
|
||||||
resp["user"]["graffitiUrl"] = user.graffitiUrl;
|
resp["user"]["bannerUrl"] = user.bannerUrl;
|
||||||
resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt;
|
resp["user"]["bannerPosition"] = user.bannerPosition;
|
||||||
resp["user"]["colorCode"] = user.colorCode;
|
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);
|
auto response = jsonResp(resp);
|
||||||
setAuthCookie(response, token);
|
setAuthCookie(response, token);
|
||||||
callback(response);
|
|
||||||
|
// 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 {
|
} else {
|
||||||
LOG_WARN << "Login failed for user: " << username;
|
LOG_WARN << "Login failed for user: " << username;
|
||||||
callback(jsonError(token.empty() ? "Invalid credentials" : token, k401Unauthorized));
|
callback(jsonError(token.empty() ? "Invalid credentials" : token, k401Unauthorized));
|
||||||
|
|
@ -227,6 +260,7 @@ void UserController::logout(const HttpRequestPtr &req,
|
||||||
|
|
||||||
auto response = jsonResp(resp);
|
auto response = jsonResp(resp);
|
||||||
clearAuthCookie(response);
|
clearAuthCookie(response);
|
||||||
|
clearRefreshCookie(response); // Also clear refresh token
|
||||||
callback(response);
|
callback(response);
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
LOG_ERROR << "Exception in logout: " << e.what();
|
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,
|
void UserController::pgpChallenge(const HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -301,31 +387,44 @@ void UserController::pgpVerify(const HttpRequestPtr &req,
|
||||||
AuthService::getInstance().verifyPgpLogin(username, signature, challenge,
|
AuthService::getInstance().verifyPgpLogin(username, signature, challenge,
|
||||||
[callback](bool success, const std::string& token, const UserInfo& user) {
|
[callback](bool success, const std::string& token, const UserInfo& user) {
|
||||||
if (success) {
|
if (success) {
|
||||||
Json::Value resp;
|
// Create refresh token family for long-lived session
|
||||||
resp["success"] = true;
|
AuthService::getInstance().createRefreshTokenFamily(user.id,
|
||||||
// Send token in body for WebSocket auth (cookie still used for HTTP requests)
|
[callback, token, user](bool refreshSuccess, const std::string& refreshToken,
|
||||||
resp["token"] = token;
|
const std::string& familyId) {
|
||||||
resp["user"]["id"] = static_cast<Json::Int64>(user.id);
|
Json::Value resp;
|
||||||
resp["user"]["username"] = user.username;
|
resp["success"] = true;
|
||||||
resp["user"]["isAdmin"] = user.isAdmin;
|
// Send token in body for WebSocket auth (cookie still used for HTTP requests)
|
||||||
resp["user"]["isStreamer"] = user.isStreamer;
|
resp["token"] = token;
|
||||||
resp["user"]["isRestreamer"] = user.isRestreamer;
|
resp["user"]["id"] = static_cast<Json::Int64>(user.id);
|
||||||
resp["user"]["isBot"] = user.isBot;
|
resp["user"]["username"] = user.username;
|
||||||
resp["user"]["isTexter"] = user.isTexter;
|
resp["user"]["isAdmin"] = user.isAdmin;
|
||||||
resp["user"]["isPgpOnly"] = user.isPgpOnly;
|
resp["user"]["isStreamer"] = user.isStreamer;
|
||||||
resp["user"]["bio"] = user.bio;
|
resp["user"]["isRestreamer"] = user.isRestreamer;
|
||||||
resp["user"]["avatarUrl"] = user.avatarUrl;
|
resp["user"]["isBot"] = user.isBot;
|
||||||
resp["user"]["bannerUrl"] = user.bannerUrl;
|
resp["user"]["isTexter"] = user.isTexter;
|
||||||
resp["user"]["bannerPosition"] = user.bannerPosition;
|
resp["user"]["isPgpOnly"] = user.isPgpOnly;
|
||||||
resp["user"]["bannerZoom"] = user.bannerZoom;
|
resp["user"]["bio"] = user.bio;
|
||||||
resp["user"]["bannerPositionX"] = user.bannerPositionX;
|
resp["user"]["avatarUrl"] = user.avatarUrl;
|
||||||
resp["user"]["graffitiUrl"] = user.graffitiUrl;
|
resp["user"]["bannerUrl"] = user.bannerUrl;
|
||||||
resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt;
|
resp["user"]["bannerPosition"] = user.bannerPosition;
|
||||||
resp["user"]["colorCode"] = user.colorCode;
|
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);
|
auto response = jsonResp(resp);
|
||||||
setAuthCookie(response, token);
|
setAuthCookie(response, token);
|
||||||
callback(response);
|
|
||||||
|
// 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 {
|
} else {
|
||||||
callback(jsonError("Invalid signature", k401Unauthorized));
|
callback(jsonError("Invalid signature", k401Unauthorized));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ public:
|
||||||
ADD_METHOD_TO(UserController::register_, "/api/auth/register", Post);
|
ADD_METHOD_TO(UserController::register_, "/api/auth/register", Post);
|
||||||
ADD_METHOD_TO(UserController::login, "/api/auth/login", Post);
|
ADD_METHOD_TO(UserController::login, "/api/auth/login", Post);
|
||||||
ADD_METHOD_TO(UserController::logout, "/api/auth/logout", 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::pgpChallenge, "/api/auth/pgp-challenge", Post);
|
||||||
ADD_METHOD_TO(UserController::pgpVerify, "/api/auth/pgp-verify", Post);
|
ADD_METHOD_TO(UserController::pgpVerify, "/api/auth/pgp-verify", Post);
|
||||||
ADD_METHOD_TO(UserController::getCurrentUser, "/api/user/me", Get);
|
ADD_METHOD_TO(UserController::getCurrentUser, "/api/user/me", Get);
|
||||||
|
|
@ -58,6 +59,9 @@ public:
|
||||||
void logout(const HttpRequestPtr &req,
|
void logout(const HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||||
|
|
||||||
|
void refresh(const HttpRequestPtr &req,
|
||||||
|
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||||
|
|
||||||
void pgpChallenge(const HttpRequestPtr &req,
|
void pgpChallenge(const HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -720,12 +720,13 @@ std::string AuthService::generateToken(const UserInfo& user) {
|
||||||
try {
|
try {
|
||||||
validateAndLoadJwtSecret();
|
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()
|
auto token = jwt::create()
|
||||||
.set_issuer("streaming-app")
|
.set_issuer("streaming-app")
|
||||||
.set_type("JWT")
|
.set_type("JWT")
|
||||||
.set_issued_at(std::chrono::system_clock::now())
|
.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("user_id", jwt::claim(std::to_string(user.id)))
|
||||||
.set_payload_claim("username", jwt::claim(user.username))
|
.set_payload_claim("username", jwt::claim(user.username))
|
||||||
.set_payload_claim("is_admin", jwt::claim(std::to_string(user.isAdmin)))
|
.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;
|
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"
|
*dbClient << "UPDATE users SET password_hash = $1, token_version = COALESCE(token_version, 0) + 1 WHERE id = $2"
|
||||||
<< newHash << userId
|
<< newHash << userId
|
||||||
>> [callback, userId](const Result&) {
|
>> [callback, userId, dbClient](const Result&) {
|
||||||
LOG_INFO << "Password updated and token_version incremented for user " << userId;
|
// Revoke all refresh token families for this user
|
||||||
callback(true, "");
|
*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) {
|
>> [callback](const DrogonDbException& e) {
|
||||||
LOG_ERROR << "Failed to update password: " << e.base().what();
|
LOG_ERROR << "Failed to update password: " << e.base().what();
|
||||||
|
|
@ -949,3 +962,271 @@ void AuthService::fetchUserInfo(int64_t userId, std::function<void(bool, const U
|
||||||
callback(false, UserInfo{});
|
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
|
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
|
// Chat service compatibility struct
|
||||||
struct UserClaims {
|
struct UserClaims {
|
||||||
std::string userId;
|
std::string userId;
|
||||||
|
|
@ -71,6 +81,20 @@ public:
|
||||||
// Chat service compatibility method
|
// Chat service compatibility method
|
||||||
std::optional<UserClaims> verifyToken(const std::string& token);
|
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
|
// New method to fetch complete user info including color
|
||||||
void fetchUserInfo(int64_t userId, std::function<void(bool, const UserInfo&)> callback);
|
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);
|
bool validatePassword(const std::string& password, std::string& error);
|
||||||
void validateAndLoadJwtSecret(); // SECURITY FIX #5
|
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;
|
||||||
};
|
};
|
||||||
|
|
@ -260,23 +260,79 @@ void WatchSyncController::broadcastRoomSync(const std::string& realmId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
void WatchSyncController::autoAdvanceToNextVideo(const std::string& realmId) {
|
void WatchSyncController::autoAdvanceToNextVideo(const std::string& realmId) {
|
||||||
// Check skip debounce to prevent multiple auto-advance calls
|
// Variables for locked video handling (set inside lock, used after)
|
||||||
|
bool isLockedVideo = false;
|
||||||
|
RoomState stateCopy;
|
||||||
|
int64_t nowMs = 0;
|
||||||
|
|
||||||
|
// Check skip debounce and in-memory lock state
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(roomStatesMutex_);
|
std::lock_guard<std::mutex> lock(roomStatesMutex_);
|
||||||
auto it = roomStates_.find(realmId);
|
auto it = roomStates_.find(realmId);
|
||||||
if (it != roomStates_.end()) {
|
if (it != roomStates_.end()) {
|
||||||
auto now = std::chrono::duration_cast<std::chrono::milliseconds>(
|
nowMs = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||||
std::chrono::system_clock::now().time_since_epoch()).count();
|
std::chrono::system_clock::now().time_since_epoch()).count();
|
||||||
if (now - it->second.lastSkipMs < SKIP_DEBOUNCE_MS) {
|
if (nowMs - it->second.lastSkipMs < SKIP_DEBOUNCE_MS) {
|
||||||
LOG_DEBUG << "Auto-advance debounced for room " << realmId;
|
LOG_DEBUG << "Auto-advance debounced for room " << realmId;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Mark skip time to prevent concurrent skips
|
// Mark skip time to prevent concurrent skips
|
||||||
it->second.lastSkipMs = now;
|
it->second.lastSkipMs = nowMs;
|
||||||
|
|
||||||
|
// CHECK IN-MEMORY LOCK STATE FIRST (CyTube-style immediate state)
|
||||||
|
if (it->second.currentVideoLocked) {
|
||||||
|
LOG_INFO << "Auto-advance: Current video is locked (in-memory), restarting in room " << realmId;
|
||||||
|
|
||||||
|
// Restart the locked video
|
||||||
|
it->second.currentTime = 0.0;
|
||||||
|
it->second.playbackState = "playing";
|
||||||
|
it->second.lastUpdateMs = nowMs;
|
||||||
|
it->second.leadInActive = true;
|
||||||
|
it->second.leadInStartMs = nowMs;
|
||||||
|
it->second.stateVersion++;
|
||||||
|
|
||||||
|
// Copy state for broadcast after releasing lock
|
||||||
|
stateCopy = it->second;
|
||||||
|
isLockedVideo = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call backend to get playlist (includes isLocked status)
|
// Handle locked video restart (outside the lock)
|
||||||
|
if (isLockedVideo) {
|
||||||
|
// Get viewer count for silent loop logic
|
||||||
|
int viewerCount = getViewerCount(realmId);
|
||||||
|
|
||||||
|
if (viewerCount > 0) {
|
||||||
|
// Broadcast locked_restart event
|
||||||
|
Json::Value stateChange;
|
||||||
|
stateChange["type"] = "state_change";
|
||||||
|
stateChange["event"] = "locked_restart";
|
||||||
|
stateChange["triggeredBy"] = "server";
|
||||||
|
stateChange["playbackState"] = "playing";
|
||||||
|
stateChange["currentTime"] = 0.0;
|
||||||
|
stateChange["serverTime"] = static_cast<Json::Int64>(nowMs);
|
||||||
|
stateChange["leadIn"] = true;
|
||||||
|
stateChange["isLocked"] = true;
|
||||||
|
|
||||||
|
if (!stateCopy.currentVideoId.empty()) {
|
||||||
|
Json::Value video;
|
||||||
|
video["id"] = static_cast<Json::Int64>(stateCopy.currentPlaylistItemId);
|
||||||
|
video["youtubeVideoId"] = stateCopy.currentVideoId;
|
||||||
|
video["title"] = stateCopy.currentVideoTitle;
|
||||||
|
video["durationSeconds"] = stateCopy.durationSeconds;
|
||||||
|
video["isLocked"] = true;
|
||||||
|
stateChange["currentVideo"] = video;
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastToRoom(realmId, stateChange);
|
||||||
|
} else {
|
||||||
|
LOG_DEBUG << "Auto-advance: Silent loop for locked video in room " << realmId << " (no viewers)";
|
||||||
|
}
|
||||||
|
return; // Don't proceed to backend query
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call backend to get playlist (fallback for non-locked videos)
|
||||||
auto client = HttpClient::newHttpClient("http://drogon-backend:8080");
|
auto client = HttpClient::newHttpClient("http://drogon-backend:8080");
|
||||||
auto httpReq = HttpRequest::newHttpRequest();
|
auto httpReq = HttpRequest::newHttpRequest();
|
||||||
httpReq->setPath("/api/watch/" + realmId + "/playlist");
|
httpReq->setPath("/api/watch/" + realmId + "/playlist");
|
||||||
|
|
@ -310,9 +366,16 @@ void WatchSyncController::autoAdvanceToNextVideo(const std::string& realmId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const Json::Value& playlist = (*json)["playlist"];
|
const Json::Value& playlist = (*json)["playlist"];
|
||||||
|
LOG_INFO << "Auto-advance: Got " << playlist.size() << " playlist items for room " << realmId;
|
||||||
|
|
||||||
for (const auto& item : playlist) {
|
for (const auto& item : playlist) {
|
||||||
std::string status = item["status"].asString();
|
std::string status = item["status"].asString();
|
||||||
bool isLocked = item.isMember("isLocked") && item["isLocked"].asBool();
|
bool isLocked = item.isMember("isLocked") && item["isLocked"].asBool();
|
||||||
|
std::string title = item.isMember("title") ? item["title"].asString() : "unknown";
|
||||||
|
|
||||||
|
LOG_INFO << " - Item: " << title << ", status: " << status
|
||||||
|
<< ", isLocked: " << (isLocked ? "true" : "false")
|
||||||
|
<< ", hasIsLocked: " << (item.isMember("isLocked") ? "yes" : "no");
|
||||||
|
|
||||||
if (status == "queued") {
|
if (status == "queued") {
|
||||||
queuedCount++;
|
queuedCount++;
|
||||||
|
|
@ -722,6 +785,8 @@ void WatchSyncController::handleNewMessage(const WebSocketConnectionPtr& wsConnP
|
||||||
handlePlaybackControl(wsConnPtr, info, json);
|
handlePlaybackControl(wsConnPtr, info, json);
|
||||||
} else if (msgType == "update_duration") {
|
} else if (msgType == "update_duration") {
|
||||||
handleUpdateDuration(wsConnPtr, info, json);
|
handleUpdateDuration(wsConnPtr, info, json);
|
||||||
|
} else if (msgType == "lock_update") {
|
||||||
|
handleLockUpdate(wsConnPtr, info, json);
|
||||||
} else {
|
} else {
|
||||||
sendError(wsConnPtr, "Unknown message type: " + msgType);
|
sendError(wsConnPtr, "Unknown message type: " + msgType);
|
||||||
}
|
}
|
||||||
|
|
@ -1468,3 +1533,47 @@ void WatchSyncController::handleUpdateDuration(const WebSocketConnectionPtr& wsC
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void WatchSyncController::handleLockUpdate(const WebSocketConnectionPtr& wsConnPtr,
|
||||||
|
const ViewerInfo& info,
|
||||||
|
const Json::Value& data) {
|
||||||
|
if (info.realmId.empty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only owner/admin can toggle lock (frontend already enforces this)
|
||||||
|
if (!info.canControlPlayback) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int64_t playlistItemId = data["playlistItemId"].asInt64();
|
||||||
|
bool locked = data["locked"].asBool();
|
||||||
|
|
||||||
|
LOG_INFO << "Lock update for room " << info.realmId
|
||||||
|
<< ": item " << playlistItemId << " locked=" << locked;
|
||||||
|
|
||||||
|
// Update in-memory state if this is the current video
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(roomStatesMutex_);
|
||||||
|
auto it = roomStates_.find(info.realmId);
|
||||||
|
if (it != roomStates_.end()) {
|
||||||
|
if (it->second.currentPlaylistItemId == playlistItemId) {
|
||||||
|
it->second.currentVideoLocked = locked;
|
||||||
|
LOG_INFO << "Updated currentVideoLocked to " << locked
|
||||||
|
<< " for room " << info.realmId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast lock change to all viewers in the room
|
||||||
|
auto now = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||||
|
std::chrono::system_clock::now().time_since_epoch()).count();
|
||||||
|
|
||||||
|
Json::Value broadcast;
|
||||||
|
broadcast["type"] = "lock_changed";
|
||||||
|
broadcast["playlistItemId"] = static_cast<Json::Int64>(playlistItemId);
|
||||||
|
broadcast["locked"] = locked;
|
||||||
|
broadcast["serverTime"] = static_cast<Json::Int64>(now);
|
||||||
|
|
||||||
|
broadcastToRoom(info.realmId, broadcast);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,10 @@ private:
|
||||||
const ViewerInfo& info,
|
const ViewerInfo& info,
|
||||||
const Json::Value& data);
|
const Json::Value& data);
|
||||||
|
|
||||||
|
void handleLockUpdate(const WebSocketConnectionPtr& wsConnPtr,
|
||||||
|
const ViewerInfo& info,
|
||||||
|
const Json::Value& data);
|
||||||
|
|
||||||
void sendError(const WebSocketConnectionPtr& wsConnPtr, const std::string& error);
|
void sendError(const WebSocketConnectionPtr& wsConnPtr, const std::string& error);
|
||||||
void sendSuccess(const WebSocketConnectionPtr& wsConnPtr, const Json::Value& data);
|
void sendSuccess(const WebSocketConnectionPtr& wsConnPtr, const Json::Value& data);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -269,6 +269,10 @@ void ChatService::cleanupMessages() {
|
||||||
LOG_DEBUG << "Cleaned up old messages for realm: " << realmId
|
LOG_DEBUG << "Cleaned up old messages for realm: " << realmId
|
||||||
<< " (retention: " << settings.retentionHours << "h)";
|
<< " (retention: " << settings.retentionHours << "h)";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up any expired self-destruct messages that weren't deleted by their timers
|
||||||
|
// (e.g., due to server restart or timer failures)
|
||||||
|
redis.cleanupExpiredSelfDestruct(realmId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,11 +67,15 @@ bool RedisMessageStore::addMessage(const ChatMessage& message) {
|
||||||
trackActiveRealm(message.realmId);
|
trackActiveRealm(message.realmId);
|
||||||
|
|
||||||
auto key = getMessagesKey(message.realmId);
|
auto key = getMessagesKey(message.realmId);
|
||||||
|
auto indexKey = getMessageIndexKey(message.realmId);
|
||||||
auto serialized = message.serialize();
|
auto serialized = message.serialize();
|
||||||
|
|
||||||
// Add to sorted set with timestamp as score
|
// Add to sorted set with timestamp as score
|
||||||
redis_->zadd(key, serialized, static_cast<double>(message.timestamp));
|
redis_->zadd(key, serialized, static_cast<double>(message.timestamp));
|
||||||
|
|
||||||
|
// Add to message index hash for reliable deletion by messageId
|
||||||
|
redis_->hset(indexKey, message.messageId, serialized);
|
||||||
|
|
||||||
// Trim to max messages per realm
|
// Trim to max messages per realm
|
||||||
auto maxMessages = drogon::app().getCustomConfig().get("chat", Json::Value::null)
|
auto maxMessages = drogon::app().getCustomConfig().get("chat", Json::Value::null)
|
||||||
.get("max_messages_per_realm", 1000).asInt64();
|
.get("max_messages_per_realm", 1000).asInt64();
|
||||||
|
|
@ -94,6 +98,9 @@ std::vector<ChatMessage> RedisMessageStore::getMessages(const std::string& realm
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
auto key = getMessagesKey(realmId);
|
auto key = getMessagesKey(realmId);
|
||||||
|
auto now = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||||
|
std::chrono::system_clock::now().time_since_epoch()
|
||||||
|
).count();
|
||||||
|
|
||||||
std::vector<std::string> results;
|
std::vector<std::string> results;
|
||||||
if (beforeTimestamp > 0) {
|
if (beforeTimestamp > 0) {
|
||||||
|
|
@ -107,7 +114,12 @@ std::vector<ChatMessage> RedisMessageStore::getMessages(const std::string& realm
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const auto& serialized : results) {
|
for (const auto& serialized : results) {
|
||||||
messages.push_back(ChatMessage::deserialize(serialized));
|
auto msg = ChatMessage::deserialize(serialized);
|
||||||
|
// Filter out expired self-destruct messages
|
||||||
|
if (msg.selfDestructAt > 0 && msg.selfDestructAt <= now) {
|
||||||
|
continue; // Skip expired messages
|
||||||
|
}
|
||||||
|
messages.push_back(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reverse to get chronological order
|
// Reverse to get chronological order
|
||||||
|
|
@ -120,21 +132,33 @@ std::vector<ChatMessage> RedisMessageStore::getMessages(const std::string& realm
|
||||||
}
|
}
|
||||||
|
|
||||||
bool RedisMessageStore::deleteMessage(const std::string& realmId, const std::string& messageId) {
|
bool RedisMessageStore::deleteMessage(const std::string& realmId, const std::string& messageId) {
|
||||||
|
if (!redis_) {
|
||||||
|
LOG_ERROR << "Redis not initialized - cannot delete message";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
auto key = getMessagesKey(realmId);
|
auto key = getMessagesKey(realmId);
|
||||||
|
auto indexKey = getMessageIndexKey(realmId);
|
||||||
|
|
||||||
// Get all messages and remove the one with matching ID
|
// Look up the exact serialized string from the message index
|
||||||
std::vector<std::string> messages;
|
auto serialized = redis_->hget(indexKey, messageId);
|
||||||
redis_->zrange(key, 0, -1, std::back_inserter(messages));
|
if (!serialized) {
|
||||||
|
LOG_WARN << "Message not found in index: " << messageId;
|
||||||
for (const auto& serialized : messages) {
|
return false;
|
||||||
auto msg = ChatMessage::deserialize(serialized);
|
|
||||||
if (msg.messageId == messageId) {
|
|
||||||
redis_->zrem(key, serialized);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
|
// Remove from sorted set using the exact serialized string
|
||||||
|
auto removed = redis_->zrem(key, *serialized);
|
||||||
|
|
||||||
|
// Remove from message index
|
||||||
|
redis_->hdel(indexKey, messageId);
|
||||||
|
|
||||||
|
if (removed == 0) {
|
||||||
|
LOG_WARN << "Message was in index but not in sorted set: " << messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DEBUG << "Deleted message: " << messageId << " (zrem=" << removed << ")";
|
||||||
|
return true;
|
||||||
} catch (const Error& e) {
|
} catch (const Error& e) {
|
||||||
LOG_ERROR << "Redis error deleting message: " << e.what();
|
LOG_ERROR << "Redis error deleting message: " << e.what();
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -155,6 +179,41 @@ void RedisMessageStore::cleanupOldMessages(const std::string& realmId, int reten
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RedisMessageStore::cleanupExpiredSelfDestruct(const std::string& realmId) {
|
||||||
|
if (!redis_) return;
|
||||||
|
try {
|
||||||
|
auto key = getMessagesKey(realmId);
|
||||||
|
auto indexKey = getMessageIndexKey(realmId);
|
||||||
|
auto now = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||||
|
std::chrono::system_clock::now().time_since_epoch()
|
||||||
|
).count();
|
||||||
|
|
||||||
|
// Get all messages to check for expired self-destruct
|
||||||
|
std::vector<std::string> messages;
|
||||||
|
redis_->zrange(key, 0, -1, std::back_inserter(messages));
|
||||||
|
|
||||||
|
int cleanedCount = 0;
|
||||||
|
for (const auto& serialized : messages) {
|
||||||
|
auto msg = ChatMessage::deserialize(serialized);
|
||||||
|
// Check if message has expired selfDestructAt time
|
||||||
|
if (msg.selfDestructAt > 0 && msg.selfDestructAt <= now) {
|
||||||
|
// Remove from sorted set
|
||||||
|
redis_->zrem(key, serialized);
|
||||||
|
// Remove from message index
|
||||||
|
redis_->hdel(indexKey, msg.messageId);
|
||||||
|
cleanedCount++;
|
||||||
|
LOG_DEBUG << "Cleaned up expired self-destruct message: " << msg.messageId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanedCount > 0) {
|
||||||
|
LOG_INFO << "Cleaned up " << cleanedCount << " expired self-destruct messages in realm: " << realmId;
|
||||||
|
}
|
||||||
|
} catch (const Error& e) {
|
||||||
|
LOG_ERROR << "Redis error cleaning up expired self-destruct messages: " << e.what();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void RedisMessageStore::setGlobalSettings(const GlobalChatSettings& settings) {
|
void RedisMessageStore::setGlobalSettings(const GlobalChatSettings& settings) {
|
||||||
try {
|
try {
|
||||||
redis_->hset("chat:settings:global", "guestPrefix", settings.guestPrefix);
|
redis_->hset("chat:settings:global", "guestPrefix", settings.guestPrefix);
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ public:
|
||||||
int64_t beforeTimestamp = 0);
|
int64_t beforeTimestamp = 0);
|
||||||
bool deleteMessage(const std::string& realmId, const std::string& messageId);
|
bool deleteMessage(const std::string& realmId, const std::string& messageId);
|
||||||
void cleanupOldMessages(const std::string& realmId, int retentionHours);
|
void cleanupOldMessages(const std::string& realmId, int retentionHours);
|
||||||
|
void cleanupExpiredSelfDestruct(const std::string& realmId);
|
||||||
|
|
||||||
// Settings operations
|
// Settings operations
|
||||||
void setGlobalSettings(const models::GlobalChatSettings& settings);
|
void setGlobalSettings(const models::GlobalChatSettings& settings);
|
||||||
|
|
@ -119,6 +120,10 @@ private:
|
||||||
std::string getKickKey(const std::string& realmId, const std::string& userId) const {
|
std::string getKickKey(const std::string& realmId, const std::string& userId) const {
|
||||||
return "chat:kicked:" + realmId + ":" + userId;
|
return "chat:kicked:" + realmId + ":" + userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string getMessageIndexKey(const std::string& realmId) const {
|
||||||
|
return "chat:msg_index:" + realmId;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace services
|
} // namespace services
|
||||||
|
|
|
||||||
|
|
@ -1199,3 +1199,27 @@ END $$;
|
||||||
|
|
||||||
-- Create index for pending_uberban lookups
|
-- Create index for pending_uberban lookups
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_pending_uberban ON users(pending_uberban) WHERE pending_uberban = true;
|
CREATE INDEX IF NOT EXISTS idx_users_pending_uberban ON users(pending_uberban) WHERE pending_uberban = true;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- REFRESH TOKEN FAMILIES (JWT Token Rotation)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Refresh token families table for secure token rotation
|
||||||
|
-- Each login creates a new "family" - if an old token is reused, the whole family is revoked
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_token_families (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
family_id UUID NOT NULL UNIQUE,
|
||||||
|
current_token_hash VARCHAR(64) NOT NULL, -- SHA256 of current valid refresh token
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
revoked BOOLEAN DEFAULT FALSE,
|
||||||
|
revoked_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
last_used_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for refresh_token_families
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_families_user_id ON refresh_token_families(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_families_family_id ON refresh_token_families(family_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_families_active ON refresh_token_families(user_id, revoked) WHERE revoked = FALSE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_families_expires ON refresh_token_families(expires_at) WHERE revoked = FALSE;
|
||||||
|
|
@ -1,6 +1,32 @@
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost/api';
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost/api';
|
||||||
|
|
||||||
async function fetchAPI(endpoint, options = {}) {
|
// Track if we're currently refreshing to avoid multiple simultaneous refreshes
|
||||||
|
let isRefreshing = false;
|
||||||
|
let refreshPromise = null;
|
||||||
|
|
||||||
|
// Attempt to refresh the access token
|
||||||
|
async function attemptTokenRefresh() {
|
||||||
|
if (isRefreshing) {
|
||||||
|
// Wait for the existing refresh to complete
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRefreshing = true;
|
||||||
|
refreshPromise = fetch('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
}).then(response => {
|
||||||
|
isRefreshing = false;
|
||||||
|
return response.ok;
|
||||||
|
}).catch(() => {
|
||||||
|
isRefreshing = false;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAPI(endpoint, options = {}, retryAfterRefresh = true) {
|
||||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -10,6 +36,15 @@ async function fetchAPI(endpoint, options = {}) {
|
||||||
credentials: 'include', // Always include credentials
|
credentials: 'include', // Always include credentials
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// If we get a 401 and haven't already retried, attempt token refresh
|
||||||
|
if (response.status === 401 && retryAfterRefresh) {
|
||||||
|
const refreshed = await attemptTokenRefresh();
|
||||||
|
if (refreshed) {
|
||||||
|
// Retry the original request (but don't retry again if it fails)
|
||||||
|
return fetchAPI(endpoint, options, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API error: ${response.statusText}`);
|
throw new Error(`API error: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -371,7 +371,7 @@ class ChatWebSocket {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
attemptReconnect() {
|
async attemptReconnect() {
|
||||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||||
console.error('Max reconnect attempts reached. Use manualReconnect() to try again.');
|
console.error('Max reconnect attempts reached. Use manualReconnect() to try again.');
|
||||||
connectionStatus.set('failed');
|
connectionStatus.set('failed');
|
||||||
|
|
@ -385,7 +385,7 @@ class ChatWebSocket {
|
||||||
console.log('Resetting reconnect attempts after timeout');
|
console.log('Resetting reconnect attempts after timeout');
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
if (this.realmId) {
|
if (this.realmId) {
|
||||||
this.connect(this.realmId, this.token);
|
this.attemptReconnect();
|
||||||
}
|
}
|
||||||
}, 60000); // Try again after 1 minute
|
}, 60000); // Try again after 1 minute
|
||||||
return;
|
return;
|
||||||
|
|
@ -400,8 +400,30 @@ class ChatWebSocket {
|
||||||
|
|
||||||
console.log(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
console.log(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(async () => {
|
||||||
if (this.realmId) {
|
if (this.realmId) {
|
||||||
|
// If we have a token, try to refresh it before reconnecting
|
||||||
|
if (this.token) {
|
||||||
|
try {
|
||||||
|
console.log('[ChatWebSocket] Refreshing token before reconnect...');
|
||||||
|
const response = await fetch('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
// Update the token with the fresh one
|
||||||
|
if (data.token) {
|
||||||
|
this.token = data.token;
|
||||||
|
console.log('[ChatWebSocket] Token refreshed successfully');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('[ChatWebSocket] Token refresh failed, trying with old token');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ChatWebSocket] Token refresh error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
this.connect(this.realmId, this.token);
|
this.connect(this.realmId, this.token);
|
||||||
}
|
}
|
||||||
}, delay);
|
}, delay);
|
||||||
|
|
@ -410,13 +432,32 @@ class ChatWebSocket {
|
||||||
/**
|
/**
|
||||||
* Manually trigger reconnection - resets attempt counter and tries immediately
|
* Manually trigger reconnection - resets attempt counter and tries immediately
|
||||||
*/
|
*/
|
||||||
manualReconnect() {
|
async manualReconnect() {
|
||||||
console.log('Manual reconnect triggered');
|
console.log('Manual reconnect triggered');
|
||||||
if (this.reconnectResetTimer) {
|
if (this.reconnectResetTimer) {
|
||||||
clearTimeout(this.reconnectResetTimer);
|
clearTimeout(this.reconnectResetTimer);
|
||||||
this.reconnectResetTimer = null;
|
this.reconnectResetTimer = null;
|
||||||
}
|
}
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
|
|
||||||
|
// Refresh token before reconnecting if we have one
|
||||||
|
if (this.token && this.realmId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.token) {
|
||||||
|
this.token = data.token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Token refresh error during manual reconnect:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.realmId) {
|
if (this.realmId) {
|
||||||
this.connect(this.realmId, this.token);
|
this.connect(this.realmId, this.token);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -347,7 +347,7 @@
|
||||||
{message.username.charAt(0).toUpperCase()}
|
{message.username.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="username-btn" style="color: {safeUserColor}" on:click={handleUsernameClick} on:dblclick={handleUsernameDoubleClick}>
|
<button class="username-btn" class:guest={message.isGuest} style="color: {message.isGuest ? 'lightgrey' : safeUserColor}" on:click={handleUsernameClick} on:dblclick={handleUsernameDoubleClick}>
|
||||||
{message.username}
|
{message.username}
|
||||||
</button>
|
</button>
|
||||||
{#if message.usedRoll}
|
{#if message.usedRoll}
|
||||||
|
|
@ -579,6 +579,12 @@
|
||||||
filter: invert(1);
|
filter: invert(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.username-btn.guest {
|
||||||
|
background-color: #1f184e;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
font-size: 0.55rem;
|
font-size: 0.55rem;
|
||||||
padding: 0.05rem 0.25rem;
|
padding: 0.05rem 0.25rem;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { watchSync, canControl, currentVideo } from '$lib/stores/watchSync';
|
import { watchSync, canControl, currentVideo } from '$lib/stores/watchSync';
|
||||||
import { auth } from '$lib/stores/auth';
|
import { auth } from '$lib/stores/auth';
|
||||||
import { getGuestFingerprint } from '$lib/fingerprint';
|
import { getGuestFingerprint } from '$lib/fingerprint';
|
||||||
|
// watchSync is already imported above for sendLockUpdate
|
||||||
|
|
||||||
export let realmId;
|
export let realmId;
|
||||||
export let playlist = [];
|
export let playlist = [];
|
||||||
|
|
@ -295,6 +296,9 @@
|
||||||
item.id === itemId ? { ...item, isLocked: locked } : item
|
item.id === itemId ? { ...item, isLocked: locked } : item
|
||||||
);
|
);
|
||||||
dispatch('playlistUpdated');
|
dispatch('playlistUpdated');
|
||||||
|
|
||||||
|
// Notify watch sync WebSocket about lock change (for immediate in-memory state update)
|
||||||
|
watchSync.sendLockUpdate(itemId, locked);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to toggle lock:', e);
|
console.error('Failed to toggle lock:', e);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,71 @@ import { writable, derived } from 'svelte/store';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
// Token refresh interval (2 hours - before the 2.5-hour access token expires)
|
||||||
|
const REFRESH_INTERVAL_MS = 2 * 60 * 60 * 1000;
|
||||||
|
let refreshInterval = null;
|
||||||
|
let isRefreshing = false;
|
||||||
|
|
||||||
|
// Silent token refresh function
|
||||||
|
async function refreshAccessToken() {
|
||||||
|
if (isRefreshing) return false;
|
||||||
|
isRefreshing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
// Update user data if returned
|
||||||
|
if (data.user) {
|
||||||
|
auth.updateUser(data.user);
|
||||||
|
}
|
||||||
|
isRefreshing = false;
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
// Refresh failed - session expired
|
||||||
|
console.log('Token refresh failed - session expired');
|
||||||
|
isRefreshing = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token refresh error:', error);
|
||||||
|
isRefreshing = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start automatic token refresh
|
||||||
|
function startTokenRefresh() {
|
||||||
|
if (!browser) return;
|
||||||
|
|
||||||
|
// Clear any existing interval
|
||||||
|
if (refreshInterval) {
|
||||||
|
clearInterval(refreshInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh every 12 minutes
|
||||||
|
refreshInterval = setInterval(async () => {
|
||||||
|
const success = await refreshAccessToken();
|
||||||
|
if (!success) {
|
||||||
|
// Stop refresh interval and logout
|
||||||
|
stopTokenRefresh();
|
||||||
|
auth.logout();
|
||||||
|
}
|
||||||
|
}, REFRESH_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop automatic token refresh
|
||||||
|
function stopTokenRefresh() {
|
||||||
|
if (refreshInterval) {
|
||||||
|
clearInterval(refreshInterval);
|
||||||
|
refreshInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createAuthStore() {
|
function createAuthStore() {
|
||||||
const { subscribe, set, update } = writable({
|
const { subscribe, set, update } = writable({
|
||||||
user: null,
|
user: null,
|
||||||
|
|
@ -23,12 +88,16 @@ function createAuthStore() {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
set({ user: data.user, loading: false });
|
set({ user: data.user, loading: false });
|
||||||
|
// Start token refresh when authenticated
|
||||||
|
startTokenRefresh();
|
||||||
} else {
|
} else {
|
||||||
set({ user: null, loading: false });
|
set({ user: null, loading: false });
|
||||||
|
stopTokenRefresh();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auth init error:', error);
|
console.error('Auth init error:', error);
|
||||||
set({ user: null, loading: false });
|
set({ user: null, loading: false });
|
||||||
|
stopTokenRefresh();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -46,6 +115,8 @@ function createAuthStore() {
|
||||||
// Server sets httpOnly cookie for HTTP requests
|
// Server sets httpOnly cookie for HTTP requests
|
||||||
// Token is NOT stored in localStorage to prevent XSS attacks
|
// Token is NOT stored in localStorage to prevent XSS attacks
|
||||||
set({ user: data.user, loading: false });
|
set({ user: data.user, loading: false });
|
||||||
|
// Start token refresh after successful login
|
||||||
|
startTokenRefresh();
|
||||||
goto('/');
|
goto('/');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
@ -67,6 +138,8 @@ function createAuthStore() {
|
||||||
// Server sets httpOnly cookie for HTTP requests
|
// Server sets httpOnly cookie for HTTP requests
|
||||||
// Token is NOT stored in localStorage to prevent XSS attacks
|
// Token is NOT stored in localStorage to prevent XSS attacks
|
||||||
set({ user: data.user, loading: false });
|
set({ user: data.user, loading: false });
|
||||||
|
// Start token refresh after successful login
|
||||||
|
startTokenRefresh();
|
||||||
goto('/');
|
goto('/');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
@ -132,20 +205,26 @@ function createAuthStore() {
|
||||||
},
|
},
|
||||||
|
|
||||||
async logout() {
|
async logout() {
|
||||||
// Call logout endpoint to clear httpOnly cookie
|
// Stop token refresh interval
|
||||||
|
stopTokenRefresh();
|
||||||
|
|
||||||
|
// Call logout endpoint to clear httpOnly cookies
|
||||||
await fetch('/api/auth/logout', {
|
await fetch('/api/auth/logout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear token from localStorage
|
// Clear token from localStorage (legacy cleanup)
|
||||||
if (browser) {
|
if (browser) {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
}
|
}
|
||||||
|
|
||||||
set({ user: null, loading: false });
|
set({ user: null, loading: false });
|
||||||
goto('/login');
|
goto('/login');
|
||||||
}
|
},
|
||||||
|
|
||||||
|
// Export refresh function for use by other modules (e.g., WebSocket)
|
||||||
|
refreshToken: refreshAccessToken
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,15 @@ function createWatchSyncStore() {
|
||||||
error: data.error || 'Unknown error'
|
error: data.error || 'Unknown error'
|
||||||
}));
|
}));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'lock_changed':
|
||||||
|
// Notify components about lock state change
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new CustomEvent('playlist-lock-changed', {
|
||||||
|
detail: { playlistItemId: data.playlistItemId, locked: data.locked }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -333,6 +342,15 @@ function createWatchSyncStore() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send lock update to server (for immediate in-memory state update)
|
||||||
|
function sendLockUpdate(playlistItemId, locked) {
|
||||||
|
send({
|
||||||
|
type: 'lock_update',
|
||||||
|
playlistItemId: playlistItemId,
|
||||||
|
locked: locked
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Check if local player needs to sync
|
// Check if local player needs to sync
|
||||||
function checkSync(localTime) {
|
function checkSync(localTime) {
|
||||||
let state;
|
let state;
|
||||||
|
|
@ -358,6 +376,7 @@ function createWatchSyncStore() {
|
||||||
requestSync,
|
requestSync,
|
||||||
checkSync,
|
checkSync,
|
||||||
reportDuration,
|
reportDuration,
|
||||||
|
sendLockUpdate,
|
||||||
getExpectedTime: () => {
|
getExpectedTime: () => {
|
||||||
let state;
|
let state;
|
||||||
subscribe(s => { state = s; })();
|
subscribe(s => { state = s; })();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue