diff --git a/Database Updates/011_HotelLogin.sql b/Database Updates/011_HotelLogin.sql index 76585c8d..666a9b5f 100644 --- a/Database Updates/011_HotelLogin.sql +++ b/Database Updates/011_HotelLogin.sql @@ -110,22 +110,26 @@ CREATE TABLE IF NOT EXISTS `room_templates_items` ( FOREIGN KEY (`item_id`) REFERENCES `items_base` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; -CREATE TABLE IF NOT EXISTS `users_remember_tokens` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `user_id` int(11) NOT NULL, - `token_hash` char(64) NOT NULL, - `created_at` int(11) NOT NULL, - `expires_at` int(11) NOT NULL, - `ip_address` varchar(45) NOT NULL DEFAULT '', - PRIMARY KEY (`id`), - UNIQUE KEY `token_hash` (`token_hash`), - KEY `user_id` (`user_id`), +CREATE TABLE IF NOT EXISTS `users_remember_families` ( + `family_id` char(36) NOT NULL, + `user_id` int(11) NOT NULL, + `current_version` int(11) NOT NULL DEFAULT 1, + `created_at` int(11) NOT NULL, + `expires_at` int(11) NOT NULL, + `revoked` tinyint(1) NOT NULL DEFAULT 0, + `last_ip` varchar(45) NOT NULL DEFAULT '', + PRIMARY KEY (`family_id`), + KEY `user_id` (`user_id`), KEY `expires_at` (`expires_at`), - CONSTRAINT `fk_remember_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE + CONSTRAINT `fk_remember_family_user` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; --- Optional: configure how long a remember token is valid (default 30 days). +DROP TABLE IF EXISTS `users_remember_tokens`; + INSERT INTO `emulator_settings` (`key`, `value`) VALUES - ('login.remember.duration.days', '30') + ('login.remember.duration.days', '30'), + ('login.remember.rotate.interval.minutes', '15'), + ('login.remember.jwt.secret', '') ON DUPLICATE KEY UPDATE `value` = `value`; 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 bdda2039..174bcbb7 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 @@ -34,6 +34,7 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { private static final String CHECK_USERNAME_PATH = "/api/auth/check-username"; private static final String ROOM_TEMPLATES_PATH = "/api/auth/room-templates"; private static final String REMEMBER_PATH = "/api/auth/remember"; + private static final String REFRESH_PATH = "/api/auth/refresh"; private static final String HEALTH_PATH = "/api/health"; private static final Pattern USERNAME_RE = Pattern.compile("^[A-Za-z0-9._-]{3,32}$"); @@ -56,6 +57,7 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { && !path.equals(CHECK_EMAIL_PATH) && !path.equals(CHECK_USERNAME_PATH) && !path.equals(ROOM_TEMPLATES_PATH) && !path.equals(REMEMBER_PATH) + && !path.equals(REFRESH_PATH) && !path.equals(HEALTH_PATH)) { super.channelRead(ctx, msg); return; @@ -139,6 +141,10 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { handleRemember(ctx, req, body, ip); return; } + if (path.equals(REFRESH_PATH)) { + handleRefresh(ctx, req, body, ip); + return; + } String turnstileToken = readString(body, "turnstileToken"); if (!TurnstileVerifier.verify(turnstileToken, ip)) { @@ -154,8 +160,6 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { } } - /* ─── Availability probes ─── */ - private void handleCheckEmail(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { if (!AuthRateLimiter.tryProbe(ip)) { long secs = AuthRateLimiter.secondsUntilProbeReset(ip); @@ -235,8 +239,6 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { sendJson(ctx, req, HttpResponseStatus.OK, res); } - /* ─── Logout ─── */ - private void handleLogout(ChannelHandlerContext ctx, FullHttpRequest req, com.google.gson.JsonObject body) { String ssoTicket = readString(body, "ssoTicket"); String rememberToken = readString(body, "rememberToken").trim(); @@ -273,17 +275,8 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { } } - // Delete only the specific remember token for this device. - // Other devices keep their tokens and can still silent-login. if (!rememberToken.isEmpty()) { - String hash = sha256Hex(rememberToken); - if (hash != null) { - try (PreparedStatement del = conn.prepareStatement( - "DELETE FROM users_remember_tokens WHERE token_hash = ?")) { - del.setString(1, hash); - del.executeUpdate(); - } - } + RememberJwtService.revokeFromToken(conn, rememberToken); } } catch (Exception e) { LOGGER.error("Logout cleanup failed", e); @@ -292,69 +285,16 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { sendJson(ctx, req, HttpResponseStatus.OK, ok); } - /* ─── Remember me ─── */ - private void handleRemember(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { - String rememberToken = readString(body, "rememberToken").trim(); - if (rememberToken.isEmpty()) { + String jwt = readString(body, "rememberToken").trim(); + if (jwt.isEmpty()) { sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Missing rememberToken.")); return; } - String hash = sha256Hex(rememberToken); - if (hash == null) { - sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error.")); - return; - } - - int now = Emulator.getIntUnixTimestamp(); - try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) { - int userId = 0; - int tokenRowId = 0; - - try (PreparedStatement sel = conn.prepareStatement( - "SELECT id, user_id, expires_at FROM users_remember_tokens WHERE token_hash = ? LIMIT 1")) { - sel.setString(1, hash); - try (ResultSet rs = sel.executeQuery()) { - if (rs.next()) { - if (rs.getInt("expires_at") > now) { - userId = rs.getInt("user_id"); - tokenRowId = rs.getInt("id"); - } else { - tokenRowId = rs.getInt("id"); // expired - still purge below - } - } - } - } - - if (userId <= 0) { - if (tokenRowId > 0) { - try (PreparedStatement del = conn.prepareStatement( - "DELETE FROM users_remember_tokens WHERE id = ?")) { - del.setInt(1, tokenRowId); - del.executeUpdate(); - } - } - sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Remember token invalid or expired.")); - return; - } - - String username = null; - try (PreparedStatement usr = conn.prepareStatement( - "SELECT username FROM users WHERE id = ? LIMIT 1")) { - usr.setInt(1, userId); - try (ResultSet rs = usr.executeQuery()) { - if (rs.next()) username = rs.getString("username"); - } - } - - if (username == null) { - try (PreparedStatement del = conn.prepareStatement( - "DELETE FROM users_remember_tokens WHERE id = ?")) { - del.setInt(1, tokenRowId); - del.executeUpdate(); - } + RememberJwtService.RotationResult rot = RememberJwtService.rotate(conn, jwt, ip); + if (rot == null) { sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Remember token invalid or expired.")); return; } @@ -364,23 +304,15 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { "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.setInt(3, rot.userId); upd.executeUpdate(); } - // Rotate: drop the consumed token and issue a new one. - try (PreparedStatement del = conn.prepareStatement( - "DELETE FROM users_remember_tokens WHERE id = ?")) { - del.setInt(1, tokenRowId); - del.executeUpdate(); - } - - String newToken = issueRememberToken(conn, userId, ip); - JsonObject ok = new JsonObject(); ok.addProperty("ssoTicket", ssoTicket); - ok.addProperty("username", username); - if (newToken != null) ok.addProperty("rememberToken", newToken); + ok.addProperty("username", rot.username); + ok.addProperty("rememberToken", rot.jwt); + ok.addProperty("expiresAt", rot.expiresAt); sendJson(ctx, req, HttpResponseStatus.OK, ok); } catch (Exception e) { LOGGER.error("Remember login failed", e); @@ -388,57 +320,29 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { } } - /** - * Generates a fresh remember-me token for a user, stores the hash, - * and returns the raw base64url string to embed in the response. - * Returns null on failure (the login still succeeds). - */ - private static String issueRememberToken(Connection conn, int userId, String ip) { - byte[] buf = new byte[32]; - RNG.nextBytes(buf); - String raw = Base64.getUrlEncoder().withoutPadding().encodeToString(buf); - String hash = sha256Hex(raw); - if (hash == null) return null; - - int now = Emulator.getIntUnixTimestamp(); - int days = Math.max(1, Emulator.getConfig().getInt("login.remember.duration.days", 30)); - int expiresAt = now + (days * 24 * 60 * 60); - - try (PreparedStatement ins = conn.prepareStatement( - "INSERT INTO users_remember_tokens (user_id, token_hash, created_at, expires_at, ip_address) VALUES (?, ?, ?, ?, ?)")) { - ins.setInt(1, userId); - ins.setString(2, hash); - ins.setInt(3, now); - ins.setInt(4, expiresAt); - ins.setString(5, ip == null ? "" : ip); - ins.executeUpdate(); - } catch (SQLException e) { - LOGGER.error("Failed to persist remember token for userId=" + userId, e); - return null; + private void handleRefresh(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { + String jwt = readString(body, "rememberToken").trim(); + if (jwt.isEmpty()) { + sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Missing rememberToken.")); + return; } - return raw; - } - - private static String sha256Hex(String input) { - try { - java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); - byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8)); - StringBuilder sb = new StringBuilder(digest.length * 2); - for (byte b : digest) { - String h = Integer.toHexString(b & 0xff); - if (h.length() == 1) sb.append('0'); - sb.append(h); + try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) { + RememberJwtService.RotationResult rot = RememberJwtService.rotate(conn, jwt, ip); + if (rot == null) { + sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Remember token invalid or expired.")); + return; } - return sb.toString(); + JsonObject ok = new JsonObject(); + ok.addProperty("rememberToken", rot.jwt); + ok.addProperty("expiresAt", rot.expiresAt); + sendJson(ctx, req, HttpResponseStatus.OK, ok); } catch (Exception e) { - LOGGER.error("sha256Hex failed", e); - return null; + LOGGER.error("Refresh failed", e); + sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error.")); } } - /* ─── Login ─── */ - private void handleLogin(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { String username = readString(body, "username").trim(); String password = readString(body, "password"); @@ -487,7 +391,16 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { upd.executeUpdate(); } - String rememberToken = rememberMe ? issueRememberToken(conn, userId, ip) : null; + String rememberToken = null; + if (rememberMe) { + try { + RememberJwtService.RotationResult issued = RememberJwtService.issueForNewFamily( + conn, userId, rs.getString("username"), ip); + rememberToken = issued.jwt; + } catch (SQLException e) { + LOGGER.error("Failed to issue remember-me JWT for userId=" + userId, e); + } + } AuthRateLimiter.recordSuccess(ip); @@ -503,8 +416,6 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { } } - /* ─── 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.")); @@ -633,13 +544,6 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { } } - /** - * If the template carries a custom heightmap (override_model='1' and a - * non-empty heightmap), creates the matching room_models_custom row keyed - * by the new room id and renames the room's model to custom_<newRoomId>. - * Without this, cloned rooms reference a layout that doesn't exist - * (the source room's id) and load as a black screen. - */ private static void materializeCustomLayout(Connection conn, int templateId, int newRoomId) { String overrideModel = "0"; String heightmap = ""; @@ -697,11 +601,6 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { LOGGER.info("[auth/register] materialized custom layout '{}' for roomId={}", customName, newRoomId); } - /** - * Seeds starting balances into users_currency for duckets (type=0) and - * diamonds (type=5). Only inserts when the amount is > 0. Credits live - * in users.credits and are set directly during the register INSERT. - */ private static void seedUserCurrencies(Connection conn, int userId, int duckets, int diamonds) { try (PreparedStatement ins = conn.prepareStatement( "INSERT INTO users_currency (user_id, type, amount) VALUES (?, ?, ?) " + @@ -725,8 +624,6 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { } } - /* ─── Room templates (registration step 3) ─── */ - private void handleRoomTemplates(ChannelHandlerContext ctx, FullHttpRequest req) { JsonArray templates = new JsonArray(); try (Connection conn = Emulator.getDatabase().getDataSource().getConnection(); @@ -754,11 +651,6 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { sendJson(ctx, req, HttpResponseStatus.OK, res); } - /** - * Clones a room_templates entry + its room_templates_items into the new - * user's rooms/items rows, then points their home_room at the new room. - * Failures here do not abort registration; the account is still created. - */ private static void cloneTemplateForUser(Connection conn, int templateId, int userId, String userName) { LOGGER.info("[auth/register] cloning template id={} for user id={} name='{}'", templateId, userId, userName); @@ -836,8 +728,6 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { } } - /* ─── Forgot password ─── */ - private void handleForgot(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { String email = readString(body, "email").trim(); @@ -891,8 +781,6 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { 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 { diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/RememberJwtService.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/RememberJwtService.java new file mode 100644 index 00000000..bba9dbce --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/RememberJwtService.java @@ -0,0 +1,276 @@ +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 javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Base64; +import java.util.UUID; + +public final class RememberJwtService { + + private static final Logger LOGGER = LoggerFactory.getLogger(RememberJwtService.class); + private static final SecureRandom RNG = new SecureRandom(); + private static final Base64.Encoder URL_ENC = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder URL_DEC = Base64.getUrlDecoder(); + + private static volatile String cachedSecret = null; + + private RememberJwtService() {} + + public static final class RotationResult { + public final String jwt; + public final int userId; + public final String username; + public final long expiresAt; + + RotationResult(String jwt, int userId, String username, long expiresAt) { + this.jwt = jwt; + this.userId = userId; + this.username = username; + this.expiresAt = expiresAt; + } + } + + private static int familyTtlDays() { + return Math.max(1, Emulator.getConfig().getInt("login.remember.duration.days", 30)); + } + + private static long familyTtlSeconds() { + return familyTtlDays() * 86400L; + } + + private static String secret() { + String s = cachedSecret; + if (s != null && !s.isEmpty()) return s; + + synchronized (RememberJwtService.class) { + if (cachedSecret != null && !cachedSecret.isEmpty()) return cachedSecret; + + String configured = Emulator.getConfig().getValue("login.remember.jwt.secret", ""); + if (configured != null && !configured.isEmpty()) { + cachedSecret = configured; + return configured; + } + + byte[] buf = new byte[48]; + RNG.nextBytes(buf); + String generated = Base64.getEncoder().withoutPadding().encodeToString(buf); + + try (Connection conn = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement stmt = conn.prepareStatement( + "INSERT INTO emulator_settings (`key`, `value`) VALUES ('login.remember.jwt.secret', ?) " + + "ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)")) { + stmt.setString(1, generated); + stmt.executeUpdate(); + } catch (SQLException e) { + LOGGER.error("Could not persist generated login.remember.jwt.secret; using in-memory only", e); + } + + Emulator.getConfig().update("login.remember.jwt.secret", generated); + cachedSecret = generated; + LOGGER.info("[auth/remember] generated new JWT signing secret (persisted to emulator_settings)"); + return generated; + } + } + + public static RotationResult issueForNewFamily(Connection conn, int userId, String username, String ip) throws SQLException { + String familyId = UUID.randomUUID().toString(); + long now = Emulator.getIntUnixTimestamp(); + long expiresAt = now + familyTtlSeconds(); + + try (PreparedStatement ins = conn.prepareStatement( + "INSERT INTO users_remember_families (family_id, user_id, current_version, created_at, expires_at, revoked, last_ip) " + + "VALUES (?, ?, 1, ?, ?, 0, ?)")) { + ins.setString(1, familyId); + ins.setInt(2, userId); + ins.setLong(3, now); + ins.setLong(4, expiresAt); + ins.setString(5, ip == null ? "" : ip); + ins.executeUpdate(); + } + + String jwt = buildJwt(userId, familyId, 1, now, expiresAt); + return new RotationResult(jwt, userId, username, expiresAt); + } + + public static RotationResult rotate(Connection conn, String jwt, String ip) { + ParsedJwt parsed; + try { + parsed = verifyAndParse(jwt); + } catch (Exception e) { + LOGGER.debug("[auth/remember] invalid JWT: {}", e.getMessage()); + return null; + } + + long now = Emulator.getIntUnixTimestamp(); + if (parsed.exp <= now) return null; + + int familyVersion = 0; + boolean revoked = false; + long familyExpiresAt = 0; + try (PreparedStatement sel = conn.prepareStatement( + "SELECT current_version, revoked, expires_at FROM users_remember_families WHERE family_id = ? AND user_id = ? LIMIT 1")) { + sel.setString(1, parsed.familyId); + sel.setInt(2, parsed.userId); + try (ResultSet rs = sel.executeQuery()) { + if (!rs.next()) return null; + familyVersion = rs.getInt("current_version"); + revoked = rs.getInt("revoked") != 0; + familyExpiresAt = rs.getLong("expires_at"); + } + } catch (SQLException e) { + LOGGER.error("[auth/remember] family lookup failed", e); + return null; + } + + if (revoked || familyExpiresAt <= now) return null; + + if (parsed.version < familyVersion) { + LOGGER.warn("[auth/remember] replay detected: familyId={} presented v={} but current is v={}, revoking family", + parsed.familyId, parsed.version, familyVersion); + revokeFamilyById(conn, parsed.familyId); + return null; + } + if (parsed.version > familyVersion) { + LOGGER.warn("[auth/remember] future version: familyId={} presented v={} but current is v={}", + parsed.familyId, parsed.version, familyVersion); + return null; + } + + int newVersion = familyVersion + 1; + long newExpiresAt = now + familyTtlSeconds(); + + try (PreparedStatement upd = conn.prepareStatement( + "UPDATE users_remember_families SET current_version = ?, expires_at = ?, last_ip = ? " + + "WHERE family_id = ? AND current_version = ? AND revoked = 0")) { + upd.setInt(1, newVersion); + upd.setLong(2, newExpiresAt); + upd.setString(3, ip == null ? "" : ip); + upd.setString(4, parsed.familyId); + upd.setInt(5, familyVersion); + int rows = upd.executeUpdate(); + if (rows == 0) return null; + } catch (SQLException e) { + LOGGER.error("[auth/remember] rotation update failed", e); + return null; + } + + String username = null; + try (PreparedStatement usr = conn.prepareStatement("SELECT username FROM users WHERE id = ? LIMIT 1")) { + usr.setInt(1, parsed.userId); + try (ResultSet rs = usr.executeQuery()) { + if (rs.next()) username = rs.getString("username"); + } + } catch (SQLException e) { + LOGGER.error("[auth/remember] username lookup failed", e); + } + + if (username == null) return null; + + String newJwt = buildJwt(parsed.userId, parsed.familyId, newVersion, now, newExpiresAt); + return new RotationResult(newJwt, parsed.userId, username, newExpiresAt); + } + + public static void revokeFromToken(Connection conn, String jwt) { + try { + ParsedJwt p = verifyAndParse(jwt); + revokeFamilyById(conn, p.familyId); + } catch (Exception ignored) { } + } + + private static void revokeFamilyById(Connection conn, String familyId) { + try (PreparedStatement upd = conn.prepareStatement( + "UPDATE users_remember_families SET revoked = 1 WHERE family_id = ?")) { + upd.setString(1, familyId); + upd.executeUpdate(); + } catch (SQLException e) { + LOGGER.error("[auth/remember] revoke failed for familyId=" + familyId, e); + } + } + + private static String buildJwt(int userId, String familyId, int version, long iat, long exp) { + JsonObject header = new JsonObject(); + header.addProperty("alg", "HS256"); + header.addProperty("typ", "JWT"); + + JsonObject payload = new JsonObject(); + payload.addProperty("sub", userId); + payload.addProperty("fid", familyId); + payload.addProperty("v", version); + payload.addProperty("iat", iat); + payload.addProperty("exp", exp); + payload.addProperty("typ", "refresh"); + + String h = URL_ENC.encodeToString(header.toString().getBytes(StandardCharsets.UTF_8)); + String p = URL_ENC.encodeToString(payload.toString().getBytes(StandardCharsets.UTF_8)); + String signingInput = h + "." + p; + String sig = URL_ENC.encodeToString(hmacSha256(secret().getBytes(StandardCharsets.UTF_8), + signingInput.getBytes(StandardCharsets.UTF_8))); + return signingInput + "." + sig; + } + + private static final class ParsedJwt { + final int userId; + final String familyId; + final int version; + final long exp; + + ParsedJwt(int userId, String familyId, int version, long exp) { + this.userId = userId; + this.familyId = familyId; + this.version = version; + this.exp = exp; + } + } + + private static ParsedJwt verifyAndParse(String jwt) throws Exception { + if (jwt == null || jwt.isEmpty()) throw new IllegalArgumentException("empty"); + + String[] parts = jwt.split("\\."); + if (parts.length != 3) throw new IllegalArgumentException("not 3 segments"); + + String signingInput = parts[0] + "." + parts[1]; + byte[] expected = hmacSha256(secret().getBytes(StandardCharsets.UTF_8), signingInput.getBytes(StandardCharsets.UTF_8)); + byte[] provided = URL_DEC.decode(parts[2]); + if (!constantTimeEquals(expected, provided)) throw new SecurityException("bad signature"); + + byte[] payloadBytes = URL_DEC.decode(parts[1]); + JsonObject payload = JsonParser.parseString(new String(payloadBytes, StandardCharsets.UTF_8)).getAsJsonObject(); + + if (!payload.has("typ") || !"refresh".equals(payload.get("typ").getAsString())) throw new IllegalArgumentException("wrong typ"); + int userId = payload.get("sub").getAsInt(); + String fid = payload.get("fid").getAsString(); + int version = payload.get("v").getAsInt(); + long exp = payload.get("exp").getAsLong(); + + return new ParsedJwt(userId, fid, version, exp); + } + + private static byte[] hmacSha256(byte[] key, byte[] data) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return mac.doFinal(data); + } catch (Exception e) { + throw new IllegalStateException("HmacSHA256 unavailable", e); + } + } + + private static boolean constantTimeEquals(byte[] a, byte[] b) { + if (a == null || b == null || a.length != b.length) return false; + int r = 0; + for (int i = 0; i < a.length; i++) r |= a[i] ^ b[i]; + return r == 0; + } +} diff --git a/Latest_Compiled_Version/Habbo-4.1.2-jar-with-dependencies.jar b/Latest_Compiled_Version/Habbo-4.1.2-jar-with-dependencies.jar index 70b49e34..470be101 100644 Binary files a/Latest_Compiled_Version/Habbo-4.1.2-jar-with-dependencies.jar and b/Latest_Compiled_Version/Habbo-4.1.2-jar-with-dependencies.jar differ