diff --git a/Emulator/sqlupdates/remember_login_tokens.sql b/Emulator/sqlupdates/remember_login_tokens.sql new file mode 100644 index 00000000..770ec27f --- /dev/null +++ b/Emulator/sqlupdates/remember_login_tokens.sql @@ -0,0 +1,8 @@ +ALTER TABLE `users` + ADD COLUMN IF NOT EXISTS `remember_token_hash` VARCHAR(64) NOT NULL DEFAULT '' AFTER `auth_ticket`; + +ALTER TABLE `users` + ADD COLUMN IF NOT EXISTS `remember_token_expires_at` INT(11) UNSIGNED NOT NULL DEFAULT 0 AFTER `remember_token_hash`; + +ALTER TABLE `users` + ADD INDEX IF NOT EXISTS `idx_users_remember_token_hash` (`remember_token_hash`); diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpHandler.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpHandler.java index b1fdbb3d..c201ded2 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpHandler.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpHandler.java @@ -16,6 +16,7 @@ import org.slf4j.LoggerFactory; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.security.SecureRandom; import java.sql.*; import java.time.Instant; @@ -29,6 +30,7 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { private static final String REGISTER_PATH = "/api/auth/register"; private static final String FORGOT_PATH = "/api/auth/forgot-password"; private static final String LOGOUT_PATH = "/api/auth/logout"; + private static final String REMEMBER_PATH = "/api/auth/remember"; private static final String CHECK_EMAIL_PATH = "/api/auth/check-email"; private static final String CHECK_USERNAME_PATH = "/api/auth/check-username"; private static final String HEALTH_PATH = "/api/health"; @@ -49,6 +51,7 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { if (!path.equals(LOGIN_PATH) && !path.equals(REGISTER_PATH) && !path.equals(FORGOT_PATH) && !path.equals(LOGOUT_PATH) + && !path.equals(REMEMBER_PATH) && !path.equals(CHECK_EMAIL_PATH) && !path.equals(CHECK_USERNAME_PATH) && !path.equals(HEALTH_PATH)) { super.channelRead(ctx, msg); @@ -111,6 +114,10 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { handleLogout(ctx, req, body); return; } + if (path.equals(REMEMBER_PATH)) { + handleRemember(ctx, req, body, ip); + return; + } if (path.equals(CHECK_EMAIL_PATH)) { handleCheckEmail(ctx, req, body, ip); @@ -220,26 +227,46 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { private void handleLogout(ChannelHandlerContext ctx, FullHttpRequest req, com.google.gson.JsonObject body) { String ssoTicket = readString(body, "ssoTicket"); + String rememberToken = readString(body, "rememberToken"); JsonObject ok = new JsonObject(); ok.addProperty("message", "Logged out."); - if (ssoTicket == null || ssoTicket.isEmpty()) { + if ((ssoTicket == null || ssoTicket.isEmpty()) && rememberToken.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); + try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) { int userId = 0; - try (ResultSet rs = lookup.executeQuery()) { - if (rs.next()) userId = rs.getInt("id"); + + if (ssoTicket != null && !ssoTicket.isEmpty()) { + try (PreparedStatement lookup = conn.prepareStatement( + "SELECT id FROM users WHERE auth_ticket = ? LIMIT 1")) { + lookup.setString(1, ssoTicket); + try (ResultSet rs = lookup.executeQuery()) { + if (rs.next()) userId = rs.getInt("id"); + } + } + } + + if (!rememberToken.isEmpty()) { + String rememberHash = sha256Hex(rememberToken); + if (userId == 0) { + try (PreparedStatement lookupRemember = conn.prepareStatement( + "SELECT id FROM users WHERE remember_token_hash = ? LIMIT 1")) { + lookupRemember.setString(1, rememberHash); + try (ResultSet rs = lookupRemember.executeQuery()) { + if (rs.next()) userId = rs.getInt("id"); + } + } + } else { + clearRememberToken(conn, rememberHash); + } } if (userId > 0) { try (PreparedStatement clear = conn.prepareStatement( - "UPDATE users SET auth_ticket = '', online = '0' WHERE id = ? LIMIT 1")) { + "UPDATE users SET auth_ticket = '', online = '0', remember_token_hash = '', remember_token_expires_at = 0 WHERE id = ? LIMIT 1")) { clear.setInt(1, userId); clear.executeUpdate(); } @@ -265,6 +292,7 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { private void handleLogin(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { String username = readString(body, "username").trim(); String password = readString(body, "password"); + boolean remember = readBoolean(body, "remember") || readBoolean(body, "rememberMe"); if (username.isEmpty() || password.isEmpty()) { sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Missing credentials.")); @@ -300,12 +328,21 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { } String ssoTicket = mintSsoTicket(); + String rememberToken = ""; + int rememberExpiresAt = 0; + + if (remember && rememberEnabled()) { + rememberToken = mintRememberToken(); + rememberExpiresAt = rememberExpiresAt(); + } try (PreparedStatement upd = conn.prepareStatement( - "UPDATE users SET auth_ticket = ?, ip_current = ? WHERE id = ? LIMIT 1")) { + "UPDATE users SET auth_ticket = ?, ip_current = ?, remember_token_hash = ?, remember_token_expires_at = ? WHERE id = ? LIMIT 1")) { upd.setString(1, ssoTicket); upd.setString(2, ip == null ? "" : ip); - upd.setInt(3, userId); + upd.setString(3, rememberToken.isEmpty() ? "" : sha256Hex(rememberToken)); + upd.setInt(4, rememberExpiresAt); + upd.setInt(5, userId); upd.executeUpdate(); } @@ -314,6 +351,10 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { JsonObject ok = new JsonObject(); ok.addProperty("ssoTicket", ssoTicket); ok.addProperty("username", rs.getString("username")); + if (!rememberToken.isEmpty()) { + ok.addProperty("rememberToken", rememberToken); + ok.addProperty("rememberExpiresAt", rememberExpiresAt); + } sendJson(ctx, req, HttpResponseStatus.OK, ok); } } catch (Exception e) { @@ -322,6 +363,69 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { } } +/* ─── Remember login ─── */ + + private void handleRemember(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { + if (!rememberEnabled()) { + sendJson(ctx, req, HttpResponseStatus.FORBIDDEN, errorPayload("Remember login is disabled.")); + return; + } + + String rememberToken = readString(body, "rememberToken").trim(); + + if (rememberToken.isEmpty()) { + sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Missing remember token.")); + return; + } + + int now = Emulator.getIntUnixTimestamp(); + String rememberHash = sha256Hex(rememberToken); + + try (Connection conn = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement stmt = conn.prepareStatement( + "SELECT id, username FROM users WHERE remember_token_hash = ? AND remember_token_expires_at > ? LIMIT 1")) { + stmt.setString(1, rememberHash); + stmt.setInt(2, now); + + try (ResultSet rs = stmt.executeQuery()) { + if (!rs.next()) { + clearRememberToken(conn, rememberHash); + AuthRateLimiter.recordFailure(ip); + sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Remember login expired.")); + return; + } + + int userId = rs.getInt("id"); + String username = rs.getString("username"); + String ssoTicket = mintSsoTicket(); + String nextRememberToken = mintRememberToken(); + int rememberExpiresAt = rememberExpiresAt(); + + try (PreparedStatement upd = conn.prepareStatement( + "UPDATE users SET auth_ticket = ?, ip_current = ?, remember_token_hash = ?, remember_token_expires_at = ? WHERE id = ? LIMIT 1")) { + upd.setString(1, ssoTicket); + upd.setString(2, ip == null ? "" : ip); + upd.setString(3, sha256Hex(nextRememberToken)); + upd.setInt(4, rememberExpiresAt); + upd.setInt(5, userId); + upd.executeUpdate(); + } + + AuthRateLimiter.recordSuccess(ip); + + JsonObject ok = new JsonObject(); + ok.addProperty("ssoTicket", ssoTicket); + ok.addProperty("username", username); + ok.addProperty("rememberToken", nextRememberToken); + ok.addProperty("rememberExpiresAt", rememberExpiresAt); + sendJson(ctx, req, HttpResponseStatus.OK, ok); + } + } catch (Exception e) { + LOGGER.error("Remember-login failed", e); + sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error.")); + } + } + /* ─── Register ─── */ private void handleRegister(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { @@ -501,6 +605,12 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { return Base64.getUrlEncoder().withoutPadding().encodeToString(buf); } + private static String mintRememberToken() { + byte[] buf = new byte[48]; + RNG.nextBytes(buf); + return "remember-" + 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 { @@ -510,6 +620,59 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { } } + private static boolean readBoolean(JsonObject obj, String key) { + if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) return false; + try { + if (obj.get(key).isJsonPrimitive()) { + String value = obj.get(key).getAsString(); + return "true".equalsIgnoreCase(value) || "1".equals(value) || "yes".equalsIgnoreCase(value); + } + } catch (Exception ignored) { + } + try { + return obj.get(key).getAsBoolean(); + } catch (Exception e) { + return false; + } + } + + private static boolean rememberEnabled() { + return Emulator.getConfig().getBoolean("login.remember.enabled", true); + } + + private static int rememberExpiresAt() { + int days = Math.max(1, Emulator.getConfig().getInt("login.remember.days", 30)); + long expiresAt = (long) Emulator.getIntUnixTimestamp() + (days * 86400L); + return (int) Math.min(Integer.MAX_VALUE, expiresAt); + } + + private static void clearRememberToken(Connection conn, String rememberHash) { + if (rememberHash == null || rememberHash.isEmpty()) return; + try (PreparedStatement clear = conn.prepareStatement( + "UPDATE users SET remember_token_hash = '', remember_token_expires_at = 0 WHERE remember_token_hash = ? LIMIT 1")) { + clear.setString(1, rememberHash); + clear.executeUpdate(); + } catch (Exception e) { + LOGGER.debug("Unable to clear remember token", e); + } + } + + private static String sha256Hex(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder builder = new StringBuilder(hash.length * 2); + + for (byte b : hash) { + builder.append(String.format("%02x", b)); + } + + return builder.toString(); + } catch (Exception e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } + private static String resolveClientIp(ChannelHandlerContext ctx, FullHttpRequest req) { String ipHeader = Emulator.getConfig() != null ? Emulator.getConfig().getValue("ws.ip.header", "") @@ -565,7 +728,8 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { response.headers().set("Access-Control-Allow-Credentials", "true"); } response.headers().set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS"); - response.headers().set("Access-Control-Allow-Headers", "Content-Type, X-Requested-With"); + response.headers().set("Access-Control-Allow-Headers", "Content-Type, X-Requested-With, X-Nitro-Key, X-Nitro-Api"); + response.headers().set("Access-Control-Expose-Headers", "X-Nitro-Sec, X-Nitro-Key-Fp, X-Nitro-Derive-Fp"); } private static boolean isKeepAlive(FullHttpRequest req) { diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureApiHandler.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureApiHandler.java index c788988f..c29e7f90 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureApiHandler.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureApiHandler.java @@ -1,5 +1,7 @@ package com.eu.habbo.networking.gameserver.auth; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.ChannelDuplexHandler; @@ -17,12 +19,16 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayDeque; import java.util.Deque; import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; public class NitroSecureApiHandler extends ChannelDuplexHandler { private static final Logger LOGGER = LoggerFactory.getLogger(NitroSecureApiHandler.class); private static final String API_PREFIX = "/api/"; private static final AttributeKey> SECURE_CONTEXTS = AttributeKey.valueOf("nitroSecureApiContexts"); + private static final ConcurrentHashMap NONCE_CACHE = new ConcurrentHashMap<>(); + private static final long MAX_REQUEST_SKEW_MS = 90_000L; + private static final long NONCE_TTL_MS = 2 * 60 * 1000L; @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { @@ -72,6 +78,7 @@ public class NitroSecureApiHandler extends ChannelDuplexHandler { byte[] encrypted = new byte[req.content().readableBytes()]; req.content().getBytes(req.content().readerIndex(), encrypted); byte[] clear = NitroSecureAssetHandler.decrypt(sessionKey, NitroSecureAssetHandler.fromHex(new String(encrypted, StandardCharsets.UTF_8))); + clear = unwrapEnvelope(clear, req, secureContext); FullHttpRequest decryptedReq = new DefaultFullHttpRequest( req.protocolVersion(), @@ -156,6 +163,56 @@ public class NitroSecureApiHandler extends ChannelDuplexHandler { return "1".equals(req.headers().get("X-Nitro-Api")); } + private static byte[] unwrapEnvelope(byte[] clear, FullHttpRequest req, SecureApiContext secureContext) { + if (!requiresReplayEnvelope(req.method())) return clear; + + JsonObject envelope = JsonParser.parseString(new String(clear, StandardCharsets.UTF_8)).getAsJsonObject(); + long ts = envelope.has("ts") ? envelope.get("ts").getAsLong() : 0L; + String nonce = envelope.has("nonce") ? envelope.get("nonce").getAsString() : ""; + String method = envelope.has("method") ? envelope.get("method").getAsString() : ""; + String path = envelope.has("path") ? envelope.get("path").getAsString() : ""; + String body = envelope.has("body") ? envelope.get("body").getAsString() : ""; + long now = System.currentTimeMillis(); + + if (Math.abs(now - ts) > MAX_REQUEST_SKEW_MS) { + throw new IllegalArgumentException("Secure request expired."); + } + + if (!req.method().name().equalsIgnoreCase(method)) { + throw new IllegalArgumentException("Secure request method mismatch."); + } + + String requestPath = req.uri(); + if (!requestPath.equals(path)) { + throw new IllegalArgumentException("Secure request path mismatch."); + } + + if (nonce.isBlank()) { + throw new IllegalArgumentException("Missing secure request nonce."); + } + + cleanupExpiredNonces(now); + + String replayKey = secureContext.derivedFingerprint() + ':' + nonce; + if (NONCE_CACHE.putIfAbsent(replayKey, now + NONCE_TTL_MS) != null) { + throw new IllegalArgumentException("Secure request replay detected."); + } + + return java.util.Base64.getDecoder().decode(body); + } + + private static boolean requiresReplayEnvelope(HttpMethod method) { + return method == HttpMethod.POST + || method == HttpMethod.PUT + || method == HttpMethod.PATCH + || method == HttpMethod.DELETE; + } + + private static void cleanupExpiredNonces(long now) { + if (NONCE_CACHE.size() < 512) return; + NONCE_CACHE.entrySet().removeIf(entry -> entry.getValue() < now); + } + private static void enqueueContext(ChannelHandlerContext ctx, SecureApiContext context) { Deque queue = ctx.channel().attr(SECURE_CONTEXTS).get(); if (queue == null) { diff --git a/Latest_Compiled_Version/config.ini.example b/Latest_Compiled_Version/config.ini.example index 98500a03..07657db0 100644 --- a/Latest_Compiled_Version/config.ini.example +++ b/Latest_Compiled_Version/config.ini.example @@ -47,3 +47,7 @@ nitro.secure.config.root= nitro.secure.gamedata.root= # Set a persistent secret when using Cloudflare / multiple backend requests. nitro.secure.master_key=change-me-to-a-long-random-secret + +# Remember-me login tokens. +login.remember.enabled=true +login.remember.days=30