diff --git a/Database Updates/011_HotelLogin.sql b/Database Updates/011_HotelLogin.sql
new file mode 100644
index 00000000..11210bc8
--- /dev/null
+++ b/Database Updates/011_HotelLogin.sql
@@ -0,0 +1,38 @@
+ALTER TABLE emulator_settings
+CHANGE COLUMN `comment` `comment` TEXT NULL DEFAULT '' ;
+
+CREATE TABLE IF NOT EXISTS `password_resets` (
+ `user_id` INT NOT NULL PRIMARY KEY,
+ `token` VARCHAR(128) NOT NULL,
+ `expires_at` TIMESTAMP NOT NULL,
+ `created_ip` VARCHAR(64) NOT NULL DEFAULT '',
+ UNIQUE KEY `idx_token` (`token`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
+
+INSERT INTO `emulator_settings` (`key`, `value`) VALUES
+ ('login.turnstile.enabled', '0'),
+ ('login.turnstile.sitekey', ''),
+ ('login.turnstile.secretkey', ''),
+
+ ('login.ratelimit.enabled', '1'),
+ ('login.ratelimit.max_attempts','5'),
+ ('login.ratelimit.window_sec', '60'),
+ ('login.ratelimit.lockout_sec', '120'),
+
+ ('login.register.enabled', '1'),
+ ('register.max_per_ip', '5'),
+ ('register.default.look', 'hr-100-7.hd-180-1.ch-210-66.lg-270-82.sh-290-80'),
+ ('register.default.motto', 'I love Habbo!'),
+
+ ('password.reset.url', 'http://localhost/reset-password'),
+
+ ('smtp.provider', 'own'),
+ ('smtp.host', 'localhost'),
+ ('smtp.port', '587'),
+ ('smtp.username', ''),
+ ('smtp.password', ''),
+ ('smtp.from_address', 'no-reply@example.com'),
+ ('smtp.from_name', 'Habbo Hotel'),
+ ('smtp.use_tls', '1'),
+ ('smtp.use_ssl', '0')
+ON DUPLICATE KEY UPDATE `value` = VALUES(`value`);
diff --git a/Emulator/pom.xml b/Emulator/pom.xml
index 50829731..acdbd61e 100644
--- a/Emulator/pom.xml
+++ b/Emulator/pom.xml
@@ -162,5 +162,21 @@
joda-time
2.13.0
+
+
+
+ org.mindrot
+ jbcrypt
+ 0.4
+
+
+
+
+ org.eclipse.angus
+ jakarta.mail
+ 2.0.3
+
-
\ No newline at end of file
+
diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java
index 54234640..99a7ebbe 100644
--- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java
+++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java
@@ -1,6 +1,7 @@
package com.eu.habbo.networking.gameserver;
import com.eu.habbo.messages.PacketManager;
+import com.eu.habbo.networking.gameserver.auth.AuthHttpHandler;
import com.eu.habbo.networking.gameserver.codec.WebSocketCodec;
import com.eu.habbo.networking.gameserver.decoders.*;
import com.eu.habbo.networking.gameserver.encoders.GameServerMessageEncoder;
@@ -49,10 +50,9 @@ public class WebSocketChannelInitializer extends ChannelInitializer handleLogin(ctx, req, body, ip);
+ case REGISTER_PATH -> handleRegister(ctx, req, body, ip);
+ case FORGOT_PATH -> handleForgot(ctx, req, body, ip);
+ }
+ }
+
+ /* ─── Logout ────────────────────────────────────────────────────────── */
+
+ private void handleLogout(ChannelHandlerContext ctx, FullHttpRequest req, com.google.gson.JsonObject body) {
+ String ssoTicket = readString(body, "ssoTicket");
+ JsonObject ok = new JsonObject();
+ ok.addProperty("message", "Logged out.");
+
+ if (ssoTicket == null || ssoTicket.isEmpty()) {
+ sendJson(ctx, req, HttpResponseStatus.OK, ok);
+ return;
+ }
+
+ try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
+ PreparedStatement lookup = conn.prepareStatement(
+ "SELECT id FROM users WHERE auth_ticket = ? LIMIT 1")) {
+ lookup.setString(1, ssoTicket);
+ int userId = 0;
+ try (ResultSet rs = lookup.executeQuery()) {
+ if (rs.next()) userId = rs.getInt("id");
+ }
+
+ 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());
+ }
+ }
+ }
+ } catch (Exception e) {
+ LOGGER.error("Logout cleanup failed for ticket", e);
+ }
+
+ sendJson(ctx, req, HttpResponseStatus.OK, ok);
+ }
+
+ /* ─── Login ─────────────────────────────────────────────────────────── */
+
+ private void handleLogin(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
+ String username = readString(body, "username").trim();
+ String password = readString(body, "password");
+
+ if (username.isEmpty() || password.isEmpty()) {
+ sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Missing credentials."));
+ return;
+ }
+
+ try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
+ PreparedStatement stmt = conn.prepareStatement(
+ "SELECT id, username, password FROM users WHERE username = ? LIMIT 1")) {
+ stmt.setString(1, username);
+ try (ResultSet rs = stmt.executeQuery()) {
+ if (!rs.next()) {
+ LOGGER.info("[auth/login] user not found username='{}' ip={}", username, ip);
+ AuthRateLimiter.recordFailure(ip);
+ sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED,
+ errorPayload("Invalid Habbo name or password."));
+ 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;
+ }
+
+ String ssoTicket = mintSsoTicket();
+
+ try (PreparedStatement upd = conn.prepareStatement(
+ "UPDATE users SET auth_ticket = ?, ip_current = ? WHERE id = ? LIMIT 1")) {
+ upd.setString(1, ssoTicket);
+ upd.setString(2, ip == null ? "" : ip);
+ upd.setInt(3, userId);
+ upd.executeUpdate();
+ }
+
+ AuthRateLimiter.recordSuccess(ip);
+
+ JsonObject ok = new JsonObject();
+ ok.addProperty("ssoTicket", ssoTicket);
+ ok.addProperty("username", rs.getString("username"));
+ sendJson(ctx, req, HttpResponseStatus.OK, ok);
+ }
+ } catch (Exception e) {
+ LOGGER.error("Login query failed for username=" + username, e);
+ sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
+ }
+ }
+
+ /* ─── Register ──────────────────────────────────────────────────────── */
+
+ private void handleRegister(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
+ if (!Emulator.getConfig().getBoolean("login.register.enabled", true)) {
+ sendJson(ctx, req, HttpResponseStatus.FORBIDDEN, errorPayload("Registration is closed."));
+ return;
+ }
+
+ String username = readString(body, "username").trim();
+ String email = readString(body, "email").trim();
+ String password = readString(body, "password");
+
+ 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();
+
+ try (PreparedStatement ins = conn.prepareStatement(
+ "INSERT INTO users (username, password, mail, account_created, " +
+ "ip_register, ip_current, last_online, last_login, motto, look, gender, " +
+ "credits, `rank`, home_room, machine_id, auth_ticket, online) " +
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'M', 0, 1, 0, '', '', '0')",
+ 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, defaultLook);
+ ins.executeUpdate();
+ }
+
+ 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."));
+ }
+ }
+
+ /* ─── Forgot password ───────────────────────────────────────────────── */
+
+ 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);
+ }
+
+ /* ─── Helpers ───────────────────────────────────────────────────────── */
+
+ 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 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("Vary", "Origin");
+ response.headers().set("Access-Control-Allow-Credentials", "true");
+ }
+ response.headers().set("Access-Control-Allow-Methods", "POST, OPTIONS");
+ response.headers().set("Access-Control-Allow-Headers", "Content-Type, X-Requested-With");
+ }
+
+ private static boolean isKeepAlive(FullHttpRequest req) {
+ String connection = req.headers().get(HttpHeaderNames.CONNECTION);
+ return connection == null || !"close".equalsIgnoreCase(connection);
+ }
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthRateLimiter.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthRateLimiter.java
new file mode 100644
index 00000000..a4149a17
--- /dev/null
+++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthRateLimiter.java
@@ -0,0 +1,71 @@
+package com.eu.habbo.networking.gameserver.auth;
+
+import com.eu.habbo.Emulator;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicReference;
+
+public final class AuthRateLimiter {
+
+ private static final Map> STATE = new ConcurrentHashMap<>();
+
+ private AuthRateLimiter() {}
+
+ public static boolean isLocked(String ip) {
+ if (!isEnabled() || ip == null || ip.isEmpty()) return false;
+
+ AtomicReference ref = STATE.get(ip);
+ if (ref == null) return false;
+
+ State current = ref.get();
+ return current != null && current.lockedUntilMillis > System.currentTimeMillis();
+ }
+
+ public static long secondsUntilUnlock(String ip) {
+ AtomicReference ref = STATE.get(ip);
+ if (ref == null) return 0;
+
+ State current = ref.get();
+ if (current == null) return 0;
+
+ long remainingMs = current.lockedUntilMillis - System.currentTimeMillis();
+ return remainingMs > 0 ? (remainingMs / 1000L) + 1L : 0L;
+ }
+
+ public static void recordFailure(String ip) {
+ if (!isEnabled() || ip == null || ip.isEmpty()) return;
+
+ long now = System.currentTimeMillis();
+ long windowMs = configInt("login.ratelimit.window_sec", 60) * 1000L;
+ int maxAttempts = configInt("login.ratelimit.max_attempts", 5);
+ long lockoutMs = configInt("login.ratelimit.lockout_sec", 120) * 1000L;
+
+ STATE.computeIfAbsent(ip, k -> new AtomicReference<>(new State(0, 0L, 0L)))
+ .updateAndGet(prev -> {
+ if (prev == null || (now - prev.windowStartMillis) > windowMs) {
+ return new State(1, now, 0L);
+ }
+
+ int attempts = prev.attempts + 1;
+ long lockedUntil = attempts >= maxAttempts ? now + lockoutMs : 0L;
+ return new State(attempts, prev.windowStartMillis, lockedUntil);
+ });
+ }
+
+ public static void recordSuccess(String ip) {
+ if (ip == null || ip.isEmpty()) return;
+ STATE.remove(ip);
+ }
+
+ private static boolean isEnabled() {
+ return Emulator.getConfig() != null
+ && Emulator.getConfig().getBoolean("login.ratelimit.enabled", true);
+ }
+
+ private static int configInt(String key, int fallback) {
+ return Emulator.getConfig() != null ? Emulator.getConfig().getInt(key, fallback) : fallback;
+ }
+
+ private record State(int attempts, long windowStartMillis, long lockedUntilMillis) {}
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/SmtpMailService.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/SmtpMailService.java
new file mode 100644
index 00000000..af9e40f7
--- /dev/null
+++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/SmtpMailService.java
@@ -0,0 +1,101 @@
+package com.eu.habbo.networking.gameserver.auth;
+
+import com.eu.habbo.Emulator;
+import jakarta.mail.Authenticator;
+import jakarta.mail.Message;
+import jakarta.mail.PasswordAuthentication;
+import jakarta.mail.Session;
+import jakarta.mail.Transport;
+import jakarta.mail.internet.InternetAddress;
+import jakarta.mail.internet.MimeMessage;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Properties;
+
+public final class SmtpMailService {
+ private static final Logger LOGGER = LoggerFactory.getLogger(SmtpMailService.class);
+
+ private SmtpMailService() {}
+
+ public static boolean send(String toAddress, String subject, String body) {
+ try {
+ String provider = Emulator.getConfig().getValue("smtp.provider", "own").toLowerCase();
+ String username = Emulator.getConfig().getValue("smtp.username", "");
+ String password = Emulator.getConfig().getValue("smtp.password", "");
+ String fromAddr = Emulator.getConfig().getValue("smtp.from_address", username);
+ String fromName = Emulator.getConfig().getValue("smtp.from_name", "Habbo Hotel");
+
+ if (toAddress == null || toAddress.isEmpty() || fromAddr == null || fromAddr.isEmpty()) {
+ LOGGER.warn("SMTP send aborted — missing to/from address (to={}, from={})", toAddress, fromAddr);
+ return false;
+ }
+
+ String host;
+ int port;
+ boolean useSsl;
+ boolean useTls;
+
+ switch (provider) {
+ case "gmail" -> {
+ host = "smtp.gmail.com";
+ port = 465;
+ useSsl = true;
+ useTls = false;
+ }
+ case "sendgrid" -> {
+ host = "smtp.sendgrid.net";
+ port = 587;
+ useSsl = false;
+ useTls = true;
+ }
+ case "mailgun" -> {
+ host = "smtp.mailgun.org";
+ port = 587;
+ useSsl = false;
+ useTls = true;
+ }
+ default -> {
+ host = Emulator.getConfig().getValue("smtp.host", "localhost");
+ port = Emulator.getConfig().getInt("smtp.port", 587);
+ useSsl = Emulator.getConfig().getBoolean("smtp.use_ssl", false);
+ useTls = Emulator.getConfig().getBoolean("smtp.use_tls", true);
+ }
+ }
+
+ Properties props = new Properties();
+ props.put("mail.smtp.host", host);
+ props.put("mail.smtp.port", String.valueOf(port));
+ props.put("mail.smtp.auth", String.valueOf(!username.isEmpty()));
+ if (useTls) props.put("mail.smtp.starttls.enable", "true");
+ if (useSsl) {
+ props.put("mail.smtp.ssl.enable", "true");
+ props.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");
+ props.put("mail.smtp.socketFactory.port", String.valueOf(port));
+ }
+ props.put("mail.smtp.connectiontimeout", "10000");
+ props.put("mail.smtp.timeout", "10000");
+
+ Session session = username.isEmpty()
+ ? Session.getInstance(props)
+ : Session.getInstance(props, new Authenticator() {
+ @Override
+ protected PasswordAuthentication getPasswordAuthentication() {
+ return new PasswordAuthentication(username, password);
+ }
+ });
+
+ MimeMessage message = new MimeMessage(session);
+ message.setFrom(new InternetAddress(fromAddr, fromName, "UTF-8"));
+ message.addRecipient(Message.RecipientType.TO, new InternetAddress(toAddress));
+ message.setSubject(subject, "UTF-8");
+ message.setText(body, "UTF-8");
+
+ Transport.send(message);
+ return true;
+ } catch (Exception e) {
+ LOGGER.error("Failed to send SMTP mail to " + toAddress, e);
+ return false;
+ }
+ }
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/TurnstileVerifier.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/TurnstileVerifier.java
new file mode 100644
index 00000000..0eb3f53f
--- /dev/null
+++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/TurnstileVerifier.java
@@ -0,0 +1,76 @@
+package com.eu.habbo.networking.gameserver.auth;
+
+import com.eu.habbo.Emulator;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+
+public final class TurnstileVerifier {
+ private static final Logger LOGGER = LoggerFactory.getLogger(TurnstileVerifier.class);
+ private static final String VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
+
+ private static final HttpClient CLIENT = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(5))
+ .build();
+
+ private TurnstileVerifier() {}
+
+ public static boolean isEnabled() {
+ return Emulator.getConfig() != null
+ && Emulator.getConfig().getBoolean("login.turnstile.enabled", false);
+ }
+
+ public static boolean verify(String token, String remoteIp) {
+ if (!isEnabled()) return true;
+
+ if (token == null || token.isEmpty()) return false;
+
+ String secret = Emulator.getConfig().getValue("login.turnstile.secretkey", "");
+ if (secret.isEmpty()) {
+ LOGGER.warn("login.turnstile.enabled=1 but login.turnstile.secretkey is empty — refusing the request");
+ return false;
+ }
+
+ StringBuilder form = new StringBuilder();
+ form.append("secret=").append(URLEncoder.encode(secret, StandardCharsets.UTF_8));
+ form.append("&response=").append(URLEncoder.encode(token, StandardCharsets.UTF_8));
+ if (remoteIp != null && !remoteIp.isEmpty()) {
+ form.append("&remoteip=").append(URLEncoder.encode(remoteIp, StandardCharsets.UTF_8));
+ }
+
+ try {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(VERIFY_URL))
+ .timeout(Duration.ofSeconds(8))
+ .header("Content-Type", "application/x-www-form-urlencoded")
+ .POST(HttpRequest.BodyPublishers.ofString(form.toString(), StandardCharsets.UTF_8))
+ .build();
+
+ HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
+
+ if (response.statusCode() != 200) {
+ LOGGER.warn("Turnstile siteverify returned HTTP {} for ip={}", response.statusCode(), remoteIp);
+ return false;
+ }
+
+ JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
+ boolean success = json.has("success") && json.get("success").getAsBoolean();
+ if (!success) {
+ LOGGER.info("Turnstile token rejected for ip={} body={}", remoteIp, response.body());
+ }
+ return success;
+ } catch (Exception e) {
+ LOGGER.error("Turnstile verification failed for ip=" + remoteIp, e);
+ return false;
+ }
+ }
+}
diff --git a/Latest_Compiled_Version/Habbo-4.1.1-jar-with-dependencies.jar b/Latest_Compiled_Version/Habbo-4.1.1-jar-with-dependencies.jar
index bfb8e246..30f4654e 100644
Binary files a/Latest_Compiled_Version/Habbo-4.1.1-jar-with-dependencies.jar and b/Latest_Compiled_Version/Habbo-4.1.1-jar-with-dependencies.jar differ