You've already forked Arcturus-Morningstar-Extended
mirror of
https://github.com/duckietm/Arcturus-Morningstar-Extended.git
synced 2026-06-20 07:26:18 +00:00
@@ -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');
|
||||||
+15
@@ -34,3 +34,18 @@ INSERT IGNORE INTO `custom_nick_icons_catalog` (`icon_key`, `display_name`, `poi
|
|||||||
('6', 'Icon 6', 10, 0, 1, 6);
|
('6', 'Icon 6', 10, 0, 1, 6);
|
||||||
ALTER TABLE `custom_nick_icons_catalog`
|
ALTER TABLE `custom_nick_icons_catalog`
|
||||||
ADD COLUMN IF NOT EXISTS `display_name` VARCHAR(100) NOT NULL DEFAULT '' AFTER `icon_key`;
|
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,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 BlockAlertCommand());
|
||||||
addCommand(new BotsCommand());
|
addCommand(new BotsCommand());
|
||||||
addCommand(new CalendarCommand());
|
addCommand(new CalendarCommand());
|
||||||
addCommand(new ChangeNameCommand());
|
|
||||||
addCommand(new ChatTypeCommand());
|
addCommand(new ChatTypeCommand());
|
||||||
addCommand(new CommandsCommand());
|
addCommand(new CommandsCommand());
|
||||||
addCommand(new ControlCommand());
|
addCommand(new ControlCommand());
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package com.eu.habbo.habbohotel.guilds;
|
|||||||
|
|
||||||
import com.eu.habbo.Emulator;
|
import com.eu.habbo.Emulator;
|
||||||
import com.eu.habbo.habbohotel.gameclients.GameClient;
|
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.guilds.forums.ForumView;
|
||||||
import com.eu.habbo.habbohotel.items.interactions.InteractionGuildFurni;
|
import com.eu.habbo.habbohotel.items.interactions.InteractionGuildFurni;
|
||||||
import com.eu.habbo.habbohotel.rooms.Room;
|
import com.eu.habbo.habbohotel.rooms.Room;
|
||||||
import com.eu.habbo.habbohotel.users.Habbo;
|
import com.eu.habbo.habbohotel.users.Habbo;
|
||||||
import com.eu.habbo.messages.outgoing.guilds.GuildJoinErrorComposer;
|
import com.eu.habbo.messages.outgoing.guilds.GuildJoinErrorComposer;
|
||||||
|
import com.eu.habbo.messages.outgoing.guilds.forums.GuildForumDataComposer;
|
||||||
import gnu.trove.TCollections;
|
import gnu.trove.TCollections;
|
||||||
import gnu.trove.iterator.TIntObjectIterator;
|
import gnu.trove.iterator.TIntObjectIterator;
|
||||||
import gnu.trove.map.TIntObjectMap;
|
import gnu.trove.map.TIntObjectMap;
|
||||||
@@ -142,12 +144,36 @@ public class GuildManager {
|
|||||||
deleteFavourite.execute();
|
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 = ?")) {
|
try (PreparedStatement statement = connection.prepareStatement("DELETE FROM guilds_members WHERE guild_id = ?")) {
|
||||||
statement.setInt(1, guild.getId());
|
statement.setInt(1, guild.getId());
|
||||||
statement.execute();
|
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 = ?")) {
|
try (PreparedStatement statement = connection.prepareStatement("DELETE FROM guilds WHERE id = ?")) {
|
||||||
statement.setInt(1, guild.getId());
|
statement.setInt(1, guild.getId());
|
||||||
statement.execute();
|
statement.execute();
|
||||||
@@ -161,6 +187,10 @@ public class GuildManager {
|
|||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
LOGGER.error("Caught SQL exception", e);
|
LOGGER.error("Caught SQL exception", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.guilds.remove(guild.getId());
|
||||||
|
ForumThread.clearCacheForGuild(guild.getId());
|
||||||
|
GuildForumDataComposer.invalidateUnreadCache(guild.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -29,7 +29,9 @@ public class GuildAcceptMembershipEvent extends MessageHandler {
|
|||||||
|
|
||||||
if (guild != null) {
|
if (guild != null) {
|
||||||
GuildMember groupMember = Emulator.getGameEnvironment().getGuildManager().getGuildMember(guild, this.client.getHabbo());
|
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 != null) {
|
||||||
if (habbo.getHabboStats().hasGuild(guild.getId())) {
|
if (habbo.getHabboStats().hasGuild(guild.getId())) {
|
||||||
this.client.sendResponse(new GuildAcceptMemberErrorComposer(guild.getId(), GuildAcceptMemberErrorComposer.ALREADY_ACCEPTED));
|
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;
|
import com.eu.habbo.plugin.events.guilds.GuildGivenAdminEvent;
|
||||||
|
|
||||||
public class GuildSetAdminEvent extends MessageHandler {
|
public class GuildSetAdminEvent extends MessageHandler {
|
||||||
|
@Override
|
||||||
|
public int getRatelimit() {
|
||||||
|
return 500;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void handle() throws Exception {
|
public void handle() throws Exception {
|
||||||
int guildId = this.packet.readInt();
|
int guildId = this.packet.readInt();
|
||||||
|
|||||||
+2
-2
@@ -48,7 +48,7 @@ public class GuildForumPostThreadEvent extends MessageHandler {
|
|||||||
|
|
||||||
if (threadId == 0) {
|
if (threadId == 0) {
|
||||||
if (!((guild.canPostThreads().state == 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 == 2 && member != null && (member.getRank().type < GuildRank.MEMBER.type))
|
||||||
|| (guild.canPostThreads().state == 3 && guild.getOwnerId() == this.client.getHabbo().getHabboInfo().getId())
|
|| (guild.canPostThreads().state == 3 && guild.getOwnerId() == this.client.getHabbo().getHabboInfo().getId())
|
||||||
|| isStaff)) {
|
|| isStaff)) {
|
||||||
@@ -87,7 +87,7 @@ public class GuildForumPostThreadEvent extends MessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!((guild.canPostMessages().state == 0)
|
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 == 2 && member != null && (member.getRank().type < GuildRank.MEMBER.type))
|
||||||
|| (guild.canPostMessages().state == 3 && guild.getOwnerId() == this.client.getHabbo().getHabboInfo().getId())
|
|| (guild.canPostMessages().state == 3 && guild.getOwnerId() == this.client.getHabbo().getHabboInfo().getId())
|
||||||
|| isStaff)) {
|
|| isStaff)) {
|
||||||
|
|||||||
+2
@@ -18,6 +18,7 @@ import io.netty.channel.ChannelInitializer;
|
|||||||
import io.netty.channel.socket.SocketChannel;
|
import io.netty.channel.socket.SocketChannel;
|
||||||
import io.netty.handler.codec.http.HttpObjectAggregator;
|
import io.netty.handler.codec.http.HttpObjectAggregator;
|
||||||
import io.netty.handler.codec.http.HttpServerCodec;
|
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.WebSocketServerProtocolConfig;
|
||||||
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
|
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
|
||||||
import io.netty.handler.logging.LoggingHandler;
|
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("authHttpHandler", new AuthHttpHandler());
|
||||||
ch.pipeline().addLast("badgeHttpHandler", new BadgeHttpHandler());
|
ch.pipeline().addLast("badgeHttpHandler", new BadgeHttpHandler());
|
||||||
ch.pipeline().addLast("wsProtocolHandler", new WebSocketServerProtocolHandler(this.wsConfig));
|
ch.pipeline().addLast("wsProtocolHandler", new WebSocketServerProtocolHandler(this.wsConfig));
|
||||||
|
ch.pipeline().addLast("wsFrameAggregator", new WebSocketFrameAggregator(MAX_FRAME_SIZE));
|
||||||
ch.pipeline().addLast("wsCodec", new WebSocketCodec());
|
ch.pipeline().addLast("wsCodec", new WebSocketCodec());
|
||||||
|
|
||||||
if (Emulator.getConfig().getBoolean("crypto.ws.enabled", false)) {
|
if (Emulator.getConfig().getBoolean("crypto.ws.enabled", false)) {
|
||||||
|
|||||||
+503
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+106
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
-1031
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-3
@@ -265,13 +265,23 @@ public class NitroSecureApiHandler extends ChannelDuplexHandler {
|
|||||||
|
|
||||||
private static void applyCors(FullHttpRequest req, FullHttpResponse response) {
|
private static void applyCors(FullHttpRequest req, FullHttpResponse response) {
|
||||||
String origin = req.headers().get(HttpHeaderNames.ORIGIN);
|
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("Access-Control-Allow-Origin", origin);
|
||||||
response.headers().set("Vary", "Origin");
|
|
||||||
response.headers().set("Access-Control-Allow-Credentials", "true");
|
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-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");
|
response.headers().set("Access-Control-Expose-Headers", "X-Nitro-Sec, X-Nitro-Key-Fp, X-Nitro-Derive-Fp");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-3
@@ -297,12 +297,21 @@ public class NitroSecureAssetHandler extends ChannelInboundHandlerAdapter {
|
|||||||
|
|
||||||
private static void applyCors(FullHttpRequest req, FullHttpResponse response) {
|
private static void applyCors(FullHttpRequest req, FullHttpResponse response) {
|
||||||
String origin = req.headers().get(HttpHeaderNames.ORIGIN);
|
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("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-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");
|
response.headers().set("Access-Control-Expose-Headers", "X-Nitro-Sec, X-Nitro-Key-Fp, X-Nitro-Derive-Fp");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+175
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+148
@@ -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."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Reference in New Issue
Block a user