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
🆕 Added UI login to the Emu
This commit is contained in:
@@ -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
@@ -162,5 +162,21 @@
|
||||
<artifactId>joda-time</artifactId>
|
||||
<version>2.13.0</version>
|
||||
</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>
|
||||
</project>
|
||||
</project>
|
||||
|
||||
+2
-4
@@ -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<SocketChanne
|
||||
ch.pipeline().addLast("httpCodec", new HttpServerCodec());
|
||||
ch.pipeline().addLast("httpAggregator", new HttpObjectAggregator(MAX_FRAME_SIZE));
|
||||
ch.pipeline().addLast("wsHttpHandler", new WebSocketHttpHandler());
|
||||
ch.pipeline().addLast("authHttpHandler", new AuthHttpHandler());
|
||||
ch.pipeline().addLast("wsProtocolHandler", new WebSocketServerProtocolHandler(this.wsConfig));
|
||||
ch.pipeline().addLast("wsCodec", new WebSocketCodec());
|
||||
|
||||
// Standard game decoders
|
||||
ch.pipeline().addLast(new GamePolicyDecoder());
|
||||
ch.pipeline().addLast(new GameByteFrameDecoder());
|
||||
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(new GameMessageRateLimit());
|
||||
ch.pipeline().addLast(new GameMessageHandler());
|
||||
|
||||
// Encoders
|
||||
ch.pipeline().addLast("messageEncoder", new GameServerMessageEncoder());
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user