You've already forked Arcturus-Morningstar-Extended
mirror of
https://github.com/duckietm/Arcturus-Morningstar-Extended.git
synced 2026-06-19 15:06:19 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d85eecd624 | |||
| c50098a945 | |||
| 0224f3f416 | |||
| 03d37650a0 | |||
| f4e5449443 | |||
| 1ebc8314a8 | |||
| 85a60cf591 | |||
| 41d7420251 | |||
| 5dd602ebab | |||
| 6d203c1267 | |||
| a8bcb27d27 | |||
| b18d65bd79 | |||
| 13958cb11e | |||
| 7414bc2589 | |||
| da2307f3b5 | |||
| 030b5ec174 | |||
| ec54dc5c85 | |||
| 50acf6217e | |||
| dd06f2b15c | |||
| d5497e49ad | |||
| 1916c6c785 | |||
| 4c46c0cb00 | |||
| fdcc33212f | |||
| bcee750ff8 | |||
| 872dd11bd2 |
@@ -1,37 +1,3 @@
|
||||
-- =============================================================================
|
||||
-- Consolidated Database Updates - All-in-One
|
||||
-- =============================================================================
|
||||
-- This file combines ALL individual update scripts from SQL/Database Updates/
|
||||
-- into a single idempotent migration. Every statement is safe to re-run:
|
||||
-- - ALTER TABLE ADD COLUMN IF NOT EXISTS (MariaDB 10.0+)
|
||||
-- - ALTER TABLE CHANGE/MODIFY COLUMN IF EXISTS
|
||||
-- - CREATE TABLE IF NOT EXISTS
|
||||
-- - INSERT IGNORE / ON DUPLICATE KEY UPDATE for settings
|
||||
-- - TRUNCATE + re-insert for reference data (breeding)
|
||||
--
|
||||
-- Run order: This file FIRST, then 001_optimize_gameserver.sql
|
||||
--
|
||||
-- Source files (in applied order):
|
||||
-- 1. UpdateDatabase_Allow_diagonale.sql
|
||||
-- 2. UpdateDatabase_BOT.sql
|
||||
-- 3. UpdateDatabase_Banners.sql
|
||||
-- 4. UpdateDatabase_DanceCMD.sql
|
||||
-- 5. UpdateDatabase_Happiness.sql
|
||||
-- 6. UpdateDatabase_Websocket.sql
|
||||
-- 7. UpdateDatabase_unignorable.sql
|
||||
-- 8. Default_Camera.sql
|
||||
-- 9. 07012026_UpdateDatabase_to_4-0-1.sql
|
||||
-- 10. 09012026_UpdateDatabase_to_4-0-2.sql
|
||||
-- 11. 12012026_Battle Banzai.sql (same as #10, deduplicated)
|
||||
-- 12. 12012026_Breeding Fixes.sql
|
||||
-- 13. 12012026_ChatBubbles.sql
|
||||
-- 14. 16032026_updateall_command.sql
|
||||
-- 15. 17032026_allow_underpass.sql
|
||||
-- 16. 19032026_hotel_timezone.sql
|
||||
-- 17. 21022026_user_prefixes.sql
|
||||
-- 18. 06042026_builders_club_catalog_offers.sql
|
||||
-- =============================================================================
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
SET @OLD_SQL_MODE = @@SQL_MODE;
|
||||
@@ -512,8 +478,13 @@ ALTER TABLE `users_settings`
|
||||
ADD COLUMN IF NOT EXISTS `builders_club_bonus_furni` INT(11) NOT NULL DEFAULT 0 AFTER `hc_gifts_claimed`;
|
||||
|
||||
|
||||
INSERT INTO `permission_definitions` (`permission_key`, `max_value`, `comment`)
|
||||
VALUES ( 'acc_staff_chat', 1, 'Grants access to the in-game Staff Chat group buddy: receives broadcasts from other staff and can broadcast to anyone holding this permission.' )
|
||||
ON DUPLICATE KEY UPDATE `max_value` = VALUES(`max_value`), `comment` = VALUES(`comment`);
|
||||
|
||||
-- =============================================================================
|
||||
-- Done
|
||||
-- Done.
|
||||
-- =============================================================================
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
SET SQL_MODE = @OLD_SQL_MODE;
|
||||
|
||||
@@ -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`;
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
|
||||
<groupId>com.eu.habbo</groupId>
|
||||
<artifactId>Habbo</artifactId>
|
||||
<version>4.1.5</version>
|
||||
<version>4.1.9</version>
|
||||
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -219,6 +219,10 @@ public class Messenger {
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Caught SQL exception", e);
|
||||
}
|
||||
|
||||
if (habbo.hasPermission(StaffChatBuddy.PERMISSION_KEY)) {
|
||||
this.friends.putIfAbsent(StaffChatBuddy.BUDDY_ID, new StaffChatBuddy(habbo.getHabboInfo().getId()));
|
||||
}
|
||||
}
|
||||
|
||||
public MessengerBuddy loadFriend(Habbo habbo, int userId) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.eu.habbo.habbohotel.messenger;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.habbohotel.commands.CommandHandler;
|
||||
import com.eu.habbo.habbohotel.users.Habbo;
|
||||
import com.eu.habbo.habbohotel.users.HabboGender;
|
||||
import com.eu.habbo.messages.ServerMessage;
|
||||
import com.eu.habbo.messages.outgoing.friends.FriendChatMessageComposer;
|
||||
|
||||
public class StaffChatBuddy extends MessengerBuddy {
|
||||
public static final int BUDDY_ID = -1;
|
||||
public static final String PERMISSION_KEY = "acc_staff_chat";
|
||||
public static final String DISPLAY_NAME = "Staff Chat";
|
||||
public static final String DEFAULT_LOOK = "ADM";
|
||||
|
||||
public StaffChatBuddy(int userOne) {
|
||||
super(BUDDY_ID, DISPLAY_NAME, DEFAULT_LOOK, (short) 0, userOne);
|
||||
this.setOnline(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(Habbo from, String message) {
|
||||
if (from == null || message == null || message.isEmpty()) return;
|
||||
// Re-check permission so a staff member who was demoted mid-session
|
||||
// can no longer broadcast to the staff channel.
|
||||
if (!from.hasPermission(PERMISSION_KEY)) return;
|
||||
|
||||
if (message.charAt(0) == ':') {
|
||||
CommandHandler.handleCommand(from.getClient(), message);
|
||||
return;
|
||||
}
|
||||
|
||||
Message chatMessage = new Message(from.getHabboInfo().getId(), BUDDY_ID, message);
|
||||
Emulator.getGameServer().getGameClientManager().sendBroadcastResponse(
|
||||
new FriendChatMessageComposer(chatMessage, BUDDY_ID, from.getHabboInfo().getId()).compose(),
|
||||
PERMISSION_KEY,
|
||||
from.getClient());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(ServerMessage message) {
|
||||
message.appendInt(this.getId());
|
||||
message.appendString(this.getUsername());
|
||||
message.appendInt(this.getGender().equals(HabboGender.M) ? 0 : 1);
|
||||
message.appendBoolean(true); // online
|
||||
message.appendBoolean(false); // not in room
|
||||
message.appendString(this.getLook());
|
||||
message.appendInt(0); // category
|
||||
message.appendString(""); // motto
|
||||
message.appendString(""); // last seen
|
||||
message.appendString(""); // realname
|
||||
message.appendBoolean(true); // offline messaging supported
|
||||
message.appendBoolean(false);
|
||||
message.appendBoolean(false);
|
||||
message.appendShort(0); // relation
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,4 +10,6 @@ public class GameServerAttributes {
|
||||
public static final AttributeKey<HabboRC4> CRYPTO_CLIENT = AttributeKey.valueOf("CryptoClient");
|
||||
public static final AttributeKey<HabboRC4> CRYPTO_SERVER = AttributeKey.valueOf("CryptoServer");
|
||||
public static final AttributeKey<String> WS_IP = AttributeKey.valueOf("WebSocketIP");
|
||||
public static final AttributeKey<byte[]> WS_AES_KEY = AttributeKey.valueOf("WsAesKey");
|
||||
}
|
||||
|
||||
|
||||
+7
@@ -1,8 +1,10 @@
|
||||
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.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;
|
||||
@@ -53,6 +55,11 @@ public class WebSocketChannelInitializer extends ChannelInitializer<SocketChanne
|
||||
ch.pipeline().addLast("authHttpHandler", new AuthHttpHandler());
|
||||
ch.pipeline().addLast("wsProtocolHandler", new WebSocketServerProtocolHandler(this.wsConfig));
|
||||
ch.pipeline().addLast("wsCodec", new WebSocketCodec());
|
||||
|
||||
if (Emulator.getConfig().getBoolean("crypto.ws.enabled", false)) {
|
||||
ch.pipeline().addLast(WsHandshakeHandler.HANDLER_NAME, new WsHandshakeHandler());
|
||||
}
|
||||
|
||||
ch.pipeline().addLast(new GamePolicyDecoder());
|
||||
ch.pipeline().addLast(new GameByteFrameDecoder());
|
||||
ch.pipeline().addLast(new GameByteDecoder());
|
||||
|
||||
+628
-80
@@ -2,6 +2,7 @@ package com.eu.habbo.networking.gameserver.auth;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
import com.eu.habbo.networking.gameserver.GameServerAttributes;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import io.netty.buffer.Unpooled;
|
||||
@@ -25,14 +26,23 @@ import java.util.regex.Pattern;
|
||||
public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(AuthHttpHandler.class);
|
||||
|
||||
private static final String LOGIN_PATH = "/api/auth/login";
|
||||
private static final String REGISTER_PATH = "/api/auth/register";
|
||||
private static final String FORGOT_PATH = "/api/auth/forgot-password";
|
||||
private static final String LOGOUT_PATH = "/api/auth/logout";
|
||||
private static final String LOGIN_PATH = "/api/auth/login";
|
||||
private static final String REGISTER_PATH = "/api/auth/register";
|
||||
private static final String FORGOT_PATH = "/api/auth/forgot-password";
|
||||
private static final String LOGOUT_PATH = "/api/auth/logout";
|
||||
private static final String CHECK_EMAIL_PATH = "/api/auth/check-email";
|
||||
private static final String CHECK_USERNAME_PATH = "/api/auth/check-username";
|
||||
private static final String ROOM_TEMPLATES_PATH = "/api/auth/room-templates";
|
||||
private static final String REMEMBER_PATH = "/api/auth/remember";
|
||||
private static final String REFRESH_PATH = "/api/auth/refresh";
|
||||
private static final String SERVER_KEY_PATH = "/api/auth/server-key";
|
||||
private static final String HEALTH_PATH = "/api/health";
|
||||
|
||||
private static final Pattern USERNAME_RE = Pattern.compile("^[A-Za-z0-9._-]{3,32}$");
|
||||
private static final Pattern EMAIL_RE = Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$");
|
||||
private static final Pattern FIGURE_RE = Pattern.compile("^[A-Za-z0-9.\\-]{1,200}$");
|
||||
private static final SecureRandom RNG = new SecureRandom();
|
||||
private static final int MAX_BODY_BYTES = 8 * 1024;
|
||||
|
||||
@Override
|
||||
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
|
||||
@@ -44,7 +54,13 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
||||
String path = new QueryStringDecoder(req.uri()).path();
|
||||
|
||||
if (!path.equals(LOGIN_PATH) && !path.equals(REGISTER_PATH)
|
||||
&& !path.equals(FORGOT_PATH) && !path.equals(LOGOUT_PATH)) {
|
||||
&& !path.equals(FORGOT_PATH) && !path.equals(LOGOUT_PATH)
|
||||
&& !path.equals(CHECK_EMAIL_PATH) && !path.equals(CHECK_USERNAME_PATH)
|
||||
&& !path.equals(ROOM_TEMPLATES_PATH)
|
||||
&& !path.equals(REMEMBER_PATH)
|
||||
&& !path.equals(REFRESH_PATH)
|
||||
&& !path.equals(SERVER_KEY_PATH)
|
||||
&& !path.equals(HEALTH_PATH)) {
|
||||
super.channelRead(ctx, msg);
|
||||
return;
|
||||
}
|
||||
@@ -62,6 +78,35 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.equals(HEALTH_PATH)) {
|
||||
if (req.method() != HttpMethod.GET && req.method() != HttpMethod.HEAD) {
|
||||
sendJson(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, errorPayload("Use GET."));
|
||||
return;
|
||||
}
|
||||
JsonObject ok = new JsonObject();
|
||||
ok.addProperty("status", "ok");
|
||||
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.equals(ROOM_TEMPLATES_PATH)) {
|
||||
if (req.method() != HttpMethod.GET && req.method() != HttpMethod.HEAD) {
|
||||
sendJson(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, errorPayload("Use GET."));
|
||||
return;
|
||||
}
|
||||
handleRoomTemplates(ctx, req);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.equals(SERVER_KEY_PATH)) {
|
||||
if (req.method() != HttpMethod.GET && req.method() != HttpMethod.HEAD) {
|
||||
sendJson(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, errorPayload("Use GET."));
|
||||
return;
|
||||
}
|
||||
handleServerKey(ctx, req);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method() != HttpMethod.POST) {
|
||||
sendJson(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, errorPayload("Use POST."));
|
||||
return;
|
||||
@@ -76,6 +121,11 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.content().readableBytes() > MAX_BODY_BYTES) {
|
||||
sendJson(ctx, req, HttpResponseStatus.REQUEST_ENTITY_TOO_LARGE, errorPayload("Payload too large."));
|
||||
return;
|
||||
}
|
||||
|
||||
JsonObject body;
|
||||
try {
|
||||
String text = req.content().toString(StandardCharsets.UTF_8);
|
||||
@@ -90,6 +140,23 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.equals(CHECK_EMAIL_PATH)) {
|
||||
handleCheckEmail(ctx, req, body, ip);
|
||||
return;
|
||||
}
|
||||
if (path.equals(CHECK_USERNAME_PATH)) {
|
||||
handleCheckUsername(ctx, req, body, ip);
|
||||
return;
|
||||
}
|
||||
if (path.equals(REMEMBER_PATH)) {
|
||||
handleRemember(ctx, req, body, ip);
|
||||
return;
|
||||
}
|
||||
if (path.equals(REFRESH_PATH)) {
|
||||
handleRefresh(ctx, req, body, ip);
|
||||
return;
|
||||
}
|
||||
|
||||
String turnstileToken = readString(body, "turnstileToken");
|
||||
if (!TurnstileVerifier.verify(turnstileToken, ip)) {
|
||||
AuthRateLimiter.recordFailure(ip);
|
||||
@@ -104,105 +171,275 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Logout ────────────────────────────────────────────────────────── */
|
||||
|
||||
private void handleLogout(ChannelHandlerContext ctx, FullHttpRequest req, com.google.gson.JsonObject body) {
|
||||
String ssoTicket = readString(body, "ssoTicket");
|
||||
JsonObject ok = new JsonObject();
|
||||
ok.addProperty("message", "Logged out.");
|
||||
|
||||
if (ssoTicket == null || ssoTicket.isEmpty()) {
|
||||
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
||||
private void handleCheckEmail(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
||||
if (!AuthRateLimiter.tryProbe(ip)) {
|
||||
long secs = AuthRateLimiter.secondsUntilProbeReset(ip);
|
||||
sendJson(ctx, req, HttpResponseStatus.TOO_MANY_REQUESTS,
|
||||
errorPayload("Too many requests. Try again in " + secs + "s."));
|
||||
return;
|
||||
}
|
||||
String email = readString(body, "email").trim();
|
||||
if (email.isEmpty() || email.length() > 254 || !EMAIL_RE.matcher(email).matches()) {
|
||||
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Invalid email address."));
|
||||
return;
|
||||
}
|
||||
|
||||
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement lookup = conn.prepareStatement(
|
||||
"SELECT id FROM users WHERE auth_ticket = ? LIMIT 1")) {
|
||||
lookup.setString(1, ssoTicket);
|
||||
int userId = 0;
|
||||
try (ResultSet rs = lookup.executeQuery()) {
|
||||
if (rs.next()) userId = rs.getInt("id");
|
||||
Boolean cached = AvailabilityCache.lookupEmail(email);
|
||||
boolean taken;
|
||||
if (cached != null) {
|
||||
taken = !cached;
|
||||
} else {
|
||||
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement stmt = conn.prepareStatement(
|
||||
"SELECT 1 FROM users WHERE mail = ? LIMIT 1")) {
|
||||
stmt.setString(1, email);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
taken = rs.next();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("check-email failed", e);
|
||||
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
|
||||
return;
|
||||
}
|
||||
AvailabilityCache.storeEmail(email, !taken);
|
||||
}
|
||||
|
||||
if (userId > 0) {
|
||||
try (PreparedStatement clear = conn.prepareStatement(
|
||||
"UPDATE users SET auth_ticket = '', online = '0' WHERE id = ? LIMIT 1")) {
|
||||
clear.setInt(1, userId);
|
||||
clear.executeUpdate();
|
||||
JsonObject res = new JsonObject();
|
||||
res.addProperty("available", !taken);
|
||||
if (taken) res.addProperty("error", "This email is already in use.");
|
||||
sendJson(ctx, req, HttpResponseStatus.OK, res);
|
||||
}
|
||||
|
||||
private void handleCheckUsername(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
||||
if (!AuthRateLimiter.tryProbe(ip)) {
|
||||
long secs = AuthRateLimiter.secondsUntilProbeReset(ip);
|
||||
sendJson(ctx, req, HttpResponseStatus.TOO_MANY_REQUESTS,
|
||||
errorPayload("Too many requests. Try again in " + secs + "s."));
|
||||
return;
|
||||
}
|
||||
String username = readString(body, "username").trim();
|
||||
if (!USERNAME_RE.matcher(username).matches()) {
|
||||
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
|
||||
errorPayload("Username must be 3-32 chars (letters, numbers, . _ -)."));
|
||||
return;
|
||||
}
|
||||
|
||||
Boolean cached = AvailabilityCache.lookupUsername(username);
|
||||
boolean taken;
|
||||
if (cached != null) {
|
||||
taken = !cached;
|
||||
} else {
|
||||
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement stmt = conn.prepareStatement(
|
||||
"SELECT 1 FROM users WHERE username = ? LIMIT 1")) {
|
||||
stmt.setString(1, username);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
taken = rs.next();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("check-username failed", e);
|
||||
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
|
||||
return;
|
||||
}
|
||||
AvailabilityCache.storeUsername(username, !taken);
|
||||
}
|
||||
|
||||
JsonObject res = new JsonObject();
|
||||
res.addProperty("available", !taken);
|
||||
if (taken) res.addProperty("error", "This Habbo name is already taken.");
|
||||
sendJson(ctx, req, HttpResponseStatus.OK, res);
|
||||
}
|
||||
|
||||
private void handleLogout(ChannelHandlerContext ctx, FullHttpRequest req, com.google.gson.JsonObject body) {
|
||||
String ssoTicket = readString(body, "ssoTicket");
|
||||
String rememberToken = readString(body, "rememberToken").trim();
|
||||
JsonObject ok = new JsonObject();
|
||||
ok.addProperty("message", "Logged out.");
|
||||
|
||||
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) {
|
||||
int userId = 0;
|
||||
|
||||
if (ssoTicket != null && !ssoTicket.isEmpty()) {
|
||||
try (PreparedStatement lookup = conn.prepareStatement(
|
||||
"SELECT id FROM users WHERE auth_ticket = ? LIMIT 1")) {
|
||||
lookup.setString(1, ssoTicket);
|
||||
try (ResultSet rs = lookup.executeQuery()) {
|
||||
if (rs.next()) userId = rs.getInt("id");
|
||||
}
|
||||
}
|
||||
|
||||
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 (userId > 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()) {
|
||||
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);
|
||||
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);
|
||||
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 rememberMe = readBoolean(body, "remember", false);
|
||||
|
||||
if (username.isEmpty() || password.isEmpty()) {
|
||||
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Missing credentials."));
|
||||
return;
|
||||
}
|
||||
|
||||
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
|
||||
PreparedStatement stmt = conn.prepareStatement(
|
||||
"SELECT id, username, password FROM users WHERE username = ? LIMIT 1")) {
|
||||
stmt.setString(1, username);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
if (!rs.next()) {
|
||||
LOGGER.info("[auth/login] user not found username='{}' ip={}", username, ip);
|
||||
AuthRateLimiter.recordFailure(ip);
|
||||
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED,
|
||||
errorPayload("Invalid Habbo name or password."));
|
||||
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) {
|
||||
if (ip != null && !ip.isEmpty()) {
|
||||
BanInfo ipBan = lookupIpBan(conn, ip);
|
||||
if (ipBan != null) {
|
||||
LOGGER.info("[auth/login] ip ban hit ip={} type={} expires={}",
|
||||
ip, ipBan.type, ipBan.expiresAt);
|
||||
sendJson(ctx, req, HttpResponseStatus.FORBIDDEN, bannedPayload(ipBan));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
int userId = rs.getInt("id");
|
||||
String stored = rs.getString("password");
|
||||
String storedPreview = stored == null
|
||||
? "<null>"
|
||||
: (stored.isEmpty() ? "<empty>" : stored.substring(0, Math.min(7, stored.length())) + "…(" + stored.length() + " chars)");
|
||||
try (PreparedStatement stmt = conn.prepareStatement(
|
||||
"SELECT id, username, password FROM users WHERE username = ? LIMIT 1")) {
|
||||
stmt.setString(1, username);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
if (!rs.next()) {
|
||||
LOGGER.info("[auth/login] user not found username='{}' ip={}", username, ip);
|
||||
AuthRateLimiter.recordFailure(ip);
|
||||
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED,
|
||||
errorPayload("Invalid Habbo name or password."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (stored == null || stored.isEmpty() || !checkPassword(password, stored)) {
|
||||
LOGGER.info("[auth/login] password mismatch for user id={} username='{}' stored='{}'",
|
||||
userId, username, storedPreview);
|
||||
AuthRateLimiter.recordFailure(ip);
|
||||
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED,
|
||||
errorPayload("Invalid Habbo name or password."));
|
||||
return;
|
||||
int userId = rs.getInt("id");
|
||||
String stored = rs.getString("password");
|
||||
String storedPreview = stored == null
|
||||
? "<null>"
|
||||
: (stored.isEmpty() ? "<empty>" : stored.substring(0, Math.min(7, stored.length())) + "…(" + stored.length() + " chars)");
|
||||
|
||||
if (stored == null || stored.isEmpty() || !checkPassword(password, stored)) {
|
||||
LOGGER.info("[auth/login] password mismatch for user id={} username='{}' stored='{}'",
|
||||
userId, username, storedPreview);
|
||||
AuthRateLimiter.recordFailure(ip);
|
||||
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED,
|
||||
errorPayload("Invalid Habbo name or password."));
|
||||
return;
|
||||
}
|
||||
|
||||
BanInfo accountBan = lookupAccountBan(conn, userId);
|
||||
if (accountBan != null) {
|
||||
LOGGER.info("[auth/login] account ban hit userId={} type={} expires={}",
|
||||
userId, accountBan.type, accountBan.expiresAt);
|
||||
AuthRateLimiter.recordSuccess(ip);
|
||||
sendJson(ctx, req, HttpResponseStatus.FORBIDDEN, bannedPayload(accountBan));
|
||||
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, 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 != null) ok.addProperty("rememberToken", rememberToken);
|
||||
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
||||
}
|
||||
|
||||
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, userId);
|
||||
upd.executeUpdate();
|
||||
}
|
||||
|
||||
AuthRateLimiter.recordSuccess(ip);
|
||||
|
||||
JsonObject ok = new JsonObject();
|
||||
ok.addProperty("ssoTicket", ssoTicket);
|
||||
ok.addProperty("username", rs.getString("username"));
|
||||
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Login query failed for username=" + username, e);
|
||||
@@ -210,8 +447,6 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 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."));
|
||||
@@ -221,6 +456,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,
|
||||
@@ -280,11 +518,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);
|
||||
@@ -295,10 +541,31 @@ 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);
|
||||
AvailabilityCache.invalidateUsername(username);
|
||||
|
||||
JsonObject ok = new JsonObject();
|
||||
ok.addProperty("message", "Welcome aboard, " + username + "! Your account is ready — log in below with the password you just chose.");
|
||||
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
||||
@@ -308,7 +575,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();
|
||||
@@ -363,7 +824,73 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
||||
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
||||
}
|
||||
|
||||
/* ─── Helpers ───────────────────────────────────────────────────────── */
|
||||
private static final long PERMANENT_BAN_THRESHOLD_SECONDS = 30L * 365L * 24L * 60L * 60L;
|
||||
|
||||
private static final class BanInfo {
|
||||
final String type;
|
||||
final String reason;
|
||||
final int expiresAt;
|
||||
|
||||
BanInfo(String type, String reason, int expiresAt) {
|
||||
this.type = type == null ? "account" : type;
|
||||
this.reason = reason == null ? "" : reason;
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
boolean isPermanent() {
|
||||
return (long) expiresAt - Emulator.getIntUnixTimestamp() > PERMANENT_BAN_THRESHOLD_SECONDS;
|
||||
}
|
||||
}
|
||||
|
||||
private static BanInfo lookupAccountBan(Connection conn, int userId) throws SQLException {
|
||||
try (PreparedStatement stmt = conn.prepareStatement(
|
||||
"SELECT ban_expire, ban_reason, type FROM bans " +
|
||||
"WHERE user_id = ? AND ban_expire >= ? AND (type = 'account' OR type = 'super') " +
|
||||
"ORDER BY ban_expire DESC LIMIT 1")) {
|
||||
stmt.setInt(1, userId);
|
||||
stmt.setInt(2, Emulator.getIntUnixTimestamp());
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
return new BanInfo(rs.getString("type"), rs.getString("ban_reason"), rs.getInt("ban_expire"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static BanInfo lookupIpBan(Connection conn, String ip) throws SQLException {
|
||||
try (PreparedStatement stmt = conn.prepareStatement(
|
||||
"SELECT ban_expire, ban_reason, type FROM bans " +
|
||||
"WHERE ip = ? AND ban_expire >= ? AND (type = 'ip' OR type = 'super') " +
|
||||
"ORDER BY ban_expire DESC LIMIT 1")) {
|
||||
stmt.setString(1, ip);
|
||||
stmt.setInt(2, Emulator.getIntUnixTimestamp());
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
return new BanInfo(rs.getString("type"), rs.getString("ban_reason"), rs.getInt("ban_expire"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static JsonObject bannedPayload(BanInfo ban) {
|
||||
boolean permanent = ban.isPermanent();
|
||||
String message = permanent
|
||||
? "Your account has been permanently banned."
|
||||
: "Your account is temporarily banned.";
|
||||
|
||||
JsonObject details = new JsonObject();
|
||||
details.addProperty("type", ban.type);
|
||||
details.addProperty("reason", ban.reason);
|
||||
details.addProperty("permanent", permanent);
|
||||
if (!permanent) details.addProperty("expiresAt", ban.expiresAt);
|
||||
|
||||
JsonObject obj = new JsonObject();
|
||||
obj.addProperty("error", message);
|
||||
obj.add("ban", details);
|
||||
return obj;
|
||||
}
|
||||
|
||||
private static boolean checkPassword(String plain, String stored) {
|
||||
String compatible = stored.startsWith("$2y$") ? "$2a$" + stored.substring(4) : stored;
|
||||
@@ -395,6 +922,27 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
private static int readInt(JsonObject obj, String key, int defaultValue) {
|
||||
if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) return defaultValue;
|
||||
try {
|
||||
return obj.get(key).getAsInt();
|
||||
} catch (Exception e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean readBoolean(JsonObject obj, String key, boolean defaultValue) {
|
||||
if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) return defaultValue;
|
||||
try {
|
||||
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) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static String resolveClientIp(ChannelHandlerContext ctx, FullHttpRequest req) {
|
||||
String ipHeader = Emulator.getConfig() != null
|
||||
? Emulator.getConfig().getValue("ws.ip.header", "")
|
||||
@@ -449,7 +997,7 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
||||
response.headers().set("Vary", "Origin");
|
||||
response.headers().set("Access-Control-Allow-Credentials", "true");
|
||||
}
|
||||
response.headers().set("Access-Control-Allow-Methods", "POST, OPTIONS");
|
||||
response.headers().set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS");
|
||||
response.headers().set("Access-Control-Allow-Headers", "Content-Type, X-Requested-With");
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import java.util.concurrent.atomic.AtomicReference;
|
||||
public final class AuthRateLimiter {
|
||||
|
||||
private static final Map<String, AtomicReference<State>> STATE = new ConcurrentHashMap<>();
|
||||
private static final Map<String, AtomicReference<ProbeState>> PROBE_STATE = new ConcurrentHashMap<>();
|
||||
|
||||
private AuthRateLimiter() {}
|
||||
|
||||
@@ -58,6 +59,35 @@ public final class AuthRateLimiter {
|
||||
STATE.remove(ip);
|
||||
}
|
||||
|
||||
public static boolean tryProbe(String ip) {
|
||||
if (!isEnabled() || ip == null || ip.isEmpty()) return true;
|
||||
if (isLocked(ip)) return false;
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
long windowMs = configInt("login.probe.window_sec", 60) * 1000L;
|
||||
int maxAttempts = configInt("login.probe.max_attempts", 20);
|
||||
|
||||
ProbeState next = PROBE_STATE.computeIfAbsent(ip, k -> new AtomicReference<>(new ProbeState(0, now)))
|
||||
.updateAndGet(prev -> {
|
||||
if (prev == null || (now - prev.windowStartMillis) > windowMs) {
|
||||
return new ProbeState(1, now);
|
||||
}
|
||||
return new ProbeState(prev.count + 1, prev.windowStartMillis);
|
||||
});
|
||||
|
||||
return next.count <= maxAttempts;
|
||||
}
|
||||
|
||||
public static long secondsUntilProbeReset(String ip) {
|
||||
AtomicReference<ProbeState> ref = PROBE_STATE.get(ip);
|
||||
if (ref == null) return 0;
|
||||
ProbeState current = ref.get();
|
||||
if (current == null) return 0;
|
||||
long windowMs = configInt("login.probe.window_sec", 60) * 1000L;
|
||||
long remainingMs = (current.windowStartMillis + windowMs) - System.currentTimeMillis();
|
||||
return remainingMs > 0 ? (remainingMs / 1000L) + 1L : 0L;
|
||||
}
|
||||
|
||||
private static boolean isEnabled() {
|
||||
return Emulator.getConfig() != null
|
||||
&& Emulator.getConfig().getBoolean("login.ratelimit.enabled", true);
|
||||
@@ -68,4 +98,5 @@ public final class AuthRateLimiter {
|
||||
}
|
||||
|
||||
private record State(int attempts, long windowStartMillis, long lockedUntilMillis) {}
|
||||
private record ProbeState(int count, long windowStartMillis) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.eu.habbo.networking.gameserver.auth;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
|
||||
import java.util.Iterator;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class AvailabilityCache {
|
||||
|
||||
private static final Map<String, Entry> EMAIL_CACHE = new ConcurrentHashMap<>();
|
||||
private static final Map<String, Entry> USERNAME_CACHE = new ConcurrentHashMap<>();
|
||||
|
||||
private AvailabilityCache() {}
|
||||
|
||||
public static Boolean lookupEmail(String email) {
|
||||
return read(EMAIL_CACHE, key(email));
|
||||
}
|
||||
|
||||
public static Boolean lookupUsername(String username) {
|
||||
return read(USERNAME_CACHE, key(username));
|
||||
}
|
||||
|
||||
public static void storeEmail(String email, boolean available) {
|
||||
write(EMAIL_CACHE, key(email), available);
|
||||
}
|
||||
|
||||
public static void storeUsername(String username, boolean available) {
|
||||
write(USERNAME_CACHE, key(username), available);
|
||||
}
|
||||
|
||||
public static void invalidateEmail(String email) {
|
||||
EMAIL_CACHE.remove(key(email));
|
||||
}
|
||||
|
||||
public static void invalidateUsername(String username) {
|
||||
USERNAME_CACHE.remove(key(username));
|
||||
}
|
||||
|
||||
private static String key(String value) {
|
||||
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static Boolean read(Map<String, Entry> cache, String key) {
|
||||
if (!isEnabled() || key.isEmpty()) return null;
|
||||
Entry entry = cache.get(key);
|
||||
if (entry == null) return null;
|
||||
if (entry.expiresAt < System.currentTimeMillis()) {
|
||||
cache.remove(key, entry);
|
||||
return null;
|
||||
}
|
||||
return entry.available;
|
||||
}
|
||||
|
||||
private static void write(Map<String, Entry> cache, String key, boolean available) {
|
||||
if (!isEnabled() || key.isEmpty()) return;
|
||||
|
||||
int maxEntries = configInt("login.probe.cache_max_entries", 10_000);
|
||||
if (cache.size() >= maxEntries) evict(cache, maxEntries);
|
||||
|
||||
long ttlMs = configInt("login.probe.cache_ttl_sec", 60) * 1000L;
|
||||
cache.put(key, new Entry(available, System.currentTimeMillis() + ttlMs));
|
||||
}
|
||||
|
||||
private static void evict(Map<String, Entry> cache, int maxEntries) {
|
||||
long now = System.currentTimeMillis();
|
||||
cache.values().removeIf(e -> e.expiresAt < now);
|
||||
|
||||
if (cache.size() < maxEntries) return;
|
||||
|
||||
int overflow = cache.size() - maxEntries + 1;
|
||||
Iterator<String> it = cache.keySet().iterator();
|
||||
while (overflow > 0 && it.hasNext()) {
|
||||
it.next();
|
||||
it.remove();
|
||||
overflow--;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isEnabled() {
|
||||
return Emulator.getConfig() == null
|
||||
|| Emulator.getConfig().getBoolean("login.probe.cache_enabled", true);
|
||||
}
|
||||
|
||||
private static int configInt(String key, int fallback) {
|
||||
return Emulator.getConfig() != null ? Emulator.getConfig().getInt(key, fallback) : fallback;
|
||||
}
|
||||
|
||||
private record Entry(boolean available, long expiresAt) {}
|
||||
}
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
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() {
|
||||
return Math.max(1, Emulator.getConfig().getInt("login.remember.duration.days", 30));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
+90
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ByteBuf> {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(WsAesDecoder.class);
|
||||
|
||||
@Override
|
||||
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
|
||||
byte[] key = ctx.channel().attr(GameServerAttributes.WS_AES_KEY).get();
|
||||
if (key == null) {
|
||||
LOGGER.warn("[ws-crypto] inbound frame with no session key, closing");
|
||||
ctx.close();
|
||||
return;
|
||||
}
|
||||
|
||||
int readable = in.readableBytes();
|
||||
if (readable < WsSessionCrypto.NONCE_LEN + 16) {
|
||||
LOGGER.warn("[ws-crypto] inbound frame too short ({} bytes)", readable);
|
||||
ctx.close();
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] nonce = new byte[WsSessionCrypto.NONCE_LEN];
|
||||
in.readBytes(nonce);
|
||||
|
||||
byte[] ct = new byte[in.readableBytes()];
|
||||
in.readBytes(ct);
|
||||
|
||||
try {
|
||||
byte[] plain = WsSessionCrypto.aesGcmDecrypt(key, nonce, ct);
|
||||
out.add(Unpooled.wrappedBuffer(plain));
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("[ws-crypto] AES-GCM decrypt failed ({}), closing channel", e.getClass().getSimpleName());
|
||||
ctx.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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.MessageToMessageEncoder;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class WsAesEncoder extends MessageToMessageEncoder<ByteBuf> {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(WsAesEncoder.class);
|
||||
|
||||
@Override
|
||||
protected void encode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
|
||||
byte[] key = ctx.channel().attr(GameServerAttributes.WS_AES_KEY).get();
|
||||
if (key == null) {
|
||||
LOGGER.warn("[ws-crypto] outbound frame with no session key, dropping");
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] plain = new byte[in.readableBytes()];
|
||||
in.readBytes(plain);
|
||||
|
||||
byte[] nonce = WsSessionCrypto.randomNonce();
|
||||
byte[] ct = WsSessionCrypto.aesGcmEncrypt(key, nonce, plain);
|
||||
|
||||
ByteBuf framed = ctx.alloc().buffer(nonce.length + ct.length);
|
||||
framed.writeBytes(nonce);
|
||||
framed.writeBytes(ct);
|
||||
out.add(framed);
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package com.eu.habbo.networking.gameserver.crypto;
|
||||
|
||||
import com.eu.habbo.Emulator;
|
||||
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.channel.ChannelInboundHandlerAdapter;
|
||||
import io.netty.channel.ChannelPipeline;
|
||||
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.security.KeyPair;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
|
||||
public class WsHandshakeHandler extends ChannelInboundHandlerAdapter {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(WsHandshakeHandler.class);
|
||||
public static final String HANDLER_NAME = "wsCryptoHandshake";
|
||||
private static final boolean SIGN_ENABLED = Emulator.getConfig().getBoolean("crypto.ws.signing.enabled", false);
|
||||
private KeyPair serverKeyPair;
|
||||
private boolean helloSent = false;
|
||||
private boolean handshakeComplete = false;
|
||||
|
||||
@Override
|
||||
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
|
||||
if (evt instanceof WebSocketServerProtocolHandler.HandshakeComplete) {
|
||||
sendServerHello(ctx);
|
||||
}
|
||||
super.userEventTriggered(ctx, evt);
|
||||
}
|
||||
|
||||
private void sendServerHello(ChannelHandlerContext ctx) {
|
||||
if (helloSent) return;
|
||||
try {
|
||||
this.serverKeyPair = WsSessionCrypto.generateEphemeralKeyPair();
|
||||
byte[] spki = WsSessionCrypto.encodePublicKeySpki(serverKeyPair.getPublic());
|
||||
byte[] sigIeee = null;
|
||||
if (SIGN_ENABLED) {
|
||||
KeyPair signingKp = CryptoSigningKeyManager.get();
|
||||
byte[] sigDer = WsSessionCrypto.signEcdsaSha256(signingKp.getPrivate(), spki);
|
||||
sigIeee = WsSessionCrypto.derToIeee1363(sigDer);
|
||||
}
|
||||
|
||||
int frameLen = 4 + 1 + 2 + spki.length + (sigIeee != null ? 2 + sigIeee.length : 0);
|
||||
ByteBuf buf = ctx.alloc().buffer(frameLen);
|
||||
buf.writeInt(WsSessionCrypto.HANDSHAKE_MAGIC);
|
||||
buf.writeByte(WsSessionCrypto.TYPE_SERVER_HELLO);
|
||||
buf.writeShort(spki.length);
|
||||
buf.writeBytes(spki);
|
||||
if (sigIeee != null) {
|
||||
buf.writeShort(sigIeee.length);
|
||||
buf.writeBytes(sigIeee);
|
||||
}
|
||||
|
||||
ctx.writeAndFlush(buf);
|
||||
helloSent = true;
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("[ws-crypto] failed to send server_hello", e);
|
||||
ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
|
||||
if (handshakeComplete) {
|
||||
ctx.fireChannelRead(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(msg instanceof ByteBuf)) {
|
||||
ctx.fireChannelRead(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
ByteBuf in = (ByteBuf) msg;
|
||||
try {
|
||||
if (in.readableBytes() < 7) {
|
||||
LOGGER.warn("[ws-crypto] handshake frame too short ({} bytes) from {}", in.readableBytes(), clientAddress(ctx));
|
||||
ctx.close();
|
||||
return;
|
||||
}
|
||||
|
||||
int magic = in.readInt();
|
||||
if (magic != WsSessionCrypto.HANDSHAKE_MAGIC) {
|
||||
LOGGER.warn("[ws-crypto] handshake magic mismatch: 0x{} from {}", Integer.toHexString(magic), clientAddress(ctx));
|
||||
ctx.close();
|
||||
return;
|
||||
}
|
||||
|
||||
byte type = in.readByte();
|
||||
if (type != WsSessionCrypto.TYPE_CLIENT_HELLO) {
|
||||
LOGGER.warn("[ws-crypto] expected client_hello, got type=0x{} from {}", Integer.toHexString(type & 0xff), clientAddress(ctx));
|
||||
ctx.close();
|
||||
return;
|
||||
}
|
||||
|
||||
int keyLen = in.readUnsignedShort();
|
||||
if (keyLen <= 0 || keyLen > in.readableBytes() || keyLen > 2048) {
|
||||
LOGGER.warn("[ws-crypto] invalid client key length {} from {}", keyLen, clientAddress(ctx));
|
||||
ctx.close();
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] clientSpki = new byte[keyLen];
|
||||
in.readBytes(clientSpki);
|
||||
|
||||
PublicKey clientPub = WsSessionCrypto.decodePublicKeySpki(clientSpki);
|
||||
PrivateKey ourPriv = serverKeyPair.getPrivate();
|
||||
byte[] shared = WsSessionCrypto.deriveSharedSecret(ourPriv, clientPub);
|
||||
byte[] aesKey = WsSessionCrypto.deriveAesKey(shared);
|
||||
ctx.channel().attr(GameServerAttributes.WS_AES_KEY).set(aesKey);
|
||||
ChannelPipeline p = ctx.pipeline();
|
||||
p.addAfter(HANDLER_NAME, "wsAesDecoder", new WsAesDecoder());
|
||||
p.addAfter(HANDLER_NAME, "wsAesEncoder", new WsAesEncoder());
|
||||
handshakeComplete = true;
|
||||
p.remove(this);
|
||||
|
||||
LOGGER.debug("[ws-crypto] handshake complete for {}", clientAddress(ctx));
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("[ws-crypto] handshake failed from {} : {}", clientAddress(ctx), friendlyReason(e));
|
||||
ctx.close();
|
||||
} finally {
|
||||
in.release();
|
||||
}
|
||||
}
|
||||
|
||||
private static String clientAddress(ChannelHandlerContext ctx) {
|
||||
String wsIp = ctx.channel().attr(GameServerAttributes.WS_IP).get();
|
||||
if (wsIp != null && !wsIp.isEmpty()) return wsIp;
|
||||
return String.valueOf(ctx.channel().remoteAddress());
|
||||
}
|
||||
|
||||
private static String friendlyReason(Throwable t) {
|
||||
if (t == null) return "unknown";
|
||||
String name = t.getClass().getSimpleName();
|
||||
String msg = t.getMessage();
|
||||
return (msg == null || msg.isEmpty()) ? name : name + ": " + msg;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
|
||||
if (cause instanceof java.io.IOException) {
|
||||
LOGGER.debug("[ws-crypto] client disconnected during handshake ({}): {}",
|
||||
clientAddress(ctx), friendlyReason(cause));
|
||||
} else {
|
||||
LOGGER.error("[ws-crypto] handshake handler error from " + clientAddress(ctx), cause);
|
||||
}
|
||||
ctx.close();
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package com.eu.habbo.networking.gameserver.crypto;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.KeyAgreement;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.*;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Arrays;
|
||||
|
||||
public final class WsSessionCrypto {
|
||||
|
||||
public static final int HANDSHAKE_MAGIC = 0xC0DEC0DE;
|
||||
public static final byte TYPE_SERVER_HELLO = 0x01;
|
||||
public static final byte TYPE_CLIENT_HELLO = 0x02;
|
||||
|
||||
public static final String HKDF_INFO = "nitro-ws-v1";
|
||||
public static final int AES_KEY_LEN = 32;
|
||||
public static final int NONCE_LEN = 12;
|
||||
public static final int GCM_TAG_BITS = 128;
|
||||
|
||||
private static final SecureRandom RNG = new SecureRandom();
|
||||
|
||||
private WsSessionCrypto() {}
|
||||
|
||||
public static KeyPair generateEphemeralKeyPair() throws GeneralSecurityException {
|
||||
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
|
||||
kpg.initialize(new java.security.spec.ECGenParameterSpec("secp256r1"), RNG);
|
||||
return kpg.generateKeyPair();
|
||||
}
|
||||
|
||||
public static byte[] encodePublicKeySpki(PublicKey publicKey) {
|
||||
return publicKey.getEncoded();
|
||||
}
|
||||
|
||||
public static PublicKey decodePublicKeySpki(byte[] spki) throws GeneralSecurityException {
|
||||
KeyFactory kf = KeyFactory.getInstance("EC");
|
||||
return kf.generatePublic(new X509EncodedKeySpec(spki));
|
||||
}
|
||||
|
||||
public static byte[] deriveSharedSecret(PrivateKey ourPrivate, PublicKey theirPublic) throws GeneralSecurityException {
|
||||
KeyAgreement ka = KeyAgreement.getInstance("ECDH");
|
||||
ka.init(ourPrivate);
|
||||
ka.doPhase(theirPublic, true);
|
||||
return ka.generateSecret();
|
||||
}
|
||||
|
||||
public static byte[] hkdfSha256(byte[] ikm, byte[] salt, byte[] info, int outLen) throws GeneralSecurityException {
|
||||
if (salt == null || salt.length == 0) salt = new byte[32];
|
||||
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(salt, "HmacSHA256"));
|
||||
byte[] prk = mac.doFinal(ikm);
|
||||
|
||||
int hashLen = 32;
|
||||
int n = (outLen + hashLen - 1) / hashLen;
|
||||
if (n > 255) throw new GeneralSecurityException("HKDF output too long");
|
||||
|
||||
ByteArrayOutputStream okm = new ByteArrayOutputStream();
|
||||
byte[] t = new byte[0];
|
||||
|
||||
for (int i = 1; i <= n; i++) {
|
||||
mac.init(new SecretKeySpec(prk, "HmacSHA256"));
|
||||
mac.update(t);
|
||||
if (info != null) mac.update(info);
|
||||
mac.update((byte) i);
|
||||
t = mac.doFinal();
|
||||
okm.write(t, 0, t.length);
|
||||
}
|
||||
|
||||
byte[] result = okm.toByteArray();
|
||||
return (result.length == outLen) ? result : Arrays.copyOf(result, outLen);
|
||||
}
|
||||
|
||||
public static byte[] deriveAesKey(byte[] sharedSecret) throws GeneralSecurityException {
|
||||
return hkdfSha256(sharedSecret, null, HKDF_INFO.getBytes(StandardCharsets.UTF_8), AES_KEY_LEN);
|
||||
}
|
||||
|
||||
public static byte[] aesGcmEncrypt(byte[] key, byte[] nonce, byte[] plaintext) throws GeneralSecurityException {
|
||||
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
c.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(GCM_TAG_BITS, nonce));
|
||||
return c.doFinal(plaintext);
|
||||
}
|
||||
|
||||
public static byte[] aesGcmDecrypt(byte[] key, byte[] nonce, byte[] ciphertextWithTag) throws GeneralSecurityException {
|
||||
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
c.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(GCM_TAG_BITS, nonce));
|
||||
return c.doFinal(ciphertextWithTag);
|
||||
}
|
||||
|
||||
public static byte[] randomNonce() {
|
||||
byte[] n = new byte[NONCE_LEN];
|
||||
RNG.nextBytes(n);
|
||||
return n;
|
||||
}
|
||||
|
||||
public static KeyPair generateSigningKeyPair() throws GeneralSecurityException {
|
||||
return generateEphemeralKeyPair();
|
||||
}
|
||||
|
||||
public static PrivateKey decodePrivateKeyPkcs8(byte[] pkcs8) throws GeneralSecurityException {
|
||||
KeyFactory kf = KeyFactory.getInstance("EC");
|
||||
return kf.generatePrivate(new PKCS8EncodedKeySpec(pkcs8));
|
||||
}
|
||||
|
||||
public static byte[] encodePrivateKeyPkcs8(PrivateKey privateKey) {
|
||||
return privateKey.getEncoded();
|
||||
}
|
||||
|
||||
public static byte[] signEcdsaSha256(PrivateKey signingKey, byte[] message) throws GeneralSecurityException {
|
||||
Signature sig = Signature.getInstance("SHA256withECDSA");
|
||||
sig.initSign(signingKey);
|
||||
sig.update(message);
|
||||
return sig.sign();
|
||||
}
|
||||
|
||||
public static byte[] derToIeee1363(byte[] der) throws GeneralSecurityException {
|
||||
if (der == null || der.length < 8 || der[0] != 0x30) {
|
||||
throw new GeneralSecurityException("Malformed DER signature");
|
||||
}
|
||||
|
||||
int seqLen;
|
||||
int idx;
|
||||
if ((der[1] & 0x80) == 0) {
|
||||
seqLen = der[1] & 0xff;
|
||||
idx = 2;
|
||||
} else {
|
||||
int lenBytes = der[1] & 0x7f;
|
||||
if (lenBytes > 2) throw new GeneralSecurityException("DER length too big");
|
||||
seqLen = 0;
|
||||
for (int i = 0; i < lenBytes; i++) seqLen = (seqLen << 8) | (der[2 + i] & 0xff);
|
||||
idx = 2 + lenBytes;
|
||||
}
|
||||
if (idx + seqLen > der.length) throw new GeneralSecurityException("DER truncated");
|
||||
|
||||
if (der[idx] != 0x02) throw new GeneralSecurityException("Expected INTEGER r");
|
||||
int rLen = der[idx + 1] & 0xff;
|
||||
int rStart = idx + 2;
|
||||
|
||||
int sHeader = rStart + rLen;
|
||||
if (der[sHeader] != 0x02) throw new GeneralSecurityException("Expected INTEGER s");
|
||||
int sLen = der[sHeader + 1] & 0xff;
|
||||
int sStart = sHeader + 2;
|
||||
|
||||
byte[] r = stripLeadingZero(Arrays.copyOfRange(der, rStart, rStart + rLen));
|
||||
byte[] s = stripLeadingZero(Arrays.copyOfRange(der, sStart, sStart + sLen));
|
||||
|
||||
byte[] out = new byte[64];
|
||||
System.arraycopy(r, 0, out, 32 - r.length, r.length);
|
||||
System.arraycopy(s, 0, out, 64 - s.length, s.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static byte[] stripLeadingZero(byte[] v) {
|
||||
int i = 0;
|
||||
while (i < v.length - 1 && v[i] == 0x00) i++;
|
||||
return Arrays.copyOfRange(v, i, v.length);
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -2,7 +2,6 @@ package com.eu.habbo.networking.gameserver.decoders;
|
||||
|
||||
import com.eu.habbo.messages.ClientMessage;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.handler.codec.ByteToMessageDecoder;
|
||||
|
||||
@@ -12,8 +11,7 @@ public class GameByteDecoder extends ByteToMessageDecoder {
|
||||
@Override
|
||||
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
|
||||
short header = in.readShort();
|
||||
ByteBuf body = Unpooled.copiedBuffer(in.readBytes(in.readableBytes()));
|
||||
|
||||
ByteBuf body = in.readBytes(in.readableBytes());
|
||||
out.add(new ClientMessage(header, body));
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -8,6 +8,9 @@ db.params=
|
||||
db.pool.minsize=25
|
||||
db.pool.maxsize=100
|
||||
|
||||
# Encrypt your traffic
|
||||
crypto.ws.enabled=0
|
||||
|
||||
#Game Configuration.
|
||||
#Host IP. Most likely just 0.0.0.0 Use 127.0.0.1 if you want to play on LAN.
|
||||
game.host=0.0.0.0
|
||||
|
||||
Reference in New Issue
Block a user