From 75f2e814408b2dcb89500890b3efb392f124cace Mon Sep 17 00:00:00 2001 From: Xephi Date: Wed, 13 May 2026 02:41:30 +0200 Subject: [PATCH 1/3] refactor(premium): Add a feature flag to keep offline uuid compatibility --- authme-bungee/pom.xml | 5 + .../authme/bungee/BungeeProxyBridge.java | 149 +++++------ .../bungee/BungeeProxyConfiguration.java | 12 +- .../authme/bungee/ProxyMessageSecurity.java | 8 +- .../bungee/config/BungeeConfigProperties.java | 8 + .../authme/bungee/premium/AesCfb8Decoder.java | 25 ++ .../authme/bungee/premium/AesCfb8Encoder.java | 23 ++ .../BungeePremiumOnlineModeHandler.java | 27 ++ .../BungeePremiumVerificationManager.java | 81 ++++++ .../premium/ProxyPremiumLoginVerifier.java | 234 +++++++++++++++++ ...roxyPremiumVerificationPacketListener.java | 243 ++++++++++++++++++ authme-bungee/src/main/resources/bungee.yml | 2 + .../bungee/BungeeConfigManagerTest.java | 36 +-- .../authme/bungee/BungeeProxyBridgeTest.java | 97 +------ .../bungee/BungeeReloadCommandTest.java | 2 +- .../BungeePremiumOnlineModeHandlerTest.java | 35 +++ .../authme/data/ProxySessionManager.java | 59 ++++- .../authme/process/join/AsynchronousJoin.java | 49 +++- .../service/ProxyLoginRequestValidator.java | 100 +++++++ .../service/bungeecord/BungeeReceiver.java | 89 ++++--- .../process/join/AsynchronousJoinTest.java | 42 ++- .../ProxyLoginRequestValidatorTest.java | 104 ++++++++ .../bungeecord/BungeeReceiverTest.java | 30 ++- .../authme/velocity/AuthMeVelocityPlugin.java | 28 +- .../authme/velocity/ProxyMessageSecurity.java | 8 +- .../authme/velocity/VelocityProxyBridge.java | 133 ++++------ .../velocity/VelocityProxyConfiguration.java | 12 +- .../config/VelocityConfigProperties.java | 8 + .../premium/ProxyPremiumLoginVerifier.java | 234 +++++++++++++++++ .../VelocityPremiumVerificationManager.java | 87 +++++++ .../velocity/VelocityConfigManagerTest.java | 26 +- .../velocity/VelocityProxyBridgeTest.java | 109 +------- ...elocityPremiumVerificationManagerTest.java | 76 ++++++ docs/premium.md | 132 +++++++--- docs/proxies/bungee/config.yml | 7 +- docs/proxies/configuration.md | 23 ++ docs/proxies/velocity/config.yml | 7 +- pom.xml | 15 +- 38 files changed, 1843 insertions(+), 522 deletions(-) create mode 100644 authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/AesCfb8Decoder.java create mode 100644 authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/AesCfb8Encoder.java create mode 100644 authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/BungeePremiumOnlineModeHandler.java create mode 100644 authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/BungeePremiumVerificationManager.java create mode 100644 authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/ProxyPremiumLoginVerifier.java create mode 100644 authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/ProxyPremiumVerificationPacketListener.java create mode 100644 authme-bungee/src/test/java/fr/xephi/authme/bungee/premium/BungeePremiumOnlineModeHandlerTest.java create mode 100644 authme-core/src/main/java/fr/xephi/authme/service/ProxyLoginRequestValidator.java create mode 100644 authme-core/src/test/java/fr/xephi/authme/service/ProxyLoginRequestValidatorTest.java create mode 100644 authme-velocity/src/main/java/fr/xephi/authme/velocity/premium/ProxyPremiumLoginVerifier.java create mode 100644 authme-velocity/src/main/java/fr/xephi/authme/velocity/premium/VelocityPremiumVerificationManager.java create mode 100644 authme-velocity/src/test/java/fr/xephi/authme/velocity/premium/VelocityPremiumVerificationManagerTest.java diff --git a/authme-bungee/pom.xml b/authme-bungee/pom.xml index 84e961cfe4..050b1c71df 100644 --- a/authme-bungee/pom.xml +++ b/authme-bungee/pom.xml @@ -31,6 +31,11 @@ configme compile + + com.github.retrooper + packetevents-bungeecord + provided + org.junit.jupiter junit-jupiter diff --git a/authme-bungee/src/main/java/fr/xephi/authme/bungee/BungeeProxyBridge.java b/authme-bungee/src/main/java/fr/xephi/authme/bungee/BungeeProxyBridge.java index 10b164ff6a..71b5e1d11f 100644 --- a/authme-bungee/src/main/java/fr/xephi/authme/bungee/BungeeProxyBridge.java +++ b/authme-bungee/src/main/java/fr/xephi/authme/bungee/BungeeProxyBridge.java @@ -3,6 +3,8 @@ import com.google.common.io.ByteArrayDataInput; import com.google.common.io.ByteArrayDataOutput; import com.google.common.io.ByteStreams; +import fr.xephi.authme.bungee.premium.BungeePremiumOnlineModeHandler; +import fr.xephi.authme.bungee.premium.BungeePremiumVerificationManager; import net.md_5.bungee.api.ChatColor; import net.md_5.bungee.api.chat.TextComponent; import net.md_5.bungee.api.ProxyServer; @@ -10,11 +12,9 @@ import net.md_5.bungee.api.connection.ProxiedPlayer; import net.md_5.bungee.api.connection.Server; import net.md_5.bungee.api.event.ChatEvent; -import net.md_5.bungee.api.event.LoginEvent; import net.md_5.bungee.api.event.PlayerDisconnectEvent; +import net.md_5.bungee.api.event.PlayerHandshakeEvent; import net.md_5.bungee.api.event.PluginMessageEvent; -import net.md_5.bungee.api.event.PostLoginEvent; -import net.md_5.bungee.api.event.PreLoginEvent; import net.md_5.bungee.api.event.ServerConnectEvent; import net.md_5.bungee.api.event.ServerSwitchEvent; import net.md_5.bungee.api.plugin.Listener; @@ -31,6 +31,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.UUID; import java.util.logging.Logger; public final class BungeeProxyBridge implements Listener { @@ -60,11 +61,8 @@ public final class BungeeProxyBridge implements Listener { private List premiumListBuffer = new ArrayList<>(); // Players with a pending premium verification (ran /premium but not yet confirmed via reconnect) private volatile Set pendingPremiumUsernames = ConcurrentHashMap.newKeySet(); - // Players for whom we already forced online-mode once to verify premium status; if they appear - // in onPreLogin again without having reached onLogin, Mojang auth failed → cancel the request. - private final Set pendingVerificationAttempted = ConcurrentHashMap.newKeySet(); - // Players whose Mojang UUID was confirmed by the proxy during the login phase (LoginSuccess with UUID v4) - private final Set proxyVerifiedPremium = ConcurrentHashMap.newKeySet(); + private final BungeePremiumOnlineModeHandler premiumOnlineModeHandler; + private final BungeePremiumVerificationManager premiumVerificationManager; private final ScheduledExecutorService retryScheduler = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "authme-bungee-retry"); t.setDaemon(true); @@ -77,15 +75,17 @@ public final class BungeeProxyBridge implements Listener { this.logger = logger; this.configuration = configuration; this.authenticationStore = authenticationStore; - } - - private void markProxyVerifiedPremium(String normalizedName) { - proxyVerifiedPremium.add(normalizedName); - logger.info("Proxy-verified premium: '" + normalizedName + "' authenticated online-mode with Mojang"); + this.premiumOnlineModeHandler = new BungeePremiumOnlineModeHandler(this::requiresPremiumVerification); + this.premiumVerificationManager = + new BungeePremiumVerificationManager(proxyServer, logger, + this::requiresPremiumVerification, this::isPendingPremiumVerification, + this::clearPendingPremiumVerification, + () -> this.configuration.keepOfflineUuidCompatibility()); } void reload(BungeeProxyConfiguration configuration) { this.configuration = configuration; + premiumVerificationManager.refreshRegistration(); logger.info("Configuration reloaded"); } @@ -104,6 +104,9 @@ void logConfigurationDetails() { logger.info("autoLogin is disabled"); } + logger.info("premium.keepOfflineUuidCompatibility is " + + (configuration.keepOfflineUuidCompatibility() ? "enabled" : "disabled")); + if (configuration.sendOnLogoutEnabled() && configuration.sendOnLogoutTarget().isEmpty()) { logger.warning("sendOnLogout is enabled but unloggedUserServer is empty; logout redirects will be skipped"); } @@ -113,6 +116,30 @@ void registerChannels() { proxyServer.registerChannel(AUTHME_CHANNEL); logger.info("Registered AuthMe BungeeCord bridge channel"); broadcastProxyStartedHandshake(); + premiumVerificationManager.register(); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onPlayerHandshake(PlayerHandshakeEvent event) { + if (!configuration.keepOfflineUuidCompatibility()) { + premiumOnlineModeHandler.enableOnlineModeIfRequired(event.getConnection()); + } + } + + private boolean requiresPremiumVerification(String normalizedName) { + return premiumUsernames.contains(normalizedName) || pendingPremiumUsernames.contains(normalizedName); + } + + private boolean isPendingPremiumVerification(String normalizedName) { + return pendingPremiumUsernames.contains(normalizedName); + } + + private void clearPendingPremiumVerification(String normalizedName) { + if (pendingPremiumUsernames.remove(normalizedName)) { + premiumVerificationManager.clearVerifiedPremium(normalizedName); + logger.warning("Cleared pending premium verification for '" + normalizedName + + "' after a failed proxy-side premium handshake"); + } } void broadcastProxyStartedHandshake() { @@ -190,9 +217,11 @@ public void onPluginMessage(PluginMessageEvent event) { } else if (PREMIUM_UNSET_MESSAGE.equals(parsedMessage.typeId())) { premiumUsernames.remove(parsedMessage.playerName()); pendingPremiumUsernames.remove(parsedMessage.playerName()); + premiumVerificationManager.clearVerifiedPremium(parsedMessage.playerName()); logger.fine(() -> "Premium disabled for '" + parsedMessage.playerName() + "' (proxy cache updated)"); } else if (PREMIUM_PENDING_SET_MESSAGE.equals(parsedMessage.typeId())) { pendingPremiumUsernames.add(parsedMessage.playerName()); + premiumVerificationManager.clearVerifiedPremium(parsedMessage.playerName()); logger.fine(() -> "Pending premium verification started for '" + parsedMessage.playerName() + "'"); } else if (PREMIUM_LIST_MESSAGE.equals(parsedMessage.typeId())) { Set newPremiumSet = ConcurrentHashMap.newKeySet(); @@ -255,24 +284,20 @@ public void onServerSwitch(ServerSwitchEvent event) { } String normalizedName = normalizeName(player.getName()); - - // Pending players have passed Mojang auth at the proxy, but we must NOT send PERFORM_LOGIN - // for them: the backend needs to run canBypassWithPremium() to finalize (persist) the premium - // UUID. Only confirmed premium players (premiumUsernames) trigger the auto-login bypass. - boolean isPremiumJoin = connectingToAuthServer - && proxyVerifiedPremium.contains(normalizedName) - && !pendingPremiumUsernames.contains(normalizedName); + UUID verifiedPremiumUuid = premiumVerificationManager.getVerifiedPremiumUuid(normalizedName); + boolean isPremiumJoin = connectingToAuthServer && verifiedPremiumUuid != null; if (!authenticationStore.isAuthenticated(player) && !isPremiumJoin) { return; } if (isPremiumJoin) { - logger.fine("Proxy-verified premium player " + normalizedName + logger.fine("PacketEvents-verified premium player " + normalizedName + " joining auth server — sending perform.login immediately"); } String serverName = currentServer.getInfo().getName(); logger.info("Sending auto-login request to server '" + serverName + "' for player " + normalizedName); - currentServer.getInfo().sendData(AUTHME_CHANNEL, createPerformLoginMessage(normalizedName), false); + currentServer.getInfo().sendData( + AUTHME_CHANNEL, createPerformLoginMessage(normalizedName, verifiedPremiumUuid), false); initiatePendingLogin(normalizedName); } @@ -340,73 +365,12 @@ public void onPlayerDisconnect(PlayerDisconnectEvent event) { } cancelPendingLogin(normalizedName); authenticationStore.clear(event.getPlayer()); - proxyVerifiedPremium.remove(normalizedName); - pendingVerificationAttempted.remove(normalizedName); - } - - @EventHandler - public void onPreLogin(PreLoginEvent event) { - String normalizedName = normalizeName(event.getConnection().getName()); - if (premiumUsernames.contains(normalizedName)) { - event.getConnection().setOnlineMode(true); - logger.fine("Forcing online-mode for premium player '" + normalizedName + "'"); - } else if (pendingPremiumUsernames.contains(normalizedName)) { - if (pendingVerificationAttempted.contains(normalizedName)) { - // The player was already given a forced online-mode attempt but never reached onLogin — - // meaning Mojang rejected them. Cancel the premium request so they can reconnect normally. - pendingPremiumUsernames.remove(normalizedName); - pendingVerificationAttempted.remove(normalizedName); - logger.info("Pending premium verification failed for '" + normalizedName - + "' (Mojang auth rejected) — premium request cancelled"); - } else { - // First attempt: force online-mode and track that the attempt is in progress. - pendingVerificationAttempted.add(normalizedName); - event.getConnection().setOnlineMode(true); - logger.fine("Forcing online-mode for pending premium player '" + normalizedName + "'"); - } - } - } - - /** - * Fires after the proxy has finished the Mojang authentication phase for a connecting player. - * If the connection ended up in online mode (real Mojang account verified at the proxy), the - * player is recorded as proxy-verified premium so the auto-login bypass on the auth server - * will fire on {@link ServerSwitchEvent}. - */ - @EventHandler - public void onLogin(LoginEvent event) { - if (event.isCancelled()) { - return; - } - if (!event.getConnection().isOnlineMode()) { - return; - } - String normalizedName = normalizeName(event.getConnection().getName()); - // Mojang auth succeeded: the attempt tracking entry is no longer needed. - pendingVerificationAttempted.remove(normalizedName); - markProxyVerifiedPremium(normalizedName); - } - - /** - * Fallback: if for any reason the {@link LoginEvent} hook did not flag the player (e.g. the - * proxy is in global online mode and {@code isOnlineMode()} on PendingConnection is reported - * after {@code LoginEvent}), {@link PostLoginEvent} still gives us the verified UUID from the - * proxy. A version-4 UUID means Mojang verified the identity. - */ - @EventHandler - public void onPostLogin(PostLoginEvent event) { - ProxiedPlayer player = event.getPlayer(); - if (player.getUniqueId() != null && player.getUniqueId().version() == 4) { - String normalizedName = normalizeName(player.getName()); - if (proxyVerifiedPremium.add(normalizedName)) { - logger.info("Proxy-verified premium (PostLogin fallback): '" + normalizedName - + "' has a Mojang UUID"); - } - } + premiumVerificationManager.clearVerifiedPremium(normalizedName); } void shutdown() { proxyServer.unregisterChannel(AUTHME_CHANNEL); + premiumVerificationManager.shutdown(); retryScheduler.shutdownNow(); } @@ -429,7 +393,9 @@ private void sendAutoLoginIfAlreadySwitched(String normalizedName, ServerInfo au String currentServerName = currentConn.getInfo().getName(); logger.info("Player " + normalizedName + " already on server '" + currentServerName + "' when login message arrived — sending auto-login immediately"); - currentConn.getInfo().sendData(AUTHME_CHANNEL, createPerformLoginMessage(normalizedName), false); + UUID verifiedPremiumUuid = premiumVerificationManager.getVerifiedPremiumUuid(normalizedName); + currentConn.getInfo().sendData( + AUTHME_CHANNEL, createPerformLoginMessage(normalizedName, verifiedPremiumUuid), false); initiatePendingLogin(normalizedName); } @@ -488,7 +454,9 @@ private void scheduleRetry(String normalizedName) { String serverName = server.getInfo().getName(); logger.fine("Retrying auto-login for " + normalizedName + " on server '" + serverName + "' (attempt " + (current + 1) + "/" + MAX_RETRIES + ")"); - server.getInfo().sendData(AUTHME_CHANNEL, createPerformLoginMessage(normalizedName), false); + UUID verifiedPremiumUuid = premiumVerificationManager.getVerifiedPremiumUuid(normalizedName); + server.getInfo().sendData( + AUTHME_CHANNEL, createPerformLoginMessage(normalizedName, verifiedPremiumUuid), false); scheduleRetry(normalizedName); }, 1, TimeUnit.SECONDS); } @@ -507,7 +475,6 @@ private ParsedPluginMessage parsePluginMessage(byte[] data) { && !PREMIUM_PENDING_SET_MESSAGE.equals(typeId)) { return ParsedPluginMessage.ignored(); } - // premium.list and premium.list.chunk carry non-player-name data; read as-is String argument = input.readUTF(); return new ParsedPluginMessage(typeId, (PREMIUM_LIST_MESSAGE.equals(typeId) || PREMIUM_LIST_CHUNK_MESSAGE.equals(typeId)) @@ -563,13 +530,15 @@ private void redirectLoggedOutPlayer(String normalizedPlayerName) { player.connect(targetServer); } - private byte[] createPerformLoginMessage(String normalizedName) { + private byte[] createPerformLoginMessage(String normalizedName, UUID verifiedPremiumUuid) { long timestamp = System.currentTimeMillis(); - String hmac = ProxyMessageSecurity.computeHmac(configuration.sharedSecret(), normalizedName, timestamp); + String hmac = ProxyMessageSecurity.computeHmac( + configuration.sharedSecret(), normalizedName, timestamp, verifiedPremiumUuid); ByteArrayDataOutput output = ByteStreams.newDataOutput(); output.writeUTF(PERFORM_LOGIN_MESSAGE); output.writeUTF(normalizedName); output.writeLong(timestamp); + output.writeUTF(verifiedPremiumUuid == null ? "" : verifiedPremiumUuid.toString()); output.writeUTF(hmac); return output.toByteArray(); } diff --git a/authme-bungee/src/main/java/fr/xephi/authme/bungee/BungeeProxyConfiguration.java b/authme-bungee/src/main/java/fr/xephi/authme/bungee/BungeeProxyConfiguration.java index 0ff9677cf0..c318b1866c 100644 --- a/authme-bungee/src/main/java/fr/xephi/authme/bungee/BungeeProxyConfiguration.java +++ b/authme-bungee/src/main/java/fr/xephi/authme/bungee/BungeeProxyConfiguration.java @@ -23,13 +23,15 @@ final class BungeeProxyConfiguration { private final String sendOnLogoutTarget; private final String loginServer; private final String sharedSecret; + private final boolean keepOfflineUuidCompatibility; BungeeProxyConfiguration(Set authServers, boolean allServersAreAuthServers, boolean commandsRequireAuth, Set commandWhitelist, boolean chatRequiresAuth, boolean serverSwitchRequiresAuth, String serverSwitchKickMessage, boolean autoLoginEnabled, boolean sendOnLogoutEnabled, String sendOnLogoutTarget, - String loginServer, String sharedSecret) { + String loginServer, String sharedSecret, + boolean keepOfflineUuidCompatibility) { this.authServers = authServers; this.allServersAreAuthServers = allServersAreAuthServers; this.commandsRequireAuth = commandsRequireAuth; @@ -42,6 +44,7 @@ final class BungeeProxyConfiguration { this.sendOnLogoutTarget = normalizeServerName(sendOnLogoutTarget); this.loginServer = normalizeServerName(loginServer); this.sharedSecret = sharedSecret; + this.keepOfflineUuidCompatibility = keepOfflineUuidCompatibility; } static BungeeProxyConfiguration from(SettingsManager settingsManager) { @@ -57,7 +60,8 @@ static BungeeProxyConfiguration from(SettingsManager settingsManager) { settingsManager.getProperty(BungeeConfigProperties.ENABLE_SEND_ON_LOGOUT), settingsManager.getProperty(BungeeConfigProperties.SEND_ON_LOGOUT_TARGET), settingsManager.getProperty(BungeeConfigProperties.LOGIN_SERVER), - settingsManager.getProperty(BungeeConfigProperties.PROXY_SHARED_SECRET)); + settingsManager.getProperty(BungeeConfigProperties.PROXY_SHARED_SECRET), + settingsManager.getProperty(BungeeConfigProperties.PREMIUM_KEEP_OFFLINE_UUID_COMPATIBILITY)); } Set authServers() { @@ -104,6 +108,10 @@ String sharedSecret() { return sharedSecret; } + boolean keepOfflineUuidCompatibility() { + return keepOfflineUuidCompatibility; + } + boolean isAuthServer(ServerInfo serverInfo) { return allServersAreAuthServers || authServers.contains(normalizeServerName(serverInfo.getName())); } diff --git a/authme-bungee/src/main/java/fr/xephi/authme/bungee/ProxyMessageSecurity.java b/authme-bungee/src/main/java/fr/xephi/authme/bungee/ProxyMessageSecurity.java index 85393c8213..18438919ce 100644 --- a/authme-bungee/src/main/java/fr/xephi/authme/bungee/ProxyMessageSecurity.java +++ b/authme-bungee/src/main/java/fr/xephi/authme/bungee/ProxyMessageSecurity.java @@ -7,6 +7,7 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.HexFormat; +import java.util.UUID; final class ProxyMessageSecurity { @@ -16,11 +17,12 @@ final class ProxyMessageSecurity { private ProxyMessageSecurity() { } - static String computeHmac(String secret, String playerName, long timestamp) { + static String computeHmac(String secret, String playerName, long timestamp, UUID verifiedPremiumUuid) { try { Mac mac = Mac.getInstance(HMAC_ALGO); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGO)); - byte[] hmacBytes = mac.doFinal((playerName + ":" + timestamp).getBytes(StandardCharsets.UTF_8)); + String payload = playerName + ":" + timestamp + ":" + (verifiedPremiumUuid == null ? "" : verifiedPremiumUuid); + byte[] hmacBytes = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(hmacBytes); } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new IllegalStateException("Failed to compute HMAC-SHA256", e); @@ -31,7 +33,7 @@ static boolean verifyHmac(String secret, String playerName, long timestamp, Stri if (Math.abs(System.currentTimeMillis() - timestamp) > MAX_AGE_MILLIS) { return false; } - String expectedHmac = computeHmac(secret, playerName, timestamp); + String expectedHmac = computeHmac(secret, playerName, timestamp, null); return MessageDigest.isEqual( expectedHmac.getBytes(StandardCharsets.UTF_8), providedHmac.getBytes(StandardCharsets.UTF_8)); diff --git a/authme-bungee/src/main/java/fr/xephi/authme/bungee/config/BungeeConfigProperties.java b/authme-bungee/src/main/java/fr/xephi/authme/bungee/config/BungeeConfigProperties.java index 9c7f23c37b..41bcaf13d3 100644 --- a/authme-bungee/src/main/java/fr/xephi/authme/bungee/config/BungeeConfigProperties.java +++ b/authme-bungee/src/main/java/fr/xephi/authme/bungee/config/BungeeConfigProperties.java @@ -67,6 +67,14 @@ public final class BungeeConfigProperties implements SettingsHolder { public static final Property PROXY_SHARED_SECRET = newProperty("proxySharedSecret", ""); + @Comment({ + "Keep verified premium players on the backend offline UUID for plugin compatibility.", + "When false, premium players keep their Mojang UUID on the backend instead.", + "False uses Bungee's local online-mode handshake path and does not require PacketEvents." + }) + public static final Property PREMIUM_KEEP_OFFLINE_UUID_COMPATIBILITY = + newProperty("premium.keepOfflineUuidCompatibility", false); + private BungeeConfigProperties() { } } diff --git a/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/AesCfb8Decoder.java b/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/AesCfb8Decoder.java new file mode 100644 index 0000000000..4565c41745 --- /dev/null +++ b/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/AesCfb8Decoder.java @@ -0,0 +1,25 @@ +package fr.xephi.authme.bungee.premium; + +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.MessageToMessageDecoder; +import io.netty.buffer.ByteBuf; + +import javax.crypto.Cipher; +import java.util.List; + +final class AesCfb8Decoder extends MessageToMessageDecoder { + + private final Cipher cipher; + + AesCfb8Decoder(Cipher cipher) { + this.cipher = cipher; + } + + @Override + protected void decode(ChannelHandlerContext ctx, ByteBuf msg, List out) { + byte[] input = new byte[msg.readableBytes()]; + msg.readBytes(input); + out.add(Unpooled.wrappedBuffer(cipher.update(input))); + } +} diff --git a/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/AesCfb8Encoder.java b/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/AesCfb8Encoder.java new file mode 100644 index 0000000000..526ae2b0b4 --- /dev/null +++ b/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/AesCfb8Encoder.java @@ -0,0 +1,23 @@ +package fr.xephi.authme.bungee.premium; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.MessageToByteEncoder; + +import javax.crypto.Cipher; + +final class AesCfb8Encoder extends MessageToByteEncoder { + + private final Cipher cipher; + + AesCfb8Encoder(Cipher cipher) { + this.cipher = cipher; + } + + @Override + protected void encode(ChannelHandlerContext ctx, ByteBuf in, ByteBuf out) { + byte[] input = new byte[in.readableBytes()]; + in.readBytes(input); + out.writeBytes(cipher.update(input)); + } +} diff --git a/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/BungeePremiumOnlineModeHandler.java b/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/BungeePremiumOnlineModeHandler.java new file mode 100644 index 0000000000..642a73c092 --- /dev/null +++ b/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/BungeePremiumOnlineModeHandler.java @@ -0,0 +1,27 @@ +package fr.xephi.authme.bungee.premium; + +import net.md_5.bungee.api.connection.PendingConnection; + +import java.util.Locale; +import java.util.function.Predicate; + +public final class BungeePremiumOnlineModeHandler { + + private final Predicate requiresVerification; + + public BungeePremiumOnlineModeHandler(Predicate requiresVerification) { + this.requiresVerification = requiresVerification; + } + + public void enableOnlineModeIfRequired(PendingConnection connection) { + String username = connection.getName(); + if (username == null) { + return; + } + + String normalizedName = username.toLowerCase(Locale.ROOT); + if (requiresVerification.test(normalizedName) && !connection.isOnlineMode()) { + connection.setOnlineMode(true); + } + } +} diff --git a/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/BungeePremiumVerificationManager.java b/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/BungeePremiumVerificationManager.java new file mode 100644 index 0000000000..d26c7e4712 --- /dev/null +++ b/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/BungeePremiumVerificationManager.java @@ -0,0 +1,81 @@ +package fr.xephi.authme.bungee.premium; + +import com.github.retrooper.packetevents.PacketEvents; +import net.md_5.bungee.api.ProxyServer; + +import java.util.UUID; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.logging.Logger; + +public final class BungeePremiumVerificationManager { + + private final ProxyServer proxyServer; + private final Logger logger; + private final Predicate requiresVerification; + private final Predicate isPendingVerification; + private final Consumer pendingVerificationFailureHandler; + private final BooleanSupplier keepOfflineUuidCompatibility; + private final ProxyPremiumLoginVerifier loginVerifier; + private ProxyPremiumVerificationPacketListener packetListener; + private boolean registered; + + public BungeePremiumVerificationManager(ProxyServer proxyServer, Logger logger, + Predicate requiresVerification, + Predicate isPendingVerification, + Consumer pendingVerificationFailureHandler, + BooleanSupplier keepOfflineUuidCompatibility) { + this.proxyServer = proxyServer; + this.logger = logger; + this.requiresVerification = requiresVerification; + this.isPendingVerification = isPendingVerification; + this.pendingVerificationFailureHandler = pendingVerificationFailureHandler; + this.keepOfflineUuidCompatibility = keepOfflineUuidCompatibility; + this.loginVerifier = new ProxyPremiumLoginVerifier("authme-bungee-premium", this.logger::warning); + } + + public void register() { + refreshRegistration(); + } + + public void refreshRegistration() { + if (!keepOfflineUuidCompatibility.getAsBoolean()) { + unregisterPacketListener(); + return; + } + if (registered) { + return; + } + if (proxyServer.getPluginManager().getPlugin("packetevents") == null) { + logger.warning("PacketEvents is not loaded on the proxy; premium proxy verification stays disabled"); + return; + } + packetListener = new ProxyPremiumVerificationPacketListener( + requiresVerification, isPendingVerification, pendingVerificationFailureHandler, loginVerifier, logger::warning); + PacketEvents.getAPI().getEventManager().registerListener(packetListener); + registered = true; + logger.info("Registered PacketEvents premium verification on the Bungee proxy"); + } + + public UUID getVerifiedPremiumUuid(String normalizedName) { + return loginVerifier.getVerifiedUuid(normalizedName); + } + + public void clearVerifiedPremium(String normalizedName) { + loginVerifier.clearVerified(normalizedName); + } + + public void shutdown() { + unregisterPacketListener(); + loginVerifier.shutdown(); + } + + private void unregisterPacketListener() { + if (registered && packetListener != null) { + PacketEvents.getAPI().getEventManager().unregisterListener(packetListener); + packetListener = null; + registered = false; + } + } +} diff --git a/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/ProxyPremiumLoginVerifier.java b/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/ProxyPremiumLoginVerifier.java new file mode 100644 index 0000000000..2c2f71a311 --- /dev/null +++ b/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/ProxyPremiumLoginVerifier.java @@ -0,0 +1,234 @@ +package fr.xephi.authme.bungee.premium; + +import javax.crypto.Cipher; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.math.BigInteger; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +final class ProxyPremiumLoginVerifier { + + private static final String HAS_JOINED_URL = + "https://sessionserver.mojang.com/session/minecraft/hasJoined"; + private static final Pattern UUID_PATTERN = + Pattern.compile("\"id\"\\s*:\\s*\"([0-9a-fA-F]{32})\""); + private static final long VERIFIED_TTL_MS = 60_000L; + private static final long PENDING_TTL_MS = 30_000L; + + private final KeyPair rsaKeyPair; + private final SecureRandom secureRandom; + private final Consumer warningLogger; + private final ExecutorService verificationExecutor; + private final ConcurrentHashMap pending = new ConcurrentHashMap<>(); + private final ConcurrentHashMap verified = new ConcurrentHashMap<>(); + + ProxyPremiumLoginVerifier(String threadName, Consumer warningLogger) { + this.warningLogger = warningLogger; + this.secureRandom = new SecureRandom(); + this.verificationExecutor = Executors.newSingleThreadExecutor(r -> { + Thread thread = new Thread(r, threadName); + thread.setDaemon(true); + return thread; + }); + try { + KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); + gen.initialize(1024, secureRandom); + this.rsaKeyPair = gen.generateKeyPair(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("RSA algorithm not available", e); + } + } + + PublicKey getPublicKey() { + return rsaKeyPair.getPublic(); + } + + byte[] startVerification(String connectionKey, String username, UUID playerUuid, boolean pendingPremiumEnrollment) { + evictStalePendingEntries(); + verified.remove(username.toLowerCase(Locale.ROOT)); + byte[] verifyToken = new byte[4]; + secureRandom.nextBytes(verifyToken); + pending.put(connectionKey, + new PendingVerification(username, playerUuid, verifyToken, System.currentTimeMillis(), pendingPremiumEnrollment)); + return verifyToken; + } + + boolean hasPending(String connectionKey) { + return pending.containsKey(connectionKey); + } + + String getPendingUsername(String connectionKey) { + PendingVerification pendingVerification = pending.get(connectionKey); + return pendingVerification != null ? pendingVerification.username() : null; + } + + UUID getPendingPlayerUuid(String connectionKey) { + PendingVerification pendingVerification = pending.get(connectionKey); + return pendingVerification != null ? pendingVerification.playerUuid() : null; + } + + boolean isPendingPremiumEnrollment(String connectionKey) { + PendingVerification pendingVerification = pending.get(connectionKey); + return pendingVerification != null && pendingVerification.pendingPremiumEnrollment(); + } + + void cleanupPending(String connectionKey) { + pending.remove(connectionKey); + } + + byte[] decryptData(byte[] encrypted) throws GeneralSecurityException { + return rsaDecrypt(encrypted); + } + + CompletableFuture> completeVerification( + String connectionKey, byte[] sharedSecret, byte[] encryptedVerifyToken) { + PendingVerification pendingVerification = pending.remove(connectionKey); + if (pendingVerification == null) { + return CompletableFuture.completedFuture(Optional.empty()); + } + return CompletableFuture.supplyAsync(() -> { + try { + byte[] decryptedToken = rsaDecrypt(encryptedVerifyToken); + if (!Arrays.equals(decryptedToken, pendingVerification.verifyToken())) { + warningLogger.accept("Proxy premium verification failed for '" + pendingVerification.username() + + "': verify token mismatch"); + return Optional.empty(); + } + String serverHash = computeServerHash(sharedSecret); + return hasJoined(pendingVerification.username(), serverHash); + } catch (Exception e) { + warningLogger.accept("Proxy premium verification failed for '" + pendingVerification.username() + + "': " + e.getMessage()); + return Optional.empty(); + } + }, verificationExecutor); + } + + void storeVerified(String username, UUID mojangUuid) { + verified.put(username.toLowerCase(Locale.ROOT), new VerifiedSession(mojangUuid, System.currentTimeMillis())); + } + + UUID getVerifiedUuid(String username) { + String normalizedName = username.toLowerCase(Locale.ROOT); + VerifiedSession verifiedSession = verified.get(normalizedName); + if (verifiedSession == null) { + return null; + } + if (System.currentTimeMillis() - verifiedSession.verifiedAt() > VERIFIED_TTL_MS) { + verified.remove(normalizedName); + return null; + } + return verifiedSession.mojangUuid(); + } + + void clearVerified(String username) { + verified.remove(username.toLowerCase(Locale.ROOT)); + } + + void shutdown() { + verificationExecutor.shutdownNow(); + pending.clear(); + verified.clear(); + } + + private void evictStalePendingEntries() { + long now = System.currentTimeMillis(); + pending.entrySet().removeIf(entry -> now - entry.getValue().startedAt() > PENDING_TTL_MS); + } + + private byte[] rsaDecrypt(byte[] encrypted) throws GeneralSecurityException { + Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); + cipher.init(Cipher.DECRYPT_MODE, rsaKeyPair.getPrivate()); + return cipher.doFinal(encrypted); + } + + private String computeServerHash(byte[] sharedSecret) throws NoSuchAlgorithmException { + MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); + sha1.update("".getBytes(StandardCharsets.ISO_8859_1)); + sha1.update(sharedSecret); + sha1.update(rsaKeyPair.getPublic().getEncoded()); + return new BigInteger(sha1.digest()).toString(16); + } + + private Optional hasJoined(String username, String serverHash) { + try { + HttpURLConnection connection = openGet(HAS_JOINED_URL + "?username=" + username + "&serverId=" + serverHash); + int code = connection.getResponseCode(); + if (code == HttpURLConnection.HTTP_NO_CONTENT || code == HttpURLConnection.HTTP_NOT_FOUND) { + return Optional.empty(); + } + if (code != HttpURLConnection.HTTP_OK) { + warningLogger.accept("Mojang hasJoined returned " + code + " for '" + username + "'"); + return Optional.empty(); + } + return parseUuid(readBody(connection), username); + } catch (IOException e) { + warningLogger.accept("Failed to contact Mojang session server for '" + username + "': " + e.getMessage()); + return Optional.empty(); + } + } + + private HttpURLConnection openGet(String urlString) throws IOException { + HttpURLConnection connection = (HttpURLConnection) new URL(urlString).openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(5000); + connection.setReadTimeout(5000); + return connection; + } + + private Optional parseUuid(String body, String username) { + Matcher matcher = UUID_PATTERN.matcher(body); + if (!matcher.find()) { + return Optional.empty(); + } + String raw = matcher.group(1); + String dashed = raw.substring(0, 8) + "-" + raw.substring(8, 12) + "-" + + raw.substring(12, 16) + "-" + raw.substring(16, 20) + "-" + raw.substring(20); + try { + return Optional.of(UUID.fromString(dashed)); + } catch (IllegalArgumentException e) { + warningLogger.accept("Mojang returned an unparseable UUID for '" + username + "': " + raw); + return Optional.empty(); + } + } + + private static String readBody(HttpURLConnection connection) throws IOException { + StringBuilder builder = new StringBuilder(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + builder.append(line); + } + } + return builder.toString(); + } + + private record PendingVerification( + String username, UUID playerUuid, byte[] verifyToken, long startedAt, boolean pendingPremiumEnrollment) { + } + + private record VerifiedSession(UUID mojangUuid, long verifiedAt) { + } +} diff --git a/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/ProxyPremiumVerificationPacketListener.java b/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/ProxyPremiumVerificationPacketListener.java new file mode 100644 index 0000000000..5c3049e2aa --- /dev/null +++ b/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/ProxyPremiumVerificationPacketListener.java @@ -0,0 +1,243 @@ +package fr.xephi.authme.bungee.premium; + +import com.github.retrooper.packetevents.PacketEvents; +import com.github.retrooper.packetevents.event.PacketListenerAbstract; +import com.github.retrooper.packetevents.event.PacketReceiveEvent; +import com.github.retrooper.packetevents.netty.channel.ChannelHelper; +import com.github.retrooper.packetevents.protocol.packettype.PacketType; +import com.github.retrooper.packetevents.protocol.player.ClientVersion; +import com.github.retrooper.packetevents.protocol.player.User; +import com.github.retrooper.packetevents.wrapper.login.client.WrapperLoginClientEncryptionResponse; +import com.github.retrooper.packetevents.wrapper.login.client.WrapperLoginClientLoginStart; +import com.github.retrooper.packetevents.wrapper.login.server.WrapperLoginServerEncryptionRequest; +import io.netty.channel.ChannelPipeline; +import io.netty.util.ReferenceCountUtil; + +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.net.InetSocketAddress; +import java.security.GeneralSecurityException; +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +final class ProxyPremiumVerificationPacketListener extends PacketListenerAbstract { + + private static final Pattern VALID_USERNAME = Pattern.compile("^[a-zA-Z0-9_]{2,16}$"); + + private final Predicate requiresVerification; + private final Predicate isPendingVerification; + private final Consumer pendingVerificationFailureHandler; + private final ProxyPremiumLoginVerifier loginVerifier; + private final Consumer warningLogger; + + ProxyPremiumVerificationPacketListener(Predicate requiresVerification, + Predicate isPendingVerification, + Consumer pendingVerificationFailureHandler, + ProxyPremiumLoginVerifier loginVerifier, + Consumer warningLogger) { + this.requiresVerification = requiresVerification; + this.isPendingVerification = isPendingVerification; + this.pendingVerificationFailureHandler = pendingVerificationFailureHandler; + this.loginVerifier = loginVerifier; + this.warningLogger = warningLogger; + } + + @Override + public void onPacketReceive(PacketReceiveEvent event) { + try { + if (event.getPacketType() == PacketType.Login.Client.LOGIN_START) { + handleLoginStart(event); + } else if (event.getPacketType() == PacketType.Login.Client.ENCRYPTION_RESPONSE) { + handleEncryptionResponse(event); + } + } catch (RuntimeException e) { + User user = event.getUser(); + String connectionKey = connectionKey(user); + String username = loginVerifier.getPendingUsername(connectionKey); + boolean pendingPremiumEnrollment = loginVerifier.isPendingPremiumEnrollment(connectionKey); + loginVerifier.cleanupPending(connectionKey); + handlePendingVerificationFailure(username, pendingPremiumEnrollment); + warningLogger.accept("Unhandled proxy premium verification error for connection " + connectionKey + + ": " + e.getMessage()); + closeConnection(user, username == null ? connectionKey : username); + } + } + + private void handleLoginStart(PacketReceiveEvent event) { + WrapperLoginClientLoginStart wrapper = new WrapperLoginClientLoginStart(event); + String username = wrapper.getUsername(); + if (username == null || !VALID_USERNAME.matcher(username).matches()) { + return; + } + + String normalizedName = username.toLowerCase(Locale.ROOT); + if (!requiresVerification.test(normalizedName)) { + return; + } + boolean pendingPremiumEnrollment = isPendingVerification.test(normalizedName); + + User user = event.getUser(); + String connectionKey = connectionKey(user); + UUID playerUuid = wrapper.getPlayerUUID().orElse(null); + + event.setCancelled(true); + byte[] verifyToken = loginVerifier.startVerification( + connectionKey, normalizedName, playerUuid, pendingPremiumEnrollment); + WrapperLoginServerEncryptionRequest encReq = new WrapperLoginServerEncryptionRequest( + "", loginVerifier.getPublicKey(), verifyToken, true); + user.sendPacket(encReq); + } + + private void handleEncryptionResponse(PacketReceiveEvent event) { + User user = event.getUser(); + String connectionKey = connectionKey(user); + if (!loginVerifier.hasPending(connectionKey)) { + return; + } + + String username = loginVerifier.getPendingUsername(connectionKey); + UUID playerUuid = loginVerifier.getPendingPlayerUuid(connectionKey); + boolean pendingPremiumEnrollment = loginVerifier.isPendingPremiumEnrollment(connectionKey); + ClientVersion clientVersion = user.getClientVersion(); + WrapperLoginClientEncryptionResponse wrapper = new WrapperLoginClientEncryptionResponse(event); + + byte[] encryptedSharedSecret = wrapper.getEncryptedSharedSecret().clone(); + event.setCancelled(true); + + byte[] sharedSecret; + try { + sharedSecret = loginVerifier.decryptData(encryptedSharedSecret); + } catch (GeneralSecurityException e) { + warningLogger.accept("Proxy premium RSA decryption failed for '" + username + "': " + e.getMessage()); + loginVerifier.cleanupPending(connectionKey); + handlePendingVerificationFailure(username, pendingPremiumEnrollment); + closeConnection(user, username); + return; + } + + if (!enableChannelEncryption(user.getChannel(), sharedSecret)) { + loginVerifier.cleanupPending(connectionKey); + handlePendingVerificationFailure(username, pendingPremiumEnrollment); + closeConnection(user, username); + return; + } + + Optional encryptedVerifyTokenOpt = wrapper.getEncryptedVerifyToken(); + if (!encryptedVerifyTokenOpt.isPresent()) { + warningLogger.accept("Proxy premium verification for '" + username + + "' received a signed nonce instead of a verify token; resuming offline login over the encrypted channel"); + handlePendingVerificationFailure(username, pendingPremiumEnrollment); + resumeLogin(user, username, clientVersion, playerUuid); + return; + } + + byte[] encryptedVerifyToken = encryptedVerifyTokenOpt.get().clone(); + + loginVerifier.completeVerification(connectionKey, sharedSecret, encryptedVerifyToken) + .thenAccept(maybeUuid -> { + if (maybeUuid.isPresent()) { + loginVerifier.storeVerified(username, maybeUuid.get()); + } else { + handlePendingVerificationFailure(username, pendingPremiumEnrollment); + } + resumeLogin(user, username, clientVersion, playerUuid); + }) + .exceptionally(ex -> { + warningLogger.accept("Unexpected proxy premium verification error for '" + username + "': " + + ex.getMessage()); + handlePendingVerificationFailure(username, pendingPremiumEnrollment); + resumeLogin(user, username, clientVersion, playerUuid); + return null; + }); + } + + private boolean enableChannelEncryption(Object channel, byte[] sharedSecret) { + try { + SecretKeySpec key = new SecretKeySpec(sharedSecret, "AES"); + IvParameterSpec iv = new IvParameterSpec(sharedSecret); + + Cipher decryptCipher = Cipher.getInstance("AES/CFB8/NoPadding"); + decryptCipher.init(Cipher.DECRYPT_MODE, key, iv); + + Cipher encryptCipher = Cipher.getInstance("AES/CFB8/NoPadding"); + encryptCipher.init(Cipher.ENCRYPT_MODE, key, iv); + + ChannelPipeline pipeline = (ChannelPipeline) ChannelHelper.getPipeline(channel); + String decoderAnchor = findPipelineAnchor(pipeline, PacketEvents.DECODER_NAME, "splitter"); + String encoderAnchor = findPipelineAnchor(pipeline, PacketEvents.ENCODER_NAME, "prepender"); + if (decoderAnchor == null || encoderAnchor == null) { + warningLogger.accept("Failed to install proxy AES cipher handlers: missing pipeline anchors in " + + ChannelHelper.pipelineHandlerNamesAsString(channel)); + return false; + } + if (pipeline.get("authme-premium-decrypt") != null) { + pipeline.remove("authme-premium-decrypt"); + } + if (pipeline.get("authme-premium-encrypt") != null) { + pipeline.remove("authme-premium-encrypt"); + } + pipeline.addBefore(decoderAnchor, "authme-premium-decrypt", new AesCfb8Decoder(decryptCipher)); + pipeline.addBefore(encoderAnchor, "authme-premium-encrypt", new AesCfb8Encoder(encryptCipher)); + return true; + } catch (GeneralSecurityException | RuntimeException e) { + warningLogger.accept("Failed to install proxy AES cipher handlers: " + e.getMessage() + + " | pipeline=" + ChannelHelper.pipelineHandlerNamesAsString(channel)); + return false; + } + } + + private void resumeLogin(User user, String username, ClientVersion clientVersion, UUID playerUuid) { + ChannelHelper.runInEventLoop(user.getChannel(), () -> { + WrapperLoginClientLoginStart resumePacket = + new WrapperLoginClientLoginStart(clientVersion, username, null, playerUuid); + resumePacket.prepareForSend(user.getChannel(), false); + + if (!resumeLoginInDecoderContext(user, resumePacket)) { + ReferenceCountUtil.release(resumePacket.getBuffer()); + closeConnection(user, username); + } + }); + } + + private void closeConnection(User user, String username) { + ChannelHelper.runInEventLoop(user.getChannel(), user::closeConnection); + warningLogger.accept("Closed proxy premium verification connection for '" + username + "'"); + } + + private void handlePendingVerificationFailure(String username, boolean pendingPremiumEnrollment) { + if (pendingPremiumEnrollment && username != null) { + pendingVerificationFailureHandler.accept(username.toLowerCase(Locale.ROOT)); + } + } + + private static String findPipelineAnchor(ChannelPipeline pipeline, String... candidates) { + for (String candidate : candidates) { + if (candidate != null && pipeline.get(candidate) != null) { + return candidate; + } + } + return null; + } + + private static String connectionKey(User user) { + InetSocketAddress address = user.getAddress(); + return address.getAddress().getHostAddress() + ":" + address.getPort(); + } + + private boolean resumeLoginInDecoderContext(User user, WrapperLoginClientLoginStart resumePacket) { + ChannelPipeline pipeline = (ChannelPipeline) ChannelHelper.getPipeline(user.getChannel()); + if (pipeline.get(PacketEvents.DECODER_NAME) == null) { + warningLogger.accept("Failed to resume proxy premium login for '" + resumePacket.getUsername() + + "': missing PacketEvents decoder in " + ChannelHelper.pipelineHandlerNamesAsString(user.getChannel())); + return false; + } + + ChannelHelper.fireChannelReadInContext(user.getChannel(), PacketEvents.DECODER_NAME, resumePacket.getBuffer()); + return true; + } +} diff --git a/authme-bungee/src/main/resources/bungee.yml b/authme-bungee/src/main/resources/bungee.yml index b41ec1c84a..85b571f222 100644 --- a/authme-bungee/src/main/resources/bungee.yml +++ b/authme-bungee/src/main/resources/bungee.yml @@ -2,3 +2,5 @@ name: ${project.name} main: fr.xephi.authme.bungee.AuthMeBungeePlugin version: ${project.version} author: AuthMeTeam +softDepends: + - packetevents diff --git a/authme-bungee/src/test/java/fr/xephi/authme/bungee/BungeeConfigManagerTest.java b/authme-bungee/src/test/java/fr/xephi/authme/bungee/BungeeConfigManagerTest.java index 7b6626f192..3e204111fe 100644 --- a/authme-bungee/src/test/java/fr/xephi/authme/bungee/BungeeConfigManagerTest.java +++ b/authme-bungee/src/test/java/fr/xephi/authme/bungee/BungeeConfigManagerTest.java @@ -28,6 +28,7 @@ void shouldCreateConfigFileWithDefaults() { assertTrue(configManager.getConfiguration().serverSwitchRequiresAuth()); assertFalse(configManager.getConfiguration().autoLoginEnabled()); assertFalse(configManager.getConfiguration().sharedSecret().isEmpty()); + assertFalse(configManager.getConfiguration().keepOfflineUuidCompatibility()); } @Test @@ -42,22 +43,24 @@ void shouldPreserveExistingSharedSecret() throws IOException { @Test void shouldNormalizeConfiguredSettings() throws IOException { Files.writeString(tempDirectory.resolve("config.yml"), """ - authServers: - - Lobby - - HUB - allServersAreAuthServers: false - commands: - whitelist: - - login - - /REG - chatRequiresAuth: false - serverSwitch: - requiresAuth: false - kickMessage: Please authenticate first. - autoLogin: true - sendOnLogout: true - unloggedUserServer: LiMbO - """); +authServers: +- Lobby +- HUB +allServersAreAuthServers: false +commands: + whitelist: + - login + - /REG +chatRequiresAuth: false +serverSwitch: + requiresAuth: false + kickMessage: Please authenticate first. +autoLogin: true +sendOnLogout: true +unloggedUserServer: LiMbO +premium: + keepOfflineUuidCompatibility: true +"""); BungeeProxyConfiguration configuration = new BungeeConfigManager(tempDirectory).getConfiguration(); @@ -71,5 +74,6 @@ void shouldNormalizeConfiguredSettings() throws IOException { assertTrue(configuration.autoLoginEnabled()); assertTrue(configuration.sendOnLogoutEnabled()); assertEquals("limbo", configuration.sendOnLogoutTarget()); + assertTrue(configuration.keepOfflineUuidCompatibility()); } } diff --git a/authme-bungee/src/test/java/fr/xephi/authme/bungee/BungeeProxyBridgeTest.java b/authme-bungee/src/test/java/fr/xephi/authme/bungee/BungeeProxyBridgeTest.java index 17682e9023..5138b82ebb 100644 --- a/authme-bungee/src/test/java/fr/xephi/authme/bungee/BungeeProxyBridgeTest.java +++ b/authme-bungee/src/test/java/fr/xephi/authme/bungee/BungeeProxyBridgeTest.java @@ -9,8 +9,8 @@ import net.md_5.bungee.api.connection.Server; import net.md_5.bungee.api.event.ChatEvent; import net.md_5.bungee.api.event.PlayerDisconnectEvent; +import net.md_5.bungee.api.event.PlayerHandshakeEvent; import net.md_5.bungee.api.event.PluginMessageEvent; -import net.md_5.bungee.api.event.PreLoginEvent; import net.md_5.bungee.api.event.ServerConnectEvent; import net.md_5.bungee.api.event.ServerSwitchEvent; import org.junit.jupiter.api.Test; @@ -79,6 +79,9 @@ class BungeeProxyBridgeTest { @Mock private ServerConnectEvent serverConnectEvent; + @Mock + private PlayerHandshakeEvent playerHandshakeEvent; + @Mock private PendingConnection pendingConnection; @@ -160,7 +163,7 @@ void shouldRedirectPlayerOnLogoutWhenConfigured() { BungeeProxyBridge bridge = new BungeeProxyBridge( proxyServer, logger, new BungeeProxyConfiguration( Set.of("lobby"), false, true, Set.of("/login"), true, true, - "Authentication required.", true, true, "limbo", "", ""), + "Authentication required.", true, true, "limbo", "", "", false), new BungeeAuthenticationStore()); bridge.onPluginMessage(pluginMessageEvent); @@ -444,93 +447,20 @@ void shouldSendAutoLoginImmediatelyWhenPlayerAlreadySwitchedBeforeLoginMessage() } @Test - void shouldUpdatePremiumSetAfterReceivingAllChunks() { + void shouldForceOnlineModeForPremiumHandshakeWhenOfflineCompatibilityDisabled() { given(pluginMessageEvent.isCancelled()).willReturn(false); given(pluginMessageEvent.getTag()).willReturn(BungeeProxyBridge.AUTHME_CHANNEL); given(pluginMessageEvent.getSender()).willReturn(sourceServer); - BungeeProxyBridge bridge = new BungeeProxyBridge(proxyServer, logger, createConfiguration(), new BungeeAuthenticationStore()); - - given(pluginMessageEvent.getData()).willReturn(createChunkPayload(0, false, "alice,bob")); - bridge.onPluginMessage(pluginMessageEvent); - given(pluginMessageEvent.getData()).willReturn(createChunkPayload(1, true, "charlie")); - bridge.onPluginMessage(pluginMessageEvent); - - PreLoginEvent preLoginEvent = org.mockito.Mockito.mock(PreLoginEvent.class); - given(preLoginEvent.getConnection()).willReturn(pendingConnection); + given(pluginMessageEvent.getData()).willReturn(createAuthMePayload("premium.set", "Alice")); + given(playerHandshakeEvent.getConnection()).willReturn(pendingConnection); given(pendingConnection.getName()).willReturn("Alice"); - bridge.onPreLogin(preLoginEvent); - verify(pendingConnection).setOnlineMode(true); - } + given(pendingConnection.isOnlineMode()).willReturn(false); - @Test - void shouldNotUpdatePremiumSetOnPartialChunkOnly() { - given(pluginMessageEvent.isCancelled()).willReturn(false); - given(pluginMessageEvent.getTag()).willReturn(BungeeProxyBridge.AUTHME_CHANNEL); - given(pluginMessageEvent.getSender()).willReturn(sourceServer); BungeeProxyBridge bridge = new BungeeProxyBridge(proxyServer, logger, createConfiguration(), new BungeeAuthenticationStore()); - - // Only first chunk (not last) — set must not be updated yet - given(pluginMessageEvent.getData()).willReturn(createChunkPayload(0, false, "alice,bob")); bridge.onPluginMessage(pluginMessageEvent); + bridge.onPlayerHandshake(playerHandshakeEvent); - PreLoginEvent preLoginEvent = org.mockito.Mockito.mock(PreLoginEvent.class); - given(preLoginEvent.getConnection()).willReturn(pendingConnection); - given(pendingConnection.getName()).willReturn("Alice"); - bridge.onPreLogin(preLoginEvent); - verify(pendingConnection, never()).setOnlineMode(true); - } - - @Test - void shouldPreservePendingPremiumStateAcrossDisconnectReconnect() { - given(pluginMessageEvent.isCancelled()).willReturn(false); - given(pluginMessageEvent.getTag()).willReturn(BungeeProxyBridge.AUTHME_CHANNEL); - given(pluginMessageEvent.getSender()).willReturn(sourceServer); - given(player.getName()).willReturn("alice"); - BungeeProxyBridge bridge = new BungeeProxyBridge(proxyServer, logger, createConfiguration(), new BungeeAuthenticationStore()); - - // Backend kicks the player for premium verification and sends PREMIUM_PENDING_SET - given(pluginMessageEvent.getData()).willReturn(createAuthMePayload("premium.pending.set", "alice")); - bridge.onPluginMessage(pluginMessageEvent); - - // Player is kicked by the backend — disconnect must NOT clear the pending state - given(playerDisconnectEvent.getPlayer()).willReturn(player); - bridge.onPlayerDisconnect(playerDisconnectEvent); - - // On reconnect, Bungee must still force online-mode for the pending player - PreLoginEvent reconnectAttempt = org.mockito.Mockito.mock(PreLoginEvent.class); - PendingConnection reconnectConn = org.mockito.Mockito.mock(PendingConnection.class); - given(reconnectAttempt.getConnection()).willReturn(reconnectConn); - given(reconnectConn.getName()).willReturn("alice"); - bridge.onPreLogin(reconnectAttempt); - verify(reconnectConn).setOnlineMode(true); - } - - @Test - void shouldForceOnlineModeOnFirstPendingAttemptThenCancelOnSecond() { - given(pluginMessageEvent.isCancelled()).willReturn(false); - given(pluginMessageEvent.getTag()).willReturn(BungeeProxyBridge.AUTHME_CHANNEL); - given(pluginMessageEvent.getSender()).willReturn(sourceServer); - BungeeProxyBridge bridge = new BungeeProxyBridge(proxyServer, logger, createConfiguration(), new BungeeAuthenticationStore()); - - given(pluginMessageEvent.getData()).willReturn(createAuthMePayload("premium.pending.set", "alice")); - bridge.onPluginMessage(pluginMessageEvent); - - // First reconnect: should force online-mode so Mojang can verify - PreLoginEvent firstAttempt = org.mockito.Mockito.mock(PreLoginEvent.class); - PendingConnection firstConn = org.mockito.Mockito.mock(PendingConnection.class); - given(firstAttempt.getConnection()).willReturn(firstConn); - given(firstConn.getName()).willReturn("Alice"); - bridge.onPreLogin(firstAttempt); - verify(firstConn).setOnlineMode(true); - - // Mojang rejected the player (no onLogin fired) — second reconnect should cancel the pending - // request and NOT force online-mode, so the player can rejoin in offline mode - PreLoginEvent secondAttempt = org.mockito.Mockito.mock(PreLoginEvent.class); - PendingConnection secondConn = org.mockito.Mockito.mock(PendingConnection.class); - given(secondAttempt.getConnection()).willReturn(secondConn); - given(secondConn.getName()).willReturn("Alice"); - bridge.onPreLogin(secondAttempt); - verify(secondConn, never()).setOnlineMode(true); + verify(pendingConnection).setOnlineMode(true); } private static byte[] createChunkPayload(int seq, boolean last, String csv) { @@ -543,7 +473,7 @@ private static byte[] createChunkPayload(int seq, boolean last, String csv) { private static BungeeProxyConfiguration createConfiguration() { return new BungeeProxyConfiguration( Set.of("lobby"), false, true, Set.of("/login", "/register", "/l", "/reg", "/email", "/captcha", "/2fa", "/totp", "/log"), - true, true, "Authentication required.", true, false, "", "", "test-secret"); + true, true, "Authentication required.", true, false, "", "", "test-secret", false); } private static byte[] createAuthMePayload(String typeId, String playerName) { @@ -558,8 +488,9 @@ private static void assertPerformLoginPayload(byte[] payload, String expectedPla assertEquals("perform.login", in.readUTF()); assertEquals(expectedPlayerName, in.readUTF()); long timestamp = in.readLong(); + assertEquals("", in.readUTF()); String hmac = in.readUTF(); assertTrue(Math.abs(System.currentTimeMillis() - timestamp) < 5000L, "timestamp should be recent"); - assertEquals(ProxyMessageSecurity.computeHmac(sharedSecret, expectedPlayerName, timestamp), hmac); + assertEquals(ProxyMessageSecurity.computeHmac(sharedSecret, expectedPlayerName, timestamp, null), hmac); } } diff --git a/authme-bungee/src/test/java/fr/xephi/authme/bungee/BungeeReloadCommandTest.java b/authme-bungee/src/test/java/fr/xephi/authme/bungee/BungeeReloadCommandTest.java index 6f06f8d2e1..e9f0869d17 100644 --- a/authme-bungee/src/test/java/fr/xephi/authme/bungee/BungeeReloadCommandTest.java +++ b/authme-bungee/src/test/java/fr/xephi/authme/bungee/BungeeReloadCommandTest.java @@ -28,7 +28,7 @@ class BungeeReloadCommandTest { void shouldReloadConfigAndProxyBridge() { BungeeProxyConfiguration configuration = new BungeeProxyConfiguration( Set.of("lobby"), false, true, Set.of("/login"), true, true, - "Authentication required.", true, false, "", "", ""); + "Authentication required.", true, false, "", "", "", false); given(configManager.reload()).willReturn(configuration); BungeeReloadCommand command = new BungeeReloadCommand(configManager, proxyBridge); diff --git a/authme-bungee/src/test/java/fr/xephi/authme/bungee/premium/BungeePremiumOnlineModeHandlerTest.java b/authme-bungee/src/test/java/fr/xephi/authme/bungee/premium/BungeePremiumOnlineModeHandlerTest.java new file mode 100644 index 0000000000..e268fe2f75 --- /dev/null +++ b/authme-bungee/src/test/java/fr/xephi/authme/bungee/premium/BungeePremiumOnlineModeHandlerTest.java @@ -0,0 +1,35 @@ +package fr.xephi.authme.bungee.premium; + +import net.md_5.bungee.api.connection.PendingConnection; +import org.junit.jupiter.api.Test; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +class BungeePremiumOnlineModeHandlerTest { + + @Test + void shouldEnableOnlineModeForPremiumPlayer() { + PendingConnection connection = mock(PendingConnection.class); + given(connection.getName()).willReturn("Alice"); + given(connection.isOnlineMode()).willReturn(false); + + BungeePremiumOnlineModeHandler handler = new BungeePremiumOnlineModeHandler("alice"::equals); + handler.enableOnlineModeIfRequired(connection); + + verify(connection).setOnlineMode(true); + } + + @Test + void shouldIgnoreNonPremiumPlayer() { + PendingConnection connection = mock(PendingConnection.class); + given(connection.getName()).willReturn("Bob"); + + BungeePremiumOnlineModeHandler handler = new BungeePremiumOnlineModeHandler("alice"::equals); + handler.enableOnlineModeIfRequired(connection); + + verify(connection, never()).setOnlineMode(true); + } +} diff --git a/authme-core/src/main/java/fr/xephi/authme/data/ProxySessionManager.java b/authme-core/src/main/java/fr/xephi/authme/data/ProxySessionManager.java index d0c60fb2a3..53baf92c80 100644 --- a/authme-core/src/main/java/fr/xephi/authme/data/ProxySessionManager.java +++ b/authme-core/src/main/java/fr/xephi/authme/data/ProxySessionManager.java @@ -1,49 +1,92 @@ package fr.xephi.authme.data; import fr.xephi.authme.initialization.HasCleanup; -import fr.xephi.authme.util.expiring.ExpiringSet; +import fr.xephi.authme.util.expiring.ExpiringMap; import javax.inject.Inject; import java.util.Locale; +import java.util.UUID; import java.util.concurrent.TimeUnit; public class ProxySessionManager implements HasCleanup { - private final ExpiringSet activeProxySessions; + private final ExpiringMap activeProxySessions; @Inject public ProxySessionManager() { long countTimeout = 5; - activeProxySessions = new ExpiringSet<>(countTimeout, TimeUnit.SECONDS); + activeProxySessions = new ExpiringMap<>(countTimeout, TimeUnit.SECONDS); } /** - * Saves the player in the set + * Stores an auto-login request coming from the proxy. + * * @param name the player's name + * @param verifiedPremiumUuid the Mojang UUID cryptographically verified by the proxy, or null */ - private void setActiveSession(String name) { - activeProxySessions.add(name.toLowerCase(Locale.ROOT)); + public void processProxySessionMessage(String name, UUID verifiedPremiumUuid) { + String normalizedName = name.toLowerCase(Locale.ROOT); + activeProxySessions.put(normalizedName, new ProxyLoginRequest(normalizedName, verifiedPremiumUuid)); } /** * Process a proxy session message from AuthMeBungee + * * @param name the player to process */ public void processProxySessionMessage(String name) { - setActiveSession(name); + processProxySessionMessage(name, null); } /** * Returns if the player should be logged in or not + * * @param name the name of the player to check * @return true if player has to be logged in, false otherwise */ public boolean shouldResumeSession(String name) { - return activeProxySessions.contains(name); + return getLoginRequest(name) != null; + } + + /** + * Returns the current auto-login request for the player, or null if none is queued. + * + * @param name the player name + * @return the queued proxy login request, or null + */ + public ProxyLoginRequest getLoginRequest(String name) { + return activeProxySessions.get(name.toLowerCase(Locale.ROOT)); + } + + /** + * Removes and returns the current auto-login request for the player. + * + * @param name the player name + * @return the queued proxy login request, or null + */ + public ProxyLoginRequest consumeLoginRequest(String name) { + String normalizedName = name.toLowerCase(Locale.ROOT); + ProxyLoginRequest loginRequest = activeProxySessions.get(normalizedName); + if (loginRequest != null) { + activeProxySessions.remove(normalizedName); + } + return loginRequest; + } + + /** + * Removes the queued auto-login request for the given player. + * + * @param name the player name + */ + public void removeLoginRequest(String name) { + activeProxySessions.remove(name.toLowerCase(Locale.ROOT)); } @Override public void performCleanup() { activeProxySessions.removeExpiredEntries(); } + + public record ProxyLoginRequest(String playerName, UUID verifiedPremiumUuid) { + } } diff --git a/authme-core/src/main/java/fr/xephi/authme/process/join/AsynchronousJoin.java b/authme-core/src/main/java/fr/xephi/authme/process/join/AsynchronousJoin.java index 01835a0606..71d0f1c867 100644 --- a/authme-core/src/main/java/fr/xephi/authme/process/join/AsynchronousJoin.java +++ b/authme-core/src/main/java/fr/xephi/authme/process/join/AsynchronousJoin.java @@ -28,6 +28,7 @@ import fr.xephi.authme.service.PremiumLoginVerifier; import fr.xephi.authme.service.PremiumService; import fr.xephi.authme.service.PluginHookService; +import fr.xephi.authme.service.ProxyLoginRequestValidator; import fr.xephi.authme.service.SessionService; import fr.xephi.authme.service.ValidationService; import fr.xephi.authme.service.bungeecord.BungeeSender; @@ -128,6 +129,9 @@ public class AsynchronousJoin implements AsynchronousProcess { @Inject private PremiumService premiumService; + @Inject + private ProxyLoginRequestValidator proxyLoginRequestValidator; + @Inject private EmailService emailService; @@ -208,19 +212,29 @@ public void processJoin(Player player) { bukkitService.runTaskOptionallyAsync(() -> asynchronousLogin.forceLogin(player)); } return; - } else if (proxySessionManager.shouldResumeSession(name)) { - service.send(player, MessageKey.SESSION_RECONNECTION); - // Run commands - bukkitService.scheduleSyncTaskFromOptionallyAsyncTask(player, - () -> commandManager.runCommandsOnSessionLogin(player)); - // Use forceLoginFromProxy (quiet=true, no BungeeCord redirect) so that if - // BungeeReceiver.performLogin() concurrently already completed the login, this - // call is a no-op rather than sending an "already logged in" error. - bukkitService.runTaskOptionallyAsync(() -> asynchronousLogin.forceLoginFromProxy(player)); - logger.info("The user " + player.getName() + " has been automatically logged in, " - + "as present in autologin queue."); - return; - } else if (sessionService.canResumeSession(player)) { + } else { + ProxySessionManager.ProxyLoginRequest proxyLoginRequest = proxySessionManager.consumeLoginRequest(name); + if (proxyLoginRequest != null) { + if (!proxyLoginRequestValidator.validate(player, proxyLoginRequest.verifiedPremiumUuid())) { + return; + } + if (playerCache.isAuthenticated(name)) { + return; + } + service.send(player, MessageKey.SESSION_RECONNECTION); + // Run commands + bukkitService.scheduleSyncTaskFromOptionallyAsyncTask(player, + () -> commandManager.runCommandsOnSessionLogin(player)); + // Use forceLoginFromProxy (quiet=true, no BungeeCord redirect) so that if + // BungeeReceiver.performLogin() concurrently already completed the login, this + // call is a no-op rather than sending an "already logged in" error. + bukkitService.runTaskOptionallyAsync(() -> asynchronousLogin.forceLoginFromProxy(player)); + logger.info("The user " + player.getName() + " has been automatically logged in, " + + "as present in autologin queue."); + return; + } + } + if (sessionService.canResumeSession(player)) { service.send(player, MessageKey.SESSION_RECONNECTION); // Run commands bukkitService.scheduleSyncTaskFromOptionallyAsyncTask(player, @@ -427,6 +441,11 @@ private boolean canBypassWithPremium(Player player) { if (playerId.version() == 4) { // Proxy already performed Mojang authentication — UUID v4 is the confirmed Mojang UUID. confirmedUuid = playerId.equals(pendingUuid) ? playerId : null; + } else if (bungeeSender.isEnabled()) { + // Behind a proxy we intentionally keep the backend UUID on the offline v3 value. + // Wait for the signed perform.login request carrying the verified Mojang UUID + // instead of failing the pending premium enrollment during join. + return false; } else { // No proxy: require cryptographic session verification via PacketEvents. UUID verified = premiumLoginVerifier.getVerifiedUuid(name); @@ -450,6 +469,10 @@ private boolean canBypassWithPremium(Player player) { // UUID v4 = Mojang online UUID (online-mode server or proxy forwarding): compare directly. // Security relies on the backend port being firewalled to only accept proxy connections. return playerId.equals(auth.getPremiumUuid()); + } else if (bungeeSender.isEnabled()) { + // Behind a proxy, existing premium auto-login is validated through the signed + // perform.login message rather than the backend player's offline UUID. + return false; } // UUID v3 = Bukkit offline UUID: require cryptographic session verification via PacketEvents. UUID verifiedUuid = premiumLoginVerifier.getVerifiedUuid(name); diff --git a/authme-core/src/main/java/fr/xephi/authme/service/ProxyLoginRequestValidator.java b/authme-core/src/main/java/fr/xephi/authme/service/ProxyLoginRequestValidator.java new file mode 100644 index 0000000000..2f8f5c4c5c --- /dev/null +++ b/authme-core/src/main/java/fr/xephi/authme/service/ProxyLoginRequestValidator.java @@ -0,0 +1,100 @@ +package fr.xephi.authme.service; + +import fr.xephi.authme.ConsoleLogger; +import fr.xephi.authme.data.auth.PlayerAuth; +import fr.xephi.authme.data.auth.PlayerCache; +import fr.xephi.authme.datasource.DataSource; +import fr.xephi.authme.message.MessageKey; +import fr.xephi.authme.message.Messages; +import fr.xephi.authme.output.ConsoleLoggerFactory; +import fr.xephi.authme.service.bungeecord.BungeeSender; +import org.bukkit.entity.Player; + +import javax.inject.Inject; +import java.util.Locale; +import java.util.UUID; + +/** + * Validates premium data attached to proxy-initiated auto-login requests. + * + *

The backend only accepts a proxy-supplied Mojang UUID if it matches either the stored premium + * UUID for the account or a currently pending premium enrollment being finalized. Any mismatch is + * treated as an invalid premium claim and the auto-login request is rejected.

+ */ +public class ProxyLoginRequestValidator { + + private final ConsoleLogger logger = ConsoleLoggerFactory.get(ProxyLoginRequestValidator.class); + + @Inject + private DataSource dataSource; + + @Inject + private PlayerCache playerCache; + + @Inject + private PendingPremiumCache pendingPremiumCache; + + @Inject + private PremiumService premiumService; + + @Inject + private BungeeSender bungeeSender; + + @Inject + private Messages messages; + + ProxyLoginRequestValidator() { + } + + /** + * Validates an optional premium UUID coming from the proxy and finalizes pending premium + * enrollment when the UUID was verified by the proxy and matches the pending entry. + * + * @param player the player to validate for + * @param verifiedPremiumUuid the Mojang UUID verified by the proxy, or null for a regular session resume + * @return true if the proxy login request can proceed, false otherwise + */ + public boolean validate(Player player, UUID verifiedPremiumUuid) { + if (verifiedPremiumUuid == null) { + return true; + } + + String playerName = player.getName(); + PlayerAuth auth = playerCache.getAuth(playerName); + if (auth == null) { + auth = dataSource.getAuth(playerName.toLowerCase(Locale.ROOT)); + } + if (auth == null) { + logger.warning("Rejected proxy premium login for " + playerName + ": no auth record found"); + return false; + } + + if (auth.isPremium()) { + if (verifiedPremiumUuid.equals(auth.getPremiumUuid())) { + return true; + } + logger.warning("Rejected proxy premium login for " + playerName + + ": verified UUID does not match stored premium UUID"); + return false; + } + + UUID pendingPremiumUuid = pendingPremiumCache.getPendingUuid(playerName); + if (pendingPremiumUuid == null) { + logger.warning("Rejected proxy premium login for " + playerName + + ": account is neither premium nor pending premium verification"); + return false; + } + + pendingPremiumCache.removePending(playerName); + if (!verifiedPremiumUuid.equals(pendingPremiumUuid)) { + logger.warning("Rejected proxy premium login for " + playerName + + ": verified UUID does not match pending premium UUID"); + bungeeSender.sendPremiumUnset(playerName); + messages.send(player, MessageKey.PREMIUM_PENDING_FAIL); + return false; + } + + premiumService.finalizePendingPremium(player, verifiedPremiumUuid); + return true; + } +} diff --git a/authme-core/src/main/java/fr/xephi/authme/service/bungeecord/BungeeReceiver.java b/authme-core/src/main/java/fr/xephi/authme/service/bungeecord/BungeeReceiver.java index b6de492aa7..d258bf48e1 100644 --- a/authme-core/src/main/java/fr/xephi/authme/service/bungeecord/BungeeReceiver.java +++ b/authme-core/src/main/java/fr/xephi/authme/service/bungeecord/BungeeReceiver.java @@ -11,10 +11,10 @@ import fr.xephi.authme.process.Management; import fr.xephi.authme.security.HashUtils; import fr.xephi.authme.service.BukkitService; -import fr.xephi.authme.service.PendingPremiumCache; -import fr.xephi.authme.service.PremiumService; +import fr.xephi.authme.service.ProxyLoginRequestValidator; import fr.xephi.authme.settings.Settings; import fr.xephi.authme.settings.properties.HooksSettings; +import fr.xephi.authme.util.UuidUtils; import org.bukkit.entity.Player; import org.bukkit.plugin.messaging.Messenger; import org.bukkit.plugin.messaging.PluginMessageListener; @@ -35,8 +35,7 @@ public class BungeeReceiver implements PluginMessageListener, SettingsDependent private final Management management; private final BungeeSender bungeeSender; private final DataSource dataSource; - private final PendingPremiumCache pendingPremiumCache; - private final PremiumService premiumService; + private final ProxyLoginRequestValidator proxyLoginRequestValidator; private static final String AUTHME_CHANNEL = "authme:main"; private static final long MAX_AGE_MILLIS = 30_000L; @@ -47,16 +46,14 @@ public class BungeeReceiver implements PluginMessageListener, SettingsDependent @Inject BungeeReceiver(AuthMe plugin, BukkitService bukkitService, ProxySessionManager proxySessionManager, Management management, BungeeSender bungeeSender, DataSource dataSource, - PendingPremiumCache pendingPremiumCache, PremiumService premiumService, - Settings settings) { + ProxyLoginRequestValidator proxyLoginRequestValidator, Settings settings) { this.plugin = plugin; this.bukkitService = bukkitService; this.proxySessionManager = proxySessionManager; this.management = management; this.bungeeSender = bungeeSender; this.dataSource = dataSource; - this.pendingPremiumCache = pendingPremiumCache; - this.premiumService = premiumService; + this.proxyLoginRequestValidator = proxyLoginRequestValidator; reload(settings); } @@ -130,27 +127,41 @@ public void onPluginMessageReceived(String channel, Player player, byte[] data) if (type.get() == MessageType.PERFORM_LOGIN) { long timestamp; + String uuidOrHmac; + UUID verifiedPremiumUuid = null; String hmac; try { timestamp = in.readLong(); - hmac = in.readUTF(); + uuidOrHmac = in.readUTF(); } catch (IllegalStateException e) { logger.warning("Received perform.login without HMAC — update your proxy plugin"); return; } - if (!verifyHmac(argument, timestamp, hmac)) { + try { + UUID parsedUuid = UuidUtils.parseUuidSafely(uuidOrHmac); + if (parsedUuid != null || uuidOrHmac.isEmpty()) { + verifiedPremiumUuid = parsedUuid; + hmac = in.readUTF(); + } else { + hmac = uuidOrHmac; + } + } catch (IllegalStateException e) { + hmac = uuidOrHmac; + } + if (!verifyHmac(argument, timestamp, verifiedPremiumUuid, hmac)) { return; } - performLogin(argument); + performLogin(argument, verifiedPremiumUuid); } } - private boolean verifyHmac(String playerName, long timestamp, String providedHmac) { + private boolean verifyHmac(String playerName, long timestamp, UUID verifiedPremiumUuid, String providedHmac) { if (Math.abs(System.currentTimeMillis() - timestamp) > MAX_AGE_MILLIS) { logger.warning("Rejected perform.login for " + playerName + ": message has expired"); return false; } - String expectedHmac = HashUtils.hmacSha256(proxySharedSecret, playerName + ":" + timestamp); + String expectedHmac = HashUtils.hmacSha256(proxySharedSecret, + buildPerformLoginPayload(playerName, timestamp, verifiedPremiumUuid)); if (!HashUtils.isEqual(expectedHmac, providedHmac)) { logger.warning("Rejected perform.login for " + playerName + ": invalid HMAC"); return false; @@ -158,39 +169,45 @@ private boolean verifyHmac(String playerName, long timestamp, String providedHma return true; } - private void performLogin(String name) { + private String buildPerformLoginPayload(String playerName, long timestamp, UUID verifiedPremiumUuid) { + return playerName + ":" + timestamp + ":" + (verifiedPremiumUuid == null ? "" : verifiedPremiumUuid); + } + + private void performLogin(String name, UUID verifiedPremiumUuid) { logger.debug("Received perform.login request for {0}", name); // Always queue in the proxy session manager so processJoin can consume it even when // the player is already online (PlayerJoinEvent fires before ServerSwitchEvent on the // proxy, so processJoin may run before perform.login arrives at this backend). - proxySessionManager.processProxySessionMessage(name); + proxySessionManager.processProxySessionMessage(name, verifiedPremiumUuid); Player player = bukkitService.getPlayerExact(name); if (player != null && player.isOnline()) { - // If the player has a pending premium verification, the proxy has already confirmed - // their Mojang identity via its own online-mode handshake (UUID v4). Finalize the - // verification before force-logging them in so the UUID is persisted to the database. - UUID pendingUuid = pendingPremiumCache.removePending(name); - if (pendingUuid != null) { - UUID playerId = player.getUniqueId(); - if (playerId.version() == 4) { - premiumService.finalizePendingPremium(player, playerId); - } else { - // Unexpected: proxy sent PERFORM_LOGIN but player has an offline UUID. - // Discard the pending state and let the player use a password. - logger.warning("Received PERFORM_LOGIN for pending-premium player " + name - + " but their UUID is not v4 — discarding pending verification"); - bungeeSender.sendPremiumUnset(name); - } + if (verifiedPremiumUuid == null) { + completeProxyLogin(player); + } else { + bukkitService.runTaskAsynchronously(() -> { + if (!proxyLoginRequestValidator.validate(player, verifiedPremiumUuid)) { + proxySessionManager.removeLoginRequest(name); + return; + } + bukkitService.scheduleSyncTaskFromOptionallyAsyncTask(player, () -> { + if (player.isOnline()) { + completeProxyLogin(player); + } + }); + }); } - // Player is already online: also drive the login directly in case processJoin - // has already run past the proxy-session check and created a limbo player. - management.forceLoginFromProxy(player); - logger.debug("Sending auto-login ACK for {0}", player.getName()); - bungeeSender.sendAuthMeBungeecordMessage(player, MessageType.PERFORM_LOGIN_ACK); - logger.info(player.getName() + " has been automatically logged in via proxy request."); } else { logger.info(name + " is not yet online; queued for auto-login when they connect."); } } + private void completeProxyLogin(Player player) { + // Player is already online: also drive the login directly in case processJoin + // has already run past the proxy-session check and created a limbo player. + management.forceLoginFromProxy(player); + logger.debug("Sending auto-login ACK for {0}", player.getName()); + bungeeSender.sendAuthMeBungeecordMessage(player, MessageType.PERFORM_LOGIN_ACK); + logger.info(player.getName() + " has been automatically logged in via proxy request."); + } + } diff --git a/authme-core/src/test/java/fr/xephi/authme/process/join/AsynchronousJoinTest.java b/authme-core/src/test/java/fr/xephi/authme/process/join/AsynchronousJoinTest.java index a17e221186..8c22edca51 100644 --- a/authme-core/src/test/java/fr/xephi/authme/process/join/AsynchronousJoinTest.java +++ b/authme-core/src/test/java/fr/xephi/authme/process/join/AsynchronousJoinTest.java @@ -13,10 +13,13 @@ import fr.xephi.authme.service.PreJoinDialogService; import fr.xephi.authme.service.BukkitService; import fr.xephi.authme.service.CommonService; +import fr.xephi.authme.service.PendingPremiumCache; import fr.xephi.authme.service.PremiumLoginVerifier; +import fr.xephi.authme.service.PremiumService; import fr.xephi.authme.service.DialogStateService; import fr.xephi.authme.service.DialogWindowService; import fr.xephi.authme.service.PluginHookService; +import fr.xephi.authme.service.ProxyLoginRequestValidator; import fr.xephi.authme.service.SessionService; import fr.xephi.authme.service.ValidationService; import fr.xephi.authme.service.bungeecord.BungeeSender; @@ -37,6 +40,7 @@ import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import java.util.UUID; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; @@ -96,6 +100,12 @@ public class AsynchronousJoinTest { private PreJoinDialogService preJoinDialogService; @Mock private PremiumLoginVerifier premiumLoginVerifier; + @Mock + private PendingPremiumCache pendingPremiumCache; + @Mock + private PremiumService premiumService; + @Mock + private ProxyLoginRequestValidator proxyLoginRequestValidator; @BeforeAll public static void initLogger() { @@ -199,7 +209,9 @@ public void shouldAutoLoginFromProxySessionWithoutCreatingLimbo() { // given Player player = mockPlayer("Bobby"); setUpRegisteredJoin(player); - given(proxySessionManager.shouldResumeSession("bobby")).willReturn(true); + given(proxySessionManager.consumeLoginRequest("bobby")) + .willReturn(new ProxySessionManager.ProxyLoginRequest("bobby", null)); + given(proxyLoginRequestValidator.validate(player, null)).willReturn(true); // when asynchronousJoin.processJoin(player); @@ -233,6 +245,33 @@ public void shouldProcessPendingPreJoinLoginInsteadOfShowingDialog() { verify(dialogAdapter, never()).showLoginDialog(eq(player), any(DialogWindowSpec.class)); } + @Test + public void shouldWaitForSignedProxyPremiumLoginWithoutFailingPendingEnrollment() { + // given + Player player = mockPlayer("Bobby"); + setUpRegisteredJoin(player); + given(service.getProperty(PremiumSettings.ENABLE_PREMIUM)).willReturn(true); + given(bungeeSender.isEnabled()).willReturn(true); + UUID offlineUuid = UUID.fromString("f0647d73-8421-3979-bdb6-6b88dc3d03d4"); + UUID pendingPremiumUuid = UUID.fromString("8d6d0684-d8b4-4d40-8d2d-0dd4df5555c8"); + given(player.getUniqueId()).willReturn(offlineUuid); + + fr.xephi.authme.data.auth.PlayerAuth auth = mock(fr.xephi.authme.data.auth.PlayerAuth.class); + given(database.getAuth("bobby")).willReturn(auth); + given(auth.isPremium()).willReturn(false); + given(pendingPremiumCache.getPendingUuid("Bobby")).willReturn(pendingPremiumUuid); + + // when + asynchronousJoin.processJoin(player); + + // then + verify(pendingPremiumCache, never()).removePending("Bobby"); + verify(bungeeSender, never()).sendPremiumUnset("Bobby"); + verify(service, never()).send(eq(player), eq(fr.xephi.authme.message.MessageKey.PREMIUM_PENDING_FAIL)); + verify(premiumService, never()).finalizePendingPremium(any(), any()); + verify(limboService).createLimboPlayer(player, true); + } + @Test public void shouldForceLoginPlayerApprovedViaPreJoinDialog() { // given @@ -284,6 +323,7 @@ private void setUpRegisteredJoin(Player player) { given(sessionService.canResumeSession(player)).willReturn(false); given(proxySessionManager.shouldResumeSession(normalizedName)).willReturn(false); given(service.getProperty(RestrictionSettings.LOGIN_TIMEOUT)).willReturn(30); + given(service.getProperty(RegistrationSettings.USE_DIALOG_UI)).willReturn(false); given(pluginHookService.isEssentialsAvailable()).willReturn(false); given(service.getProperty(RegistrationSettings.APPLY_BLIND_EFFECT)).willReturn(false); } diff --git a/authme-core/src/test/java/fr/xephi/authme/service/ProxyLoginRequestValidatorTest.java b/authme-core/src/test/java/fr/xephi/authme/service/ProxyLoginRequestValidatorTest.java new file mode 100644 index 0000000000..1f6da937f4 --- /dev/null +++ b/authme-core/src/test/java/fr/xephi/authme/service/ProxyLoginRequestValidatorTest.java @@ -0,0 +1,104 @@ +package fr.xephi.authme.service; + +import fr.xephi.authme.data.auth.PlayerAuth; +import fr.xephi.authme.datasource.DataSource; +import fr.xephi.authme.message.MessageKey; +import fr.xephi.authme.message.Messages; +import fr.xephi.authme.service.bungeecord.BungeeSender; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.WARN) +class ProxyLoginRequestValidatorTest { + + @InjectMocks + private ProxyLoginRequestValidator validator; + + @Mock + private DataSource dataSource; + + @Mock + private fr.xephi.authme.data.auth.PlayerCache playerCache; + + @Mock + private PendingPremiumCache pendingPremiumCache; + + @Mock + private PremiumService premiumService; + + @Mock + private BungeeSender bungeeSender; + + @Mock + private Messages messages; + + @Mock + private Player player; + + @Test + void shouldAcceptStoredPremiumUuidFromProxy() { + UUID premiumUuid = UUID.randomUUID(); + PlayerAuth auth = PlayerAuth.builder().name("bobby").premiumUuid(premiumUuid).build(); + given(player.getName()).willReturn("Bobby"); + given(playerCache.getAuth("Bobby")).willReturn(auth); + + assertTrue(validator.validate(player, premiumUuid)); + verify(premiumService, never()).finalizePendingPremium(player, premiumUuid); + } + + @Test + void shouldRejectStoredPremiumUuidMismatch() { + UUID storedUuid = UUID.randomUUID(); + UUID forwardedUuid = UUID.randomUUID(); + PlayerAuth auth = PlayerAuth.builder().name("bobby").premiumUuid(storedUuid).build(); + given(player.getName()).willReturn("Bobby"); + given(playerCache.getAuth("Bobby")).willReturn(auth); + + assertFalse(validator.validate(player, forwardedUuid)); + verify(premiumService, never()).finalizePendingPremium(player, forwardedUuid); + } + + @Test + void shouldFinalizeMatchingPendingPremiumUuid() { + UUID pendingUuid = UUID.randomUUID(); + PlayerAuth auth = PlayerAuth.builder().name("bobby").build(); + given(player.getName()).willReturn("Bobby"); + given(playerCache.getAuth("Bobby")).willReturn(auth); + given(pendingPremiumCache.getPendingUuid("Bobby")).willReturn(pendingUuid); + + assertTrue(validator.validate(player, pendingUuid)); + verify(pendingPremiumCache).removePending("Bobby"); + verify(premiumService).finalizePendingPremium(player, pendingUuid); + } + + @Test + void shouldRejectPendingPremiumUuidMismatchAndNotifyPlayer() { + UUID pendingUuid = UUID.randomUUID(); + UUID forwardedUuid = UUID.randomUUID(); + PlayerAuth auth = PlayerAuth.builder().name("bobby").build(); + given(player.getName()).willReturn("Bobby"); + given(playerCache.getAuth("Bobby")).willReturn(auth); + given(pendingPremiumCache.getPendingUuid("Bobby")).willReturn(pendingUuid); + + assertFalse(validator.validate(player, forwardedUuid)); + verify(pendingPremiumCache).removePending("Bobby"); + verify(bungeeSender).sendPremiumUnset("Bobby"); + verify(messages).send(player, MessageKey.PREMIUM_PENDING_FAIL); + verify(premiumService, never()).finalizePendingPremium(player, forwardedUuid); + } +} diff --git a/authme-core/src/test/java/fr/xephi/authme/service/bungeecord/BungeeReceiverTest.java b/authme-core/src/test/java/fr/xephi/authme/service/bungeecord/BungeeReceiverTest.java index 33d13e4a7b..6bb3e9d88a 100644 --- a/authme-core/src/test/java/fr/xephi/authme/service/bungeecord/BungeeReceiverTest.java +++ b/authme-core/src/test/java/fr/xephi/authme/service/bungeecord/BungeeReceiverTest.java @@ -8,8 +8,7 @@ import fr.xephi.authme.process.Management; import fr.xephi.authme.security.HashUtils; import fr.xephi.authme.service.BukkitService; -import fr.xephi.authme.service.PendingPremiumCache; -import fr.xephi.authme.service.PremiumService; +import fr.xephi.authme.service.ProxyLoginRequestValidator; import fr.xephi.authme.settings.Settings; import fr.xephi.authme.settings.properties.HooksSettings; import org.bukkit.Server; @@ -53,10 +52,7 @@ class BungeeReceiverTest { private DataSource dataSource; @Mock - private PendingPremiumCache pendingPremiumCache; - - @Mock - private PremiumService premiumService; + private ProxyLoginRequestValidator proxyLoginRequestValidator; @Mock private Settings settings; @@ -78,7 +74,8 @@ void shouldRegisterIncomingChannelWhenEnabled() { given(settings.getProperty(HooksSettings.BUNGEECORD)).willReturn(true); given(messenger.isIncomingChannelRegistered(plugin, "authme:main")).willReturn(false); - new BungeeReceiver(plugin, bukkitService, proxySessionManager, management, bungeeSender, dataSource, pendingPremiumCache, premiumService, settings); + new BungeeReceiver(plugin, bukkitService, proxySessionManager, management, bungeeSender, dataSource, + proxyLoginRequestValidator, settings); verify(messenger).registerIncomingPluginChannel(eq(plugin), eq("authme:main"), any(BungeeReceiver.class)); } @@ -89,7 +86,8 @@ void shouldUnregisterIncomingChannelWhenDisabledOnReload() { given(messenger.isIncomingChannelRegistered(plugin, "authme:main")).willReturn(false, true); BungeeReceiver bungeeReceiver = - new BungeeReceiver(plugin, bukkitService, proxySessionManager, management, bungeeSender, dataSource, pendingPremiumCache, premiumService, settings); + new BungeeReceiver(plugin, bukkitService, proxySessionManager, management, bungeeSender, dataSource, + proxyLoginRequestValidator, settings); bungeeReceiver.reload(settings); verify(messenger).registerIncomingPluginChannel(plugin, "authme:main", bungeeReceiver); @@ -102,7 +100,7 @@ void shouldQueueSessionAndForceLoginWhenPerformLoginReceivedForOnlinePlayer() { String sharedSecret = "test-secret"; String playerName = "Bobby"; long timestamp = System.currentTimeMillis(); - String hmac = HashUtils.hmacSha256(sharedSecret, playerName + ":" + timestamp); + String hmac = HashUtils.hmacSha256(sharedSecret, playerName + ":" + timestamp + ":"); given(settings.getProperty(HooksSettings.BUNGEECORD)).willReturn(true); given(settings.getProperty(HooksSettings.PROXY_SHARED_SECRET)).willReturn(sharedSecret); @@ -111,9 +109,11 @@ void shouldQueueSessionAndForceLoginWhenPerformLoginReceivedForOnlinePlayer() { Player player = mock(Player.class); given(player.isOnline()).willReturn(true); given(bukkitService.getPlayerExact(playerName)).willReturn(player); + given(proxyLoginRequestValidator.validate(player, null)).willReturn(true); BungeeReceiver receiver = - new BungeeReceiver(plugin, bukkitService, proxySessionManager, management, bungeeSender, dataSource, pendingPremiumCache, premiumService, settings); + new BungeeReceiver(plugin, bukkitService, proxySessionManager, management, bungeeSender, dataSource, + proxyLoginRequestValidator, settings); byte[] payload = buildPerformLoginPayload(playerName, timestamp, hmac); @@ -121,7 +121,7 @@ void shouldQueueSessionAndForceLoginWhenPerformLoginReceivedForOnlinePlayer() { receiver.onPluginMessageReceived("authme:main", player, payload); // then - verify(proxySessionManager).processProxySessionMessage(playerName); + verify(proxySessionManager).processProxySessionMessage(playerName, null); verify(management).forceLoginFromProxy(player); verify(bungeeSender).sendAuthMeBungeecordMessage(player, MessageType.PERFORM_LOGIN_ACK); } @@ -132,7 +132,7 @@ void shouldOnlyQueueSessionWhenPerformLoginReceivedForOfflinePlayer() { String sharedSecret = "test-secret"; String playerName = "Bobby"; long timestamp = System.currentTimeMillis(); - String hmac = HashUtils.hmacSha256(sharedSecret, playerName + ":" + timestamp); + String hmac = HashUtils.hmacSha256(sharedSecret, playerName + ":" + timestamp + ":"); given(settings.getProperty(HooksSettings.BUNGEECORD)).willReturn(true); given(settings.getProperty(HooksSettings.PROXY_SHARED_SECRET)).willReturn(sharedSecret); @@ -140,7 +140,8 @@ void shouldOnlyQueueSessionWhenPerformLoginReceivedForOfflinePlayer() { given(bukkitService.getPlayerExact(playerName)).willReturn(null); BungeeReceiver receiver = - new BungeeReceiver(plugin, bukkitService, proxySessionManager, management, bungeeSender, dataSource, pendingPremiumCache, premiumService, settings); + new BungeeReceiver(plugin, bukkitService, proxySessionManager, management, bungeeSender, dataSource, + proxyLoginRequestValidator, settings); Player carrier = mock(Player.class); byte[] payload = buildPerformLoginPayload(playerName, timestamp, hmac); @@ -149,7 +150,7 @@ void shouldOnlyQueueSessionWhenPerformLoginReceivedForOfflinePlayer() { receiver.onPluginMessageReceived("authme:main", carrier, payload); // then - verify(proxySessionManager).processProxySessionMessage(playerName); + verify(proxySessionManager).processProxySessionMessage(playerName, null); verify(management, never()).forceLoginFromProxy(any()); verify(bungeeSender, never()).sendAuthMeBungeecordMessage(any(), any()); } @@ -159,6 +160,7 @@ private static byte[] buildPerformLoginPayload(String playerName, long timestamp out.writeUTF(MessageType.PERFORM_LOGIN.getId()); out.writeUTF(playerName); out.writeLong(timestamp); + out.writeUTF(""); out.writeUTF(hmac); return out.toByteArray(); } diff --git a/authme-velocity/src/main/java/fr/xephi/authme/velocity/AuthMeVelocityPlugin.java b/authme-velocity/src/main/java/fr/xephi/authme/velocity/AuthMeVelocityPlugin.java index 1fc06b37fa..8ab3099d78 100644 --- a/authme-velocity/src/main/java/fr/xephi/authme/velocity/AuthMeVelocityPlugin.java +++ b/authme-velocity/src/main/java/fr/xephi/authme/velocity/AuthMeVelocityPlugin.java @@ -5,10 +5,9 @@ import com.velocitypowered.api.event.Subscribe; import com.velocitypowered.api.event.command.CommandExecuteEvent; import com.velocitypowered.api.event.connection.DisconnectEvent; -import com.velocitypowered.api.event.connection.LoginEvent; import com.velocitypowered.api.event.connection.PluginMessageEvent; -import com.velocitypowered.api.event.connection.PostLoginEvent; import com.velocitypowered.api.event.connection.PreLoginEvent; +import com.velocitypowered.api.event.player.GameProfileRequestEvent; import com.velocitypowered.api.event.player.PlayerChatEvent; import com.velocitypowered.api.event.player.ServerConnectedEvent; import com.velocitypowered.api.event.player.ServerPreConnectEvent; @@ -62,6 +61,16 @@ public void onPluginMessage(PluginMessageEvent event) { proxyBridge.onPluginMessage(event); } + @Subscribe + public void onPreLogin(PreLoginEvent event) { + proxyBridge.onPreLogin(event); + } + + @Subscribe + public void onGameProfileRequest(GameProfileRequestEvent event) { + proxyBridge.onGameProfileRequest(event); + } + @Subscribe public void onServerConnected(ServerConnectedEvent event) { proxyBridge.onServerConnected(event); @@ -82,21 +91,6 @@ public void onPlayerChat(PlayerChatEvent event) { proxyBridge.onPlayerChat(event); } - @Subscribe - public void onPreLogin(PreLoginEvent event) { - proxyBridge.onPreLogin(event); - } - - @Subscribe - public void onLogin(LoginEvent event) { - proxyBridge.onLogin(event); - } - - @Subscribe - public void onPostLogin(PostLoginEvent event) { - proxyBridge.onPostLogin(event); - } - @Subscribe public void onDisconnect(DisconnectEvent event) { proxyBridge.onDisconnect(event); diff --git a/authme-velocity/src/main/java/fr/xephi/authme/velocity/ProxyMessageSecurity.java b/authme-velocity/src/main/java/fr/xephi/authme/velocity/ProxyMessageSecurity.java index b156d6ceae..1ab721009c 100644 --- a/authme-velocity/src/main/java/fr/xephi/authme/velocity/ProxyMessageSecurity.java +++ b/authme-velocity/src/main/java/fr/xephi/authme/velocity/ProxyMessageSecurity.java @@ -7,6 +7,7 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.HexFormat; +import java.util.UUID; final class ProxyMessageSecurity { @@ -16,11 +17,12 @@ final class ProxyMessageSecurity { private ProxyMessageSecurity() { } - static String computeHmac(String secret, String playerName, long timestamp) { + static String computeHmac(String secret, String playerName, long timestamp, UUID verifiedPremiumUuid) { try { Mac mac = Mac.getInstance(HMAC_ALGO); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGO)); - byte[] hmacBytes = mac.doFinal((playerName + ":" + timestamp).getBytes(StandardCharsets.UTF_8)); + String payload = playerName + ":" + timestamp + ":" + (verifiedPremiumUuid == null ? "" : verifiedPremiumUuid); + byte[] hmacBytes = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(hmacBytes); } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new IllegalStateException("Failed to compute HMAC-SHA256", e); @@ -31,7 +33,7 @@ static boolean verifyHmac(String secret, String playerName, long timestamp, Stri if (Math.abs(System.currentTimeMillis() - timestamp) > MAX_AGE_MILLIS) { return false; } - String expectedHmac = computeHmac(secret, playerName, timestamp); + String expectedHmac = computeHmac(secret, playerName, timestamp, null); return MessageDigest.isEqual( expectedHmac.getBytes(StandardCharsets.UTF_8), providedHmac.getBytes(StandardCharsets.UTF_8)); diff --git a/authme-velocity/src/main/java/fr/xephi/authme/velocity/VelocityProxyBridge.java b/authme-velocity/src/main/java/fr/xephi/authme/velocity/VelocityProxyBridge.java index 045a26986d..3d6ecef1b8 100644 --- a/authme-velocity/src/main/java/fr/xephi/authme/velocity/VelocityProxyBridge.java +++ b/authme-velocity/src/main/java/fr/xephi/authme/velocity/VelocityProxyBridge.java @@ -5,10 +5,10 @@ import com.google.common.io.ByteStreams; import com.velocitypowered.api.event.command.CommandExecuteEvent; import com.velocitypowered.api.event.connection.DisconnectEvent; -import com.velocitypowered.api.event.connection.LoginEvent; import com.velocitypowered.api.event.connection.PluginMessageEvent; -import com.velocitypowered.api.event.connection.PostLoginEvent; +import com.velocitypowered.api.event.connection.PreLoginEvent; import com.velocitypowered.api.event.player.PlayerChatEvent; +import com.velocitypowered.api.event.player.GameProfileRequestEvent; import com.velocitypowered.api.event.player.ServerConnectedEvent; import com.velocitypowered.api.event.player.ServerPreConnectEvent; import com.velocitypowered.api.proxy.ConnectionRequestBuilder; @@ -19,6 +19,7 @@ import com.velocitypowered.api.proxy.messages.LegacyChannelIdentifier; import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier; import com.velocitypowered.api.proxy.server.RegisteredServer; +import fr.xephi.authme.velocity.premium.VelocityPremiumVerificationManager; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.slf4j.Logger; @@ -34,6 +35,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.UUID; final class VelocityProxyBridge { @@ -63,11 +65,7 @@ final class VelocityProxyBridge { private List premiumListBuffer = new ArrayList<>(); // Players with a pending premium verification (ran /premium but not yet confirmed via reconnect) private volatile Set pendingPremiumUsernames = ConcurrentHashMap.newKeySet(); - // Players for whom we already forced online-mode once to verify premium status; if they appear - // in onPreLogin again without having reached onLogin, Mojang auth failed → cancel the request. - private final Set pendingVerificationAttempted = ConcurrentHashMap.newKeySet(); - // Players whose Mojang UUID was confirmed by the proxy during the login phase (LoginSuccess with UUID v4) - private final Set proxyVerifiedPremium = ConcurrentHashMap.newKeySet(); + private final VelocityPremiumVerificationManager premiumVerificationManager; private final ScheduledExecutorService retryScheduler = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "authme-velocity-retry"); t.setDaemon(true); @@ -80,42 +78,10 @@ final class VelocityProxyBridge { this.logger = logger; this.configuration = configuration; this.authenticationStore = authenticationStore; - } - - private void markProxyVerifiedPremium(String normalizedName) { - if (proxyVerifiedPremium.add(normalizedName)) { - logger.info("Proxy-verified premium: '{}' authenticated online-mode with Mojang", normalizedName); - } - } - - /** - * Records the player as proxy-verified premium when the proxy finished the Mojang authentication - * phase in online mode. Called from {@code AuthMeVelocityPlugin#onLogin(LoginEvent)}. - */ - void onLogin(LoginEvent event) { - Player player = event.getPlayer(); - if (!player.isOnlineMode()) { - return; - } - String normalizedName = normalizeName(player.getUsername()); - // Mojang auth succeeded: the attempt tracking entry is no longer needed. - pendingVerificationAttempted.remove(normalizedName); - markProxyVerifiedPremium(normalizedName); - } - - /** - * Fallback hook on {@link PostLoginEvent}: if the player has a UUID v4 (Mojang-issued), they - * are recorded as proxy-verified premium even if the {@link LoginEvent} listener missed them. - * - *

This is a secondary signal only — the primary gate is the backend's UUID comparison - * ({@code verifiedUuid.equals(auth.getPremiumUuid())}), so a fake v4 UUID injected by a - * third-party plugin would still be rejected there unless it matches the stored premium UUID.

- */ - void onPostLogin(PostLoginEvent event) { - Player player = event.getPlayer(); - if (player.getUniqueId() != null && player.getUniqueId().version() == 4) { - markProxyVerifiedPremium(normalizeName(player.getUsername())); - } + this.premiumVerificationManager = + new VelocityPremiumVerificationManager(logger, + this::requiresPremiumVerification, this::isPendingPremiumVerification, + () -> this.configuration.keepOfflineUuidCompatibility()); } void reload(VelocityProxyConfiguration configuration) { @@ -127,6 +93,23 @@ void registerChannels() { proxyServer.getChannelRegistrar().register(AUTHME_CHANNEL, AUTHME_LEGACY_CHANNEL); logger.info("Registered AuthMe Velocity bridge channel"); broadcastProxyStartedHandshake(); + premiumVerificationManager.register(); + } + + private boolean requiresPremiumVerification(String normalizedName) { + return premiumUsernames.contains(normalizedName) || pendingPremiumUsernames.contains(normalizedName); + } + + private boolean isPendingPremiumVerification(String normalizedName) { + return pendingPremiumUsernames.contains(normalizedName); + } + + private void clearPendingPremiumVerification(String normalizedName) { + if (pendingPremiumUsernames.remove(normalizedName)) { + premiumVerificationManager.clearVerifiedPremium(normalizedName); + logger.warn("Cleared pending premium verification for '{}' after a failed proxy-side premium handshake", + normalizedName); + } } private void broadcastProxyStartedHandshake() { @@ -176,6 +159,9 @@ void logConfigurationDetails() { logger.info("autoLogin is disabled"); } + logger.info("premium.keepOfflineUuidCompatibility is {}", + configuration.keepOfflineUuidCompatibility() ? "enabled" : "disabled"); + if (configuration.sendOnLogoutEnabled() && configuration.sendOnLogoutTarget().isEmpty()) { logger.warn("sendOnLogout is enabled but unloggedUserServer is empty; logout redirects will be skipped"); } @@ -234,9 +220,11 @@ void onPluginMessage(PluginMessageEvent event) { } else if (PREMIUM_UNSET_MESSAGE.equals(parsedMessage.typeId())) { premiumUsernames.remove(parsedMessage.playerName()); pendingPremiumUsernames.remove(parsedMessage.playerName()); + premiumVerificationManager.clearVerifiedPremium(parsedMessage.playerName()); logger.debug("Premium disabled for '{}' (proxy cache updated)", parsedMessage.playerName()); } else if (PREMIUM_PENDING_SET_MESSAGE.equals(parsedMessage.typeId())) { pendingPremiumUsernames.add(parsedMessage.playerName()); + premiumVerificationManager.clearVerifiedPremium(parsedMessage.playerName()); logger.debug("Pending premium verification started for '{}'", parsedMessage.playerName()); } else if (PREMIUM_LIST_MESSAGE.equals(parsedMessage.typeId())) { Set newPremiumSet = ConcurrentHashMap.newKeySet(); @@ -302,13 +290,9 @@ void onServerConnected(ServerConnectedEvent event) { } String normalizedName = normalizeName(playerName); + UUID verifiedPremiumUuid = premiumVerificationManager.getVerifiedPremiumUuid(normalizedName); - // Pending players have passed Mojang auth at the proxy, but we must NOT send PERFORM_LOGIN - // for them: the backend needs to run canBypassWithPremium() to finalize (persist) the premium - // UUID. Only confirmed premium players (premiumUsernames) trigger the auto-login bypass. - boolean isPremiumJoin = connectingToAuthServer - && proxyVerifiedPremium.contains(normalizedName) - && !pendingPremiumUsernames.contains(normalizedName); + boolean isPremiumJoin = connectingToAuthServer && verifiedPremiumUuid != null; if (!authenticationStore.isAuthenticated(normalizedName) && !isPremiumJoin) { logger.debug("Skipping auto-login for {} — not authenticated or proxy-verified premium", normalizedName); return; @@ -327,7 +311,8 @@ void onServerConnected(ServerConnectedEvent event) { } String serverName = currentServer.get().getServer().getServerInfo().getName(); - boolean sent = currentServer.get().sendPluginMessage(AUTHME_CHANNEL, createPerformLoginMessage(normalizedName)); + boolean sent = currentServer.get().sendPluginMessage( + AUTHME_CHANNEL, createPerformLoginMessage(normalizedName, verifiedPremiumUuid)); if (sent) { logger.info("Sending auto-login request to server '{}' for player {}", serverName, normalizedName); initiatePendingLogin(normalizedName); @@ -337,6 +322,14 @@ void onServerConnected(ServerConnectedEvent event) { } } + void onPreLogin(PreLoginEvent event) { + premiumVerificationManager.onPreLogin(event); + } + + void onGameProfileRequest(GameProfileRequestEvent event) { + premiumVerificationManager.onGameProfileRequest(event); + } + void onServerPreConnect(ServerPreConnectEvent event) { if (!configuration.serverSwitchRequiresAuth()) { return; @@ -401,28 +394,6 @@ void onPlayerChat(PlayerChatEvent event) { event.setResult(PlayerChatEvent.ChatResult.denied()); } - void onPreLogin(com.velocitypowered.api.event.connection.PreLoginEvent event) { - String normalizedName = normalizeName(event.getUsername()); - if (premiumUsernames.contains(normalizedName)) { - event.setResult(com.velocitypowered.api.event.connection.PreLoginEvent.PreLoginComponentResult.forceOnlineMode()); - logger.debug("Forcing online-mode for premium player '{}'", normalizedName); - } else if (pendingPremiumUsernames.contains(normalizedName)) { - if (pendingVerificationAttempted.contains(normalizedName)) { - // The player was already given a forced online-mode attempt but never reached onLogin — - // meaning Mojang rejected them. Cancel the premium request so they can reconnect normally. - pendingPremiumUsernames.remove(normalizedName); - pendingVerificationAttempted.remove(normalizedName); - logger.info("Pending premium verification failed for '{}' (Mojang auth rejected) — premium request cancelled", - normalizedName); - } else { - // First attempt: force online-mode and track that the attempt is in progress. - pendingVerificationAttempted.add(normalizedName); - event.setResult(com.velocitypowered.api.event.connection.PreLoginEvent.PreLoginComponentResult.forceOnlineMode()); - logger.debug("Forcing online-mode for pending premium player '{}'", normalizedName); - } - } - } - void onDisconnect(DisconnectEvent event) { String normalizedName = normalizeName(event.getPlayer().getUsername()); if (pendingAutoLogins.containsKey(normalizedName)) { @@ -433,12 +404,12 @@ void onDisconnect(DisconnectEvent event) { logger.debug("Clearing auth state for {} (player disconnected)", normalizedName); } authenticationStore.clear(event.getPlayer()); - proxyVerifiedPremium.remove(normalizedName); - pendingVerificationAttempted.remove(normalizedName); + premiumVerificationManager.clearVerifiedPremium(normalizedName); } void shutdown() { logger.info("Shutting down retry scheduler"); + premiumVerificationManager.shutdown(); retryScheduler.shutdownNow(); } @@ -463,7 +434,9 @@ private void sendAutoLoginIfAlreadySwitched(String normalizedName, RegisteredSer String currentServerName = currentServer.getServerInfo().getName(); logger.info("Player {} already on server '{}' when login message arrived — sending auto-login immediately", normalizedName, currentServerName); - boolean sent = currentConn.get().sendPluginMessage(AUTHME_CHANNEL, createPerformLoginMessage(normalizedName)); + UUID verifiedPremiumUuid = premiumVerificationManager.getVerifiedPremiumUuid(normalizedName); + boolean sent = currentConn.get().sendPluginMessage( + AUTHME_CHANNEL, createPerformLoginMessage(normalizedName, verifiedPremiumUuid)); if (sent) { initiatePendingLogin(normalizedName); } else { @@ -543,7 +516,9 @@ private void scheduleRetry(String normalizedName) { String serverName = serverOpt.get().getServer().getServerInfo().getName(); logger.debug("Retrying auto-login for {} on server '{}' (attempt {}/{})", normalizedName, serverName, current + 1, MAX_RETRIES); - serverOpt.get().sendPluginMessage(AUTHME_CHANNEL, createPerformLoginMessage(normalizedName)); + UUID verifiedPremiumUuid = premiumVerificationManager.getVerifiedPremiumUuid(normalizedName); + serverOpt.get().sendPluginMessage(AUTHME_CHANNEL, + createPerformLoginMessage(normalizedName, verifiedPremiumUuid)); scheduleRetry(normalizedName); }, 1, TimeUnit.SECONDS); } @@ -573,13 +548,15 @@ private ParsedMessage parseMessage(byte[] data) { } } - private byte[] createPerformLoginMessage(String normalizedName) { + private byte[] createPerformLoginMessage(String normalizedName, UUID verifiedPremiumUuid) { long timestamp = System.currentTimeMillis(); - String hmac = ProxyMessageSecurity.computeHmac(configuration.sharedSecret(), normalizedName, timestamp); + String hmac = ProxyMessageSecurity.computeHmac( + configuration.sharedSecret(), normalizedName, timestamp, verifiedPremiumUuid); ByteArrayDataOutput output = ByteStreams.newDataOutput(); output.writeUTF(PERFORM_LOGIN_MESSAGE); output.writeUTF(normalizedName); output.writeLong(timestamp); + output.writeUTF(verifiedPremiumUuid == null ? "" : verifiedPremiumUuid.toString()); output.writeUTF(hmac); return output.toByteArray(); } diff --git a/authme-velocity/src/main/java/fr/xephi/authme/velocity/VelocityProxyConfiguration.java b/authme-velocity/src/main/java/fr/xephi/authme/velocity/VelocityProxyConfiguration.java index ac4ca4d054..fc19761462 100644 --- a/authme-velocity/src/main/java/fr/xephi/authme/velocity/VelocityProxyConfiguration.java +++ b/authme-velocity/src/main/java/fr/xephi/authme/velocity/VelocityProxyConfiguration.java @@ -25,13 +25,15 @@ final class VelocityProxyConfiguration { private final boolean chatRequiresAuth; private final String loginServer; private final String sharedSecret; + private final boolean keepOfflineUuidCompatibility; VelocityProxyConfiguration(Set authServers, boolean allServersAreAuthServers, boolean serverSwitchRequiresAuth, String serverSwitchKickMessage, boolean autoLoginEnabled, boolean sendOnLogoutEnabled, String sendOnLogoutTarget, boolean commandsRequireAuth, Set commandWhitelist, boolean chatRequiresAuth, - String loginServer, String sharedSecret) { + String loginServer, String sharedSecret, + boolean keepOfflineUuidCompatibility) { this.authServers = authServers; this.allServersAreAuthServers = allServersAreAuthServers; this.serverSwitchRequiresAuth = serverSwitchRequiresAuth; @@ -44,6 +46,7 @@ final class VelocityProxyConfiguration { this.chatRequiresAuth = chatRequiresAuth; this.loginServer = normalizeServerName(loginServer); this.sharedSecret = sharedSecret; + this.keepOfflineUuidCompatibility = keepOfflineUuidCompatibility; } static VelocityProxyConfiguration from(SettingsManager settingsManager) { @@ -59,7 +62,8 @@ static VelocityProxyConfiguration from(SettingsManager settingsManager) { normalizeCommandAliases(settingsManager.getProperty(VelocityConfigProperties.COMMANDS_WHITELIST)), settingsManager.getProperty(VelocityConfigProperties.CHAT_REQUIRES_AUTH), settingsManager.getProperty(VelocityConfigProperties.LOGIN_SERVER), - settingsManager.getProperty(VelocityConfigProperties.PROXY_SHARED_SECRET)); + settingsManager.getProperty(VelocityConfigProperties.PROXY_SHARED_SECRET), + settingsManager.getProperty(VelocityConfigProperties.PREMIUM_KEEP_OFFLINE_UUID_COMPATIBILITY)); } Set authServers() { @@ -106,6 +110,10 @@ String sharedSecret() { return sharedSecret; } + boolean keepOfflineUuidCompatibility() { + return keepOfflineUuidCompatibility; + } + boolean isAuthServer(RegisteredServer server) { return allServersAreAuthServers || authServers.contains(normalizeServerName(server.getServerInfo().getName())); } diff --git a/authme-velocity/src/main/java/fr/xephi/authme/velocity/config/VelocityConfigProperties.java b/authme-velocity/src/main/java/fr/xephi/authme/velocity/config/VelocityConfigProperties.java index 6fec8773a4..fc491a5a16 100644 --- a/authme-velocity/src/main/java/fr/xephi/authme/velocity/config/VelocityConfigProperties.java +++ b/authme-velocity/src/main/java/fr/xephi/authme/velocity/config/VelocityConfigProperties.java @@ -67,6 +67,14 @@ public final class VelocityConfigProperties implements SettingsHolder { public static final Property PROXY_SHARED_SECRET = newProperty("proxySharedSecret", ""); + @Comment({ + "Keep verified premium players on the backend offline UUID for plugin compatibility.", + "When false, premium players keep their Mojang UUID on the backend instead.", + "Velocity can do both modes natively; false is the default." + }) + public static final Property PREMIUM_KEEP_OFFLINE_UUID_COMPATIBILITY = + newProperty("premium.keepOfflineUuidCompatibility", false); + private VelocityConfigProperties() { } } diff --git a/authme-velocity/src/main/java/fr/xephi/authme/velocity/premium/ProxyPremiumLoginVerifier.java b/authme-velocity/src/main/java/fr/xephi/authme/velocity/premium/ProxyPremiumLoginVerifier.java new file mode 100644 index 0000000000..e0f88aea29 --- /dev/null +++ b/authme-velocity/src/main/java/fr/xephi/authme/velocity/premium/ProxyPremiumLoginVerifier.java @@ -0,0 +1,234 @@ +package fr.xephi.authme.velocity.premium; + +import javax.crypto.Cipher; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.math.BigInteger; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +final class ProxyPremiumLoginVerifier { + + private static final String HAS_JOINED_URL = + "https://sessionserver.mojang.com/session/minecraft/hasJoined"; + private static final Pattern UUID_PATTERN = + Pattern.compile("\"id\"\\s*:\\s*\"([0-9a-fA-F]{32})\""); + private static final long VERIFIED_TTL_MS = 60_000L; + private static final long PENDING_TTL_MS = 30_000L; + + private final KeyPair rsaKeyPair; + private final SecureRandom secureRandom; + private final Consumer warningLogger; + private final ExecutorService verificationExecutor; + private final ConcurrentHashMap pending = new ConcurrentHashMap<>(); + private final ConcurrentHashMap verified = new ConcurrentHashMap<>(); + + ProxyPremiumLoginVerifier(String threadName, Consumer warningLogger) { + this.warningLogger = warningLogger; + this.secureRandom = new SecureRandom(); + this.verificationExecutor = Executors.newSingleThreadExecutor(r -> { + Thread thread = new Thread(r, threadName); + thread.setDaemon(true); + return thread; + }); + try { + KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); + gen.initialize(1024, secureRandom); + this.rsaKeyPair = gen.generateKeyPair(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("RSA algorithm not available", e); + } + } + + PublicKey getPublicKey() { + return rsaKeyPair.getPublic(); + } + + byte[] startVerification(String connectionKey, String username, UUID playerUuid, boolean pendingPremiumEnrollment) { + evictStalePendingEntries(); + verified.remove(username.toLowerCase(Locale.ROOT)); + byte[] verifyToken = new byte[4]; + secureRandom.nextBytes(verifyToken); + pending.put(connectionKey, + new PendingVerification(username, playerUuid, verifyToken, System.currentTimeMillis(), pendingPremiumEnrollment)); + return verifyToken; + } + + boolean hasPending(String connectionKey) { + return pending.containsKey(connectionKey); + } + + String getPendingUsername(String connectionKey) { + PendingVerification pendingVerification = pending.get(connectionKey); + return pendingVerification != null ? pendingVerification.username() : null; + } + + UUID getPendingPlayerUuid(String connectionKey) { + PendingVerification pendingVerification = pending.get(connectionKey); + return pendingVerification != null ? pendingVerification.playerUuid() : null; + } + + boolean isPendingPremiumEnrollment(String connectionKey) { + PendingVerification pendingVerification = pending.get(connectionKey); + return pendingVerification != null && pendingVerification.pendingPremiumEnrollment(); + } + + void cleanupPending(String connectionKey) { + pending.remove(connectionKey); + } + + byte[] decryptData(byte[] encrypted) throws GeneralSecurityException { + return rsaDecrypt(encrypted); + } + + CompletableFuture> completeVerification( + String connectionKey, byte[] sharedSecret, byte[] encryptedVerifyToken) { + PendingVerification pendingVerification = pending.remove(connectionKey); + if (pendingVerification == null) { + return CompletableFuture.completedFuture(Optional.empty()); + } + return CompletableFuture.supplyAsync(() -> { + try { + byte[] decryptedToken = rsaDecrypt(encryptedVerifyToken); + if (!Arrays.equals(decryptedToken, pendingVerification.verifyToken())) { + warningLogger.accept("Proxy premium verification failed for '" + pendingVerification.username() + + "': verify token mismatch"); + return Optional.empty(); + } + String serverHash = computeServerHash(sharedSecret); + return hasJoined(pendingVerification.username(), serverHash); + } catch (Exception e) { + warningLogger.accept("Proxy premium verification failed for '" + pendingVerification.username() + + "': " + e.getMessage()); + return Optional.empty(); + } + }, verificationExecutor); + } + + void storeVerified(String username, UUID mojangUuid) { + verified.put(username.toLowerCase(Locale.ROOT), new VerifiedSession(mojangUuid, System.currentTimeMillis())); + } + + UUID getVerifiedUuid(String username) { + String normalizedName = username.toLowerCase(Locale.ROOT); + VerifiedSession verifiedSession = verified.get(normalizedName); + if (verifiedSession == null) { + return null; + } + if (System.currentTimeMillis() - verifiedSession.verifiedAt() > VERIFIED_TTL_MS) { + verified.remove(normalizedName); + return null; + } + return verifiedSession.mojangUuid(); + } + + void clearVerified(String username) { + verified.remove(username.toLowerCase(Locale.ROOT)); + } + + void shutdown() { + verificationExecutor.shutdownNow(); + pending.clear(); + verified.clear(); + } + + private void evictStalePendingEntries() { + long now = System.currentTimeMillis(); + pending.entrySet().removeIf(entry -> now - entry.getValue().startedAt() > PENDING_TTL_MS); + } + + private byte[] rsaDecrypt(byte[] encrypted) throws GeneralSecurityException { + Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); + cipher.init(Cipher.DECRYPT_MODE, rsaKeyPair.getPrivate()); + return cipher.doFinal(encrypted); + } + + private String computeServerHash(byte[] sharedSecret) throws NoSuchAlgorithmException { + MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); + sha1.update("".getBytes(StandardCharsets.ISO_8859_1)); + sha1.update(sharedSecret); + sha1.update(rsaKeyPair.getPublic().getEncoded()); + return new BigInteger(sha1.digest()).toString(16); + } + + private Optional hasJoined(String username, String serverHash) { + try { + HttpURLConnection connection = openGet(HAS_JOINED_URL + "?username=" + username + "&serverId=" + serverHash); + int code = connection.getResponseCode(); + if (code == HttpURLConnection.HTTP_NO_CONTENT || code == HttpURLConnection.HTTP_NOT_FOUND) { + return Optional.empty(); + } + if (code != HttpURLConnection.HTTP_OK) { + warningLogger.accept("Mojang hasJoined returned " + code + " for '" + username + "'"); + return Optional.empty(); + } + return parseUuid(readBody(connection), username); + } catch (IOException e) { + warningLogger.accept("Failed to contact Mojang session server for '" + username + "': " + e.getMessage()); + return Optional.empty(); + } + } + + private HttpURLConnection openGet(String urlString) throws IOException { + HttpURLConnection connection = (HttpURLConnection) new URL(urlString).openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(5000); + connection.setReadTimeout(5000); + return connection; + } + + private Optional parseUuid(String body, String username) { + Matcher matcher = UUID_PATTERN.matcher(body); + if (!matcher.find()) { + return Optional.empty(); + } + String raw = matcher.group(1); + String dashed = raw.substring(0, 8) + "-" + raw.substring(8, 12) + "-" + + raw.substring(12, 16) + "-" + raw.substring(16, 20) + "-" + raw.substring(20); + try { + return Optional.of(UUID.fromString(dashed)); + } catch (IllegalArgumentException e) { + warningLogger.accept("Mojang returned an unparseable UUID for '" + username + "': " + raw); + return Optional.empty(); + } + } + + private static String readBody(HttpURLConnection connection) throws IOException { + StringBuilder builder = new StringBuilder(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + builder.append(line); + } + } + return builder.toString(); + } + + private record PendingVerification( + String username, UUID playerUuid, byte[] verifyToken, long startedAt, boolean pendingPremiumEnrollment) { + } + + private record VerifiedSession(UUID mojangUuid, long verifiedAt) { + } +} diff --git a/authme-velocity/src/main/java/fr/xephi/authme/velocity/premium/VelocityPremiumVerificationManager.java b/authme-velocity/src/main/java/fr/xephi/authme/velocity/premium/VelocityPremiumVerificationManager.java new file mode 100644 index 0000000000..71349a3433 --- /dev/null +++ b/authme-velocity/src/main/java/fr/xephi/authme/velocity/premium/VelocityPremiumVerificationManager.java @@ -0,0 +1,87 @@ +package fr.xephi.authme.velocity.premium; + +import com.velocitypowered.api.event.connection.PreLoginEvent; +import com.velocitypowered.api.event.player.GameProfileRequestEvent; +import com.velocitypowered.api.util.UuidUtils; +import org.slf4j.Logger; + +import java.util.Locale; +import java.util.UUID; +import java.util.function.BooleanSupplier; +import java.util.function.Predicate; + +public final class VelocityPremiumVerificationManager { + + private final Logger logger; + private final Predicate requiresVerification; + private final Predicate isPendingVerification; + private final BooleanSupplier keepOfflineUuidCompatibility; + private final ProxyPremiumLoginVerifier loginVerifier; + private boolean registered; + + public VelocityPremiumVerificationManager(Logger logger, + Predicate requiresVerification, + Predicate isPendingVerification, + BooleanSupplier keepOfflineUuidCompatibility) { + this.logger = logger; + this.requiresVerification = requiresVerification; + this.isPendingVerification = isPendingVerification; + this.keepOfflineUuidCompatibility = keepOfflineUuidCompatibility; + this.loginVerifier = new ProxyPremiumLoginVerifier("authme-velocity-premium", + message -> this.logger.warn(message)); + } + + public void register() { + if (registered) { + return; + } + registered = true; + logger.info("Registered native Velocity premium verification"); + } + + public void onPreLogin(PreLoginEvent event) { + String normalizedName = normalize(event.getUsername()); + if (requiresVerification.test(normalizedName)) { + event.setResult(PreLoginEvent.PreLoginComponentResult.forceOnlineMode()); + } + } + + public void onGameProfileRequest(GameProfileRequestEvent event) { + String normalizedName = normalize(event.getUsername()); + if (!requiresVerification.test(normalizedName) || !event.isOnlineMode()) { + return; + } + + UUID verifiedPremiumUuid = event.getOriginalProfile().getId(); + loginVerifier.storeVerified(normalizedName, verifiedPremiumUuid); + if (keepOfflineUuidCompatibility.getAsBoolean()) { + event.setGameProfile(event.getGameProfile().withId(UuidUtils.generateOfflinePlayerUuid(event.getUsername()))); + } + + if (isPendingVerification.test(normalizedName)) { + logger.info("Premium enrollment for '{}' was verified on the Velocity proxy", normalizedName); + } else { + logger.debug("Verified premium login for '{}' on the Velocity proxy", normalizedName); + } + } + + public UUID getVerifiedPremiumUuid(String normalizedName) { + return loginVerifier.getVerifiedUuid(normalizedName); + } + + public void clearVerifiedPremium(String normalizedName) { + loginVerifier.clearVerified(normalizedName); + } + + public void shutdown() { + if (!registered) { + return; + } + registered = false; + loginVerifier.shutdown(); + } + + private static String normalize(String username) { + return username.toLowerCase(Locale.ROOT); + } +} diff --git a/authme-velocity/src/test/java/fr/xephi/authme/velocity/VelocityConfigManagerTest.java b/authme-velocity/src/test/java/fr/xephi/authme/velocity/VelocityConfigManagerTest.java index 686186f209..c2819c2d2b 100644 --- a/authme-velocity/src/test/java/fr/xephi/authme/velocity/VelocityConfigManagerTest.java +++ b/authme-velocity/src/test/java/fr/xephi/authme/velocity/VelocityConfigManagerTest.java @@ -31,6 +31,7 @@ void shouldCreateConfigFileWithDefaults() { assertTrue(configManager.getConfiguration().isWhitelistedCommand("/login")); assertTrue(configManager.getConfiguration().chatRequiresAuth()); assertFalse(configManager.getConfiguration().sharedSecret().isEmpty()); + assertFalse(configManager.getConfiguration().keepOfflineUuidCompatibility()); } @Test @@ -45,17 +46,19 @@ void shouldPreserveExistingSharedSecret() throws IOException { @Test void shouldNormalizeConfiguredServersAndLogoutTarget() throws IOException { Files.writeString(tempDirectory.resolve("config.yml"), """ - authServers: - - Lobby - - HUB - allServersAreAuthServers: false - serverSwitch: - requiresAuth: false - kickMessage: Please authenticate first. - autoLogin: true - sendOnLogout: true - unloggedUserServer: LiMbO - """); +authServers: +- Lobby +- HUB +allServersAreAuthServers: false +serverSwitch: + requiresAuth: false + kickMessage: Please authenticate first. +autoLogin: true +sendOnLogout: true +unloggedUserServer: LiMbO +premium: + keepOfflineUuidCompatibility: true +"""); VelocityProxyConfiguration configuration = new VelocityConfigManager(tempDirectory).getConfiguration(); @@ -66,6 +69,7 @@ void shouldNormalizeConfiguredServersAndLogoutTarget() throws IOException { assertTrue(configuration.autoLoginEnabled()); assertTrue(configuration.sendOnLogoutEnabled()); assertEquals("limbo", configuration.sendOnLogoutTarget()); + assertTrue(configuration.keepOfflineUuidCompatibility()); } @Test diff --git a/authme-velocity/src/test/java/fr/xephi/authme/velocity/VelocityProxyBridgeTest.java b/authme-velocity/src/test/java/fr/xephi/authme/velocity/VelocityProxyBridgeTest.java index 939999d411..f9c04b977f 100644 --- a/authme-velocity/src/test/java/fr/xephi/authme/velocity/VelocityProxyBridgeTest.java +++ b/authme-velocity/src/test/java/fr/xephi/authme/velocity/VelocityProxyBridgeTest.java @@ -191,7 +191,7 @@ void shouldRedirectPlayerOnLogoutWhenConfigured() { VelocityProxyBridge bridge = new VelocityProxyBridge( proxyServer, logger, new VelocityProxyConfiguration(Set.of("lobby"), false, true, "Authentication required.", true, true, "limbo", true, - Set.of("/login", "/register"), true, "", ""), + Set.of("/login", "/register"), true, "", "", false), new VelocityAuthenticationStore()); bridge.onPluginMessage(pluginMessageEvent); @@ -435,7 +435,7 @@ void shouldAllowCommandIfSourceIsNotAPlayer() { void shouldNotBlockCommandIfCommandsRequireAuthIsDisabled() { VelocityProxyConfiguration config = new VelocityProxyConfiguration( Set.of("lobby"), false, true, "Authentication required.", false, false, "", - false, Set.of("/login"), true, "", ""); + false, Set.of("/login"), true, "", "", false); VelocityProxyBridge bridge = new VelocityProxyBridge(proxyServer, logger, config, new VelocityAuthenticationStore()); bridge.onCommandExecute(commandEvent); @@ -507,7 +507,7 @@ void shouldAllowChatIfPlayerHasNoCurrentServer() { void shouldNotBlockChatIfChatRequiresAuthIsDisabled() { VelocityProxyConfiguration config = new VelocityProxyConfiguration( Set.of("lobby"), false, true, "Authentication required.", false, false, "", - true, Set.of("/login"), false, "", ""); + true, Set.of("/login"), false, "", "", false); VelocityProxyBridge bridge = new VelocityProxyBridge(proxyServer, logger, config, new VelocityAuthenticationStore()); bridge.onPlayerChat(chatEvent); @@ -515,104 +515,6 @@ void shouldNotBlockChatIfChatRequiresAuthIsDisabled() { verify(chatEvent, never()).setResult(any()); } - @Test - void shouldUpdatePremiumSetAfterReceivingAllChunks() { - given(pluginMessageEvent.getResult()).willReturn(PluginMessageEvent.ForwardResult.forward()); - given(pluginMessageEvent.getIdentifier()).willReturn(VelocityProxyBridge.AUTHME_CHANNEL); - given(pluginMessageEvent.getSource()).willReturn(sourceConnection); - given(sourceConnection.getServer()).willReturn(authServer); - given(authServer.getServerInfo()).willReturn(authServerInfo); - given(authServerInfo.getName()).willReturn("lobby"); - VelocityProxyBridge bridge = new VelocityProxyBridge(proxyServer, logger, createConfiguration(), new VelocityAuthenticationStore()); - - given(pluginMessageEvent.getData()).willReturn(createChunkPayload(0, false, "alice,bob")); - bridge.onPluginMessage(pluginMessageEvent); - given(pluginMessageEvent.getData()).willReturn(createChunkPayload(1, true, "charlie")); - bridge.onPluginMessage(pluginMessageEvent); - - com.velocitypowered.api.event.connection.PreLoginEvent preLoginEvent = - mock(com.velocitypowered.api.event.connection.PreLoginEvent.class); - given(preLoginEvent.getUsername()).willReturn("alice"); - bridge.onPreLogin(preLoginEvent); - verify(preLoginEvent).setResult(any()); - } - - @Test - void shouldNotUpdatePremiumSetOnPartialChunkOnly() { - given(pluginMessageEvent.getResult()).willReturn(PluginMessageEvent.ForwardResult.forward()); - given(pluginMessageEvent.getIdentifier()).willReturn(VelocityProxyBridge.AUTHME_CHANNEL); - given(pluginMessageEvent.getSource()).willReturn(sourceConnection); - given(sourceConnection.getServer()).willReturn(authServer); - given(authServer.getServerInfo()).willReturn(authServerInfo); - given(authServerInfo.getName()).willReturn("lobby"); - VelocityProxyBridge bridge = new VelocityProxyBridge(proxyServer, logger, createConfiguration(), new VelocityAuthenticationStore()); - - // Only first chunk (not last) — set must not be updated yet - given(pluginMessageEvent.getData()).willReturn(createChunkPayload(0, false, "alice,bob")); - bridge.onPluginMessage(pluginMessageEvent); - - com.velocitypowered.api.event.connection.PreLoginEvent preLoginEvent = - mock(com.velocitypowered.api.event.connection.PreLoginEvent.class); - given(preLoginEvent.getUsername()).willReturn("alice"); - bridge.onPreLogin(preLoginEvent); - verify(preLoginEvent, never()).setResult(any()); - } - - @Test - void shouldPreservePendingPremiumStateAcrossDisconnectReconnect() { - given(pluginMessageEvent.getResult()).willReturn(PluginMessageEvent.ForwardResult.forward()); - given(pluginMessageEvent.getIdentifier()).willReturn(VelocityProxyBridge.AUTHME_CHANNEL); - given(pluginMessageEvent.getSource()).willReturn(sourceConnection); - given(sourceConnection.getServer()).willReturn(authServer); - given(authServer.getServerInfo()).willReturn(authServerInfo); - given(authServerInfo.getName()).willReturn("lobby"); - given(player.getUsername()).willReturn("alice"); - VelocityProxyBridge bridge = new VelocityProxyBridge(proxyServer, logger, createConfiguration(), new VelocityAuthenticationStore()); - - // Backend kicks the player for premium verification and sends PREMIUM_PENDING_SET - given(pluginMessageEvent.getData()).willReturn(createAuthMePayload("premium.pending.set", "alice")); - bridge.onPluginMessage(pluginMessageEvent); - - // Player is kicked by the backend — disconnect must NOT clear the pending state - bridge.onDisconnect(new DisconnectEvent(player, DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN)); - - // On reconnect, Velocity must still force online-mode for the pending player - com.velocitypowered.api.event.connection.PreLoginEvent reconnectAttempt = - mock(com.velocitypowered.api.event.connection.PreLoginEvent.class); - given(reconnectAttempt.getUsername()).willReturn("alice"); - bridge.onPreLogin(reconnectAttempt); - verify(reconnectAttempt).setResult(any()); - } - - @Test - void shouldForceOnlineModeOnFirstPendingAttemptThenCancelOnSecond() { - given(pluginMessageEvent.getResult()).willReturn(PluginMessageEvent.ForwardResult.forward()); - given(pluginMessageEvent.getIdentifier()).willReturn(VelocityProxyBridge.AUTHME_CHANNEL); - given(pluginMessageEvent.getSource()).willReturn(sourceConnection); - given(sourceConnection.getServer()).willReturn(authServer); - given(authServer.getServerInfo()).willReturn(authServerInfo); - given(authServerInfo.getName()).willReturn("lobby"); - VelocityProxyBridge bridge = new VelocityProxyBridge(proxyServer, logger, createConfiguration(), new VelocityAuthenticationStore()); - - given(pluginMessageEvent.getData()).willReturn(createAuthMePayload("premium.pending.set", "alice")); - bridge.onPluginMessage(pluginMessageEvent); - - // First reconnect: should force online-mode so Mojang can verify - com.velocitypowered.api.event.connection.PreLoginEvent firstAttempt = - mock(com.velocitypowered.api.event.connection.PreLoginEvent.class); - given(firstAttempt.getUsername()).willReturn("alice"); - bridge.onPreLogin(firstAttempt); - verify(firstAttempt).setResult(any()); - - // Mojang rejected the player (no onLogin fired) — second reconnect should cancel the pending - // request and NOT force online-mode, so the player can rejoin in offline mode - com.velocitypowered.api.event.connection.PreLoginEvent secondAttempt = - mock(com.velocitypowered.api.event.connection.PreLoginEvent.class); - given(secondAttempt.getUsername()).willReturn("alice"); - bridge.onPreLogin(secondAttempt); - verify(secondAttempt, never()).setResult(any()); - } - private static byte[] createChunkPayload(int seq, boolean last, String csv) { ByteArrayDataOutput output = ByteStreams.newDataOutput(); output.writeUTF("premium.list.chunk"); @@ -624,7 +526,7 @@ private static VelocityProxyConfiguration createConfiguration() { return new VelocityProxyConfiguration(Set.of("lobby"), false, true, "Authentication required.", true, false, "", true, Set.of("/login", "/register", "/l", "/reg", "/email", "/captcha", "/2fa", "/totp", "/log"), - true, "", "test-secret"); + true, "", "test-secret", false); } private static byte[] createAuthMePayload(String typeId, String playerName) { @@ -639,8 +541,9 @@ private static void assertPerformLoginPayload(byte[] payload, String expectedPla assertEquals("perform.login", in.readUTF()); assertEquals(expectedPlayerName, in.readUTF()); long timestamp = in.readLong(); + assertEquals("", in.readUTF()); String hmac = in.readUTF(); assertTrue(Math.abs(System.currentTimeMillis() - timestamp) < 5000L, "timestamp should be recent"); - assertEquals(ProxyMessageSecurity.computeHmac(sharedSecret, expectedPlayerName, timestamp), hmac); + assertEquals(ProxyMessageSecurity.computeHmac(sharedSecret, expectedPlayerName, timestamp, null), hmac); } } diff --git a/authme-velocity/src/test/java/fr/xephi/authme/velocity/premium/VelocityPremiumVerificationManagerTest.java b/authme-velocity/src/test/java/fr/xephi/authme/velocity/premium/VelocityPremiumVerificationManagerTest.java new file mode 100644 index 0000000000..0246461ded --- /dev/null +++ b/authme-velocity/src/test/java/fr/xephi/authme/velocity/premium/VelocityPremiumVerificationManagerTest.java @@ -0,0 +1,76 @@ +package fr.xephi.authme.velocity.premium; + +import com.velocitypowered.api.event.connection.PreLoginEvent; +import com.velocitypowered.api.event.player.GameProfileRequestEvent; +import com.velocitypowered.api.proxy.InboundConnection; +import com.velocitypowered.api.util.GameProfile; +import com.velocitypowered.api.util.UuidUtils; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; + +class VelocityPremiumVerificationManagerTest { + + @Test + void shouldForceOnlineModeForPremiumUser() { + VelocityPremiumVerificationManager manager = new VelocityPremiumVerificationManager( + mock(Logger.class), "alice"::equals, normalizedName -> false, () -> false); + + PreLoginEvent event = new PreLoginEvent(mock(InboundConnection.class), "Alice", null); + manager.onPreLogin(event); + + assertEquals(PreLoginEvent.PreLoginComponentResult.forceOnlineMode().toString(), + event.getResult().toString()); + } + + @Test + void shouldRewriteVerifiedProfileToOfflineUuid() { + VelocityPremiumVerificationManager manager = new VelocityPremiumVerificationManager( + mock(Logger.class), "alice"::equals, normalizedName -> false, () -> true); + UUID mojangUuid = UUID.fromString("8d6d0684-d8b4-4d40-8d2d-0dd4df5555c8"); + GameProfile originalProfile = new GameProfile(mojangUuid, "Alice", List.of()); + GameProfileRequestEvent event = new GameProfileRequestEvent( + mock(InboundConnection.class), originalProfile, true); + + manager.onGameProfileRequest(event); + + assertEquals(UuidUtils.generateOfflinePlayerUuid("Alice"), event.getGameProfile().getId()); + assertEquals(mojangUuid, manager.getVerifiedPremiumUuid("alice")); + } + + @Test + void shouldIgnoreOfflineModeProfileRequest() { + VelocityPremiumVerificationManager manager = new VelocityPremiumVerificationManager( + mock(Logger.class), "alice"::equals, normalizedName -> false, () -> true); + UUID mojangUuid = UUID.fromString("8d6d0684-d8b4-4d40-8d2d-0dd4df5555c8"); + GameProfile originalProfile = new GameProfile(mojangUuid, "Alice", List.of()); + GameProfileRequestEvent event = new GameProfileRequestEvent( + mock(InboundConnection.class), originalProfile, false); + + manager.onGameProfileRequest(event); + + assertEquals(mojangUuid, event.getGameProfile().getId()); + assertNull(manager.getVerifiedPremiumUuid("alice")); + } + + @Test + void shouldKeepMojangUuidWhenOfflineCompatibilityDisabled() { + VelocityPremiumVerificationManager manager = new VelocityPremiumVerificationManager( + mock(Logger.class), "alice"::equals, normalizedName -> false, () -> false); + UUID mojangUuid = UUID.fromString("8d6d0684-d8b4-4d40-8d2d-0dd4df5555c8"); + GameProfile originalProfile = new GameProfile(mojangUuid, "Alice", List.of()); + GameProfileRequestEvent event = new GameProfileRequestEvent( + mock(InboundConnection.class), originalProfile, true); + + manager.onGameProfileRequest(event); + + assertEquals(mojangUuid, event.getGameProfile().getId()); + assertEquals(mojangUuid, manager.getVerifiedPremiumUuid("alice")); + } +} diff --git a/docs/premium.md b/docs/premium.md index 8efb1b94ad..73eec4d01c 100644 --- a/docs/premium.md +++ b/docs/premium.md @@ -7,23 +7,47 @@ dialog box. ## Requirements -- **PacketEvents** must be installed as a separate plugin on the server, **unless** you - are using proxy mode (see [Behind a proxy](#behind-a-proxy)). - Without PacketEvents in direct-connection mode, premium bypass is disabled at startup - (AuthMe logs a warning and falls back to normal password authentication for everyone). -- For direct connections: an **offline-mode server** that clients reach without a proxy. +- **Direct connections (no proxy):** + - the backend must run in **offline mode** + - **PacketEvents** must be installed on the backend server +- **Behind a proxy:** + - install the matching AuthMe proxy plugin: + - **authme-velocity** for Velocity + - **authme-bungee** for BungeeCord / Waterfall + - set `Hooks.bungeecord: true` on every backend + - set the same shared secret in the proxy config and in `Hooks.proxySharedSecret` on every backend + - choose the proxy UUID mode with `premium.keepOfflineUuidCompatibility`: + - `false` (**default**) keeps the Mojang UUID on the backend + - `true` preserves the backend offline UUID v3 for plugin compatibility + +Without the required premium-verification component for your topology, AuthMe fails closed +and falls back to normal password authentication. ## Setup -### 1. Install PacketEvents +### 1. Install the required verification component -Download **PacketEvents 2.x** and drop the jar into your `plugins/` folder. +- **Direct backend only:** install **PacketEvents 2.x** in `plugins/` +- **Velocity proxy:** install **AuthMe Velocity** +- **BungeeCord / Waterfall proxy:** install **AuthMe Bungee** + - install **PacketEvents 2.x** on the proxy only if `premium.keepOfflineUuidCompatibility: true` ### 2. Enable premium mode in `config.yml` ```yaml settings: enablePremium: true + +Hooks: + # Required only when using AuthMe behind Velocity/Bungee + bungeecord: true + proxySharedSecret: "same-secret-as-proxy" +``` + +```yaml +# Proxy config (Velocity or Bungee) +premium: + keepOfflineUuidCompatibility: false ``` ### 3. Enroll players @@ -91,12 +115,13 @@ UI shown, no password field displayed. ## Behind a proxy -### Online-mode proxy (Velocity / BungeeCord online-mode) +### AuthMe proxy plugin flow + +For premium mode behind a proxy, use the matching AuthMe proxy plugin instead of relying on +plain proxy UUID forwarding. -When Velocity or BungeeCord runs in **online mode**, the proxy authenticates players with -Mojang before they reach the backend. The proxy then forwards the real Mojang UUID to the -backend via IP forwarding. AuthMe uses that forwarded UUID directly — no PacketEvents -required, no synthetic `ENCRYPTION_REQUEST` sent. +The proxy verifies premium players first, then forwards a **signed** premium claim to the +backend. The backend UUID behavior depends on `premium.keepOfflineUuidCompatibility`. **Backend configuration:** @@ -105,38 +130,61 @@ settings: enablePremium: true Hooks: - bungeecord: true # trust the UUID forwarded by the proxy + bungeecord: true + proxySharedSecret: "same-secret-as-proxy" ``` -**Proxy configuration requirements:** +**Per-proxy behavior:** -| Proxy | Required settings | -|---|---| -| Velocity | `player-info-forwarding-mode: MODERN` in `velocity.toml` + shared secret in `paper-global.yml` (or equivalent) | -| BungeeCord | `ip_forward: true` in BungeeCord `config.yml` + `bungeecord: true` in backend `spigot.yml` | +| Proxy | `premium.keepOfflineUuidCompatibility: false` (default) | `premium.keepOfflineUuidCompatibility: true` | +|---|---|---| +| Velocity | Native per-player online-mode login, Mojang UUID forwarded to backend | Native per-player online-mode login + rewrite back to offline UUID v3 | +| BungeeCord / Waterfall | Local per-player online-mode handshake, Mojang UUID forwarded to backend | PacketEvents login-phase verification, then resume offline login with offline UUID v3 | -> **Security:** with `Hooks.bungeecord: true` the backend trusts the UUID forwarded by the proxy. -> The backend port **must** be firewalled so only the proxy can reach it — otherwise -> anyone could connect directly with an arbitrary UUID and bypass authentication. +**Requirements by mode:** -PacketEvents is **not** required in this configuration. +| Mode | Velocity | BungeeCord / Waterfall | +|---|---|---| +| `false` | No PacketEvents required | No PacketEvents required | +| `true` | No PacketEvents required | PacketEvents required on the proxy | -### Offline-mode proxy with AuthMe proxy plugin +**What the backend trusts:** -When the proxy runs in **offline mode**, install the matching AuthMe proxy plugin on the proxy: +1. the `perform.login` message must have a valid HMAC using `Hooks.proxySharedSecret` +2. the optional Mojang UUID inside that message must match either: + - the player's stored premium UUID, or + - the pending premium enrollment being finalized -- **authme-velocity** for Velocity -- **authme-bungee** for BungeeCord +If either check fails, the premium auto-login request is rejected. -These plugins maintain a list of premium-enrolled players and force per-player Mojang -authentication for them via `PreLoginEvent`. The proxy then forwards the verified Mojang UUID -to the backend the same way as in online-mode proxy setup. Set `Hooks.bungeecord: true` on the backend. +**Premium cache synchronization:** -The premium player list is synchronised automatically: - When the proxy plugin starts, the backend sends the full list of enrolled premium usernames. - When a player runs `/premium` or `/authme premium `, the backend notifies the proxy immediately so the cache stays up to date. +> **Security:** `Hooks.bungeecord: true` enables proxy-backed login handling, so backend ports +> must only be reachable by the proxy. Do not expose backend servers directly to players. + +### Choosing the backend UUID mode + +`premium.keepOfflineUuidCompatibility` is a **proxy-side feature flag**: + +- `false` (**default**): premium players keep their **Mojang UUID v4** on the backend +- `true`: premium players keep the backend **offline UUID v3** while the proxy still proves their premium identity + +Use `false` if you want the simplest premium proxy flow. Use `true` only when backend-side +plugin compatibility requires offline UUID semantics. + +### Plain online-mode proxy forwarding + +If you run a proxy in normal online mode **without** the AuthMe proxy plugin, the backend sees +the forwarded Mojang UUID from the proxy. + +That setup can work for general proxy forwarding, but it does **not** preserve the backend UUID +on the offline v3 value. If you need premium bypass **and** backend plugin compatibility based on +offline UUIDs, use the AuthMe proxy plugin flow above instead. + --- ## Configuration reference @@ -146,10 +194,14 @@ settings: # Enable premium mode: players with an official Minecraft account # can skip password authentication. # Verification method is chosen automatically: - # - online-mode=true: Bukkit already has the Mojang UUID; no PacketEvents needed. - # - offline-mode + proxy: set Hooks.bungeecord=true; UUID is forwarded by proxy. - # - offline-mode, no proxy: PacketEvents required for cryptographic verification. - # Without PacketEvents, premium auto-login is disabled (fail closed). + # - direct offline-mode backend: PacketEvents verifies the Mojang session. + # - behind AuthMe Velocity/Bungee proxy: the proxy verifies premium players + # and sends a signed premium claim. + # premium.keepOfflineUuidCompatibility=false (default) keeps the Mojang UUID. + # premium.keepOfflineUuidCompatibility=true keeps the backend offline UUID. + # - plain online-mode proxy forwarding also forwards the Mojang UUID, but + # without the AuthMe proxy plugin's signed premium flow. + # If verification is unavailable, premium auto-login is disabled (fail closed). # Players must use /premium to opt in. enablePremium: false ``` @@ -171,7 +223,9 @@ settings: **Q: Can I use this on an online-mode server?** A: Online-mode servers already enforce Mojang authentication at the server level — you do not -need AuthMe's premium bypass at all. AuthMe is primarily designed for offline-mode servers. +need AuthMe's premium bypass at all. AuthMe is primarily designed for offline-mode servers, or +for proxy setups where the proxy verifies premium identity but the backend still keeps offline +UUID semantics. **Q: What happens if Mojang's session server is down?** A: The Minecraft client must contact `sessionserver.mojang.com/session/minecraft/join` before @@ -187,6 +241,8 @@ player name, the player may need to be re-enrolled with `/authme premium` after depending on your account-linking configuration. **Q: Can a non-premium player impersonate a premium player?** -A: No. The verify-token check and the `hasJoined` call together ensure that only a client -which actually holds the Mojang session for that account can complete the handshake. An -attacker who merely knows the username cannot forge the encrypted shared secret. +A: No. In direct mode, the verify-token check and the `hasJoined` call ensure that only a client +which actually holds the Mojang session for that account can complete the handshake. Behind a +proxy, the backend additionally requires a valid HMAC-signed `perform.login` payload and refuses +any premium UUID that does not match stored or pending premium state. An attacker who merely knows +the username cannot forge those checks. diff --git a/docs/proxies/bungee/config.yml b/docs/proxies/bungee/config.yml index 113ce16669..03afb8f22a 100644 --- a/docs/proxies/bungee/config.yml +++ b/docs/proxies/bungee/config.yml @@ -1,5 +1,5 @@ # AUTO-GENERATED FILE! Do not edit this directly -# File auto-generated on Wed May 06 00:22:42 CEST 2026. See authme-tools/src/test/java/tools/docs/proxies/bungee/config.tpl.yml +# File auto-generated on Wed May 13 01:10:04 CEST 2026. See authme-tools/src/test/java/tools/docs/proxies/bungee/config.tpl.yml # # Reference configuration for the native AuthMe Bungee proxy plugin. # The live file is created in plugins/AuthMeBungee/config.yml. @@ -43,4 +43,9 @@ loginServer: '' # Generated automatically on first start — copy this value to the Hooks.proxySharedSecret # setting of every backend server running AuthMe. proxySharedSecret: '' +premium: + # Keep verified premium players on the backend offline UUID for plugin compatibility. + # When false, premium players keep their Mojang UUID on the backend instead. + # False uses Bungee's local online-mode handshake path and does not require PacketEvents. + keepOfflineUuidCompatibility: false diff --git a/docs/proxies/configuration.md b/docs/proxies/configuration.md index c8c7ef04c7..e1ed812d74 100644 --- a/docs/proxies/configuration.md +++ b/docs/proxies/configuration.md @@ -209,6 +209,29 @@ chatRequiresAuth: true --- +### Premium UUID compatibility — `premium.keepOfflineUuidCompatibility` + +Controls how the AuthMe proxy plugins expose **premium** players to backend servers. + +```yaml +premium: + keepOfflineUuidCompatibility: false # default +``` + +- `false` (**default**) — premium players keep their **Mojang UUID v4** on the backend +- `true` — premium players keep the backend **offline UUID v3** for plugin compatibility + +Behavior by proxy: + +| Proxy | `false` | `true` | +|---|---|---| +| Velocity | Native per-player online mode, Mojang UUID forwarded | Same native login flow, but the final profile is rewritten back to offline UUID | +| BungeeCord / Waterfall | Local per-player online-mode handshake, Mojang UUID forwarded | PacketEvents login-phase verification, then resume offline login | + +PacketEvents is only required on the **Bungee** proxy when this setting is `true`. + +--- + ## Multi-proxy setup Some networks run multiple proxy instances behind a load balancer (e.g., two Velocity nodes behind HAProxy or TCPShield). The following explains what works automatically and what requires manual coordination. diff --git a/docs/proxies/velocity/config.yml b/docs/proxies/velocity/config.yml index b522874de0..1d4dd99641 100644 --- a/docs/proxies/velocity/config.yml +++ b/docs/proxies/velocity/config.yml @@ -1,5 +1,5 @@ # AUTO-GENERATED FILE! Do not edit this directly -# File auto-generated on Wed May 06 00:22:42 CEST 2026. See authme-tools/src/test/java/tools/docs/proxies/velocity/config.tpl.yml +# File auto-generated on Wed May 13 01:10:04 CEST 2026. See authme-tools/src/test/java/tools/docs/proxies/velocity/config.tpl.yml # # Reference configuration for the native AuthMe Velocity proxy plugin. # The live file is created in plugins/authmevelocity/config.yml. @@ -43,4 +43,9 @@ loginServer: '' # Generated automatically on first start — copy this value to the proxySharedSecret # setting of every backend server running AuthMe. proxySharedSecret: '' +premium: + # Keep verified premium players on the backend offline UUID for plugin compatibility. + # When false, premium players keep their Mojang UUID on the backend instead. + # Velocity can do both modes natively; false is the default. + keepOfflineUuidCompatibility: false diff --git a/pom.xml b/pom.xml index a6dd20a34c..d2cb082bba 100644 --- a/pom.xml +++ b/pom.xml @@ -105,6 +105,7 @@ 3.4.0 1.21-R0.4 + 2.12.1 1.16.5-R0.1-SNAPSHOT 2.25.4 @@ -628,7 +629,19 @@ com.github.retrooper packetevents-spigot - 2.12.1 + ${dependencies.packetevents.version} + provided + + + com.github.retrooper + packetevents-bungeecord + ${dependencies.packetevents.version} + provided + + + com.github.retrooper + packetevents-velocity + ${dependencies.packetevents.version} provided From 8afb802d3aa604b21357a3ee5b99bd65bca970ad Mon Sep 17 00:00:00 2001 From: Xephi Date: Wed, 13 May 2026 15:51:56 +0200 Subject: [PATCH 2/3] fix(premium): proxy premium list has to normalize names --- .../authme/bungee/BungeeProxyBridge.java | 4 ++-- .../authme/bungee/BungeeProxyBridgeTest.java | 17 +++++++++++++++ .../service/bungeecord/BungeeSender.java | 8 +++++-- .../service/bungeecord/BungeeSenderTest.java | 17 +++++++++++++++ .../authme/velocity/VelocityProxyBridge.java | 4 ++-- .../velocity/VelocityProxyBridgeTest.java | 21 +++++++++++++++++++ 6 files changed, 65 insertions(+), 6 deletions(-) diff --git a/authme-bungee/src/main/java/fr/xephi/authme/bungee/BungeeProxyBridge.java b/authme-bungee/src/main/java/fr/xephi/authme/bungee/BungeeProxyBridge.java index 71b5e1d11f..d8a2987f18 100644 --- a/authme-bungee/src/main/java/fr/xephi/authme/bungee/BungeeProxyBridge.java +++ b/authme-bungee/src/main/java/fr/xephi/authme/bungee/BungeeProxyBridge.java @@ -228,7 +228,7 @@ public void onPluginMessage(PluginMessageEvent event) { if (!parsedMessage.playerName().isEmpty()) { for (String name : parsedMessage.playerName().split(",")) { if (!name.isEmpty()) { - newPremiumSet.add(name.trim()); + newPremiumSet.add(normalizeName(name.trim())); } } } @@ -247,7 +247,7 @@ public void onPluginMessage(PluginMessageEvent event) { if (!csv.isEmpty()) { for (String name : csv.split(",")) { if (!name.isEmpty()) { - premiumListBuffer.add(name.trim()); + premiumListBuffer.add(normalizeName(name.trim())); } } } diff --git a/authme-bungee/src/test/java/fr/xephi/authme/bungee/BungeeProxyBridgeTest.java b/authme-bungee/src/test/java/fr/xephi/authme/bungee/BungeeProxyBridgeTest.java index 5138b82ebb..0a9003f9d3 100644 --- a/authme-bungee/src/test/java/fr/xephi/authme/bungee/BungeeProxyBridgeTest.java +++ b/authme-bungee/src/test/java/fr/xephi/authme/bungee/BungeeProxyBridgeTest.java @@ -463,6 +463,23 @@ void shouldForceOnlineModeForPremiumHandshakeWhenOfflineCompatibilityDisabled() verify(pendingConnection).setOnlineMode(true); } + @Test + void shouldForceOnlineModeForPremiumHandshakeAfterChunkedPremiumListResync() { + given(pluginMessageEvent.isCancelled()).willReturn(false); + given(pluginMessageEvent.getTag()).willReturn(BungeeProxyBridge.AUTHME_CHANNEL); + given(pluginMessageEvent.getSender()).willReturn(sourceServer); + given(pluginMessageEvent.getData()).willReturn(createChunkPayload(0, true, "Alice")); + given(playerHandshakeEvent.getConnection()).willReturn(pendingConnection); + given(pendingConnection.getName()).willReturn("Alice"); + given(pendingConnection.isOnlineMode()).willReturn(false); + + BungeeProxyBridge bridge = new BungeeProxyBridge(proxyServer, logger, createConfiguration(), new BungeeAuthenticationStore()); + bridge.onPluginMessage(pluginMessageEvent); + bridge.onPlayerHandshake(playerHandshakeEvent); + + verify(pendingConnection).setOnlineMode(true); + } + private static byte[] createChunkPayload(int seq, boolean last, String csv) { ByteArrayDataOutput output = ByteStreams.newDataOutput(); output.writeUTF("premium.list.chunk"); diff --git a/authme-core/src/main/java/fr/xephi/authme/service/bungeecord/BungeeSender.java b/authme-core/src/main/java/fr/xephi/authme/service/bungeecord/BungeeSender.java index 799a2477a2..7861a11417 100644 --- a/authme-core/src/main/java/fr/xephi/authme/service/bungeecord/BungeeSender.java +++ b/authme-core/src/main/java/fr/xephi/authme/service/bungeecord/BungeeSender.java @@ -5,6 +5,7 @@ import fr.xephi.authme.AuthMe; import java.util.List; +import java.util.Locale; import java.util.Optional; import fr.xephi.authme.ConsoleLogger; import fr.xephi.authme.initialization.SettingsDependent; @@ -146,8 +147,11 @@ public void sendPremiumList(Player carrier, List premiumUsernames) { if (!isEnabled || !plugin.isEnabled()) { return; } + List normalizedUsernames = premiumUsernames.stream() + .map(name -> name.toLowerCase(Locale.ROOT)) + .toList(); int chunkSize = 1000; - int total = premiumUsernames.size(); + int total = normalizedUsernames.size(); if (total == 0) { sendPremiumListChunk(carrier, 0, true, ""); return; @@ -156,7 +160,7 @@ public void sendPremiumList(Player carrier, List premiumUsernames) { for (int i = 0; i < numChunks; i++) { int fromIndex = i * chunkSize; int toIndex = Math.min(fromIndex + chunkSize, total); - String csv = String.join(",", premiumUsernames.subList(fromIndex, toIndex)); + String csv = String.join(",", normalizedUsernames.subList(fromIndex, toIndex)); sendPremiumListChunk(carrier, i, i == numChunks - 1, csv); } } diff --git a/authme-core/src/test/java/fr/xephi/authme/service/bungeecord/BungeeSenderTest.java b/authme-core/src/test/java/fr/xephi/authme/service/bungeecord/BungeeSenderTest.java index 63e92dd164..f76c0b40f8 100644 --- a/authme-core/src/test/java/fr/xephi/authme/service/bungeecord/BungeeSenderTest.java +++ b/authme-core/src/test/java/fr/xephi/authme/service/bungeecord/BungeeSenderTest.java @@ -126,6 +126,23 @@ void shouldSendSingleChunkForSmallPremiumList() { assertTrue(field.startsWith("0:1:"), "Expected single last chunk but got: " + field); } + @Test + void shouldNormalizePremiumListUsernamesToLowercase() { + given(settings.getProperty(HooksSettings.BUNGEECORD)).willReturn(true); + given(settings.getProperty(HooksSettings.BUNGEECORD_SERVER)).willReturn(""); + given(messenger.isOutgoingChannelRegistered(plugin, "BungeeCord")).willReturn(true); + given(messenger.isOutgoingChannelRegistered(plugin, "authme:main")).willReturn(true); + given(plugin.isEnabled()).willReturn(true); + + BungeeSender sender = new BungeeSender(plugin, bukkitService, settings); + sender.sendPremiumList(carrier, List.of("Alice", "BOB")); + + verify(bukkitService).sendAuthMePluginMessage(eq(carrier), payloadCaptor.capture()); + ByteArrayDataInput in = ByteStreams.newDataInput(payloadCaptor.getValue()); + assertEquals("premium.list.chunk", in.readUTF()); + assertEquals("0:1:alice,bob", in.readUTF()); + } + @Test void shouldSendTwoChunksFor1001Names() { given(settings.getProperty(HooksSettings.BUNGEECORD)).willReturn(true); diff --git a/authme-velocity/src/main/java/fr/xephi/authme/velocity/VelocityProxyBridge.java b/authme-velocity/src/main/java/fr/xephi/authme/velocity/VelocityProxyBridge.java index 3d6ecef1b8..bec3ef17cd 100644 --- a/authme-velocity/src/main/java/fr/xephi/authme/velocity/VelocityProxyBridge.java +++ b/authme-velocity/src/main/java/fr/xephi/authme/velocity/VelocityProxyBridge.java @@ -231,7 +231,7 @@ void onPluginMessage(PluginMessageEvent event) { if (!parsedMessage.playerName().isEmpty()) { for (String name : parsedMessage.playerName().split(",")) { if (!name.isEmpty()) { - newPremiumSet.add(name.trim()); + newPremiumSet.add(normalizeName(name.trim())); } } } @@ -250,7 +250,7 @@ void onPluginMessage(PluginMessageEvent event) { if (!csv.isEmpty()) { for (String name : csv.split(",")) { if (!name.isEmpty()) { - premiumListBuffer.add(name.trim()); + premiumListBuffer.add(normalizeName(name.trim())); } } } diff --git a/authme-velocity/src/test/java/fr/xephi/authme/velocity/VelocityProxyBridgeTest.java b/authme-velocity/src/test/java/fr/xephi/authme/velocity/VelocityProxyBridgeTest.java index f9c04b977f..ab057ebf05 100644 --- a/authme-velocity/src/test/java/fr/xephi/authme/velocity/VelocityProxyBridgeTest.java +++ b/authme-velocity/src/test/java/fr/xephi/authme/velocity/VelocityProxyBridgeTest.java @@ -4,6 +4,7 @@ import com.google.common.io.ByteStreams; import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.event.command.CommandExecuteEvent; +import com.velocitypowered.api.event.connection.PreLoginEvent; import com.velocitypowered.api.event.connection.DisconnectEvent; import com.velocitypowered.api.event.connection.PluginMessageEvent; import com.velocitypowered.api.event.player.PlayerChatEvent; @@ -12,6 +13,7 @@ import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.ProxyServer; import com.velocitypowered.api.proxy.ConnectionRequestBuilder; +import com.velocitypowered.api.proxy.InboundConnection; import com.velocitypowered.api.proxy.ServerConnection; import com.velocitypowered.api.proxy.messages.ChannelRegistrar; import com.velocitypowered.api.proxy.server.RegisteredServer; @@ -515,6 +517,25 @@ void shouldNotBlockChatIfChatRequiresAuthIsDisabled() { verify(chatEvent, never()).setResult(any()); } + @Test + void shouldForceOnlineModeAfterChunkedPremiumListResync() { + given(pluginMessageEvent.getResult()).willReturn(PluginMessageEvent.ForwardResult.forward()); + given(pluginMessageEvent.getIdentifier()).willReturn(VelocityProxyBridge.AUTHME_CHANNEL); + given(pluginMessageEvent.getSource()).willReturn(sourceConnection); + given(pluginMessageEvent.getData()).willReturn(createChunkPayload(0, true, "Alice")); + given(sourceConnection.getServer()).willReturn(authServer); + given(authServer.getServerInfo()).willReturn(authServerInfo); + given(authServerInfo.getName()).willReturn("lobby"); + + VelocityProxyBridge bridge = new VelocityProxyBridge(proxyServer, logger, createConfiguration(), new VelocityAuthenticationStore()); + bridge.onPluginMessage(pluginMessageEvent); + + PreLoginEvent event = new PreLoginEvent(mock(InboundConnection.class), "Alice", null); + bridge.onPreLogin(event); + + assertEquals(PreLoginEvent.PreLoginComponentResult.forceOnlineMode().toString(), event.getResult().toString()); + } + private static byte[] createChunkPayload(int seq, boolean last, String csv) { ByteArrayDataOutput output = ByteStreams.newDataOutput(); output.writeUTF("premium.list.chunk"); From a97cd0f22d8cd032461f13812905c00d0ce2b64a Mon Sep 17 00:00:00 2001 From: Xephi Date: Tue, 19 May 2026 08:41:56 +0200 Subject: [PATCH 3/3] fix: Non empty sharedSecret --- .../xephi/authme/service/bungeecord/BungeeReceiver.java | 5 +++++ .../xephi/authme/settings/properties/HooksSettings.java | 3 ++- docs/config.md | 8 +++++--- docs/proxies/configuration.md | 9 ++++++--- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/authme-core/src/main/java/fr/xephi/authme/service/bungeecord/BungeeReceiver.java b/authme-core/src/main/java/fr/xephi/authme/service/bungeecord/BungeeReceiver.java index d258bf48e1..49edec9074 100644 --- a/authme-core/src/main/java/fr/xephi/authme/service/bungeecord/BungeeReceiver.java +++ b/authme-core/src/main/java/fr/xephi/authme/service/bungeecord/BungeeReceiver.java @@ -156,6 +156,11 @@ public void onPluginMessageReceived(String channel, Player player, byte[] data) } private boolean verifyHmac(String playerName, long timestamp, UUID verifiedPremiumUuid, String providedHmac) { + if (proxySharedSecret.isEmpty()) { + logger.warning("Rejected perform.login for " + playerName + + ": Hooks.proxySharedSecret is not configured — set the same secret on all backend servers and the proxy"); + return false; + } if (Math.abs(System.currentTimeMillis() - timestamp) > MAX_AGE_MILLIS) { logger.warning("Rejected perform.login for " + playerName + ": message has expired"); return false; diff --git a/authme-core/src/main/java/fr/xephi/authme/settings/properties/HooksSettings.java b/authme-core/src/main/java/fr/xephi/authme/settings/properties/HooksSettings.java index f3e8a35eca..bbdf84b2b1 100644 --- a/authme-core/src/main/java/fr/xephi/authme/settings/properties/HooksSettings.java +++ b/authme-core/src/main/java/fr/xephi/authme/settings/properties/HooksSettings.java @@ -26,7 +26,8 @@ public final class HooksSettings implements SettingsHolder { @Comment({ "Shared secret used to verify perform.login messages from the AuthMe proxy plugin.", "Must match the proxySharedSecret value in your AuthMe Velocity or Bungee proxy config.", - "All backend servers must have the same value set here." + "All backend servers must have the same value set here.", + "Leaving this empty disables auto-login: all perform.login messages will be rejected." }) public static final Property PROXY_SHARED_SECRET = newProperty("Hooks.proxySharedSecret", ""); diff --git a/docs/config.md b/docs/config.md index 4207d9c5b9..3018345f75 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,5 +1,5 @@ - + ## AuthMe Configuration The first time you run AuthMe it will create a config.yml file in the plugins/AuthMe folder, @@ -234,7 +234,8 @@ settings: displayOtherAccounts: true # Spawn priority; values: authme, essentials, cmi, multiverse, default, server # Use "server" to apply the world's spawnRadius gamerule (players land at a random position - # within the configured radius around the world spawn). Use "default" for the exact world spawn. + # within the configured radius around the world spawn). + # Use "default" for the exact world spawn. spawnPriority: authme,essentials,cmi,multiverse,default # Maximum Login authorized by IP maxLoginPerIp: 0 @@ -470,6 +471,7 @@ Hooks: # Shared secret used to verify perform.login messages from the AuthMe proxy plugin. # Must match the proxySharedSecret value in your AuthMe Velocity or Bungee proxy config. # All backend servers must have the same value set here. + # Leaving this empty disables auto-login: all perform.login messages will be rejected. proxySharedSecret: '' # Do we need to disable Essentials SocialSpy on join? disableSocialSpy: false @@ -647,4 +649,4 @@ To change settings on a running server, save your changes to config.yml and use --- -This page was automatically generated on the [AuthMe/AuthMeReloaded repository](https://github.com/AuthMe/AuthMeReloaded/tree/master/docs/) on Wed May 06 11:56:53 CEST 2026 +This page was automatically generated on the [AuthMe/AuthMeReloaded repository](https://github.com/AuthMe/AuthMeReloaded/tree/master/docs/) on Tue May 19 08:22:43 CEST 2026 diff --git a/docs/proxies/configuration.md b/docs/proxies/configuration.md index e1ed812d74..5cc3aa6ef3 100644 --- a/docs/proxies/configuration.md +++ b/docs/proxies/configuration.md @@ -77,8 +77,8 @@ Hooks: bungeecord: true # Shared secret — must match proxySharedSecret in the proxy plugin config. - # Leave empty only for testing; always set in production. - proxySharedSecret: "" + # Must not be empty; see "Setting up the shared secret" below. + proxySharedSecret: "copy-from-proxy-config" # Optional: redirect players to this backend after login/register. # Leave empty to keep players on the current server. @@ -114,7 +114,7 @@ The shared secret prevents malicious backend servers (or other plugins) from for 2. Copy that value to `Hooks.proxySharedSecret` in the AuthMe `config.yml` of **every backend server**. 3. Restart all backend servers (or run `/authme reload`). -If `Hooks.proxySharedSecret` is left empty on a backend, HMAC verification still runs — using an empty string as the key. This means the backend will only accept `perform.login` messages signed with the same empty key, which provides no real security. Always set a real secret in production. +If `Hooks.proxySharedSecret` is left empty on a backend, all `perform.login` messages are rejected with a warning — the backend logs `Hooks.proxySharedSecret is not configured` and refuses to auto-authenticate the player. Always copy the proxy-generated secret to every backend before enabling auto-login. --- @@ -332,6 +332,9 @@ Changes to the proxy config file can be applied without restarting the proxy: - Check `Hooks.bungeecord: true` in the backend AuthMe config. - Check `bungeecord: true` in `spigot.yml` (or Paper equivalent) — plugin messaging won't work without it. +**`Hooks.proxySharedSecret is not configured`** in backend logs +- `Hooks.proxySharedSecret` is empty in the backend `config.yml`. Copy the value from the proxy config and run `/authme reload`. + **`Rejected perform.login for : invalid HMAC`** in backend logs - The `proxySharedSecret` on this backend does not match the one in the proxy config. Copy it again and reload.