🆕 Added UI login to the Emu

This commit is contained in:
duckietm
2026-04-20 14:27:19 +02:00
parent e2c823253f
commit 7347906786
8 changed files with 752 additions and 5 deletions
+38
View File
@@ -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`);
+17 -1
View File
@@ -162,5 +162,21 @@
<artifactId>joda-time</artifactId> <artifactId>joda-time</artifactId>
<version>2.13.0</version> <version>2.13.0</version>
</dependency> </dependency>
<!-- jBCrypt — used by the built-in /api/auth/* HTTP login handler
to verify Laravel-style $2y$ BCrypt hashes from users.password -->
<dependency>
<groupId>org.mindrot</groupId>
<artifactId>jbcrypt</artifactId>
<version>0.4</version>
</dependency>
<!-- Jakarta Mail — used by the built-in forgot-password endpoint
when smtp.* keys are configured in emulator_settings -->
<dependency>
<groupId>org.eclipse.angus</groupId>
<artifactId>jakarta.mail</artifactId>
<version>2.0.3</version>
</dependency>
</dependencies> </dependencies>
</project> </project>
@@ -1,6 +1,7 @@
package com.eu.habbo.networking.gameserver; package com.eu.habbo.networking.gameserver;
import com.eu.habbo.messages.PacketManager; 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.codec.WebSocketCodec;
import com.eu.habbo.networking.gameserver.decoders.*; import com.eu.habbo.networking.gameserver.decoders.*;
import com.eu.habbo.networking.gameserver.encoders.GameServerMessageEncoder; import com.eu.habbo.networking.gameserver.encoders.GameServerMessageEncoder;
@@ -49,10 +50,9 @@ public class WebSocketChannelInitializer extends ChannelInitializer<SocketChanne
ch.pipeline().addLast("httpCodec", new HttpServerCodec()); ch.pipeline().addLast("httpCodec", new HttpServerCodec());
ch.pipeline().addLast("httpAggregator", new HttpObjectAggregator(MAX_FRAME_SIZE)); ch.pipeline().addLast("httpAggregator", new HttpObjectAggregator(MAX_FRAME_SIZE));
ch.pipeline().addLast("wsHttpHandler", new WebSocketHttpHandler()); ch.pipeline().addLast("wsHttpHandler", new WebSocketHttpHandler());
ch.pipeline().addLast("authHttpHandler", new AuthHttpHandler());
ch.pipeline().addLast("wsProtocolHandler", new WebSocketServerProtocolHandler(this.wsConfig)); ch.pipeline().addLast("wsProtocolHandler", new WebSocketServerProtocolHandler(this.wsConfig));
ch.pipeline().addLast("wsCodec", new WebSocketCodec()); ch.pipeline().addLast("wsCodec", new WebSocketCodec());
// Standard game decoders
ch.pipeline().addLast(new GamePolicyDecoder()); ch.pipeline().addLast(new GamePolicyDecoder());
ch.pipeline().addLast(new GameByteFrameDecoder()); ch.pipeline().addLast(new GameByteFrameDecoder());
ch.pipeline().addLast(new GameByteDecoder()); ch.pipeline().addLast(new GameByteDecoder());
@@ -64,8 +64,6 @@ public class WebSocketChannelInitializer extends ChannelInitializer<SocketChanne
ch.pipeline().addLast("idleEventHandler", new IdleTimeoutHandler(30, 60)); ch.pipeline().addLast("idleEventHandler", new IdleTimeoutHandler(30, 60));
ch.pipeline().addLast(new GameMessageRateLimit()); ch.pipeline().addLast(new GameMessageRateLimit());
ch.pipeline().addLast(new GameMessageHandler()); ch.pipeline().addLast(new GameMessageHandler());
// Encoders
ch.pipeline().addLast("messageEncoder", new GameServerMessageEncoder()); ch.pipeline().addLast("messageEncoder", new GameServerMessageEncoder());
if (PacketManager.DEBUG_SHOW_PACKETS) { if (PacketManager.DEBUG_SHOW_PACKETS) {
@@ -0,0 +1,447 @@
package com.eu.habbo.networking.gameserver.auth;
import com.eu.habbo.Emulator;
import com.eu.habbo.networking.gameserver.GameServerAttributes;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.codec.http.*;
import io.netty.util.ReferenceCountUtil;
import org.mindrot.jbcrypt.BCrypt;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.sql.*;
import java.time.Instant;
import java.util.Base64;
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 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();
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
if (!(msg instanceof FullHttpRequest req)) {
super.channelRead(ctx, msg);
return;
}
String path = new QueryStringDecoder(req.uri()).path();
if (!path.equals(LOGIN_PATH) && !path.equals(REGISTER_PATH)
&& !path.equals(FORGOT_PATH) && !path.equals(LOGOUT_PATH)) {
super.channelRead(ctx, msg);
return;
}
try {
handle(ctx, req, path);
} finally {
ReferenceCountUtil.release(req);
}
}
private void handle(ChannelHandlerContext ctx, FullHttpRequest req, String path) {
if (req.method() == HttpMethod.OPTIONS) {
sendCors(ctx, req);
return;
}
if (req.method() != HttpMethod.POST) {
sendJson(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, errorPayload("Use POST."));
return;
}
String ip = resolveClientIp(ctx, req);
if (AuthRateLimiter.isLocked(ip)) {
long secs = AuthRateLimiter.secondsUntilUnlock(ip);
sendJson(ctx, req, HttpResponseStatus.TOO_MANY_REQUESTS,
errorPayload("Too many attempts. Try again in " + secs + "s."));
return;
}
JsonObject body;
try {
String text = req.content().toString(StandardCharsets.UTF_8);
body = text.isEmpty() ? new JsonObject() : JsonParser.parseString(text).getAsJsonObject();
} catch (Exception e) {
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Invalid JSON body."));
return;
}
if (path.equals(LOGOUT_PATH)) {
handleLogout(ctx, req, body);
return;
}
String turnstileToken = readString(body, "turnstileToken");
if (!TurnstileVerifier.verify(turnstileToken, ip)) {
AuthRateLimiter.recordFailure(ip);
sendJson(ctx, req, HttpResponseStatus.FORBIDDEN, errorPayload("Security check failed."));
return;
}
switch (path) {
case LOGIN_PATH -> 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()) {
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");
if (stored == null || stored.isEmpty() || !checkPassword(password, stored)) {
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 1 FROM users WHERE username = ? OR mail = ? LIMIT 1")) {
check.setString(1, username);
check.setString(2, email);
try (ResultSet rs = check.executeQuery()) {
if (rs.next()) {
sendJson(ctx, req, HttpResponseStatus.CONFLICT,
errorPayload("That Habbo name or email is already taken."));
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! You can now log in.");
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", "If an account exists for that email a reset link has been sent.");
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);
}
}
@@ -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<String, AtomicReference<State>> STATE = new ConcurrentHashMap<>();
private AuthRateLimiter() {}
public static boolean isLocked(String ip) {
if (!isEnabled() || ip == null || ip.isEmpty()) return false;
AtomicReference<State> 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<State> 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) {}
}
@@ -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;
}
}
}
@@ -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<String> 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;
}
}
}