feat/fix: RCON wheel+soundboard reload, robust SSO reconnect behind Cloudflare

- RCON: add updatewheel/updatesoundboard (reload WheelManager/SoundboardManager live) so the CMS admin pages apply changes without an emulator restart.

- SSO ticket is no longer single-use: loadHabbo, session-resume and performFullDisconnect no longer clear auth_ticket. Behind Cloudflare the WS is dropped and the client retries with the same ticket; clearing it caused 'non-existing SSO token' and the 'refresh twice' / kicked-on-reconnect symptoms. The ticket now lives until its TTL (auth_ticket_expires_at), is overwritten by the CMS on the next /client load, or cleared on logout.

- SessionResume: restoreSsoTicket only restores when auth_ticket is empty (don't clobber a fresh CMS ticket); GameClient.dispose only parks/disconnects when the habbo is still attached to this client (a fast reconnect may have re-attached it to the new connection).
This commit is contained in:
medievalshell
2026-05-29 04:45:34 +02:00
parent c255f1e1b4
commit 478f7bdba0
7 changed files with 81 additions and 25 deletions
@@ -153,7 +153,13 @@ public class GameClient {
this.channel.close(); this.channel.close();
if (this.habbo != null) { if (this.habbo != null) {
if (this.habbo.isOnline()) { // Agisci sull'Habbo SOLO se è ancora attaccato a QUESTO client. Su un
// reconnect veloce (drop Cloudflare → il client riconnette) l'Habbo può
// essere già stato riassegnato alla NUOVA connessione (session resume):
// in quel caso questo dispose della vecchia connessione NON deve
// parcheggiarlo né disconnetterlo, altrimenti ucciderebbe la sessione
// appena ripristinata (era la causa del "Bye"/kick al 2° reconnect).
if (this.habbo.getClient() == this && this.habbo.isOnline()) {
// Try to park the habbo in the grace period instead of immediate disconnect // Try to park the habbo in the grace period instead of immediate disconnect
boolean parked = SessionResumeManager.getInstance().parkHabbo(this.habbo, this.ssoTicket); boolean parked = SessionResumeManager.getInstance().parkHabbo(this.habbo, this.ssoTicket);
@@ -118,16 +118,32 @@ public class SessionResumeManager {
LOGGER.error("[SessionResume] Error during deferred disconnect", e); LOGGER.error("[SessionResume] Error during deferred disconnect", e);
} }
clearSsoTicket(habbo.getHabboInfo().getId()); // NON svuotare il ticket SSO qui. Dietro Cloudflare la pagina si ricarica
// lentamente (~15s) e la grace (5s) scade prima che la nuova connessione
// arrivi: svuotando il ticket si cancellava quello NUOVO appena scritto dal
// CMS per il refresh → "non-existing SSO token" → bisognava refreshare 2 volte.
// Il ticket vive col suo TTL (auth_ticket_expires_at) e viene sovrascritto dal
// CMS al prossimo /client o azzerato al logout.
} }
private void restoreSsoTicket(int userId, String ssoTicket) { private void restoreSsoTicket(int userId, String ssoTicket) {
// Restore the old ticket ONLY if no fresh ticket has been written in the
// meantime. On a hard-refresh the CMS writes a NEW auth_ticket for the same
// user before this parking restore runs; without the guard we'd clobber it
// with the old ticket, so the new connection's SSO wouldn't be found and the
// client would get "session expired" on the first attempt. The guard means:
// normal reconnect (ticket cleared to '' after login) -> restore; hard-refresh
// (CMS already wrote a new ticket) -> leave the new ticket untouched.
try (var connection = Emulator.getDatabase().getDataSource().getConnection(); try (var connection = Emulator.getDatabase().getDataSource().getConnection();
var statement = connection.prepareStatement("UPDATE users SET auth_ticket = ? WHERE id = ? LIMIT 1")) { var statement = connection.prepareStatement("UPDATE users SET auth_ticket = ? WHERE id = ? AND (auth_ticket = '' OR auth_ticket IS NULL) LIMIT 1")) {
statement.setString(1, ssoTicket); statement.setString(1, ssoTicket);
statement.setInt(2, userId); statement.setInt(2, userId);
statement.execute(); int updated = statement.executeUpdate();
LOGGER.info("[SessionResume] Restored SSO ticket for user {} during grace period", userId); if (updated > 0) {
LOGGER.info("[SessionResume] Restored SSO ticket for user {} during grace period", userId);
} else {
LOGGER.info("[SessionResume] Skipped SSO restore for user {} — a newer ticket is already present (likely a fresh login/hard-refresh)", userId);
}
} catch (Exception e) { } catch (Exception e) {
LOGGER.error("[SessionResume] Failed to restore SSO ticket for user " + userId, e); LOGGER.error("[SessionResume] Failed to restore SSO ticket for user " + userId, e);
} }
@@ -132,15 +132,12 @@ public class HabboManager {
Emulator.getPluginManager().fireEvent(new UserRegisteredEvent(habbo)); Emulator.getPluginManager().fireEvent(new UserRegisteredEvent(habbo));
} }
if (!Emulator.debugging) { // NB: il ticket SSO NON viene svuotato qui di proposito. Dietro
try (PreparedStatement stmt = connection.prepareStatement("UPDATE users SET auth_ticket = ? WHERE id = ? LIMIT 1")) { // Cloudflare il WebSocket viene droppato e il client ritenta più
stmt.setString(1, ""); // volte con lo STESSO ticket: se lo consumassimo al primo uso, i
stmt.setInt(2, habbo.getHabboInfo().getId()); // retry (e l'hard-refresh) fallirebbero con "non-existing SSO token".
stmt.execute(); // Il ticket resta valido fino alla scadenza (auth_ticket_expires_at,
} catch (SQLException e) { // TTL gestito dal CMS) o finché il CMS non ne scrive uno nuovo / logout.
LOGGER.error("Caught SQL exception", e);
}
}
} }
} }
} catch (SQLException e) { } catch (SQLException e) {
@@ -133,17 +133,10 @@ public class SecureLoginEvent extends MessageHandler {
this.client.setHabbo(habbo); this.client.setHabbo(habbo);
this.client.setMachineId(habbo.getHabboInfo().getMachineID()); this.client.setMachineId(habbo.getHabboInfo().getMachineID());
// Clear the SSO ticket now that session is resumed (prevent reuse) // NB: NON svuotiamo il ticket SSO qui (vedi HabboManager.loadHabbo):
if (!Emulator.debugging) { // dietro Cloudflare il client ritenta la connessione con lo stesso
try (java.sql.Connection conn = Emulator.getDatabase().getDataSource().getConnection(); // ticket, quindi deve restare valido fino alla scadenza TTL. Consumarlo
java.sql.PreparedStatement stmt = conn.prepareStatement("UPDATE users SET auth_ticket = ? WHERE id = ? LIMIT 1")) { // farebbe fallire i retry / l'hard-refresh con "non-existing SSO token".
stmt.setString(1, "");
stmt.setInt(2, habbo.getHabboInfo().getId());
stmt.execute();
} catch (Exception e) {
LOGGER.error("Failed to clear SSO ticket after session resume", e);
}
}
} else { } else {
// Normal login — load from database // Normal login — load from database
habbo = Emulator.getGameEnvironment().getHabboManager().loadHabbo(sso); habbo = Emulator.getGameEnvironment().getHabboManager().loadHabbo(sso);
@@ -0,0 +1,21 @@
package com.eu.habbo.messages.rcon;
import com.eu.habbo.Emulator;
import com.google.gson.Gson;
// Ricarica i suoni della Soundboard dal DB (live), così i suoni aggiunti/caricati
// dal CMS (/admin/soundboard) si applicano senza riavviare l'emulatore.
public class UpdateSoundboard extends RCONMessage<UpdateSoundboard.SoundboardJSON> {
public UpdateSoundboard() {
super(SoundboardJSON.class);
}
@Override
public void handle(Gson gson, SoundboardJSON object) {
Emulator.getGameEnvironment().getSoundboardManager().reload();
}
static class SoundboardJSON {
}
}
@@ -0,0 +1,21 @@
package com.eu.habbo.messages.rcon;
import com.eu.habbo.Emulator;
import com.google.gson.Gson;
// Ricarica i premi/settings della Ruota della Fortuna dal DB (live), così le
// modifiche fatte dal CMS (/admin/wheel) si applicano senza riavviare l'emulatore.
public class UpdateWheel extends RCONMessage<UpdateWheel.WheelJSON> {
public UpdateWheel() {
super(WheelJSON.class);
}
@Override
public void handle(Gson gson, WheelJSON object) {
Emulator.getGameEnvironment().getWheelManager().reload();
}
static class WheelJSON {
}
}
@@ -45,6 +45,8 @@ public class RCONServer extends Server {
this.addRCONMessage("sendroombundle", SendRoomBundle.class); this.addRCONMessage("sendroombundle", SendRoomBundle.class);
this.addRCONMessage("setrank", SetRank.class); this.addRCONMessage("setrank", SetRank.class);
this.addRCONMessage("updatewordfilter", UpdateWordfilter.class); this.addRCONMessage("updatewordfilter", UpdateWordfilter.class);
this.addRCONMessage("updatewheel", UpdateWheel.class);
this.addRCONMessage("updatesoundboard", UpdateSoundboard.class);
this.addRCONMessage("updatecatalog", UpdateCatalog.class); this.addRCONMessage("updatecatalog", UpdateCatalog.class);
this.addRCONMessage("executecommand", ExecuteCommand.class); this.addRCONMessage("executecommand", ExecuteCommand.class);
this.addRCONMessage("progressachievement", ProgressAchievement.class); this.addRCONMessage("progressachievement", ProgressAchievement.class);