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
Add secure assets and remember login support
This commit is contained in:
@@ -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`);
|
||||
+172
-8
@@ -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(
|
||||
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) {
|
||||
int userId = 0;
|
||||
|
||||
if (ssoTicket != null && !ssoTicket.isEmpty()) {
|
||||
try (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 (!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) {
|
||||
|
||||
+57
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user