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