Add secure assets and remember login support

This commit is contained in:
Lorenzune
2026-04-23 16:27:01 +02:00
parent dde2c4143c
commit 585af846c4
4 changed files with 244 additions and 11 deletions
@@ -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`);
@@ -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) {
@@ -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<Deque<SecureApiContext>> SECURE_CONTEXTS =
AttributeKey.valueOf("nitroSecureApiContexts");
private static final ConcurrentHashMap<String, Long> 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<SecureApiContext> queue = ctx.channel().attr(SECURE_CONTEXTS).get();
if (queue == null) {
@@ -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