diff --git a/Emulator/pom.xml b/Emulator/pom.xml
index ac1ffcb3..db945291 100644
--- a/Emulator/pom.xml
+++ b/Emulator/pom.xml
@@ -6,7 +6,7 @@
com.eu.habbo
Habbo
- 4.1.5
+ 4.1.2
UTF-8
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 ff42c394..b1fdbb3d 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
@@ -25,14 +25,18 @@ import java.util.regex.Pattern;
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 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 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 SecureRandom RNG = new SecureRandom();
+ private static final int MAX_BODY_BYTES = 8 * 1024;
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
@@ -44,7 +48,9 @@ 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(FORGOT_PATH) && !path.equals(LOGOUT_PATH)
+ && !path.equals(CHECK_EMAIL_PATH) && !path.equals(CHECK_USERNAME_PATH)
+ && !path.equals(HEALTH_PATH)) {
super.channelRead(ctx, msg);
return;
}
@@ -62,6 +68,17 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
return;
}
+ if (path.equals(HEALTH_PATH)) {
+ if (req.method() != HttpMethod.GET && req.method() != HttpMethod.HEAD) {
+ sendJson(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, errorPayload("Use GET."));
+ return;
+ }
+ JsonObject ok = new JsonObject();
+ ok.addProperty("status", "ok");
+ sendJson(ctx, req, HttpResponseStatus.OK, ok);
+ return;
+ }
+
if (req.method() != HttpMethod.POST) {
sendJson(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, errorPayload("Use POST."));
return;
@@ -76,6 +93,11 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
return;
}
+ if (req.content().readableBytes() > MAX_BODY_BYTES) {
+ sendJson(ctx, req, HttpResponseStatus.REQUEST_ENTITY_TOO_LARGE, errorPayload("Payload too large."));
+ return;
+ }
+
JsonObject body;
try {
String text = req.content().toString(StandardCharsets.UTF_8);
@@ -90,6 +112,15 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
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;
+ }
+
String turnstileToken = readString(body, "turnstileToken");
if (!TurnstileVerifier.verify(turnstileToken, ip)) {
AuthRateLimiter.recordFailure(ip);
@@ -104,7 +135,88 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
}
}
- /* ─── Logout ────────────────────────────────────────────────────────── */
+ /* ─── Availability probes ─── */
+
+ 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);
+ }
+
+ /* ─── Logout ─── */
private void handleLogout(ChannelHandlerContext ctx, FullHttpRequest req, com.google.gson.JsonObject body) {
String ssoTicket = readString(body, "ssoTicket");
@@ -148,7 +260,7 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
sendJson(ctx, req, HttpResponseStatus.OK, ok);
}
- /* ─── Login ─────────────────────────────────────────────────────────── */
+ /* ─── Login ─── */
private void handleLogin(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
String username = readString(body, "username").trim();
@@ -210,7 +322,7 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
}
}
- /* ─── Register ──────────────────────────────────────────────────────── */
+ /* ─── Register ─── */
private void handleRegister(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
if (!Emulator.getConfig().getBoolean("login.register.enabled", true)) {
@@ -299,6 +411,9 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
ins.executeUpdate();
}
+ 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);
@@ -308,7 +423,7 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
}
}
- /* ─── Forgot password ───────────────────────────────────────────────── */
+ /* ─── Forgot password ─── */
private void handleForgot(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
String email = readString(body, "email").trim();
@@ -363,7 +478,7 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
sendJson(ctx, req, HttpResponseStatus.OK, ok);
}
- /* ─── Helpers ───────────────────────────────────────────────────────── */
+ /* ─── Helpers ─── */
private static boolean checkPassword(String plain, String stored) {
String compatible = stored.startsWith("$2y$") ? "$2a$" + stored.substring(4) : stored;
@@ -449,7 +564,7 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
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-Methods", "GET, HEAD, POST, OPTIONS");
response.headers().set("Access-Control-Allow-Headers", "Content-Type, X-Requested-With");
}
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
index a4149a17..67cabf17 100644
--- 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
@@ -9,6 +9,7 @@ import java.util.concurrent.atomic.AtomicReference;
public final class AuthRateLimiter {
private static final Map> STATE = new ConcurrentHashMap<>();
+ private static final Map> PROBE_STATE = new ConcurrentHashMap<>();
private AuthRateLimiter() {}
@@ -58,6 +59,35 @@ public final class AuthRateLimiter {
STATE.remove(ip);
}
+ public static boolean tryProbe(String ip) {
+ if (!isEnabled() || ip == null || ip.isEmpty()) return true;
+ if (isLocked(ip)) return false;
+
+ long now = System.currentTimeMillis();
+ long windowMs = configInt("login.probe.window_sec", 60) * 1000L;
+ int maxAttempts = configInt("login.probe.max_attempts", 20);
+
+ ProbeState next = PROBE_STATE.computeIfAbsent(ip, k -> new AtomicReference<>(new ProbeState(0, now)))
+ .updateAndGet(prev -> {
+ if (prev == null || (now - prev.windowStartMillis) > windowMs) {
+ return new ProbeState(1, now);
+ }
+ return new ProbeState(prev.count + 1, prev.windowStartMillis);
+ });
+
+ return next.count <= maxAttempts;
+ }
+
+ public static long secondsUntilProbeReset(String ip) {
+ AtomicReference ref = PROBE_STATE.get(ip);
+ if (ref == null) return 0;
+ ProbeState current = ref.get();
+ if (current == null) return 0;
+ long windowMs = configInt("login.probe.window_sec", 60) * 1000L;
+ long remainingMs = (current.windowStartMillis + windowMs) - System.currentTimeMillis();
+ return remainingMs > 0 ? (remainingMs / 1000L) + 1L : 0L;
+ }
+
private static boolean isEnabled() {
return Emulator.getConfig() != null
&& Emulator.getConfig().getBoolean("login.ratelimit.enabled", true);
@@ -68,4 +98,5 @@ public final class AuthRateLimiter {
}
private record State(int attempts, long windowStartMillis, long lockedUntilMillis) {}
+ private record ProbeState(int count, long windowStartMillis) {}
}
diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AvailabilityCache.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AvailabilityCache.java
new file mode 100644
index 00000000..e6a27758
--- /dev/null
+++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AvailabilityCache.java
@@ -0,0 +1,91 @@
+package com.eu.habbo.networking.gameserver.auth;
+
+import com.eu.habbo.Emulator;
+
+import java.util.Iterator;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+public final class AvailabilityCache {
+
+ private static final Map EMAIL_CACHE = new ConcurrentHashMap<>();
+ private static final Map USERNAME_CACHE = new ConcurrentHashMap<>();
+
+ private AvailabilityCache() {}
+
+ public static Boolean lookupEmail(String email) {
+ return read(EMAIL_CACHE, key(email));
+ }
+
+ public static Boolean lookupUsername(String username) {
+ return read(USERNAME_CACHE, key(username));
+ }
+
+ public static void storeEmail(String email, boolean available) {
+ write(EMAIL_CACHE, key(email), available);
+ }
+
+ public static void storeUsername(String username, boolean available) {
+ write(USERNAME_CACHE, key(username), available);
+ }
+
+ public static void invalidateEmail(String email) {
+ EMAIL_CACHE.remove(key(email));
+ }
+
+ public static void invalidateUsername(String username) {
+ USERNAME_CACHE.remove(key(username));
+ }
+
+ private static String key(String value) {
+ return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
+ }
+
+ private static Boolean read(Map cache, String key) {
+ if (!isEnabled() || key.isEmpty()) return null;
+ Entry entry = cache.get(key);
+ if (entry == null) return null;
+ if (entry.expiresAt < System.currentTimeMillis()) {
+ cache.remove(key, entry);
+ return null;
+ }
+ return entry.available;
+ }
+
+ private static void write(Map cache, String key, boolean available) {
+ if (!isEnabled() || key.isEmpty()) return;
+
+ int maxEntries = configInt("login.probe.cache_max_entries", 10_000);
+ if (cache.size() >= maxEntries) evict(cache, maxEntries);
+
+ long ttlMs = configInt("login.probe.cache_ttl_sec", 60) * 1000L;
+ cache.put(key, new Entry(available, System.currentTimeMillis() + ttlMs));
+ }
+
+ private static void evict(Map cache, int maxEntries) {
+ long now = System.currentTimeMillis();
+ cache.values().removeIf(e -> e.expiresAt < now);
+
+ if (cache.size() < maxEntries) return;
+
+ int overflow = cache.size() - maxEntries + 1;
+ Iterator it = cache.keySet().iterator();
+ while (overflow > 0 && it.hasNext()) {
+ it.next();
+ it.remove();
+ overflow--;
+ }
+ }
+
+ private static boolean isEnabled() {
+ return Emulator.getConfig() == null
+ || Emulator.getConfig().getBoolean("login.probe.cache_enabled", true);
+ }
+
+ private static int configInt(String key, int fallback) {
+ return Emulator.getConfig() != null ? Emulator.getConfig().getInt(key, fallback) : fallback;
+ }
+
+ private record Entry(boolean available, long expiresAt) {}
+}
diff --git a/Latest_Compiled_Version/Habbo-4.1.1-jar-with-dependencies.jar b/Latest_Compiled_Version/Habbo-4.1.2-jar-with-dependencies.jar
similarity index 98%
rename from Latest_Compiled_Version/Habbo-4.1.1-jar-with-dependencies.jar
rename to Latest_Compiled_Version/Habbo-4.1.2-jar-with-dependencies.jar
index 30f4654e..3cd4c116 100644
Binary files a/Latest_Compiled_Version/Habbo-4.1.1-jar-with-dependencies.jar and b/Latest_Compiled_Version/Habbo-4.1.2-jar-with-dependencies.jar differ