diff --git a/Database Updates/011_HotelLogin.sql b/Database Updates/011_HotelLogin.sql
index 11210bc8..666a9b5f 100644
--- a/Database Updates/011_HotelLogin.sql
+++ b/Database Updates/011_HotelLogin.sql
@@ -36,3 +36,100 @@ INSERT INTO `emulator_settings` (`key`, `value`) VALUES
('smtp.use_tls', '1'),
('smtp.use_ssl', '0')
ON DUPLICATE KEY UPDATE `value` = VALUES(`value`);
+
+INSERT INTO emulator_settings (`key`, `value`, `comment`) VALUES
+ ('new_user_credits', '0' , 'This is the default setting for habbo credits when creating an account for the NitroV3 Login'),
+ ('new_user_duckets', '0' , 'This is the default setting for habbo duckets when creating an account for the NitroV3 Login'),
+ ('new_user_diamonds', '0' , 'This is the default setting for habbo diamonds when creating an account for the NitroV3 Login')
+ON DUPLICATE KEY UPDATE `value` = VALUES(`value`);
+
+-- Grant to rank 7 only (adjust rank_7 if your rank id differs)
+INSERT INTO `permission_definitions` (`permission_key`, `rank_7`, `comment`) VALUES
+ ('cmd_setroom_template', '1', 'Use the setroom_template to copy the room into the template')
+ON DUPLICATE KEY UPDATE `rank_7` = VALUES(`rank_7`);
+
+INSERT INTO `emulator_texts` (`key`, `value`) VALUES
+ ('commands.keys.cmd_setroom_template', 'setroom_template;set_room_template'),
+ ('commands.succes.cmd_setroom_template.verify', 'Copy the current room "%roomname%" to room_templates? Type :setroom_template %generic.yes% to confirm.'),
+ ('commands.succes.cmd_setroom_template', 'Room saved as template id %id% with %items% items (%skipped% skipped - item_id not in items_base).'),
+ ('commands.error.cmd_setroom_template', 'Could not save room as template. Check the server log for details.'),
+ ('commands.error.cmd_setroom_template.no_room', 'You must be inside a room to use this command.')
+ON DUPLICATE KEY UPDATE `value` = VALUES(`value`);
+
+CREATE TABLE IF NOT EXISTS `room_templates` (
+ `template_id` int(11) NOT NULL AUTO_INCREMENT,
+ `title` varchar(128) NOT NULL DEFAULT '',
+ `description` varchar(256) NOT NULL DEFAULT '',
+ `thumbnail` varchar(512) NOT NULL DEFAULT '',
+ `sort_order` int(11) NOT NULL DEFAULT 0,
+ `enabled` enum('0','1') NOT NULL DEFAULT '1',
+ `name` varchar(50) NOT NULL DEFAULT '',
+ `room_description` varchar(250) NOT NULL DEFAULT '',
+ `model` varchar(100) NOT NULL,
+ `password` varchar(50) NOT NULL DEFAULT '',
+ `state` enum('open','locked','password','invisible') NOT NULL DEFAULT 'open',
+ `users_max` int(11) NOT NULL DEFAULT 25,
+ `category` int(11) NOT NULL DEFAULT 0,
+ `paper_floor` varchar(50) NOT NULL DEFAULT '0.0',
+ `paper_wall` varchar(50) NOT NULL DEFAULT '0.0',
+ `paper_landscape` varchar(50) NOT NULL DEFAULT '0.0',
+ `thickness_wall` int(11) NOT NULL DEFAULT 0,
+ `thickness_floor` int(11) NOT NULL DEFAULT 0,
+ `moodlight_data` varchar(2048) NOT NULL DEFAULT '',
+ `override_model` enum('0','1') NOT NULL DEFAULT '0',
+ `trade_mode` int(2) NOT NULL DEFAULT 2,
+ `heightmap` mediumtext NOT NULL DEFAULT '',
+ `door_x` int(11) NOT NULL DEFAULT 0,
+ `door_y` int(11) NOT NULL DEFAULT 0,
+ `door_dir` int(4) NOT NULL DEFAULT 2,
+ PRIMARY KEY (`template_id`),
+ KEY `enabled_sort` (`enabled`, `sort_order`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+-- Items belonging to a template. Clone target is `items`.
+-- `template_id` replaces `room_id`; `user_id` is absent because items
+-- are re-owned by the new user at clone time.
+-- --------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `room_templates_items` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `template_id` int(11) NOT NULL,
+ `item_id` int(11) unsigned NOT NULL,
+ `wall_pos` varchar(20) NOT NULL DEFAULT '',
+ `x` int(11) NOT NULL DEFAULT 0,
+ `y` int(11) NOT NULL DEFAULT 0,
+ `z` double(10,6) NOT NULL DEFAULT 0.000000,
+ `rot` int(11) NOT NULL DEFAULT 0,
+ `extra_data` varchar(2096) NOT NULL DEFAULT '',
+ `wired_data` varchar(4096) DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `template_id` (`template_id`),
+ CONSTRAINT `fk_rt_items_template`
+ FOREIGN KEY (`template_id`) REFERENCES `room_templates` (`template_id`) ON DELETE CASCADE,
+ CONSTRAINT `fk_rt_items_item_base`
+ FOREIGN KEY (`item_id`) REFERENCES `items_base` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+CREATE TABLE IF NOT EXISTS `users_remember_families` (
+ `family_id` char(36) NOT NULL,
+ `user_id` int(11) NOT NULL,
+ `current_version` int(11) NOT NULL DEFAULT 1,
+ `created_at` int(11) NOT NULL,
+ `expires_at` int(11) NOT NULL,
+ `revoked` tinyint(1) NOT NULL DEFAULT 0,
+ `last_ip` varchar(45) NOT NULL DEFAULT '',
+ PRIMARY KEY (`family_id`),
+ KEY `user_id` (`user_id`),
+ KEY `expires_at` (`expires_at`),
+ CONSTRAINT `fk_remember_family_user`
+ FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+DROP TABLE IF EXISTS `users_remember_tokens`;
+
+INSERT INTO `emulator_settings` (`key`, `value`) VALUES
+ ('login.remember.duration.days', '30'),
+ ('login.remember.rotate.interval.minutes', '15'),
+ ('login.remember.jwt.secret', '')
+ON DUPLICATE KEY UPDATE `value` = `value`;
+
diff --git a/Database Updates/012_crypto.sql b/Database Updates/012_crypto.sql
new file mode 100644
index 00000000..334c1fd6
--- /dev/null
+++ b/Database Updates/012_crypto.sql
@@ -0,0 +1,8 @@
+INSERT INTO `emulator_settings` (`key`, `value`) VALUES
+ ('crypto.ws.enabled', '0'),
+ ('crypto.ws.signing.enabled', '0'),
+ ('crypto.ws.signing.public_key', ''),
+ ('crypto.ws.signing.private_key', '')
+ON DUPLICATE KEY UPDATE `value` = `value`;
+
+
diff --git a/Emulator/pom.xml b/Emulator/pom.xml
index ac1ffcb3..01b2888d 100644
--- a/Emulator/pom.xml
+++ b/Emulator/pom.xml
@@ -6,7 +6,7 @@
com.eu.habbo
Habbo
- 4.1.5
+ 4.1.7
UTF-8
@@ -163,7 +163,7 @@
2.13.0
-
org.mindrot
@@ -171,7 +171,7 @@
0.4
-
org.eclipse.angus
diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/CommandHandler.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/CommandHandler.java
index 3f26738b..f85d83f2 100644
--- a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/CommandHandler.java
+++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/CommandHandler.java
@@ -190,7 +190,7 @@ public class CommandHandler {
addCommand(new ControlCommand());
addCommand(new CoordsCommand());
addCommand(new CreditsCommand());
- addCommand(new DanceCommand());
+ addCommand(new DanceCommand());
addCommand(new DiagonalCommand());
addCommand(new DisconnectCommand());
addCommand(new EjectAllCommand());
@@ -230,7 +230,7 @@ public class CommandHandler {
addCommand(new MutePetsCommand());
addCommand(new PetInfoCommand());
addCommand(new PickallCommand());
- addCommand(new PingCommand());
+ addCommand(new PingCommand());
addCommand(new PixelCommand());
addCommand(new PluginsCommand());
addCommand(new PointsCommand());
@@ -253,6 +253,7 @@ public class CommandHandler {
addCommand(new SayCommand());
addCommand(new SetMaxCommand());
addCommand(new SetPollCommand());
+ addCommand(new SetRoomTemplateCommand());
addCommand(new SetSpeedCommand());
addCommand(new ShoutAllCommand());
addCommand(new ShoutCommand());
diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/SetRoomTemplateCommand.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/SetRoomTemplateCommand.java
new file mode 100644
index 00000000..f35dd443
--- /dev/null
+++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/SetRoomTemplateCommand.java
@@ -0,0 +1,116 @@
+package com.eu.habbo.habbohotel.commands;
+
+import com.eu.habbo.Emulator;
+import com.eu.habbo.habbohotel.gameclients.GameClient;
+import com.eu.habbo.habbohotel.rooms.Room;
+import com.eu.habbo.habbohotel.rooms.RoomChatMessageBubbles;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.sql.*;
+
+public class SetRoomTemplateCommand extends Command {
+ private static final Logger LOGGER = LoggerFactory.getLogger(SetRoomTemplateCommand.class);
+
+ public SetRoomTemplateCommand() {
+ super("cmd_setroom_template", Emulator.getTexts().getValue("commands.keys.cmd_setroom_template").split(";"));
+ }
+
+ @Override
+ public boolean handle(GameClient gameClient, String[] params) throws Exception {
+ Room room = gameClient.getHabbo().getHabboInfo().getCurrentRoom();
+ if (room == null) {
+ gameClient.getHabbo().whisper(
+ Emulator.getTexts().getValue("commands.error.cmd_setroom_template.no_room"),
+ RoomChatMessageBubbles.ALERT);
+ return true;
+ }
+
+ String yes = Emulator.getTexts().getValue("generic.yes");
+
+ if (params.length < 2 || !params[1].equalsIgnoreCase(yes)) {
+ gameClient.getHabbo().alert(
+ Emulator.getTexts().getValue("commands.succes.cmd_setroom_template.verify")
+ .replace("%generic.yes%", yes)
+ .replace("%roomname%", room.getName()));
+ return true;
+ }
+
+ int newTemplateId = 0;
+ int itemsCopied = 0;
+ int itemsSkipped = 0;
+
+ try (Connection connection = Emulator.getDatabase().getDataSource().getConnection()) {
+ try (PreparedStatement insTemplate = connection.prepareStatement(
+ "INSERT INTO room_templates (title, description, thumbnail, sort_order, enabled, " +
+ "name, room_description, model, password, state, users_max, category, " +
+ "paper_floor, paper_wall, paper_landscape, thickness_wall, thickness_floor, " +
+ "moodlight_data, override_model, trade_mode) " +
+ "(SELECT name, description, '', 0, '1', " +
+ "name, description, model, password, state, users_max, category, " +
+ "paper_floor, paper_wall, paper_landscape, thickness_wall, thickness_floor, " +
+ "moodlight_data, override_model, trade_mode " +
+ "FROM rooms WHERE id = ?)",
+ Statement.RETURN_GENERATED_KEYS)) {
+ insTemplate.setInt(1, room.getId());
+ insTemplate.executeUpdate();
+ try (ResultSet keys = insTemplate.getGeneratedKeys()) {
+ if (keys.next()) newTemplateId = keys.getInt(1);
+ }
+ }
+
+ if (newTemplateId <= 0) {
+ gameClient.getHabbo().whisper(
+ Emulator.getTexts().getValue("commands.error.cmd_setroom_template"),
+ RoomChatMessageBubbles.ALERT);
+ return true;
+ }
+
+ if (room.hasCustomLayout()) {
+ try (PreparedStatement updLayout = connection.prepareStatement(
+ "UPDATE room_templates t " +
+ "JOIN room_models_custom c ON c.id = ? " +
+ "SET t.heightmap = c.heightmap, t.door_x = c.door_x, " +
+ " t.door_y = c.door_y, t.door_dir = c.door_dir " +
+ "WHERE t.template_id = ?")) {
+ updLayout.setInt(1, room.getId());
+ updLayout.setInt(2, newTemplateId);
+ updLayout.executeUpdate();
+ }
+ }
+
+ try (PreparedStatement insItems = connection.prepareStatement(
+ "INSERT INTO room_templates_items (template_id, item_id, wall_pos, x, y, z, rot, extra_data, wired_data) " +
+ "SELECT ?, i.item_id, i.wall_pos, i.x, i.y, i.z, i.rot, i.extra_data, i.wired_data " +
+ "FROM items i JOIN items_base ib ON ib.id = i.item_id " +
+ "WHERE i.room_id = ?")) {
+ insItems.setInt(1, newTemplateId);
+ insItems.setInt(2, room.getId());
+ itemsCopied = insItems.executeUpdate();
+ }
+
+ try (PreparedStatement countTotal = connection.prepareStatement(
+ "SELECT COUNT(*) FROM items WHERE room_id = ?")) {
+ countTotal.setInt(1, room.getId());
+ try (ResultSet rs = countTotal.executeQuery()) {
+ if (rs.next()) itemsSkipped = Math.max(0, rs.getInt(1) - itemsCopied);
+ }
+ }
+ } catch (SQLException e) {
+ LOGGER.error("cmd_setroom_template failed for roomId=" + room.getId(), e);
+ gameClient.getHabbo().whisper(
+ Emulator.getTexts().getValue("commands.error.cmd_setroom_template"),
+ RoomChatMessageBubbles.ALERT);
+ return true;
+ }
+
+ gameClient.getHabbo().whisper(
+ Emulator.getTexts().getValue("commands.succes.cmd_setroom_template")
+ .replace("%id%", Integer.toString(newTemplateId))
+ .replace("%items%", Integer.toString(itemsCopied))
+ .replace("%skipped%", Integer.toString(itemsSkipped)),
+ RoomChatMessageBubbles.ALERT);
+
+ return true;
+ }
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/GameServer.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/GameServer.java
index eebc86b5..b3f81c91 100644
--- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/GameServer.java
+++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/GameServer.java
@@ -96,6 +96,16 @@ public class GameServer extends Server {
LOGGER.error("Failed to start WebSocket server on {}:{}", wsHost, wsPort);
} else {
LOGGER.info("WebSocket server started on {}:{} (SSL: {})", wsHost, wsPort, wsInitializer.isSslEnabled());
+
+ if (com.eu.habbo.Emulator.getConfig().getBoolean("crypto.ws.signing.enabled", false)) {
+ try {
+ com.eu.habbo.networking.gameserver.crypto.CryptoSigningKeyManager.get();
+ LOGGER.info("[ws-crypto] signing public key ready: {}",
+ com.eu.habbo.networking.gameserver.crypto.CryptoSigningKeyManager.publicKeyBase64());
+ } catch (Exception e) {
+ LOGGER.error("[ws-crypto] failed to warm signing keypair", e);
+ }
+ }
}
}
diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/GameServerAttributes.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/GameServerAttributes.java
index 5de9d67f..a8ec86b8 100644
--- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/GameServerAttributes.java
+++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/GameServerAttributes.java
@@ -10,4 +10,6 @@ public class GameServerAttributes {
public static final AttributeKey CRYPTO_CLIENT = AttributeKey.valueOf("CryptoClient");
public static final AttributeKey CRYPTO_SERVER = AttributeKey.valueOf("CryptoServer");
public static final AttributeKey WS_IP = AttributeKey.valueOf("WebSocketIP");
+ public static final AttributeKey WS_AES_KEY = AttributeKey.valueOf("WsAesKey");
}
+
diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java
index 64c7b291..045b8cbd 100644
--- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java
+++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java
@@ -1,10 +1,12 @@
package com.eu.habbo.networking.gameserver;
+import com.eu.habbo.Emulator;
import com.eu.habbo.messages.PacketManager;
import com.eu.habbo.networking.gameserver.auth.AuthHttpHandler;
import com.eu.habbo.networking.gameserver.auth.NitroSecureApiHandler;
import com.eu.habbo.networking.gameserver.auth.NitroSecureAssetHandler;
import com.eu.habbo.networking.gameserver.codec.WebSocketCodec;
+import com.eu.habbo.networking.gameserver.crypto.WsHandshakeHandler;
import com.eu.habbo.networking.gameserver.decoders.*;
import com.eu.habbo.networking.gameserver.encoders.GameServerMessageEncoder;
import com.eu.habbo.networking.gameserver.encoders.GameServerMessageLogger;
@@ -57,6 +59,11 @@ public class WebSocketChannelInitializer extends ChannelInitializer 0) {
+ try (PreparedStatement clear = conn.prepareStatement(
+ "UPDATE users SET auth_ticket = '', online = '0' WHERE id = ? LIMIT 1")) {
+ clear.setInt(1, userId);
+ clear.executeUpdate();
+ }
+
+ if (Emulator.getGameServer() != null
+ && Emulator.getGameServer().getGameClientManager() != null) {
+ com.eu.habbo.habbohotel.users.Habbo habbo =
+ Emulator.getGameServer().getGameClientManager().getHabbo(userId);
+ if (habbo != null && habbo.getClient() != null) {
+ Emulator.getGameServer().getGameClientManager().disposeClient(habbo.getClient());
+ }
+ }
+ }
}
if (!rememberToken.isEmpty()) {
- String rememberHash = sha256Hex(rememberToken);
- if (userId == 0) {
- try (PreparedStatement lookupRemember = conn.prepareStatement(
- "SELECT id FROM users WHERE remember_token_hash = ? LIMIT 1")) {
- lookupRemember.setString(1, rememberHash);
- try (ResultSet rs = lookupRemember.executeQuery()) {
- if (rs.next()) userId = rs.getInt("id");
- }
- }
- } else {
- clearRememberToken(conn, rememberHash);
- }
- }
-
- if (userId > 0) {
- try (PreparedStatement clear = conn.prepareStatement(
- "UPDATE users SET auth_ticket = '', online = '0', remember_token_hash = '', remember_token_expires_at = 0 WHERE id = ? LIMIT 1")) {
- clear.setInt(1, userId);
- clear.executeUpdate();
- }
-
- if (Emulator.getGameServer() != null
- && Emulator.getGameServer().getGameClientManager() != null) {
- com.eu.habbo.habbohotel.users.Habbo habbo =
- Emulator.getGameServer().getGameClientManager().getHabbo(userId);
- if (habbo != null && habbo.getClient() != null) {
- Emulator.getGameServer().getGameClientManager().disposeClient(habbo.getClient());
- }
- }
+ RememberJwtService.revokeFromToken(conn, rememberToken);
}
} catch (Exception e) {
- LOGGER.error("Logout cleanup failed for ticket", e);
+ LOGGER.error("Logout cleanup failed", e);
}
sendJson(ctx, req, HttpResponseStatus.OK, ok);
}
- /* โโโ Login โโโ */
+ private void handleRemember(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
+ String jwt = readString(body, "rememberToken").trim();
+ if (jwt.isEmpty()) {
+ sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Missing rememberToken."));
+ return;
+ }
+
+ try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) {
+ RememberJwtService.RotationResult rot = RememberJwtService.rotate(conn, jwt, ip);
+ if (rot == null) {
+ sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Remember token invalid or expired."));
+ return;
+ }
+
+ String ssoTicket = mintSsoTicket();
+ try (PreparedStatement upd = conn.prepareStatement(
+ "UPDATE users SET auth_ticket = ?, ip_current = ? WHERE id = ? LIMIT 1")) {
+ upd.setString(1, ssoTicket);
+ upd.setString(2, ip == null ? "" : ip);
+ upd.setInt(3, rot.userId);
+ upd.executeUpdate();
+ }
+
+ JsonObject ok = new JsonObject();
+ ok.addProperty("ssoTicket", ssoTicket);
+ ok.addProperty("username", rot.username);
+ ok.addProperty("rememberToken", rot.jwt);
+ ok.addProperty("expiresAt", rot.expiresAt);
+ ok.addProperty("rememberExpiresAt", rot.expiresAt);
+ sendJson(ctx, req, HttpResponseStatus.OK, ok);
+ } catch (Exception e) {
+ LOGGER.error("Remember login failed", e);
+ sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
+ }
+ }
+
+ private void handleRefresh(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
+ String jwt = readString(body, "rememberToken").trim();
+ if (jwt.isEmpty()) {
+ sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Missing rememberToken."));
+ return;
+ }
+
+ try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) {
+ RememberJwtService.RotationResult rot = RememberJwtService.rotate(conn, jwt, ip);
+ if (rot == null) {
+ sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Remember token invalid or expired."));
+ return;
+ }
+ JsonObject ok = new JsonObject();
+ ok.addProperty("rememberToken", rot.jwt);
+ ok.addProperty("expiresAt", rot.expiresAt);
+ ok.addProperty("rememberExpiresAt", rot.expiresAt);
+ sendJson(ctx, req, HttpResponseStatus.OK, ok);
+ } catch (Exception e) {
+ LOGGER.error("Refresh failed", e);
+ sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
+ }
+ }
private void handleLogin(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
String username = readString(body, "username").trim();
String password = readString(body, "password");
- boolean remember = readBoolean(body, "remember") || readBoolean(body, "rememberMe");
+ boolean rememberMe = readBoolean(body, "remember", false) || readBoolean(body, "rememberMe", false);
if (username.isEmpty() || password.isEmpty()) {
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Missing credentials."));
@@ -328,33 +395,32 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
}
String ssoTicket = mintSsoTicket();
- String rememberToken = "";
- int rememberExpiresAt = 0;
-
- if (remember && rememberEnabled()) {
- rememberToken = mintRememberToken();
- rememberExpiresAt = rememberExpiresAt();
- }
try (PreparedStatement upd = conn.prepareStatement(
- "UPDATE users SET auth_ticket = ?, ip_current = ?, remember_token_hash = ?, remember_token_expires_at = ? WHERE id = ? LIMIT 1")) {
+ "UPDATE users SET auth_ticket = ?, ip_current = ? WHERE id = ? LIMIT 1")) {
upd.setString(1, ssoTicket);
upd.setString(2, ip == null ? "" : ip);
- upd.setString(3, rememberToken.isEmpty() ? "" : sha256Hex(rememberToken));
- upd.setInt(4, rememberExpiresAt);
- upd.setInt(5, userId);
+ upd.setInt(3, userId);
upd.executeUpdate();
}
+ String rememberToken = null;
+ if (rememberMe) {
+ try {
+ RememberJwtService.RotationResult issued = RememberJwtService.issueForNewFamily(
+ conn, userId, rs.getString("username"), ip);
+ rememberToken = issued.jwt;
+ } catch (SQLException e) {
+ LOGGER.error("Failed to issue remember-me JWT for userId=" + userId, e);
+ }
+ }
+
AuthRateLimiter.recordSuccess(ip);
JsonObject ok = new JsonObject();
ok.addProperty("ssoTicket", ssoTicket);
ok.addProperty("username", rs.getString("username"));
- if (!rememberToken.isEmpty()) {
- ok.addProperty("rememberToken", rememberToken);
- ok.addProperty("rememberExpiresAt", rememberExpiresAt);
- }
+ if (rememberToken != null) ok.addProperty("rememberToken", rememberToken);
sendJson(ctx, req, HttpResponseStatus.OK, ok);
}
} catch (Exception e) {
@@ -363,71 +429,6 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
}
}
-/* รขโโฌรขโโฌรขโโฌ Remember login รขโโฌรขโโฌรขโโฌ */
-
- private void handleRemember(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
- if (!rememberEnabled()) {
- sendJson(ctx, req, HttpResponseStatus.FORBIDDEN, errorPayload("Remember login is disabled."));
- return;
- }
-
- String rememberToken = readString(body, "rememberToken").trim();
-
- if (rememberToken.isEmpty()) {
- sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Missing remember token."));
- return;
- }
-
- int now = Emulator.getIntUnixTimestamp();
- String rememberHash = sha256Hex(rememberToken);
-
- try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
- PreparedStatement stmt = conn.prepareStatement(
- "SELECT id, username FROM users WHERE remember_token_hash = ? AND remember_token_expires_at > ? LIMIT 1")) {
- stmt.setString(1, rememberHash);
- stmt.setInt(2, now);
-
- try (ResultSet rs = stmt.executeQuery()) {
- if (!rs.next()) {
- clearRememberToken(conn, rememberHash);
- AuthRateLimiter.recordFailure(ip);
- sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Remember login expired."));
- return;
- }
-
- int userId = rs.getInt("id");
- String username = rs.getString("username");
- String ssoTicket = mintSsoTicket();
- String nextRememberToken = mintRememberToken();
- int rememberExpiresAt = rememberExpiresAt();
-
- try (PreparedStatement upd = conn.prepareStatement(
- "UPDATE users SET auth_ticket = ?, ip_current = ?, remember_token_hash = ?, remember_token_expires_at = ? WHERE id = ? LIMIT 1")) {
- upd.setString(1, ssoTicket);
- upd.setString(2, ip == null ? "" : ip);
- upd.setString(3, sha256Hex(nextRememberToken));
- upd.setInt(4, rememberExpiresAt);
- upd.setInt(5, userId);
- upd.executeUpdate();
- }
-
- AuthRateLimiter.recordSuccess(ip);
-
- JsonObject ok = new JsonObject();
- ok.addProperty("ssoTicket", ssoTicket);
- ok.addProperty("username", username);
- ok.addProperty("rememberToken", nextRememberToken);
- ok.addProperty("rememberExpiresAt", rememberExpiresAt);
- sendJson(ctx, req, HttpResponseStatus.OK, ok);
- }
- } catch (Exception e) {
- LOGGER.error("Remember-login failed", e);
- sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
- }
- }
-
- /* โโโ Register โโโ */
-
private void handleRegister(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
if (!Emulator.getConfig().getBoolean("login.register.enabled", true)) {
sendJson(ctx, req, HttpResponseStatus.FORBIDDEN, errorPayload("Registration is closed."));
@@ -437,6 +438,9 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
String username = readString(body, "username").trim();
String email = readString(body, "email").trim();
String password = readString(body, "password");
+ String figure = readString(body, "figure").trim();
+ String gender = readString(body, "gender").trim().toUpperCase();
+ int templateId = readInt(body, "templateId", 0);
if (!USERNAME_RE.matcher(username).matches()) {
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
@@ -496,11 +500,19 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
String defaultMotto = Emulator.getConfig().getValue("register.default.motto", "I love Habbo!");
int now = Emulator.getIntUnixTimestamp();
+ String finalLook = (figure.isEmpty() || !FIGURE_RE.matcher(figure).matches()) ? defaultLook : figure;
+ String finalGender = (gender.equals("M") || gender.equals("F")) ? gender : "M";
+
+ int startingCredits = Math.max(0, Emulator.getConfig().getInt("new_user_credits", 0));
+ int startingDuckets = Math.max(0, Emulator.getConfig().getInt("new_user_duckets", 0));
+ int startingDiamonds = Math.max(0, Emulator.getConfig().getInt("new_user_diamonds", 0));
+
+ int newUserId = 0;
try (PreparedStatement ins = conn.prepareStatement(
"INSERT INTO users (username, password, mail, account_created, " +
"ip_register, ip_current, last_online, last_login, motto, look, gender, " +
"credits, `rank`, home_room, machine_id, auth_ticket, online) " +
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'M', 0, 1, 0, '', '', '0')",
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, '', '', '0')",
Statement.RETURN_GENERATED_KEYS)) {
ins.setString(1, username);
ins.setString(2, hashed);
@@ -511,8 +523,26 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
ins.setInt(7, now);
ins.setInt(8, now);
ins.setString(9, defaultMotto);
- ins.setString(10, defaultLook);
+ ins.setString(10, finalLook);
+ ins.setString(11, finalGender);
+ ins.setInt(12, startingCredits);
ins.executeUpdate();
+ try (ResultSet keys = ins.getGeneratedKeys()) {
+ if (keys.next()) newUserId = keys.getInt(1);
+ }
+ }
+
+ if (newUserId > 0 && (startingDuckets > 0 || startingDiamonds > 0)) {
+ seedUserCurrencies(conn, newUserId, startingDuckets, startingDiamonds);
+ }
+
+ LOGGER.info("[auth/register] user created id={} username='{}' templateId={} credits={} duckets={} diamonds={}",
+ newUserId, username, templateId, startingCredits, startingDuckets, startingDiamonds);
+
+ if (newUserId > 0 && templateId > 0) {
+ cloneTemplateForUser(conn, templateId, newUserId, username);
+ } else if (templateId > 0) {
+ LOGGER.warn("[auth/register] skipping template clone: user insert did not return an id (username='{}')", username);
}
AvailabilityCache.invalidateEmail(email);
@@ -527,7 +557,201 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
}
}
- /* โโโ Forgot password โโโ */
+ private static void materializeCustomLayout(Connection conn, int templateId, int newRoomId) {
+ String overrideModel = "0";
+ String heightmap = "";
+ int doorX = 0, doorY = 0, doorDir = 2;
+ try (PreparedStatement sel = conn.prepareStatement(
+ "SELECT override_model, heightmap, door_x, door_y, door_dir " +
+ "FROM room_templates WHERE template_id = ? LIMIT 1")) {
+ sel.setInt(1, templateId);
+ try (ResultSet rs = sel.executeQuery()) {
+ if (rs.next()) {
+ overrideModel = rs.getString("override_model");
+ heightmap = rs.getString("heightmap");
+ doorX = rs.getInt("door_x");
+ doorY = rs.getInt("door_y");
+ doorDir = rs.getInt("door_dir");
+ }
+ }
+ } catch (SQLException e) {
+ LOGGER.error("[auth/register] reading template layout failed templateId=" + templateId, e);
+ return;
+ }
+
+ if (!"1".equals(overrideModel) || heightmap == null || heightmap.isEmpty()) {
+ return;
+ }
+
+ String customName = "custom_" + newRoomId;
+
+ try (PreparedStatement ins = conn.prepareStatement(
+ "INSERT INTO room_models_custom (id, name, door_x, door_y, door_dir, heightmap) " +
+ "VALUES (?, ?, ?, ?, ?, ?) " +
+ "ON DUPLICATE KEY UPDATE name = VALUES(name), door_x = VALUES(door_x), " +
+ "door_y = VALUES(door_y), door_dir = VALUES(door_dir), heightmap = VALUES(heightmap)")) {
+ ins.setInt(1, newRoomId);
+ ins.setString(2, customName);
+ ins.setInt(3, doorX);
+ ins.setInt(4, doorY);
+ ins.setInt(5, doorDir);
+ ins.setString(6, heightmap);
+ ins.executeUpdate();
+ } catch (SQLException e) {
+ LOGGER.error("[auth/register] room_models_custom insert failed roomId=" + newRoomId, e);
+ return;
+ }
+
+ try (PreparedStatement upd = conn.prepareStatement(
+ "UPDATE rooms SET model = ? WHERE id = ? LIMIT 1")) {
+ upd.setString(1, customName);
+ upd.setInt(2, newRoomId);
+ upd.executeUpdate();
+ } catch (SQLException e) {
+ LOGGER.error("[auth/register] rooms.model rename failed roomId=" + newRoomId, e);
+ }
+
+ LOGGER.info("[auth/register] materialized custom layout '{}' for roomId={}", customName, newRoomId);
+ }
+
+ private static void seedUserCurrencies(Connection conn, int userId, int duckets, int diamonds) {
+ try (PreparedStatement ins = conn.prepareStatement(
+ "INSERT INTO users_currency (user_id, type, amount) VALUES (?, ?, ?) " +
+ "ON DUPLICATE KEY UPDATE amount = VALUES(amount)")) {
+ if (duckets > 0) {
+ ins.setInt(1, userId);
+ ins.setInt(2, 0);
+ ins.setInt(3, duckets);
+ ins.addBatch();
+ }
+ if (diamonds > 0) {
+ ins.setInt(1, userId);
+ ins.setInt(2, 5);
+ ins.setInt(3, diamonds);
+ ins.addBatch();
+ }
+ ins.executeBatch();
+ } catch (SQLException e) {
+ LOGGER.error("[auth/register] seeding users_currency failed userId=" + userId
+ + " duckets=" + duckets + " diamonds=" + diamonds, e);
+ }
+ }
+
+ private void handleRoomTemplates(ChannelHandlerContext ctx, FullHttpRequest req) {
+ JsonArray templates = new JsonArray();
+ try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
+ PreparedStatement stmt = conn.prepareStatement(
+ "SELECT template_id, title, description, thumbnail " +
+ "FROM room_templates WHERE enabled = '1' " +
+ "ORDER BY sort_order ASC, template_id ASC")) {
+ try (ResultSet rs = stmt.executeQuery()) {
+ while (rs.next()) {
+ JsonObject t = new JsonObject();
+ t.addProperty("templateId", rs.getInt("template_id"));
+ t.addProperty("title", rs.getString("title"));
+ t.addProperty("description", rs.getString("description"));
+ t.addProperty("thumbnail", rs.getString("thumbnail"));
+ templates.add(t);
+ }
+ }
+ } catch (Exception e) {
+ LOGGER.error("room-templates list failed", e);
+ sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
+ return;
+ }
+ JsonObject res = new JsonObject();
+ res.add("templates", templates);
+ sendJson(ctx, req, HttpResponseStatus.OK, res);
+ }
+
+ private void handleServerKey(ChannelHandlerContext ctx, FullHttpRequest req) {
+ try {
+ JsonObject ok = new JsonObject();
+ ok.addProperty("publicKey", com.eu.habbo.networking.gameserver.crypto.CryptoSigningKeyManager.publicKeyBase64());
+ ok.addProperty("algorithm", "ECDSA-P256-SHA256");
+ sendJson(ctx, req, HttpResponseStatus.OK, ok);
+ } catch (Exception e) {
+ LOGGER.error("server-key fetch failed", e);
+ sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
+ }
+ }
+
+ private static void cloneTemplateForUser(Connection conn, int templateId, int userId, String userName) {
+ LOGGER.info("[auth/register] cloning template id={} for user id={} name='{}'", templateId, userId, userName);
+
+ try (PreparedStatement check = conn.prepareStatement(
+ "SELECT 1 FROM room_templates WHERE template_id = ? AND enabled = '1' LIMIT 1")) {
+ check.setInt(1, templateId);
+ try (ResultSet rs = check.executeQuery()) {
+ if (!rs.next()) {
+ LOGGER.warn("[auth/register] unknown/disabled room template id={} for user id={}", templateId, userId);
+ return;
+ }
+ }
+ } catch (SQLException e) {
+ LOGGER.error("[auth/register] template lookup failed for templateId=" + templateId, e);
+ return;
+ }
+
+ int newRoomId = 0;
+ int roomsInserted = 0;
+ try (PreparedStatement ins = conn.prepareStatement(
+ "INSERT INTO rooms (owner_id, owner_name, name, description, model, password, state, " +
+ "users_max, category, paper_floor, paper_wall, paper_landscape, thickness_wall, " +
+ "thickness_floor, moodlight_data, override_model, trade_mode) " +
+ "(SELECT ?, ?, name, room_description, model, password, state, " +
+ "users_max, category, paper_floor, paper_wall, paper_landscape, thickness_wall, " +
+ "thickness_floor, moodlight_data, override_model, trade_mode " +
+ "FROM room_templates WHERE template_id = ?)",
+ Statement.RETURN_GENERATED_KEYS)) {
+ ins.setInt(1, userId);
+ ins.setString(2, userName);
+ ins.setInt(3, templateId);
+ roomsInserted = ins.executeUpdate();
+ try (ResultSet keys = ins.getGeneratedKeys()) {
+ if (keys.next()) newRoomId = keys.getInt(1);
+ }
+ } catch (SQLException e) {
+ LOGGER.error("[auth/register] clone rooms failed templateId=" + templateId + " userId=" + userId, e);
+ return;
+ }
+
+ LOGGER.info("[auth/register] rooms insert: rowsAffected={} newRoomId={}", roomsInserted, newRoomId);
+
+ if (newRoomId <= 0) {
+ LOGGER.warn("[auth/register] clone aborted - no roomId returned (templateId={}, userId={})", templateId, userId);
+ return;
+ }
+
+ materializeCustomLayout(conn, templateId, newRoomId);
+
+ int itemsInserted = 0;
+ try (PreparedStatement ins = conn.prepareStatement(
+ "INSERT INTO items (user_id, room_id, item_id, wall_pos, x, y, z, rot, " +
+ "extra_data, wired_data, limited_data, guild_id) " +
+ "(SELECT ?, ?, item_id, wall_pos, x, y, z, rot, extra_data, wired_data, '0:0', 0 " +
+ "FROM room_templates_items WHERE template_id = ?)")) {
+ ins.setInt(1, userId);
+ ins.setInt(2, newRoomId);
+ ins.setInt(3, templateId);
+ itemsInserted = ins.executeUpdate();
+ } catch (SQLException e) {
+ LOGGER.error("[auth/register] clone items failed templateId=" + templateId
+ + " roomId=" + newRoomId + " userId=" + userId, e);
+ }
+
+ LOGGER.info("[auth/register] items insert: rowsAffected={} roomId={}", itemsInserted, newRoomId);
+
+ try (PreparedStatement upd = conn.prepareStatement(
+ "UPDATE users SET home_room = ? WHERE id = ? LIMIT 1")) {
+ upd.setInt(1, newRoomId);
+ upd.setInt(2, userId);
+ int rows = upd.executeUpdate();
+ LOGGER.info("[auth/register] home_room update: rowsAffected={} userId={} roomId={}", rows, userId, newRoomId);
+ } catch (SQLException e) {
+ LOGGER.error("[auth/register] setting home_room failed userId=" + userId + " roomId=" + newRoomId, e);
+ }
+ }
private void handleForgot(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
String email = readString(body, "email").trim();
@@ -582,8 +806,6 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
sendJson(ctx, req, HttpResponseStatus.OK, ok);
}
- /* โโโ Helpers โโโ */
-
private static boolean checkPassword(String plain, String stored) {
String compatible = stored.startsWith("$2y$") ? "$2a$" + stored.substring(4) : stored;
try {
@@ -605,12 +827,6 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
}
- private static String mintRememberToken() {
- byte[] buf = new byte[48];
- RNG.nextBytes(buf);
- return "remember-" + Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
- }
-
private static String readString(JsonObject obj, String key) {
if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) return "";
try {
@@ -620,56 +836,24 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
}
}
- private static boolean readBoolean(JsonObject obj, String key) {
- if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) return false;
+ private static int readInt(JsonObject obj, String key, int defaultValue) {
+ if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) return defaultValue;
try {
- if (obj.get(key).isJsonPrimitive()) {
- String value = obj.get(key).getAsString();
- return "true".equalsIgnoreCase(value) || "1".equals(value) || "yes".equalsIgnoreCase(value);
- }
- } catch (Exception ignored) {
- }
- try {
- return obj.get(key).getAsBoolean();
+ return obj.get(key).getAsInt();
} catch (Exception e) {
- return false;
+ return defaultValue;
}
}
- private static boolean rememberEnabled() {
- return Emulator.getConfig().getBoolean("login.remember.enabled", true);
- }
-
- private static int rememberExpiresAt() {
- int days = Math.max(1, Emulator.getConfig().getInt("login.remember.days", 30));
- long expiresAt = (long) Emulator.getIntUnixTimestamp() + (days * 86400L);
- return (int) Math.min(Integer.MAX_VALUE, expiresAt);
- }
-
- private static void clearRememberToken(Connection conn, String rememberHash) {
- if (rememberHash == null || rememberHash.isEmpty()) return;
- try (PreparedStatement clear = conn.prepareStatement(
- "UPDATE users SET remember_token_hash = '', remember_token_expires_at = 0 WHERE remember_token_hash = ? LIMIT 1")) {
- clear.setString(1, rememberHash);
- clear.executeUpdate();
- } catch (Exception e) {
- LOGGER.debug("Unable to clear remember token", e);
- }
- }
-
- private static String sha256Hex(String value) {
+ private static boolean readBoolean(JsonObject obj, String key, boolean defaultValue) {
+ if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) return defaultValue;
try {
- MessageDigest digest = MessageDigest.getInstance("SHA-256");
- byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8));
- StringBuilder builder = new StringBuilder(hash.length * 2);
-
- for (byte b : hash) {
- builder.append(String.format("%02x", b));
- }
-
- return builder.toString();
+ com.google.gson.JsonElement el = obj.get(key);
+ if (el.getAsJsonPrimitive().isBoolean()) return el.getAsBoolean();
+ String s = el.getAsString();
+ return "1".equals(s) || "true".equalsIgnoreCase(s);
} catch (Exception e) {
- throw new IllegalStateException("SHA-256 is unavailable", e);
+ return defaultValue;
}
}
diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/RememberJwtService.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/RememberJwtService.java
new file mode 100644
index 00000000..93763902
--- /dev/null
+++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/RememberJwtService.java
@@ -0,0 +1,279 @@
+package com.eu.habbo.networking.gameserver.auth;
+
+import com.eu.habbo.Emulator;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.Base64;
+import java.util.UUID;
+
+public final class RememberJwtService {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(RememberJwtService.class);
+ private static final SecureRandom RNG = new SecureRandom();
+ private static final Base64.Encoder URL_ENC = Base64.getUrlEncoder().withoutPadding();
+ private static final Base64.Decoder URL_DEC = Base64.getUrlDecoder();
+
+ private static volatile String cachedSecret = null;
+
+ private RememberJwtService() {}
+
+ public static final class RotationResult {
+ public final String jwt;
+ public final int userId;
+ public final String username;
+ public final long expiresAt;
+
+ RotationResult(String jwt, int userId, String username, long expiresAt) {
+ this.jwt = jwt;
+ this.userId = userId;
+ this.username = username;
+ this.expiresAt = expiresAt;
+ }
+ }
+
+ private static int familyTtlDays() {
+ int configured = Emulator.getConfig().getInt("login.remember.duration.days", 0);
+ if (configured <= 0) configured = Emulator.getConfig().getInt("login.remember.days", 30);
+ return Math.max(1, configured);
+ }
+
+ private static long familyTtlSeconds() {
+ return familyTtlDays() * 86400L;
+ }
+
+ private static String secret() {
+ String s = cachedSecret;
+ if (s != null && !s.isEmpty()) return s;
+
+ synchronized (RememberJwtService.class) {
+ if (cachedSecret != null && !cachedSecret.isEmpty()) return cachedSecret;
+
+ String configured = Emulator.getConfig().getValue("login.remember.jwt.secret", "");
+ if (configured != null && !configured.isEmpty()) {
+ cachedSecret = configured;
+ return configured;
+ }
+
+ byte[] buf = new byte[48];
+ RNG.nextBytes(buf);
+ String generated = Base64.getEncoder().withoutPadding().encodeToString(buf);
+
+ try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
+ PreparedStatement stmt = conn.prepareStatement(
+ "INSERT INTO emulator_settings (`key`, `value`) VALUES ('login.remember.jwt.secret', ?) "
+ + "ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)")) {
+ stmt.setString(1, generated);
+ stmt.executeUpdate();
+ } catch (SQLException e) {
+ LOGGER.error("Could not persist generated login.remember.jwt.secret; using in-memory only", e);
+ }
+
+ Emulator.getConfig().update("login.remember.jwt.secret", generated);
+ cachedSecret = generated;
+ LOGGER.info("[auth/remember] generated new JWT signing secret (persisted to emulator_settings)");
+ return generated;
+ }
+ }
+
+ public static RotationResult issueForNewFamily(Connection conn, int userId, String username, String ip) throws SQLException {
+ String familyId = UUID.randomUUID().toString();
+ long now = Emulator.getIntUnixTimestamp();
+ long expiresAt = now + familyTtlSeconds();
+
+ try (PreparedStatement ins = conn.prepareStatement(
+ "INSERT INTO users_remember_families (family_id, user_id, current_version, created_at, expires_at, revoked, last_ip) "
+ + "VALUES (?, ?, 1, ?, ?, 0, ?)")) {
+ ins.setString(1, familyId);
+ ins.setInt(2, userId);
+ ins.setLong(3, now);
+ ins.setLong(4, expiresAt);
+ ins.setString(5, ip == null ? "" : ip);
+ ins.executeUpdate();
+ }
+
+ String jwt = buildJwt(userId, familyId, 1, now, expiresAt);
+ return new RotationResult(jwt, userId, username, expiresAt);
+ }
+
+ public static RotationResult rotate(Connection conn, String jwt, String ip) {
+ ParsedJwt parsed;
+ try {
+ parsed = verifyAndParse(jwt);
+ } catch (Exception e) {
+ LOGGER.debug("[auth/remember] invalid JWT: {}", e.getMessage());
+ return null;
+ }
+
+ long now = Emulator.getIntUnixTimestamp();
+ if (parsed.exp <= now) return null;
+
+ int familyVersion = 0;
+ boolean revoked = false;
+ long familyExpiresAt = 0;
+ try (PreparedStatement sel = conn.prepareStatement(
+ "SELECT current_version, revoked, expires_at FROM users_remember_families WHERE family_id = ? AND user_id = ? LIMIT 1")) {
+ sel.setString(1, parsed.familyId);
+ sel.setInt(2, parsed.userId);
+ try (ResultSet rs = sel.executeQuery()) {
+ if (!rs.next()) return null;
+ familyVersion = rs.getInt("current_version");
+ revoked = rs.getInt("revoked") != 0;
+ familyExpiresAt = rs.getLong("expires_at");
+ }
+ } catch (SQLException e) {
+ LOGGER.error("[auth/remember] family lookup failed", e);
+ return null;
+ }
+
+ if (revoked || familyExpiresAt <= now) return null;
+
+ if (parsed.version < familyVersion) {
+ LOGGER.warn("[auth/remember] replay detected: familyId={} presented v={} but current is v={}, revoking family",
+ parsed.familyId, parsed.version, familyVersion);
+ revokeFamilyById(conn, parsed.familyId);
+ return null;
+ }
+ if (parsed.version > familyVersion) {
+ LOGGER.warn("[auth/remember] future version: familyId={} presented v={} but current is v={}",
+ parsed.familyId, parsed.version, familyVersion);
+ return null;
+ }
+
+ int newVersion = familyVersion + 1;
+ long newExpiresAt = now + familyTtlSeconds();
+
+ try (PreparedStatement upd = conn.prepareStatement(
+ "UPDATE users_remember_families SET current_version = ?, expires_at = ?, last_ip = ? "
+ + "WHERE family_id = ? AND current_version = ? AND revoked = 0")) {
+ upd.setInt(1, newVersion);
+ upd.setLong(2, newExpiresAt);
+ upd.setString(3, ip == null ? "" : ip);
+ upd.setString(4, parsed.familyId);
+ upd.setInt(5, familyVersion);
+ int rows = upd.executeUpdate();
+ if (rows == 0) return null;
+ } catch (SQLException e) {
+ LOGGER.error("[auth/remember] rotation update failed", e);
+ return null;
+ }
+
+ String username = null;
+ try (PreparedStatement usr = conn.prepareStatement("SELECT username FROM users WHERE id = ? LIMIT 1")) {
+ usr.setInt(1, parsed.userId);
+ try (ResultSet rs = usr.executeQuery()) {
+ if (rs.next()) username = rs.getString("username");
+ }
+ } catch (SQLException e) {
+ LOGGER.error("[auth/remember] username lookup failed", e);
+ }
+
+ if (username == null) return null;
+
+ String newJwt = buildJwt(parsed.userId, parsed.familyId, newVersion, now, newExpiresAt);
+ return new RotationResult(newJwt, parsed.userId, username, newExpiresAt);
+ }
+
+ public static void revokeFromToken(Connection conn, String jwt) {
+ try {
+ ParsedJwt p = verifyAndParse(jwt);
+ revokeFamilyById(conn, p.familyId);
+ } catch (Exception ignored) { }
+ }
+
+ private static void revokeFamilyById(Connection conn, String familyId) {
+ try (PreparedStatement upd = conn.prepareStatement(
+ "UPDATE users_remember_families SET revoked = 1 WHERE family_id = ?")) {
+ upd.setString(1, familyId);
+ upd.executeUpdate();
+ } catch (SQLException e) {
+ LOGGER.error("[auth/remember] revoke failed for familyId=" + familyId, e);
+ }
+ }
+
+ private static String buildJwt(int userId, String familyId, int version, long iat, long exp) {
+ JsonObject header = new JsonObject();
+ header.addProperty("alg", "HS256");
+ header.addProperty("typ", "JWT");
+
+ JsonObject payload = new JsonObject();
+ payload.addProperty("sub", userId);
+ payload.addProperty("fid", familyId);
+ payload.addProperty("v", version);
+ payload.addProperty("iat", iat);
+ payload.addProperty("exp", exp);
+ payload.addProperty("typ", "refresh");
+
+ String h = URL_ENC.encodeToString(header.toString().getBytes(StandardCharsets.UTF_8));
+ String p = URL_ENC.encodeToString(payload.toString().getBytes(StandardCharsets.UTF_8));
+ String signingInput = h + "." + p;
+ String sig = URL_ENC.encodeToString(hmacSha256(secret().getBytes(StandardCharsets.UTF_8),
+ signingInput.getBytes(StandardCharsets.UTF_8)));
+ return signingInput + "." + sig;
+ }
+
+ private static final class ParsedJwt {
+ final int userId;
+ final String familyId;
+ final int version;
+ final long exp;
+
+ ParsedJwt(int userId, String familyId, int version, long exp) {
+ this.userId = userId;
+ this.familyId = familyId;
+ this.version = version;
+ this.exp = exp;
+ }
+ }
+
+ private static ParsedJwt verifyAndParse(String jwt) throws Exception {
+ if (jwt == null || jwt.isEmpty()) throw new IllegalArgumentException("empty");
+
+ String[] parts = jwt.split("\\.");
+ if (parts.length != 3) throw new IllegalArgumentException("not 3 segments");
+
+ String signingInput = parts[0] + "." + parts[1];
+ byte[] expected = hmacSha256(secret().getBytes(StandardCharsets.UTF_8), signingInput.getBytes(StandardCharsets.UTF_8));
+ byte[] provided = URL_DEC.decode(parts[2]);
+ if (!constantTimeEquals(expected, provided)) throw new SecurityException("bad signature");
+
+ byte[] payloadBytes = URL_DEC.decode(parts[1]);
+ JsonObject payload = JsonParser.parseString(new String(payloadBytes, StandardCharsets.UTF_8)).getAsJsonObject();
+
+ if (!payload.has("typ") || !"refresh".equals(payload.get("typ").getAsString())) throw new IllegalArgumentException("wrong typ");
+ int userId = payload.get("sub").getAsInt();
+ String fid = payload.get("fid").getAsString();
+ int version = payload.get("v").getAsInt();
+ long exp = payload.get("exp").getAsLong();
+
+ return new ParsedJwt(userId, fid, version, exp);
+ }
+
+ private static byte[] hmacSha256(byte[] key, byte[] data) {
+ try {
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(key, "HmacSHA256"));
+ return mac.doFinal(data);
+ } catch (Exception e) {
+ throw new IllegalStateException("HmacSHA256 unavailable", e);
+ }
+ }
+
+ private static boolean constantTimeEquals(byte[] a, byte[] b) {
+ if (a == null || b == null || a.length != b.length) return false;
+ int r = 0;
+ for (int i = 0; i < a.length; i++) r |= a[i] ^ b[i];
+ return r == 0;
+ }
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/CryptoSigningKeyManager.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/CryptoSigningKeyManager.java
new file mode 100644
index 00000000..0e265f80
--- /dev/null
+++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/CryptoSigningKeyManager.java
@@ -0,0 +1,90 @@
+package com.eu.habbo.networking.gameserver.crypto;
+
+import com.eu.habbo.Emulator;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.security.KeyFactory;
+import java.security.KeyPair;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.security.spec.X509EncodedKeySpec;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.util.Base64;
+
+public final class CryptoSigningKeyManager {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(CryptoSigningKeyManager.class);
+ private static final String KEY_PUBLIC = "crypto.ws.signing.public_key";
+ private static final String KEY_PRIVATE = "crypto.ws.signing.private_key";
+ private static volatile KeyPair cached;
+ private static volatile String cachedPublicB64;
+ private CryptoSigningKeyManager() {}
+
+ public static KeyPair get() {
+ KeyPair kp = cached;
+ if (kp != null) return kp;
+
+ synchronized (CryptoSigningKeyManager.class) {
+ if (cached != null) return cached;
+
+ String pubB64 = Emulator.getConfig().getValue(KEY_PUBLIC, "");
+ String privB64 = Emulator.getConfig().getValue(KEY_PRIVATE, "");
+
+ if (pubB64 != null && !pubB64.isEmpty() && privB64 != null && !privB64.isEmpty()) {
+ try {
+ byte[] pubDer = Base64.getDecoder().decode(pubB64);
+ byte[] privDer = Base64.getDecoder().decode(privB64);
+ KeyFactory kf = KeyFactory.getInstance("EC");
+ PublicKey pub = kf.generatePublic(new X509EncodedKeySpec(pubDer));
+ PrivateKey priv = kf.generatePrivate(new PKCS8EncodedKeySpec(privDer));
+ cached = new KeyPair(pub, priv);
+ cachedPublicB64 = pubB64;
+ return cached;
+ } catch (Exception e) {
+ LOGGER.error("[ws-crypto] persisted signing key is corrupt, generating a new pair", e);
+ }
+ }
+
+ try {
+ KeyPair generated = WsSessionCrypto.generateSigningKeyPair();
+ byte[] pubDer = WsSessionCrypto.encodePublicKeySpki(generated.getPublic());
+ byte[] privDer = WsSessionCrypto.encodePrivateKeyPkcs8(generated.getPrivate());
+ String newPubB64 = Base64.getEncoder().withoutPadding().encodeToString(pubDer);
+ String newPrivB64 = Base64.getEncoder().withoutPadding().encodeToString(privDer);
+
+ persist(KEY_PUBLIC, newPubB64);
+ persist(KEY_PRIVATE, newPrivB64);
+ Emulator.getConfig().update(KEY_PUBLIC, newPubB64);
+ Emulator.getConfig().update(KEY_PRIVATE, newPrivB64);
+
+ cached = generated;
+ cachedPublicB64 = newPubB64;
+ LOGGER.info("[ws-crypto] generated a new ECDSA P-256 signing keypair (persisted to emulator_settings)");
+ return cached;
+ } catch (Exception e) {
+ throw new IllegalStateException("Cannot generate signing keypair", e);
+ }
+ }
+ }
+
+ public static String publicKeyBase64() {
+ if (cachedPublicB64 == null) get();
+ return cachedPublicB64;
+ }
+
+ private static void persist(String key, String value) {
+ try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
+ PreparedStatement stmt = conn.prepareStatement(
+ "INSERT INTO emulator_settings (`key`, `value`) VALUES (?, ?) "
+ + "ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)")) {
+ stmt.setString(1, key);
+ stmt.setString(2, value);
+ stmt.executeUpdate();
+ } catch (Exception e) {
+ LOGGER.error("[ws-crypto] failed to persist " + key + " to emulator_settings (key stays in-memory only)", e);
+ }
+ }
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/WsAesDecoder.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/WsAesDecoder.java
new file mode 100644
index 00000000..0cc947da
--- /dev/null
+++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/WsAesDecoder.java
@@ -0,0 +1,46 @@
+package com.eu.habbo.networking.gameserver.crypto;
+
+import com.eu.habbo.networking.gameserver.GameServerAttributes;
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.handler.codec.MessageToMessageDecoder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+
+public class WsAesDecoder extends MessageToMessageDecoder {
+ private static final Logger LOGGER = LoggerFactory.getLogger(WsAesDecoder.class);
+
+ @Override
+ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List