You've already forked Arcturus-Morningstar-Extended
mirror of
https://github.com/duckietm/Arcturus-Morningstar-Extended.git
synced 2026-06-19 15:06:19 +00:00
Merge remote-tracking branch 'duckie/main' into duckie-live-merge-2026-04-21
This commit is contained in:
+1
-1
@@ -6,7 +6,7 @@
|
||||
|
||||
<groupId>com.eu.habbo</groupId>
|
||||
<artifactId>Habbo</artifactId>
|
||||
<version>4.1.5</version>
|
||||
<version>4.1.3</version>
|
||||
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
|
||||
+126
-11
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import java.util.concurrent.atomic.AtomicReference;
|
||||
public final class AuthRateLimiter {
|
||||
|
||||
private static final Map<String, AtomicReference<State>> STATE = new ConcurrentHashMap<>();
|
||||
private static final Map<String, AtomicReference<ProbeState>> 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<ProbeState> 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) {}
|
||||
}
|
||||
|
||||
@@ -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<String, Entry> EMAIL_CACHE = new ConcurrentHashMap<>();
|
||||
private static final Map<String, Entry> 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<String, Entry> 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<String, Entry> 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<String, Entry> 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<String> 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) {}
|
||||
}
|
||||
Reference in New Issue
Block a user