Compare commits

..

8 Commits

Author SHA1 Message Date
github-actions[bot] 8709a72b6e 🆙 Bump version to 4.1.15 [skip ci] 2026-05-12 08:55:48 +00:00
DuckieTM c331da9fbe Merge pull request #101 from duckietm/dev
Dev
2026-05-12 10:54:51 +02:00
duckietm f9a079da02 🆙 comibe SQLs 2026-05-12 09:18:22 +02:00
duckietm 89eb989c26 🆙 Refactor AuthHttpHandler for the API and Websocket 2026-05-12 09:11:43 +02:00
duckietm 47be392d8e 🆕 Added Reset password / Email and chenge username in user settings 2026-05-11 18:06:34 +02:00
duckietm d9465a0a65 🆙 Update Some security updates for guilds 2026-05-08 15:38:14 +02:00
duckietm 90314d00fe 🆙 Fix Guilds removal 2026-05-08 15:19:00 +02:00
duckietm 56c73b9d98 🆙 Small fix for the websocket, some CF users have problems with the max frame size 2026-05-08 08:03:51 +02:00
23 changed files with 1844 additions and 1071 deletions
@@ -0,0 +1,5 @@
ALTER TABLE users
ADD COLUMN `last_username_change` INT(11) NOT NULL;
INSERT INTO emulator_settings (`key`, `value`, `comment`)
VALUES ('rename.cooldown_days', '30', 'Days between username changes');
@@ -34,3 +34,18 @@ INSERT IGNORE INTO `custom_nick_icons_catalog` (`icon_key`, `display_name`, `poi
('6', 'Icon 6', 10, 0, 1, 6);
ALTER TABLE `custom_nick_icons_catalog`
ADD COLUMN IF NOT EXISTS `display_name` VARCHAR(100) NOT NULL DEFAULT '' AFTER `icon_key`;
ALTER TABLE `users`
ADD COLUMN IF NOT EXISTS `remember_token_hash` VARCHAR(64) NOT NULL DEFAULT '' AFTER `auth_ticket`;
ALTER TABLE `users`
ADD COLUMN IF NOT EXISTS `remember_token_expires_at` INT(11) UNSIGNED NOT NULL DEFAULT 0 AFTER `remember_token_hash`;
ALTER TABLE `users`
ADD INDEX IF NOT EXISTS `idx_users_remember_token_hash` (`remember_token_hash`);
INSERT INTO `wired_emulator_settings` (`key`, `value`, `comment`)
VALUES ('hotel.wired.message.max_length', '512', 'Maximum length of text fields used by wired messages and bot text effects.')
ON DUPLICATE KEY UPDATE `value` = '512';
@@ -1,8 +0,0 @@
ALTER TABLE `users`
ADD COLUMN IF NOT EXISTS `remember_token_hash` VARCHAR(64) NOT NULL DEFAULT '' AFTER `auth_ticket`;
ALTER TABLE `users`
ADD COLUMN IF NOT EXISTS `remember_token_expires_at` INT(11) UNSIGNED NOT NULL DEFAULT 0 AFTER `remember_token_hash`;
ALTER TABLE `users`
ADD INDEX IF NOT EXISTS `idx_users_remember_token_hash` (`remember_token_hash`);
@@ -1,3 +0,0 @@
INSERT INTO `wired_emulator_settings` (`key`, `value`, `comment`)
VALUES ('hotel.wired.message.max_length', '512', 'Maximum length of text fields used by wired messages and bot text effects.')
ON DUPLICATE KEY UPDATE `value` = '512';
+1 -1
View File
@@ -6,7 +6,7 @@
<groupId>com.eu.habbo</groupId>
<artifactId>Habbo</artifactId>
<version>4.1.14</version>
<version>4.1.15</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -1,18 +0,0 @@
package com.eu.habbo.habbohotel.commands;
import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.gameclients.GameClient;
import com.eu.habbo.messages.outgoing.users.UserDataComposer;
public class ChangeNameCommand extends Command {
public ChangeNameCommand() {
super("cmd_changename", Emulator.getTexts().getValue("commands.keys.cmd_changename").split(";"));
}
@Override
public boolean handle(GameClient gameClient, String[] params) throws Exception {
gameClient.getHabbo().getHabboStats().allowNameChange = !gameClient.getHabbo().getHabboStats().allowNameChange;
gameClient.sendResponse(new UserDataComposer(gameClient.getHabbo()));
return true;
}
}
@@ -184,7 +184,6 @@ public class CommandHandler {
addCommand(new BlockAlertCommand());
addCommand(new BotsCommand());
addCommand(new CalendarCommand());
addCommand(new ChangeNameCommand());
addCommand(new ChatTypeCommand());
addCommand(new CommandsCommand());
addCommand(new ControlCommand());
@@ -2,11 +2,13 @@ package com.eu.habbo.habbohotel.guilds;
import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.gameclients.GameClient;
import com.eu.habbo.habbohotel.guilds.forums.ForumThread;
import com.eu.habbo.habbohotel.guilds.forums.ForumView;
import com.eu.habbo.habbohotel.items.interactions.InteractionGuildFurni;
import com.eu.habbo.habbohotel.rooms.Room;
import com.eu.habbo.habbohotel.users.Habbo;
import com.eu.habbo.messages.outgoing.guilds.GuildJoinErrorComposer;
import com.eu.habbo.messages.outgoing.guilds.forums.GuildForumDataComposer;
import gnu.trove.TCollections;
import gnu.trove.iterator.TIntObjectIterator;
import gnu.trove.map.TIntObjectMap;
@@ -142,12 +144,36 @@ public class GuildManager {
deleteFavourite.execute();
}
try (PreparedStatement statement = connection.prepareStatement("DELETE FROM guild_forum_views WHERE guild_id = ?")) {
statement.setInt(1, guild.getId());
statement.execute();
}
try (PreparedStatement statement = connection.prepareStatement("DELETE c FROM guilds_forums_comments c INNER JOIN guilds_forums_threads t ON c.thread_id = t.id WHERE t.guild_id = ?")) {
statement.setInt(1, guild.getId());
statement.execute();
}
try (PreparedStatement statement = connection.prepareStatement("DELETE FROM guilds_forums_threads WHERE guild_id = ?")) {
statement.setInt(1, guild.getId());
statement.execute();
}
try (PreparedStatement statement = connection.prepareStatement("DELETE FROM guilds_members WHERE guild_id = ?")) {
statement.setInt(1, guild.getId());
statement.execute();
}
try (PreparedStatement statement = connection.prepareStatement("UPDATE rooms SET guild_id = 0 WHERE guild_id = ?")) {
statement.setInt(1, guild.getId());
statement.execute();
}
try (PreparedStatement statement = connection.prepareStatement("UPDATE items SET guild_id = 0 WHERE guild_id = ?")) {
statement.setInt(1, guild.getId());
statement.execute();
}
try (PreparedStatement statement = connection.prepareStatement("DELETE FROM guilds WHERE id = ?")) {
statement.setInt(1, guild.getId());
statement.execute();
@@ -161,6 +187,10 @@ public class GuildManager {
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
}
this.guilds.remove(guild.getId());
ForumThread.clearCacheForGuild(guild.getId());
GuildForumDataComposer.invalidateUnreadCache(guild.getId());
}
@@ -29,7 +29,9 @@ public class GuildAcceptMembershipEvent extends MessageHandler {
if (guild != null) {
GuildMember groupMember = Emulator.getGameEnvironment().getGuildManager().getGuildMember(guild, this.client.getHabbo());
if (userId == this.client.getHabbo().getHabboInfo().getId() || guild.getOwnerId() == this.client.getHabbo().getHabboInfo().getId() || groupMember.getRank().equals(GuildRank.ADMIN) || groupMember.getRank().equals(GuildRank.OWNER) || this.client.getHabbo().hasPermission(Permission.ACC_GUILD_ADMIN)) {
if (guild.getOwnerId() == this.client.getHabbo().getHabboInfo().getId()
|| this.client.getHabbo().hasPermission(Permission.ACC_GUILD_ADMIN)
|| (groupMember != null && (groupMember.getRank().equals(GuildRank.ADMIN) || groupMember.getRank().equals(GuildRank.OWNER)))) {
if (habbo != null) {
if (habbo.getHabboStats().hasGuild(guild.getId())) {
this.client.sendResponse(new GuildAcceptMemberErrorComposer(guild.getId(), GuildAcceptMemberErrorComposer.ALREADY_ACCEPTED));
@@ -11,6 +11,11 @@ import com.eu.habbo.messages.outgoing.guilds.GuildMemberUpdateComposer;
import com.eu.habbo.plugin.events.guilds.GuildGivenAdminEvent;
public class GuildSetAdminEvent extends MessageHandler {
@Override
public int getRatelimit() {
return 500;
}
@Override
public void handle() throws Exception {
int guildId = this.packet.readInt();
@@ -48,7 +48,7 @@ public class GuildForumPostThreadEvent extends MessageHandler {
if (threadId == 0) {
if (!((guild.canPostThreads().state == 0)
|| (guild.canPostThreads().state == 1 && member != null)
|| (guild.canPostThreads().state == 1 && member != null && member.getRank().type <= GuildRank.MEMBER.type)
|| (guild.canPostThreads().state == 2 && member != null && (member.getRank().type < GuildRank.MEMBER.type))
|| (guild.canPostThreads().state == 3 && guild.getOwnerId() == this.client.getHabbo().getHabboInfo().getId())
|| isStaff)) {
@@ -87,7 +87,7 @@ public class GuildForumPostThreadEvent extends MessageHandler {
}
if (!((guild.canPostMessages().state == 0)
|| (guild.canPostMessages().state == 1 && member != null)
|| (guild.canPostMessages().state == 1 && member != null && member.getRank().type <= GuildRank.MEMBER.type)
|| (guild.canPostMessages().state == 2 && member != null && (member.getRank().type < GuildRank.MEMBER.type))
|| (guild.canPostMessages().state == 3 && guild.getOwnerId() == this.client.getHabbo().getHabboInfo().getId())
|| isStaff)) {
@@ -18,6 +18,7 @@ import io.netty.channel.ChannelInitializer;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.WebSocketFrameAggregator;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolConfig;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.logging.LoggingHandler;
@@ -60,6 +61,7 @@ public class WebSocketChannelInitializer extends ChannelInitializer<SocketChanne
ch.pipeline().addLast("authHttpHandler", new AuthHttpHandler());
ch.pipeline().addLast("badgeHttpHandler", new BadgeHttpHandler());
ch.pipeline().addLast("wsProtocolHandler", new WebSocketServerProtocolHandler(this.wsConfig));
ch.pipeline().addLast("wsFrameAggregator", new WebSocketFrameAggregator(MAX_FRAME_SIZE));
ch.pipeline().addLast("wsCodec", new WebSocketCodec());
if (Emulator.getConfig().getBoolean("crypto.ws.enabled", false)) {
@@ -0,0 +1,503 @@
package com.eu.habbo.networking.gameserver.auth;
import com.eu.habbo.Emulator;
import com.google.gson.JsonObject;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpResponseStatus;
import org.mindrot.jbcrypt.BCrypt;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.EMAIL_RE;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.USERNAME_RE;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.checkPassword;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.errorPayload;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.readString;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.sendJson;
final class AccountChangeEndpoints {
private static final Logger LOGGER = LoggerFactory.getLogger(AccountChangeEndpoints.class);
private AccountChangeEndpoints() {
}
static void handleChangePassword(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
int userId = verifyBearer(req, ip, ctx);
if (userId <= 0) return;
String currentPassword = readString(body, "currentPassword");
String newPassword = readString(body, "newPassword");
String confirmPassword = readString(body, "confirmPassword");
if (currentPassword.isEmpty() || newPassword.isEmpty() || confirmPassword.isEmpty()) {
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
errorPayload("All fields are required."));
return;
}
if (currentPassword.length() > 256 || newPassword.length() > 256) {
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
errorPayload("Password too long."));
return;
}
if (!newPassword.equals(confirmPassword)) {
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
errorPayload("New passwords do not match."));
return;
}
if (newPassword.length() < 8) {
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
errorPayload("Password must be at least 8 characters."));
return;
}
if (newPassword.equals(currentPassword)) {
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
errorPayload("New password must be different from the current password."));
return;
}
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) {
String storedHash = null;
String username = null;
try (PreparedStatement lookup = conn.prepareStatement(
"SELECT username, password FROM users WHERE id = ? LIMIT 1")) {
lookup.setInt(1, userId);
try (ResultSet rs = lookup.executeQuery()) {
if (rs.next()) {
username = rs.getString("username");
storedHash = rs.getString("password");
}
}
}
if (storedHash == null || storedHash.isEmpty()) {
AuthRateLimiter.recordFailure(ip);
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Account not found."));
return;
}
if (!checkPassword(currentPassword, storedHash)) {
AuthRateLimiter.recordFailure(ip);
LOGGER.info("[auth/change-password] current password mismatch for user id={} username='{}'", userId, username);
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED,
errorPayload("Current password is incorrect."));
return;
}
String hashed = BCrypt.hashpw(newPassword, BCrypt.gensalt(12));
try (PreparedStatement upd = conn.prepareStatement(
"UPDATE users SET password = ? WHERE id = ? LIMIT 1")) {
upd.setString(1, hashed);
upd.setInt(2, userId);
upd.executeUpdate();
}
AuthRateLimiter.recordSuccess(ip);
LOGGER.info("[auth/change-password] password updated for user id={} username='{}' ip='{}'", userId, username, ip);
JsonObject ok = new JsonObject();
ok.addProperty("message", "Password updated successfully.");
sendJson(ctx, req, HttpResponseStatus.OK, ok);
} catch (Exception e) {
LOGGER.error("[auth/change-password] failed for user id=" + userId, e);
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
}
}
static void handleChangeEmail(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
int userId = verifyBearer(req, ip, ctx);
if (userId <= 0) return;
String currentPassword = readString(body, "currentPassword");
String newEmail = readString(body, "newEmail").trim();
if (currentPassword.isEmpty() || newEmail.isEmpty()) {
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
errorPayload("All fields are required."));
return;
}
if (currentPassword.length() > 256 || newEmail.length() > 254) {
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
errorPayload("Field too long."));
return;
}
if (!EMAIL_RE.matcher(newEmail).matches()) {
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
errorPayload("Invalid email address."));
return;
}
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) {
String storedHash = null;
String username = null;
String currentEmail = null;
try (PreparedStatement lookup = conn.prepareStatement(
"SELECT username, password, mail FROM users WHERE id = ? LIMIT 1")) {
lookup.setInt(1, userId);
try (ResultSet rs = lookup.executeQuery()) {
if (rs.next()) {
username = rs.getString("username");
storedHash = rs.getString("password");
currentEmail = rs.getString("mail");
}
}
}
if (storedHash == null || storedHash.isEmpty()) {
AuthRateLimiter.recordFailure(ip);
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Account not found."));
return;
}
if (!checkPassword(currentPassword, storedHash)) {
AuthRateLimiter.recordFailure(ip);
LOGGER.info("[auth/change-email] password mismatch for user id={} username='{}'", userId, username);
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED,
errorPayload("Current password is incorrect."));
return;
}
if (currentEmail != null && currentEmail.equalsIgnoreCase(newEmail)) {
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
errorPayload("New email must be different from the current email."));
return;
}
try (PreparedStatement check = conn.prepareStatement(
"SELECT id FROM users WHERE mail = ? AND id <> ? LIMIT 1")) {
check.setString(1, newEmail);
check.setInt(2, userId);
try (ResultSet rs = check.executeQuery()) {
if (rs.next()) {
sendJson(ctx, req, HttpResponseStatus.CONFLICT,
errorPayload("That email address is already in use."));
return;
}
}
}
try (PreparedStatement upd = conn.prepareStatement(
"UPDATE users SET mail = ? WHERE id = ? LIMIT 1")) {
upd.setString(1, newEmail);
upd.setInt(2, userId);
upd.executeUpdate();
}
if (currentEmail != null && !currentEmail.isEmpty()) AvailabilityCache.invalidateEmail(currentEmail);
AvailabilityCache.invalidateEmail(newEmail);
AuthRateLimiter.recordSuccess(ip);
LOGGER.info("[auth/change-email] email updated for user id={} username='{}' ip='{}'", userId, username, ip);
JsonObject ok = new JsonObject();
ok.addProperty("message", "Email updated successfully.");
ok.addProperty("email", newEmail);
sendJson(ctx, req, HttpResponseStatus.OK, ok);
} catch (Exception e) {
LOGGER.error("[auth/change-email] failed for user id=" + userId, e);
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
}
}
static void handleChangeUsername(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
int userId = verifyBearer(req, ip, ctx);
if (userId <= 0) return;
String currentPassword = readString(body, "currentPassword");
String newUsername = readString(body, "newUsername").trim();
if (currentPassword.isEmpty() || newUsername.isEmpty()) {
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
errorPayload("All fields are required."));
return;
}
if (currentPassword.length() > 256) {
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
errorPayload("Field too long."));
return;
}
if (newUsername.length() > 25) {
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
errorPayload("Username can be at most 25 characters."));
return;
}
if (!USERNAME_RE.matcher(newUsername).matches()) {
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
errorPayload("Username must be 3-25 characters (letters, numbers, . _ -)."));
return;
}
long cooldownDays = Math.max(0, Emulator.getConfig().getInt("rename.cooldown_days", 30));
long cooldownSeconds = cooldownDays * 86400L;
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) {
String storedHash = null;
String currentUsername = null;
int lastChange = 0;
boolean cooldownColumnExists = true;
try (PreparedStatement lookup = conn.prepareStatement(
"SELECT username, password, last_username_change FROM users WHERE id = ? LIMIT 1")) {
lookup.setInt(1, userId);
try (ResultSet rs = lookup.executeQuery()) {
if (rs.next()) {
currentUsername = rs.getString("username");
storedHash = rs.getString("password");
lastChange = rs.getInt("last_username_change");
}
}
} catch (SQLException missingColumn) {
cooldownColumnExists = false;
LOGGER.warn("[auth/change-username] users.last_username_change column missing — cooldown disabled. Run the migration in config/Database.sql.");
try (PreparedStatement lookup = conn.prepareStatement(
"SELECT username, password FROM users WHERE id = ? LIMIT 1")) {
lookup.setInt(1, userId);
try (ResultSet rs = lookup.executeQuery()) {
if (rs.next()) {
currentUsername = rs.getString("username");
storedHash = rs.getString("password");
}
}
}
}
if (storedHash == null || storedHash.isEmpty()) {
AuthRateLimiter.recordFailure(ip);
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Account not found."));
return;
}
if (!checkPassword(currentPassword, storedHash)) {
AuthRateLimiter.recordFailure(ip);
LOGGER.info("[auth/change-username] password mismatch for user id={} username='{}'", userId, currentUsername);
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED,
errorPayload("Current password is incorrect."));
return;
}
if (currentUsername != null && currentUsername.equals(newUsername)) {
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
errorPayload("New username must be different from the current username."));
return;
}
int now = Emulator.getIntUnixTimestamp();
if (cooldownColumnExists && cooldownSeconds > 0 && lastChange > 0) {
long allowedAt = (long) lastChange + cooldownSeconds;
if (now < allowedAt) {
long remaining = allowedAt - now;
long days = remaining / 86400L;
long hours = (remaining % 86400L) / 3600L;
String wait = days > 0 ? (days + " day" + (days == 1 ? "" : "s")) : (hours + " hour" + (hours == 1 ? "" : "s"));
sendJson(ctx, req, HttpResponseStatus.TOO_MANY_REQUESTS,
errorPayload("You can rename again in " + wait + "."));
return;
}
}
try (PreparedStatement banned = conn.prepareStatement(
"SELECT 1 FROM banned_usernames WHERE LOWER(username) = LOWER(?) LIMIT 1")) {
banned.setString(1, newUsername);
try (ResultSet rs = banned.executeQuery()) {
if (rs.next()) {
sendJson(ctx, req, HttpResponseStatus.CONFLICT,
errorPayload("That username is not allowed."));
return;
}
}
} catch (SQLException bannedTableError) {
if (bannedTableError.getErrorCode() != 1146
&& !"42S02".equals(bannedTableError.getSQLState())) {
throw bannedTableError;
}
}
try (PreparedStatement check = conn.prepareStatement(
"SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id <> ? LIMIT 1")) {
check.setString(1, newUsername);
check.setInt(2, userId);
try (ResultSet rs = check.executeQuery()) {
if (rs.next()) {
sendJson(ctx, req, HttpResponseStatus.CONFLICT,
errorPayload("That username is already taken."));
return;
}
}
}
boolean previousAutoCommit = conn.getAutoCommit();
conn.setAutoCommit(false);
boolean cooldownRace = false;
boolean duplicateName = false;
try {
int rowsUpdated = 0;
try (PreparedStatement upd = conn.prepareStatement(
cooldownColumnExists
? "UPDATE users SET username = ?, last_username_change = ? "
+ "WHERE id = ? "
+ " AND (last_username_change = 0 OR last_username_change + ? <= ?) "
+ "LIMIT 1"
: "UPDATE users SET username = ? WHERE id = ? LIMIT 1")) {
upd.setString(1, newUsername);
if (cooldownColumnExists) {
upd.setInt(2, now);
upd.setInt(3, userId);
upd.setLong(4, cooldownSeconds);
upd.setInt(5, now);
} else {
upd.setInt(2, userId);
}
try {
rowsUpdated = upd.executeUpdate();
} catch (SQLException dup) {
if (dup.getErrorCode() == 1062 || "23000".equals(dup.getSQLState())) {
duplicateName = true;
} else {
throw dup;
}
}
}
if (duplicateName || (cooldownColumnExists && rowsUpdated == 0)) {
if (!duplicateName) cooldownRace = true;
conn.rollback();
} else {
try (PreparedStatement upd = conn.prepareStatement(
"UPDATE rooms SET owner_name = ? WHERE owner_id = ?")) {
upd.setString(1, newUsername);
upd.setInt(2, userId);
upd.executeUpdate();
}
try (PreparedStatement upd = conn.prepareStatement(
"UPDATE rooms_for_sale SET owner_name = ? WHERE user_id = ?")) {
upd.setString(1, newUsername);
upd.setInt(2, userId);
upd.executeUpdate();
} catch (SQLException roomsForSale) {
if (roomsForSale.getErrorCode() != 1146
&& !"42S02".equals(roomsForSale.getSQLState())) {
throw roomsForSale;
}
}
conn.commit();
}
} catch (SQLException txError) {
try { conn.rollback(); } catch (SQLException ignore) {}
throw txError;
} finally {
conn.setAutoCommit(previousAutoCommit);
}
if (duplicateName) {
LOGGER.info("[auth/change-username] dup-entry race for user id={} wanted='{}'", userId, newUsername);
sendJson(ctx, req, HttpResponseStatus.CONFLICT,
errorPayload("That username is already taken."));
return;
}
if (cooldownRace) {
LOGGER.info("[auth/change-username] cooldown race for user id={} (concurrent rename rejected)", userId);
sendJson(ctx, req, HttpResponseStatus.TOO_MANY_REQUESTS,
errorPayload("Rename already in progress — please wait."));
return;
}
try {
if (Emulator.getGameServer() != null && Emulator.getGameServer().getGameClientManager() != null
&& Emulator.getGameEnvironment() != null && Emulator.getGameEnvironment().getHabboManager() != null) {
com.eu.habbo.habbohotel.users.Habbo habbo =
Emulator.getGameServer().getGameClientManager().getHabbo(userId);
if (habbo != null) {
Emulator.getGameEnvironment().getHabboManager().removeHabbo(habbo);
habbo.getHabboInfo().setUsername(newUsername);
Emulator.getGameEnvironment().getHabboManager().addHabbo(habbo);
}
}
} catch (Exception cacheError) {
LOGGER.warn("[auth/change-username] failed to refresh HabboManager cache", cacheError);
}
try {
if (Emulator.getGameEnvironment() != null && Emulator.getGameEnvironment().getRoomManager() != null) {
for (com.eu.habbo.habbohotel.rooms.Room room : Emulator.getGameEnvironment().getRoomManager().getActiveRooms()) {
if (room.getOwnerId() == userId) {
room.setOwnerName(newUsername);
}
}
}
} catch (Exception cacheError) {
LOGGER.warn("[auth/change-username] failed to refresh Room.ownerName cache", cacheError);
}
try {
com.eu.habbo.messages.incoming.catalog.marketplace.RequestOffersEvent.cachedResults.clear();
} catch (Exception cacheError) {
LOGGER.warn("[auth/change-username] failed to clear marketplace cache", cacheError);
}
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());
}
}
AuthRateLimiter.recordSuccess(ip);
LOGGER.info("[auth/change-username] '{}' -> '{}' (user id={}, ip='{}')",
currentUsername, newUsername, userId, ip);
JsonObject ok = new JsonObject();
ok.addProperty("message", "Username updated. Please log in again with your new name.");
ok.addProperty("username", newUsername);
ok.addProperty("relogin", true);
sendJson(ctx, req, HttpResponseStatus.OK, ok);
} catch (Exception e) {
LOGGER.error("[auth/change-username] failed for user id=" + userId, e);
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
}
}
private static int verifyBearer(FullHttpRequest req, String ip, ChannelHandlerContext ctx) {
String authHeader = req.headers().get(HttpHeaderNames.AUTHORIZATION);
String bearer = "";
if (authHeader != null && authHeader.regionMatches(true, 0, "Bearer ", 0, 7)) {
bearer = authHeader.substring(7).trim();
}
int userId = AccessTokenService.verify(bearer);
if (userId <= 0) {
AuthRateLimiter.recordFailure(ip);
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Not authenticated."));
}
return userId;
}
}
@@ -0,0 +1,106 @@
package com.eu.habbo.networking.gameserver.auth;
import com.eu.habbo.Emulator;
import com.google.gson.JsonObject;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.HttpResponseStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.EMAIL_RE;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.USERNAME_RE;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.errorPayload;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.readString;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.sendJson;
final class AccountCheckEndpoints {
private static final Logger LOGGER = LoggerFactory.getLogger(AccountCheckEndpoints.class);
private AccountCheckEndpoints() {
}
static 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;
}
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);
}
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);
}
static 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);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,237 @@
package com.eu.habbo.networking.gameserver.auth;
import com.eu.habbo.Emulator;
import com.eu.habbo.networking.gameserver.GameServerAttributes;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.http.DefaultFullHttpResponse;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.FullHttpResponse;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpHeaderValues;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.codec.http.HttpVersion;
import org.mindrot.jbcrypt.BCrypt;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.InetSocketAddress;
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.util.Base64;
import java.util.regex.Pattern;
final class AuthHttpUtil {
private static final Logger LOGGER = LoggerFactory.getLogger(AuthHttpUtil.class);
static final Pattern USERNAME_RE = Pattern.compile("^[A-Za-z0-9._-]{3,32}$");
static final Pattern EMAIL_RE = Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$");
static final Pattern FIGURE_RE = Pattern.compile("^[A-Za-z0-9.\\-]{1,200}$");
static final SecureRandom RNG = new SecureRandom();
static final int MAX_BODY_BYTES = 8 * 1024;
private static final long PERMANENT_BAN_THRESHOLD_SECONDS = 30L * 365L * 24L * 60L * 60L;
private AuthHttpUtil() {
}
static String readString(JsonObject obj, String key) {
if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) return "";
try {
return obj.get(key).getAsString();
} catch (Exception e) {
return "";
}
}
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;
}
}
static boolean readBoolean(JsonObject obj, String key, boolean defaultValue) {
if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) return defaultValue;
try {
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;
}
}
static JsonObject errorPayload(String message) {
JsonObject obj = new JsonObject();
obj.addProperty("error", message);
return obj;
}
static void sendJson(ChannelHandlerContext ctx, FullHttpRequest req,
HttpResponseStatus status, JsonObject body) {
byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8);
FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1, status, Unpooled.wrappedBuffer(bytes));
response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json; charset=utf-8");
response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, bytes.length);
applyCors(req, response);
boolean keepAlive = isKeepAlive(req);
if (keepAlive) response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
var future = ctx.writeAndFlush(response);
if (!keepAlive) future.addListener(ChannelFutureListener.CLOSE);
}
static void sendCors(ChannelHandlerContext ctx, FullHttpRequest req) {
FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1, HttpResponseStatus.NO_CONTENT);
applyCors(req, response);
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
}
static void applyCors(FullHttpRequest req, FullHttpResponse response) {
String origin = req.headers().get(HttpHeaderNames.ORIGIN);
if (origin != null && !origin.isEmpty() && CorsOriginGate.isAllowed(req)) {
response.headers().set("Access-Control-Allow-Origin", origin);
response.headers().set("Access-Control-Allow-Credentials", "true");
}
response.headers().set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS");
String requestedHeaders = req.headers().get("Access-Control-Request-Headers");
if (requestedHeaders != null && !requestedHeaders.isEmpty()) {
response.headers().set("Access-Control-Allow-Headers", requestedHeaders);
} else {
response.headers().set("Access-Control-Allow-Headers",
"Authorization, Content-Type, X-Requested-With, X-Nitro-Key, X-Nitro-Api");
}
response.headers().set("Vary", "Origin, Access-Control-Request-Headers, Access-Control-Request-Method");
response.headers().set("Access-Control-Max-Age", "600");
response.headers().set("Access-Control-Expose-Headers", "X-Nitro-Sec, X-Nitro-Key-Fp, X-Nitro-Derive-Fp");
}
static boolean isKeepAlive(FullHttpRequest req) {
String connection = req.headers().get(HttpHeaderNames.CONNECTION);
return connection == null || !"close".equalsIgnoreCase(connection);
}
static String resolveClientIp(ChannelHandlerContext ctx, FullHttpRequest req) {
String ipHeader = Emulator.getConfig() != null
? Emulator.getConfig().getValue("ws.ip.header", "")
: "";
if (!ipHeader.isEmpty() && req.headers().contains(ipHeader)) {
String hv = req.headers().get(ipHeader);
if (hv != null && !hv.isEmpty()) {
int comma = hv.indexOf(',');
return (comma > 0 ? hv.substring(0, comma) : hv).trim();
}
}
if (ctx.channel().attr(GameServerAttributes.WS_IP).get() != null) {
return ctx.channel().attr(GameServerAttributes.WS_IP).get();
}
if (ctx.channel().remoteAddress() instanceof InetSocketAddress addr) {
return addr.getAddress().getHostAddress();
}
return "";
}
static boolean checkPassword(String plain, String stored) {
String compatible = stored.startsWith("$2y$") ? "$2a$" + stored.substring(4) : stored;
try {
return BCrypt.checkpw(plain, compatible);
} catch (IllegalArgumentException e) {
return false;
}
}
static String mintSsoTicket() {
byte[] buf = new byte[32];
RNG.nextBytes(buf);
return "nitro-" + Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
}
static String mintResetToken() {
byte[] buf = new byte[32];
RNG.nextBytes(buf);
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
}
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;
}
}
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;
}
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;
}
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;
}
}
@@ -0,0 +1,53 @@
package com.eu.habbo.networking.gameserver.auth;
import com.eu.habbo.Emulator;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.HttpHeaderNames;
import java.net.URI;
public final class CorsOriginGate {
private static final String CONFIG_KEY = "ws.whitelist";
private static final String CONFIG_DEFAULT = "localhost";
private CorsOriginGate() {
}
public static boolean isAllowed(FullHttpRequest req) {
if (req == null) return false;
String origin = req.headers().get(HttpHeaderNames.ORIGIN);
if (origin == null || origin.isEmpty()) return false;
String host;
try {
URI uri = new URI(origin);
host = uri.getHost();
} catch (Exception ignored) {
return false;
}
if (host == null || host.isEmpty()) return false;
if (host.startsWith("www.")) host = host.substring(4);
String configured = Emulator.getConfig().getValue(CONFIG_KEY, CONFIG_DEFAULT);
if (configured == null || configured.isEmpty()) return false;
for (String entry : configured.split(",")) {
String trimmed = entry.trim();
if (trimmed.isEmpty()) continue;
if ("*".equals(trimmed)) {
return true;
}
if (trimmed.startsWith("*")) {
String suffix = trimmed.substring(1);
if (host.endsWith(suffix) || ("." + host).equals(suffix)) {
return true;
}
} else if (host.equals(trimmed)) {
return true;
}
}
return false;
}
}
@@ -265,13 +265,23 @@ public class NitroSecureApiHandler extends ChannelDuplexHandler {
private static void applyCors(FullHttpRequest req, FullHttpResponse response) {
String origin = req.headers().get(HttpHeaderNames.ORIGIN);
if (origin != null && !origin.isEmpty()) {
if (origin != null && !origin.isEmpty() && CorsOriginGate.isAllowed(req)) {
response.headers().set("Access-Control-Allow-Origin", origin);
response.headers().set("Vary", "Origin");
response.headers().set("Access-Control-Allow-Credentials", "true");
}
response.headers().set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS");
response.headers().set("Access-Control-Allow-Headers", "Content-Type, X-Requested-With, X-Nitro-Key, X-Nitro-Api");
String requestedHeaders = req.headers().get("Access-Control-Request-Headers");
if (requestedHeaders != null && !requestedHeaders.isEmpty()) {
response.headers().set("Access-Control-Allow-Headers", requestedHeaders);
} else {
response.headers().set("Access-Control-Allow-Headers",
"Authorization, Content-Type, X-Requested-With, X-Nitro-Key, X-Nitro-Api");
}
response.headers().set("Vary", "Origin, Access-Control-Request-Headers, Access-Control-Request-Method");
response.headers().set("Access-Control-Max-Age", "600");
response.headers().set("Access-Control-Expose-Headers", "X-Nitro-Sec, X-Nitro-Key-Fp, X-Nitro-Derive-Fp");
}
@@ -297,12 +297,21 @@ public class NitroSecureAssetHandler extends ChannelInboundHandlerAdapter {
private static void applyCors(FullHttpRequest req, FullHttpResponse response) {
String origin = req.headers().get(HttpHeaderNames.ORIGIN);
if (origin != null && !origin.isEmpty()) {
if (origin != null && !origin.isEmpty() && CorsOriginGate.isAllowed(req)) {
response.headers().set("Access-Control-Allow-Origin", origin);
response.headers().set("Vary", "Origin");
}
response.headers().set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS");
response.headers().set("Access-Control-Allow-Headers", "Content-Type, X-Nitro-Key");
String requestedHeaders = req.headers().get("Access-Control-Request-Headers");
if (requestedHeaders != null && !requestedHeaders.isEmpty()) {
response.headers().set("Access-Control-Allow-Headers", requestedHeaders);
} else {
response.headers().set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Nitro-Key");
}
response.headers().set("Vary", "Origin, Access-Control-Request-Headers, Access-Control-Request-Method");
response.headers().set("Access-Control-Max-Age", "600");
response.headers().set("Access-Control-Expose-Headers", "X-Nitro-Sec, X-Nitro-Key-Fp, X-Nitro-Derive-Fp");
}
@@ -0,0 +1,175 @@
package com.eu.habbo.networking.gameserver.auth;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
final class RegistrationSupport {
private static final Logger LOGGER = LoggerFactory.getLogger(RegistrationSupport.class);
private RegistrationSupport() {
}
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);
}
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);
}
}
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);
}
}
}
@@ -0,0 +1,466 @@
package com.eu.habbo.networking.gameserver.auth;
import com.eu.habbo.Emulator;
import com.google.gson.JsonObject;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.HttpResponseStatus;
import org.mindrot.jbcrypt.BCrypt;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.Timestamp;
import java.time.Instant;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.EMAIL_RE;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.FIGURE_RE;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.USERNAME_RE;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.bannedPayload;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.checkPassword;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.errorPayload;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.lookupAccountBan;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.lookupIpBan;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.mintResetToken;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.mintSsoTicket;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.readBoolean;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.readInt;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.readString;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.sendJson;
final class SessionEndpoints {
private static final Logger LOGGER = LoggerFactory.getLogger(SessionEndpoints.class);
private SessionEndpoints() {
}
static void handleLogout(ChannelHandlerContext ctx, FullHttpRequest req, 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 (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", e);
}
sendJson(ctx, req, HttpResponseStatus.OK, ok);
}
static void handleRemember(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
String jwt = readString(body, "rememberToken").trim();
if (jwt.isEmpty()) {
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Missing rememberToken."));
return;
}
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) {
RememberJwtService.RotationResult rot = RememberJwtService.rotate(conn, jwt, ip);
if (rot == null) {
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Remember token invalid or expired."));
return;
}
String ssoTicket = mintSsoTicket();
try (PreparedStatement upd = conn.prepareStatement(
"UPDATE users SET auth_ticket = ?, ip_current = ? WHERE id = ? LIMIT 1")) {
upd.setString(1, ssoTicket);
upd.setString(2, ip == null ? "" : ip);
upd.setInt(3, rot.userId);
upd.executeUpdate();
}
JsonObject ok = new JsonObject();
ok.addProperty("ssoTicket", ssoTicket);
ok.addProperty("username", rot.username);
ok.addProperty("rememberToken", rot.jwt);
ok.addProperty("expiresAt", rot.expiresAt);
ok.addProperty("rememberExpiresAt", rot.expiresAt);
AccessTokenService.Issued access = AccessTokenService.issue(rot.userId);
ok.addProperty("accessToken", access.token);
ok.addProperty("accessTokenExpiresAt", access.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."));
}
}
static void handleSsoToken(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
String ssoTicket = readString(body, "ssoTicket").trim();
if (ssoTicket.isEmpty() || ssoTicket.length() > 128) {
AuthRateLimiter.recordFailure(ip);
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Missing or invalid ssoTicket."));
return;
}
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement lookup = conn.prepareStatement(
"SELECT id, username FROM users WHERE auth_ticket = ? LIMIT 1")) {
lookup.setString(1, ssoTicket);
try (ResultSet rs = lookup.executeQuery()) {
if (!rs.next()) {
AuthRateLimiter.recordFailure(ip);
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("SSO ticket not recognised."));
return;
}
int userId = rs.getInt("id");
String username = rs.getString("username");
AuthRateLimiter.recordSuccess(ip);
AccessTokenService.Issued access = AccessTokenService.issue(userId);
JsonObject ok = new JsonObject();
ok.addProperty("username", username);
ok.addProperty("accessToken", access.token);
ok.addProperty("accessTokenExpiresAt", access.expiresAt);
sendJson(ctx, req, HttpResponseStatus.OK, ok);
}
} catch (Exception e) {
LOGGER.error("[auth/sso-token] lookup failed", e);
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
}
}
static void handleRefresh(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
String jwt = readString(body, "rememberToken").trim();
if (jwt.isEmpty()) {
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Missing rememberToken."));
return;
}
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) {
RememberJwtService.RotationResult rot = RememberJwtService.rotate(conn, jwt, ip);
if (rot == null) {
sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Remember token invalid or expired."));
return;
}
JsonObject ok = new JsonObject();
ok.addProperty("rememberToken", rot.jwt);
ok.addProperty("expiresAt", rot.expiresAt);
ok.addProperty("rememberExpiresAt", rot.expiresAt);
AccessTokenService.Issued access = AccessTokenService.issue(rot.userId);
ok.addProperty("accessToken", access.token);
ok.addProperty("accessTokenExpiresAt", access.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."));
}
}
static 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) || readBoolean(body, "rememberMe", false);
if (username.isEmpty() || password.isEmpty()) {
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Missing credentials."));
return;
}
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) {
if (ip != null && !ip.isEmpty()) {
AuthHttpUtil.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;
}
}
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;
}
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;
}
AuthHttpUtil.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);
AccessTokenService.Issued access = AccessTokenService.issue(userId);
ok.addProperty("accessToken", access.token);
ok.addProperty("accessTokenExpiresAt", access.expiresAt);
sendJson(ctx, req, HttpResponseStatus.OK, ok);
}
}
} catch (Exception e) {
LOGGER.error("Login query failed for username=" + username, e);
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
}
}
static 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."));
return;
}
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,
errorPayload("Username must be 3-32 chars (letters, numbers, . _ -)."));
return;
}
if (!EMAIL_RE.matcher(email).matches()) {
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Invalid email address."));
return;
}
if (password.length() < 8) {
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
errorPayload("Password must be at least 8 characters."));
return;
}
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) {
int maxPerIp = Emulator.getConfig().getInt("register.max_per_ip", 5);
if (maxPerIp > 0 && ip != null && !ip.isEmpty()) {
try (PreparedStatement quota = conn.prepareStatement(
"SELECT COUNT(*) FROM users WHERE ip_register = ?")) {
quota.setString(1, ip);
try (ResultSet rs = quota.executeQuery()) {
if (rs.next() && rs.getInt(1) >= maxPerIp) {
sendJson(ctx, req, HttpResponseStatus.TOO_MANY_REQUESTS,
errorPayload("This IP has reached the maximum of "
+ maxPerIp + " registered accounts."));
return;
}
}
}
}
try (PreparedStatement check = conn.prepareStatement(
"SELECT username, mail FROM users WHERE username = ? OR mail = ? LIMIT 1")) {
check.setString(1, username);
check.setString(2, email);
try (ResultSet rs = check.executeQuery()) {
if (rs.next()) {
String existingUser = rs.getString("username");
String existingMail = rs.getString("mail");
boolean userTaken = existingUser != null && existingUser.equalsIgnoreCase(username);
boolean mailTaken = existingMail != null && existingMail.equalsIgnoreCase(email);
String message;
if (userTaken && mailTaken) message = "That Habbo name and email are already in use.";
else if (userTaken) message = "That Habbo name is already in use.";
else message = "That email address is already in use.";
sendJson(ctx, req, HttpResponseStatus.CONFLICT, errorPayload(message));
return;
}
}
}
String hashed = BCrypt.hashpw(password, BCrypt.gensalt(12));
String defaultLook = Emulator.getConfig().getValue("register.default.look",
"hr-100-7.hd-180-1.ch-210-66.lg-270-82.sh-290-80");
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, '', '', '0')",
Statement.RETURN_GENERATED_KEYS)) {
ins.setString(1, username);
ins.setString(2, hashed);
ins.setString(3, email);
ins.setInt(4, now);
ins.setString(5, ip == null ? "" : ip);
ins.setString(6, ip == null ? "" : ip);
ins.setInt(7, now);
ins.setInt(8, now);
ins.setString(9, defaultMotto);
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)) {
RegistrationSupport.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) {
RegistrationSupport.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);
} catch (Exception e) {
LOGGER.error("Register query failed for username=" + username, e);
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
}
}
static void handleForgot(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
String email = readString(body, "email").trim();
if (!EMAIL_RE.matcher(email).matches()) {
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Invalid email address."));
return;
}
JsonObject ok = new JsonObject();
ok.addProperty("message", "Email sent! If an account matches that address you'll find a reset link in your inbox shortly (check spam if it doesn't show up within a minute).");
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement stmt = conn.prepareStatement(
"SELECT id, username FROM users WHERE mail = ? LIMIT 1")) {
stmt.setString(1, email);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
int userId = rs.getInt("id");
String username = rs.getString("username");
String token = mintResetToken();
long expiresAt = Instant.now().getEpochSecond() + 60L * 60L; // 1h
try (PreparedStatement ins = conn.prepareStatement(
"INSERT INTO password_resets (user_id, token, expires_at, created_ip) " +
"VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE " +
"token = VALUES(token), expires_at = VALUES(expires_at), created_ip = VALUES(created_ip)")) {
ins.setInt(1, userId);
ins.setString(2, token);
ins.setTimestamp(3, Timestamp.from(Instant.ofEpochSecond(expiresAt)));
ins.setString(4, ip == null ? "" : ip);
ins.executeUpdate();
}
String resetUrlBase = Emulator.getConfig().getValue("password.reset.url",
"http://localhost/reset-password");
String fullUrl = resetUrlBase + (resetUrlBase.contains("?") ? "&" : "?") + "token=" + token;
String subject = "Reset your Habbo password";
String message = "Hi " + username + ",\n\n" +
"Someone (hopefully you) requested a password reset for your Habbo account.\n" +
"Click the link below within the next hour to choose a new password:\n\n" +
fullUrl + "\n\n" +
"If you didn't request this you can safely ignore this email.";
Emulator.getThreading().getService().submit((Runnable) () -> SmtpMailService.send(email, subject, message));
}
}
} catch (Exception e) {
LOGGER.error("Forgot-password query failed for email=" + email, e);
}
sendJson(ctx, req, HttpResponseStatus.OK, ok);
}
}
@@ -0,0 +1,148 @@
package com.eu.habbo.networking.gameserver.auth;
import com.eu.habbo.Emulator;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.http.DefaultFullHttpResponse;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.FullHttpResponse;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpHeaderValues;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.codec.http.HttpVersion;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.applyCors;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.errorPayload;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.isKeepAlive;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.sendJson;
final class StaticContentEndpoints {
private static final Logger LOGGER = LoggerFactory.getLogger(StaticContentEndpoints.class);
private static final long NEWS_CACHE_TTL_MS = 30_000L;
private static final int NEWS_IMAGE_MAX_BYTES = 512 * 1024;
private static volatile NewsCacheEntry NEWS_CACHE = null;
private static final class NewsCacheEntry {
final byte[] jsonBytes;
final long expiresAt;
NewsCacheEntry(byte[] j, long e) {
jsonBytes = j;
expiresAt = e;
}
}
private StaticContentEndpoints() {
}
static 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);
}
static void handleNews(ChannelHandlerContext ctx, FullHttpRequest req) {
long now = System.currentTimeMillis();
NewsCacheEntry cached = NEWS_CACHE;
if (cached == null || cached.expiresAt < now) {
JsonArray items = new JsonArray();
int limit = Math.max(1, Math.min(20, Emulator.getConfig().getInt("login.news.limit", 5)));
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement stmt = conn.prepareStatement(
"SELECT id, title, body, image, link_text, link_url " +
"FROM ui_news WHERE enabled = 1 " +
"ORDER BY sort_order ASC, id DESC LIMIT ?")) {
stmt.setInt(1, limit);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
int id = rs.getInt("id");
JsonObject n = new JsonObject();
n.addProperty("id", id);
n.addProperty("title", rs.getString("title"));
n.addProperty("body", rs.getString("body"));
String image = rs.getString("image");
if (image != null && image.length() > NEWS_IMAGE_MAX_BYTES) {
LOGGER.warn("ui_news id={} image is {} bytes (>{}KB cap), omitting in response",
id, image.length(), NEWS_IMAGE_MAX_BYTES / 1024);
image = null;
}
n.addProperty("image", image);
n.addProperty("linkText", rs.getString("link_text"));
n.addProperty("linkUrl", rs.getString("link_url"));
items.add(n);
}
}
} catch (Exception e) {
LOGGER.error("ui_news list failed", e);
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
return;
}
JsonObject res = new JsonObject();
res.add("news", items);
byte[] bytes = res.toString().getBytes(StandardCharsets.UTF_8);
cached = new NewsCacheEntry(bytes, now + NEWS_CACHE_TTL_MS);
NEWS_CACHE = cached;
}
FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1, HttpResponseStatus.OK,
Unpooled.wrappedBuffer(cached.jsonBytes));
response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json; charset=utf-8");
response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, cached.jsonBytes.length);
response.headers().set(HttpHeaderNames.CACHE_CONTROL, "public, max-age=30");
applyCors(req, response);
boolean keepAlive = isKeepAlive(req);
if (keepAlive) response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
var future = ctx.writeAndFlush(response);
if (!keepAlive) future.addListener(ChannelFutureListener.CLOSE);
}
static 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."));
}
}
}