diff --git a/Emulator/pom.xml b/Emulator/pom.xml index ac1ffcb3..46fef640 100644 --- a/Emulator/pom.xml +++ b/Emulator/pom.xml @@ -6,7 +6,7 @@ com.eu.habbo Habbo - 4.1.5 + 4.1.3 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