diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AccountChangeEndpoints.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AccountChangeEndpoints.java new file mode 100644 index 00000000..1b5b6f06 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AccountChangeEndpoints.java @@ -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; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AccountCheckEndpoints.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AccountCheckEndpoints.java new file mode 100644 index 00000000..65c80740 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AccountCheckEndpoints.java @@ -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); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpHandler.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpHandler.java index b7830d8e..a743a30b 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpHandler.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpHandler.java @@ -1,53 +1,42 @@ package com.eu.habbo.networking.gameserver.auth; -import com.eu.habbo.Emulator; -import com.eu.habbo.networking.gameserver.GameServerAttributes; -import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.google.gson.JsonParser; -import io.netty.buffer.Unpooled; -import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInboundHandlerAdapter; -import io.netty.handler.codec.http.*; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.QueryStringDecoder; import io.netty.util.ReferenceCountUtil; -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.*; -import java.time.Instant; -import java.util.Base64; -import java.util.regex.Pattern; + +import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.MAX_BODY_BYTES; +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.resolveClientIp; +import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.sendCors; +import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.sendJson; public class AuthHttpHandler extends ChannelInboundHandlerAdapter { - private static final Logger LOGGER = LoggerFactory.getLogger(AuthHttpHandler.class); - private static final String LOGIN_PATH = "/api/auth/login"; - private static final String REGISTER_PATH = "/api/auth/register"; - private static final String FORGOT_PATH = "/api/auth/forgot-password"; - private static final String LOGOUT_PATH = "/api/auth/logout"; - private static final String CHECK_EMAIL_PATH = "/api/auth/check-email"; - private static final String CHECK_USERNAME_PATH = "/api/auth/check-username"; - private static final String ROOM_TEMPLATES_PATH = "/api/auth/room-templates"; - private static final String NEWS_PATH = "/api/auth/news"; - private static final String REMEMBER_PATH = "/api/auth/remember"; - private static final String REFRESH_PATH = "/api/auth/refresh"; - private static final String SERVER_KEY_PATH = "/api/auth/server-key"; - private static final String SSO_TOKEN_PATH = "/api/auth/sso-token"; - private static final String CHANGE_PASSWORD_PATH = "/api/auth/change-password"; - private static final String CHANGE_EMAIL_PATH = "/api/auth/change-email"; - private static final String CHANGE_USERNAME_PATH = "/api/auth/change-username"; - private static final String HEALTH_PATH = "/api/health"; - - private static final Pattern USERNAME_RE = Pattern.compile("^[A-Za-z0-9._-]{3,32}$"); - private static final Pattern EMAIL_RE = Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"); - private static final Pattern FIGURE_RE = Pattern.compile("^[A-Za-z0-9.\\-]{1,200}$"); - private static final SecureRandom RNG = new SecureRandom(); - private static final int MAX_BODY_BYTES = 8 * 1024; + static final String LOGIN_PATH = "/api/auth/login"; + static final String REGISTER_PATH = "/api/auth/register"; + static final String FORGOT_PATH = "/api/auth/forgot-password"; + static final String LOGOUT_PATH = "/api/auth/logout"; + static final String CHECK_EMAIL_PATH = "/api/auth/check-email"; + static final String CHECK_USERNAME_PATH = "/api/auth/check-username"; + static final String ROOM_TEMPLATES_PATH = "/api/auth/room-templates"; + static final String NEWS_PATH = "/api/auth/news"; + static final String REMEMBER_PATH = "/api/auth/remember"; + static final String REFRESH_PATH = "/api/auth/refresh"; + static final String SERVER_KEY_PATH = "/api/auth/server-key"; + static final String SSO_TOKEN_PATH = "/api/auth/sso-token"; + static final String CHANGE_PASSWORD_PATH = "/api/auth/change-password"; + static final String CHANGE_EMAIL_PATH = "/api/auth/change-email"; + static final String CHANGE_USERNAME_PATH = "/api/auth/change-username"; + static final String HEALTH_PATH = "/api/health"; @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { @@ -58,19 +47,7 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { String path = new QueryStringDecoder(req.uri()).path(); - if (!path.equals(LOGIN_PATH) && !path.equals(REGISTER_PATH) - && !path.equals(FORGOT_PATH) && !path.equals(LOGOUT_PATH) - && !path.equals(CHECK_EMAIL_PATH) && !path.equals(CHECK_USERNAME_PATH) - && !path.equals(ROOM_TEMPLATES_PATH) - && !path.equals(NEWS_PATH) - && !path.equals(REMEMBER_PATH) - && !path.equals(REFRESH_PATH) - && !path.equals(SERVER_KEY_PATH) - && !path.equals(SSO_TOKEN_PATH) - && !path.equals(CHANGE_PASSWORD_PATH) - && !path.equals(CHANGE_EMAIL_PATH) - && !path.equals(CHANGE_USERNAME_PATH) - && !path.equals(HEALTH_PATH)) { + if (!isOurRoute(path)) { super.channelRead(ctx, msg); return; } @@ -82,6 +59,25 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { } } + private static boolean isOurRoute(String path) { + return path.equals(LOGIN_PATH) + || path.equals(REGISTER_PATH) + || path.equals(FORGOT_PATH) + || path.equals(LOGOUT_PATH) + || path.equals(CHECK_EMAIL_PATH) + || path.equals(CHECK_USERNAME_PATH) + || path.equals(ROOM_TEMPLATES_PATH) + || path.equals(NEWS_PATH) + || path.equals(REMEMBER_PATH) + || path.equals(REFRESH_PATH) + || path.equals(SERVER_KEY_PATH) + || path.equals(SSO_TOKEN_PATH) + || path.equals(CHANGE_PASSWORD_PATH) + || path.equals(CHANGE_EMAIL_PATH) + || path.equals(CHANGE_USERNAME_PATH) + || path.equals(HEALTH_PATH); + } + private void handle(ChannelHandlerContext ctx, FullHttpRequest req, String path) { if (req.method() == HttpMethod.OPTIONS) { sendCors(ctx, req); @@ -89,7 +85,7 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { } if (path.equals(HEALTH_PATH)) { - if (req.method() != HttpMethod.GET && req.method() != HttpMethod.HEAD) { + if (!isGetOrHead(req)) { sendJson(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, errorPayload("Use GET.")); return; } @@ -100,16 +96,16 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { } if (path.equals(ROOM_TEMPLATES_PATH)) { - if (req.method() != HttpMethod.GET && req.method() != HttpMethod.HEAD) { + if (!isGetOrHead(req)) { sendJson(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, errorPayload("Use GET.")); return; } - handleRoomTemplates(ctx, req); + StaticContentEndpoints.handleRoomTemplates(ctx, req); return; } if (path.equals(NEWS_PATH)) { - if (req.method() != HttpMethod.GET && req.method() != HttpMethod.HEAD) { + if (!isGetOrHead(req)) { sendJson(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, errorPayload("Use GET.")); return; } @@ -120,16 +116,16 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { errorPayload("Too many requests. Try again in " + secs + "s.")); return; } - handleNews(ctx, req); + StaticContentEndpoints.handleNews(ctx, req); return; } if (path.equals(SERVER_KEY_PATH)) { - if (req.method() != HttpMethod.GET && req.method() != HttpMethod.HEAD) { + if (!isGetOrHead(req)) { sendJson(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, errorPayload("Use GET.")); return; } - handleServerKey(ctx, req); + StaticContentEndpoints.handleServerKey(ctx, req); return; } @@ -161,43 +157,15 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { return; } - if (path.equals(LOGOUT_PATH)) { - handleLogout(ctx, req, body); - return; - } - - if (path.equals(CHECK_EMAIL_PATH)) { - handleCheckEmail(ctx, req, body, ip); - return; - } - if (path.equals(CHECK_USERNAME_PATH)) { - handleCheckUsername(ctx, req, body, ip); - return; - } - if (path.equals(REMEMBER_PATH)) { - handleRemember(ctx, req, body, ip); - return; - } - if (path.equals(REFRESH_PATH)) { - handleRefresh(ctx, req, body, ip); - return; - } - if (path.equals(SSO_TOKEN_PATH)) { - handleSsoToken(ctx, req, body, ip); - return; - } - if (path.equals(CHANGE_PASSWORD_PATH)) { - handleChangePassword(ctx, req, body, ip); - return; - } - if (path.equals(CHANGE_EMAIL_PATH)) { - handleChangeEmail(ctx, req, body, ip); - return; - } - if (path.equals(CHANGE_USERNAME_PATH)) { - handleChangeUsername(ctx, req, body, ip); - return; - } + if (path.equals(LOGOUT_PATH)) { SessionEndpoints.handleLogout(ctx, req, body); return; } + if (path.equals(CHECK_EMAIL_PATH)) { AccountCheckEndpoints.handleCheckEmail(ctx, req, body, ip); return; } + if (path.equals(CHECK_USERNAME_PATH)) { AccountCheckEndpoints.handleCheckUsername(ctx, req, body, ip); return; } + if (path.equals(REMEMBER_PATH)) { SessionEndpoints.handleRemember(ctx, req, body, ip); return; } + if (path.equals(REFRESH_PATH)) { SessionEndpoints.handleRefresh(ctx, req, body, ip); return; } + if (path.equals(SSO_TOKEN_PATH)) { SessionEndpoints.handleSsoToken(ctx, req, body, ip); return; } + if (path.equals(CHANGE_PASSWORD_PATH)) { AccountChangeEndpoints.handleChangePassword(ctx, req, body, ip); return; } + if (path.equals(CHANGE_EMAIL_PATH)) { AccountChangeEndpoints.handleChangeEmail(ctx, req, body, ip); return; } + if (path.equals(CHANGE_USERNAME_PATH)) { AccountChangeEndpoints.handleChangeUsername(ctx, req, body, ip); return; } String turnstileToken = readString(body, "turnstileToken"); if (!TurnstileVerifier.verify(turnstileToken, ip)) { @@ -207,1482 +175,13 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { } switch (path) { - case LOGIN_PATH -> handleLogin(ctx, req, body, ip); - case REGISTER_PATH -> handleRegister(ctx, req, body, ip); - case FORGOT_PATH -> handleForgot(ctx, req, body, ip); + case LOGIN_PATH -> SessionEndpoints.handleLogin(ctx, req, body, ip); + case REGISTER_PATH -> SessionEndpoints.handleRegister(ctx, req, body, ip); + case FORGOT_PATH -> SessionEndpoints.handleForgot(ctx, req, body, ip); } } - private void handleCheckEmail(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { - if (!AuthRateLimiter.tryProbe(ip)) { - long secs = AuthRateLimiter.secondsUntilProbeReset(ip); - sendJson(ctx, req, HttpResponseStatus.TOO_MANY_REQUESTS, - errorPayload("Too many requests. Try again in " + secs + "s.")); - return; - } - String email = readString(body, "email").trim(); - if (email.isEmpty() || email.length() > 254 || !EMAIL_RE.matcher(email).matches()) { - sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Invalid email address.")); - return; - } - - 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); - } - - private void handleCheckUsername(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { - if (!AuthRateLimiter.tryProbe(ip)) { - long secs = AuthRateLimiter.secondsUntilProbeReset(ip); - sendJson(ctx, req, HttpResponseStatus.TOO_MANY_REQUESTS, - errorPayload("Too many requests. Try again in " + secs + "s.")); - return; - } - String username = readString(body, "username").trim(); - if (!USERNAME_RE.matcher(username).matches()) { - sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, - errorPayload("Username must be 3-32 chars (letters, numbers, . _ -).")); - return; - } - - Boolean cached = AvailabilityCache.lookupUsername(username); - boolean taken; - if (cached != null) { - taken = !cached; - } else { - try (Connection conn = Emulator.getDatabase().getDataSource().getConnection(); - PreparedStatement stmt = conn.prepareStatement( - "SELECT 1 FROM users WHERE username = ? LIMIT 1")) { - stmt.setString(1, username); - try (ResultSet rs = stmt.executeQuery()) { - taken = rs.next(); - } - } catch (Exception e) { - LOGGER.error("check-username failed", e); - sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error.")); - return; - } - AvailabilityCache.storeUsername(username, !taken); - } - - JsonObject res = new JsonObject(); - res.addProperty("available", !taken); - if (taken) res.addProperty("error", "This Habbo name is already taken."); - sendJson(ctx, req, HttpResponseStatus.OK, res); - } - - private void handleChangePassword(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { - 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; - } - - 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.")); - } - } - - private void handleChangeEmail(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { - 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; - } - - 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.")); - } - } - - private void handleChangeUsername(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { - 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; - } - - 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; - } - - // Case-only changes (e.g. "bob" -> "Bob") are allowed; identical strings are not. - 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) { - // Only swallow "table doesn't exist" — banned_usernames is optional in some installs. - // Any other DB error (timeout, syntax, permission, connection drop) must fail the - // request so a transient outage can't silently bypass the blocklist. - 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; - - // Folding the cooldown into the WHERE clause makes the rename atomic against - // a second concurrent request with the same token: only one of them will see - // rowsUpdated == 1, the other gets 0 and we surface it as 429 instead of letting - // both succeed and writing last_username_change twice. - 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) { - // ER_DUP_ENTRY (1062) / SQLState 23000 — the UNIQUE KEY on users.username - // raced us between the pre-check and the UPDATE. Surface as 409 instead of - // a generic 500 so the UI can show "username taken". - 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) { - // rooms_for_sale is optional — only swallow "table doesn't exist". - 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; - } - - // Refresh in-memory caches BEFORE we drop the client. On disconnect, Habbo.disconnect() - // calls HabboInfo.run() which persists every field of HabboInfo back to the users row - // (including username) — so if the in-memory object still holds the old name it will - // overwrite the DB update we just committed. We also need to re-key the - // HabboManager.onlineHabbosByName map and update any loaded Room.ownerName fields so - // navigator / room cards stop showing the old name without a server restart. - 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) { - // Re-key the username map AND update the in-memory username so the - // disconnect-time HabboInfo.run() persists the new name, not the old one. - 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); - } - - // Marketplace offer-list pages bake the seller name into the serialized payload, - // so invalidate it; the next /offers request will rebuild it with the new name. - 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); - } - - // Drop any active session so the user reconnects with the fresh identity - // (friend list, navigator, messenger threads all refresh on relogin). - 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 void handleLogout(ChannelHandlerContext ctx, FullHttpRequest req, com.google.gson.JsonObject body) { - String ssoTicket = readString(body, "ssoTicket"); - String rememberToken = readString(body, "rememberToken").trim(); - JsonObject ok = new JsonObject(); - ok.addProperty("message", "Logged out."); - - try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) { - int userId = 0; - - if (ssoTicket != null && !ssoTicket.isEmpty()) { - try (PreparedStatement lookup = conn.prepareStatement( - "SELECT id FROM users WHERE auth_ticket = ? LIMIT 1")) { - lookup.setString(1, ssoTicket); - try (ResultSet rs = lookup.executeQuery()) { - if (rs.next()) userId = rs.getInt("id"); - } - } - - if (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); - } - - private void handleRemember(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { - String jwt = readString(body, "rememberToken").trim(); - if (jwt.isEmpty()) { - sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Missing rememberToken.")); - return; - } - - try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) { - RememberJwtService.RotationResult rot = RememberJwtService.rotate(conn, jwt, ip); - if (rot == null) { - sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Remember token invalid or expired.")); - return; - } - - String ssoTicket = mintSsoTicket(); - try (PreparedStatement upd = conn.prepareStatement( - "UPDATE users SET auth_ticket = ?, ip_current = ? WHERE id = ? LIMIT 1")) { - upd.setString(1, ssoTicket); - upd.setString(2, ip == null ? "" : ip); - upd.setInt(3, rot.userId); - upd.executeUpdate(); - } - - JsonObject ok = new JsonObject(); - ok.addProperty("ssoTicket", ssoTicket); - ok.addProperty("username", rot.username); - ok.addProperty("rememberToken", rot.jwt); - ok.addProperty("expiresAt", rot.expiresAt); - ok.addProperty("rememberExpiresAt", rot.expiresAt); - 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.")); - } - } - - private 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.")); - } - } - - private void handleRefresh(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { - String jwt = readString(body, "rememberToken").trim(); - if (jwt.isEmpty()) { - sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Missing rememberToken.")); - return; - } - - try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) { - RememberJwtService.RotationResult rot = RememberJwtService.rotate(conn, jwt, ip); - if (rot == null) { - sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Remember token invalid or expired.")); - return; - } - JsonObject ok = new JsonObject(); - ok.addProperty("rememberToken", rot.jwt); - ok.addProperty("expiresAt", rot.expiresAt); - ok.addProperty("rememberExpiresAt", rot.expiresAt); - 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.")); - } - } - - private void handleLogin(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { - String username = readString(body, "username").trim(); - String password = readString(body, "password"); - boolean rememberMe = readBoolean(body, "remember", false) || 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()) { - 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 - ? "" - : (stored.isEmpty() ? "" : stored.substring(0, Math.min(7, stored.length())) + "…(" + stored.length() + " chars)"); - - if (stored == null || stored.isEmpty() || !checkPassword(password, stored)) { - LOGGER.info("[auth/login] password mismatch for user id={} username='{}' stored='{}'", - userId, username, storedPreview); - AuthRateLimiter.recordFailure(ip); - sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, - errorPayload("Invalid Habbo name or password.")); - return; - } - - BanInfo accountBan = lookupAccountBan(conn, userId); - if (accountBan != null) { - LOGGER.info("[auth/login] account ban hit userId={} type={} expires={}", - userId, accountBan.type, accountBan.expiresAt); - AuthRateLimiter.recordSuccess(ip); - sendJson(ctx, req, HttpResponseStatus.FORBIDDEN, bannedPayload(accountBan)); - return; - } - - String ssoTicket = mintSsoTicket(); - - try (PreparedStatement upd = conn.prepareStatement( - "UPDATE users SET auth_ticket = ?, ip_current = ? WHERE id = ? LIMIT 1")) { - upd.setString(1, ssoTicket); - upd.setString(2, ip == null ? "" : ip); - upd.setInt(3, userId); - upd.executeUpdate(); - } - - String rememberToken = null; - if (rememberMe) { - try { - RememberJwtService.RotationResult issued = RememberJwtService.issueForNewFamily( - conn, userId, rs.getString("username"), ip); - rememberToken = issued.jwt; - } catch (SQLException e) { - LOGGER.error("Failed to issue remember-me JWT for userId=" + userId, e); - } - } - - AuthRateLimiter.recordSuccess(ip); - - JsonObject ok = new JsonObject(); - ok.addProperty("ssoTicket", ssoTicket); - ok.addProperty("username", rs.getString("username")); - if (rememberToken != null) ok.addProperty("rememberToken", rememberToken); - 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.")); - } - } - - private void handleRegister(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { - if (!Emulator.getConfig().getBoolean("login.register.enabled", true)) { - sendJson(ctx, req, HttpResponseStatus.FORBIDDEN, errorPayload("Registration is closed.")); - 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)) { - seedUserCurrencies(conn, newUserId, startingDuckets, startingDiamonds); - } - - LOGGER.info("[auth/register] user created id={} username='{}' templateId={} credits={} duckets={} diamonds={}", - newUserId, username, templateId, startingCredits, startingDuckets, startingDiamonds); - - if (newUserId > 0 && templateId > 0) { - cloneTemplateForUser(conn, templateId, newUserId, username); - } else if (templateId > 0) { - LOGGER.warn("[auth/register] skipping template clone: user insert did not return an id (username='{}')", username); - } - - AvailabilityCache.invalidateEmail(email); - AvailabilityCache.invalidateUsername(username); - - JsonObject ok = new JsonObject(); - ok.addProperty("message", "Welcome aboard, " + username + "! Your account is ready — log in below with the password you just chose."); - sendJson(ctx, req, HttpResponseStatus.OK, ok); - } catch (Exception e) { - LOGGER.error("Register query failed for username=" + username, e); - sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error.")); - } - } - - private static void materializeCustomLayout(Connection conn, int templateId, int newRoomId) { - String overrideModel = "0"; - String heightmap = ""; - int doorX = 0, doorY = 0, doorDir = 2; - try (PreparedStatement sel = conn.prepareStatement( - "SELECT override_model, heightmap, door_x, door_y, door_dir " + - "FROM room_templates WHERE template_id = ? LIMIT 1")) { - sel.setInt(1, templateId); - try (ResultSet rs = sel.executeQuery()) { - if (rs.next()) { - overrideModel = rs.getString("override_model"); - heightmap = rs.getString("heightmap"); - doorX = rs.getInt("door_x"); - doorY = rs.getInt("door_y"); - doorDir = rs.getInt("door_dir"); - } - } - } catch (SQLException e) { - LOGGER.error("[auth/register] reading template layout failed templateId=" + templateId, e); - return; - } - - if (!"1".equals(overrideModel) || heightmap == null || heightmap.isEmpty()) { - return; - } - - String customName = "custom_" + newRoomId; - - try (PreparedStatement ins = conn.prepareStatement( - "INSERT INTO room_models_custom (id, name, door_x, door_y, door_dir, heightmap) " + - "VALUES (?, ?, ?, ?, ?, ?) " + - "ON DUPLICATE KEY UPDATE name = VALUES(name), door_x = VALUES(door_x), " + - "door_y = VALUES(door_y), door_dir = VALUES(door_dir), heightmap = VALUES(heightmap)")) { - ins.setInt(1, newRoomId); - ins.setString(2, customName); - ins.setInt(3, doorX); - ins.setInt(4, doorY); - ins.setInt(5, doorDir); - ins.setString(6, heightmap); - ins.executeUpdate(); - } catch (SQLException e) { - LOGGER.error("[auth/register] room_models_custom insert failed roomId=" + newRoomId, e); - return; - } - - try (PreparedStatement upd = conn.prepareStatement( - "UPDATE rooms SET model = ? WHERE id = ? LIMIT 1")) { - upd.setString(1, customName); - upd.setInt(2, newRoomId); - upd.executeUpdate(); - } catch (SQLException e) { - LOGGER.error("[auth/register] rooms.model rename failed roomId=" + newRoomId, e); - } - - LOGGER.info("[auth/register] materialized custom layout '{}' for roomId={}", customName, newRoomId); - } - - private static void seedUserCurrencies(Connection conn, int userId, int duckets, int diamonds) { - try (PreparedStatement ins = conn.prepareStatement( - "INSERT INTO users_currency (user_id, type, amount) VALUES (?, ?, ?) " + - "ON DUPLICATE KEY UPDATE amount = VALUES(amount)")) { - if (duckets > 0) { - ins.setInt(1, userId); - ins.setInt(2, 0); - ins.setInt(3, duckets); - ins.addBatch(); - } - if (diamonds > 0) { - ins.setInt(1, userId); - ins.setInt(2, 5); - ins.setInt(3, diamonds); - ins.addBatch(); - } - ins.executeBatch(); - } catch (SQLException e) { - LOGGER.error("[auth/register] seeding users_currency failed userId=" + userId - + " duckets=" + duckets + " diamonds=" + diamonds, e); - } - } - - private void handleRoomTemplates(ChannelHandlerContext ctx, FullHttpRequest req) { - JsonArray templates = new JsonArray(); - try (Connection conn = Emulator.getDatabase().getDataSource().getConnection(); - PreparedStatement stmt = conn.prepareStatement( - "SELECT template_id, title, description, thumbnail " + - "FROM room_templates WHERE enabled = '1' " + - "ORDER BY sort_order ASC, template_id ASC")) { - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - JsonObject t = new JsonObject(); - t.addProperty("templateId", rs.getInt("template_id")); - t.addProperty("title", rs.getString("title")); - t.addProperty("description", rs.getString("description")); - t.addProperty("thumbnail", rs.getString("thumbnail")); - templates.add(t); - } - } - } catch (Exception e) { - LOGGER.error("room-templates list failed", e); - sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error.")); - return; - } - JsonObject res = new JsonObject(); - res.add("templates", templates); - sendJson(ctx, req, HttpResponseStatus.OK, res); - } - - private 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 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); // gson encodes null as JSON null - - 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); - } - - private void handleServerKey(ChannelHandlerContext ctx, FullHttpRequest req) { - try { - JsonObject ok = new JsonObject(); - ok.addProperty("publicKey", com.eu.habbo.networking.gameserver.crypto.CryptoSigningKeyManager.publicKeyBase64()); - ok.addProperty("algorithm", "ECDSA-P256-SHA256"); - sendJson(ctx, req, HttpResponseStatus.OK, ok); - } catch (Exception e) { - LOGGER.error("server-key fetch failed", e); - sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error.")); - } - } - - private static void cloneTemplateForUser(Connection conn, int templateId, int userId, String userName) { - LOGGER.info("[auth/register] cloning template id={} for user id={} name='{}'", templateId, userId, userName); - - try (PreparedStatement check = conn.prepareStatement( - "SELECT 1 FROM room_templates WHERE template_id = ? AND enabled = '1' LIMIT 1")) { - check.setInt(1, templateId); - try (ResultSet rs = check.executeQuery()) { - if (!rs.next()) { - LOGGER.warn("[auth/register] unknown/disabled room template id={} for user id={}", templateId, userId); - return; - } - } - } catch (SQLException e) { - LOGGER.error("[auth/register] template lookup failed for templateId=" + templateId, e); - return; - } - - int newRoomId = 0; - int roomsInserted = 0; - try (PreparedStatement ins = conn.prepareStatement( - "INSERT INTO rooms (owner_id, owner_name, name, description, model, password, state, " + - "users_max, category, paper_floor, paper_wall, paper_landscape, thickness_wall, " + - "thickness_floor, moodlight_data, override_model, trade_mode) " + - "(SELECT ?, ?, name, room_description, model, password, state, " + - "users_max, category, paper_floor, paper_wall, paper_landscape, thickness_wall, " + - "thickness_floor, moodlight_data, override_model, trade_mode " + - "FROM room_templates WHERE template_id = ?)", - Statement.RETURN_GENERATED_KEYS)) { - ins.setInt(1, userId); - ins.setString(2, userName); - ins.setInt(3, templateId); - roomsInserted = ins.executeUpdate(); - try (ResultSet keys = ins.getGeneratedKeys()) { - if (keys.next()) newRoomId = keys.getInt(1); - } - } catch (SQLException e) { - LOGGER.error("[auth/register] clone rooms failed templateId=" + templateId + " userId=" + userId, e); - return; - } - - LOGGER.info("[auth/register] rooms insert: rowsAffected={} newRoomId={}", roomsInserted, newRoomId); - - if (newRoomId <= 0) { - LOGGER.warn("[auth/register] clone aborted - no roomId returned (templateId={}, userId={})", templateId, userId); - return; - } - - materializeCustomLayout(conn, templateId, newRoomId); - - int itemsInserted = 0; - try (PreparedStatement ins = conn.prepareStatement( - "INSERT INTO items (user_id, room_id, item_id, wall_pos, x, y, z, rot, " + - "extra_data, wired_data, limited_data, guild_id) " + - "(SELECT ?, ?, item_id, wall_pos, x, y, z, rot, extra_data, wired_data, '0:0', 0 " + - "FROM room_templates_items WHERE template_id = ?)")) { - ins.setInt(1, userId); - ins.setInt(2, newRoomId); - ins.setInt(3, templateId); - itemsInserted = ins.executeUpdate(); - } catch (SQLException e) { - LOGGER.error("[auth/register] clone items failed templateId=" + templateId - + " roomId=" + newRoomId + " userId=" + userId, e); - } - - LOGGER.info("[auth/register] items insert: rowsAffected={} roomId={}", itemsInserted, newRoomId); - - try (PreparedStatement upd = conn.prepareStatement( - "UPDATE users SET home_room = ? WHERE id = ? LIMIT 1")) { - upd.setInt(1, newRoomId); - upd.setInt(2, userId); - int rows = upd.executeUpdate(); - LOGGER.info("[auth/register] home_room update: rowsAffected={} userId={} roomId={}", rows, userId, newRoomId); - } catch (SQLException e) { - LOGGER.error("[auth/register] setting home_room failed userId=" + userId + " roomId=" + newRoomId, e); - } - } - - private void handleForgot(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { - String email = readString(body, "email").trim(); - - 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); - } - - private static final long PERMANENT_BAN_THRESHOLD_SECONDS = 30L * 365L * 24L * 60L * 60L; - - private static final class BanInfo { - final String type; - final String reason; - final int expiresAt; - - BanInfo(String type, String reason, int expiresAt) { - this.type = type == null ? "account" : type; - this.reason = reason == null ? "" : reason; - this.expiresAt = expiresAt; - } - - boolean isPermanent() { - return (long) expiresAt - Emulator.getIntUnixTimestamp() > PERMANENT_BAN_THRESHOLD_SECONDS; - } - } - - private static BanInfo lookupAccountBan(Connection conn, int userId) throws SQLException { - try (PreparedStatement stmt = conn.prepareStatement( - "SELECT ban_expire, ban_reason, type FROM bans " + - "WHERE user_id = ? AND ban_expire >= ? AND (type = 'account' OR type = 'super') " + - "ORDER BY ban_expire DESC LIMIT 1")) { - stmt.setInt(1, userId); - stmt.setInt(2, Emulator.getIntUnixTimestamp()); - try (ResultSet rs = stmt.executeQuery()) { - if (rs.next()) { - return new BanInfo(rs.getString("type"), rs.getString("ban_reason"), rs.getInt("ban_expire")); - } - } - } - return null; - } - - private static BanInfo lookupIpBan(Connection conn, String ip) throws SQLException { - try (PreparedStatement stmt = conn.prepareStatement( - "SELECT ban_expire, ban_reason, type FROM bans " + - "WHERE ip = ? AND ban_expire >= ? AND (type = 'ip' OR type = 'super') " + - "ORDER BY ban_expire DESC LIMIT 1")) { - stmt.setString(1, ip); - stmt.setInt(2, Emulator.getIntUnixTimestamp()); - try (ResultSet rs = stmt.executeQuery()) { - if (rs.next()) { - return new BanInfo(rs.getString("type"), rs.getString("ban_reason"), rs.getInt("ban_expire")); - } - } - } - return null; - } - - private static JsonObject bannedPayload(BanInfo ban) { - boolean permanent = ban.isPermanent(); - String message = permanent - ? "Your account has been permanently banned." - : "Your account is temporarily banned."; - - JsonObject details = new JsonObject(); - details.addProperty("type", ban.type); - details.addProperty("reason", ban.reason); - details.addProperty("permanent", permanent); - if (!permanent) details.addProperty("expiresAt", ban.expiresAt); - - JsonObject obj = new JsonObject(); - obj.addProperty("error", message); - obj.add("ban", details); - return obj; - } - - private static boolean checkPassword(String plain, String stored) { - String compatible = stored.startsWith("$2y$") ? "$2a$" + stored.substring(4) : stored; - try { - return BCrypt.checkpw(plain, compatible); - } catch (IllegalArgumentException e) { - return false; - } - } - - private static String mintSsoTicket() { - byte[] buf = new byte[32]; - RNG.nextBytes(buf); - return "nitro-" + Base64.getUrlEncoder().withoutPadding().encodeToString(buf); - } - - private static String mintResetToken() { - byte[] buf = new byte[32]; - RNG.nextBytes(buf); - return Base64.getUrlEncoder().withoutPadding().encodeToString(buf); - } - - private static String readString(JsonObject obj, String key) { - if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) return ""; - try { - return obj.get(key).getAsString(); - } catch (Exception e) { - return ""; - } - } - - private static int readInt(JsonObject obj, String key, int defaultValue) { - if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) return defaultValue; - try { - return obj.get(key).getAsInt(); - } catch (Exception e) { - return defaultValue; - } - } - - private static boolean readBoolean(JsonObject obj, String key, boolean defaultValue) { - if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) return defaultValue; - try { - com.google.gson.JsonElement el = obj.get(key); - if (el.getAsJsonPrimitive().isBoolean()) return el.getAsBoolean(); - String s = el.getAsString(); - return "1".equals(s) || "true".equalsIgnoreCase(s); - } catch (Exception e) { - return defaultValue; - } - } - - private static String resolveClientIp(ChannelHandlerContext ctx, FullHttpRequest req) { - String ipHeader = Emulator.getConfig() != null - ? Emulator.getConfig().getValue("ws.ip.header", "") - : ""; - 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 ""; - } - - private static JsonObject errorPayload(String message) { - JsonObject obj = new JsonObject(); - obj.addProperty("error", message); - return obj; - } - - private 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); - } - - private 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); - } - - private static void applyCors(FullHttpRequest req, FullHttpResponse response) { - String origin = req.headers().get(HttpHeaderNames.ORIGIN); - if (origin != null && !origin.isEmpty()) { - 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"); - } - - private static boolean isKeepAlive(FullHttpRequest req) { - String connection = req.headers().get(HttpHeaderNames.CONNECTION); - return connection == null || !"close".equalsIgnoreCase(connection); + private static boolean isGetOrHead(FullHttpRequest req) { + return req.method() == HttpMethod.GET || req.method() == HttpMethod.HEAD; } } diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpUtil.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpUtil.java new file mode 100644 index 00000000..0d949ec8 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpUtil.java @@ -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; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/CorsOriginGate.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/CorsOriginGate.java new file mode 100644 index 00000000..5dd370c8 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/CorsOriginGate.java @@ -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; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureApiHandler.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureApiHandler.java index 85934953..45528857 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureApiHandler.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureApiHandler.java @@ -265,7 +265,8 @@ public class NitroSecureApiHandler extends ChannelDuplexHandler { private static void applyCors(FullHttpRequest req, FullHttpResponse response) { String origin = req.headers().get(HttpHeaderNames.ORIGIN); - if (origin != null && !origin.isEmpty()) { + + if (origin != null && !origin.isEmpty() && CorsOriginGate.isAllowed(req)) { response.headers().set("Access-Control-Allow-Origin", origin); response.headers().set("Access-Control-Allow-Credentials", "true"); } diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureAssetHandler.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureAssetHandler.java index 73c07e69..851cf90b 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureAssetHandler.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureAssetHandler.java @@ -297,7 +297,8 @@ public class NitroSecureAssetHandler extends ChannelInboundHandlerAdapter { private static void applyCors(FullHttpRequest req, FullHttpResponse response) { String origin = req.headers().get(HttpHeaderNames.ORIGIN); - if (origin != null && !origin.isEmpty()) { + + if (origin != null && !origin.isEmpty() && CorsOriginGate.isAllowed(req)) { response.headers().set("Access-Control-Allow-Origin", origin); } response.headers().set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS"); diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/RegistrationSupport.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/RegistrationSupport.java new file mode 100644 index 00000000..82892451 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/RegistrationSupport.java @@ -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); + } + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/SessionEndpoints.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/SessionEndpoints.java new file mode 100644 index 00000000..931b5b89 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/SessionEndpoints.java @@ -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 + ? "" + : (stored.isEmpty() ? "" : 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); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/StaticContentEndpoints.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/StaticContentEndpoints.java new file mode 100644 index 00000000..62eaa089 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/StaticContentEndpoints.java @@ -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.")); + } + } +}