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>
|
<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>
|
||||||
|
|||||||
+2
-4
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Reference in New Issue
Block a user