From 923de3d8fda53684caaa6a308b772fbef2c9ee45 Mon Sep 17 00:00:00 2001 From: leoaxo098 Date: Fri, 30 Oct 2026 21:30:56 +0700 Subject: [PATCH 1/5] fix: support AuthMe pre-join dialog logins Handle AuthMe login dialogs before Paper completes the player join. Validate proxy replies against the connection challenge, account name, and allowed proxy list; preserve registration and auto-login checks. --- bukkit/pom.xml | 10 +- .../fastlogin/bukkit/BukkitLoginSession.java | 9 + .../fastlogin/bukkit/hook/AuthMeHook.java | 54 +++++ .../bukkit/hook/AuthMePreJoinListener.java | 182 ++++++++++++++++ .../bukkit/listener/BungeeListener.java | 5 + .../fastlogin/bukkit/task/ForceLoginTask.java | 7 +- .../fastlogin/bukkit/hook/AuthMeHookTest.java | 81 ++++++++ .../hook/AuthMePreJoinListenerTest.java | 195 ++++++++++++++++++ .../bukkit/task/PreJoinLoginEventTest.java | 52 +++++ .../fastlogin/bungee/FastLoginBungee.java | 2 + .../listener/PluginMessageListener.java | 31 +++ .../listener/PreJoinMessageListenerTest.java | 115 +++++++++++ .../core/message/PreJoinMessage.java | 84 ++++++++ .../core/message/PreJoinMessageTest.java | 62 ++++++ .../fastlogin/velocity/FastLoginVelocity.java | 2 + .../listener/PluginMessageListener.java | 30 +++ .../listener/PreJoinMessageListenerTest.java | 118 +++++++++++ 17 files changed, 1037 insertions(+), 2 deletions(-) create mode 100644 bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListener.java create mode 100644 bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHookTest.java create mode 100644 bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListenerTest.java create mode 100644 bukkit/src/test/java/com/github/games647/fastlogin/bukkit/task/PreJoinLoginEventTest.java create mode 100644 bungee/src/test/java/com/github/games647/fastlogin/bungee/listener/PreJoinMessageListenerTest.java create mode 100644 core/src/main/java/com/github/games647/fastlogin/core/message/PreJoinMessage.java create mode 100644 core/src/test/java/com/github/games647/fastlogin/core/message/PreJoinMessageTest.java create mode 100644 velocity/src/test/java/com/github/games647/fastlogin/velocity/listener/PreJoinMessageListenerTest.java diff --git a/bukkit/pom.xml b/bukkit/pom.xml index fbd469303..2a6e7c11b 100644 --- a/bukkit/pom.xml +++ b/bukkit/pom.xml @@ -168,7 +168,8 @@ io.papermc.paper paper-api - 1.21.6-R0.1-SNAPSHOT + + 1.21.11-R0.1-SNAPSHOT provided @@ -398,5 +399,12 @@ 1.80 test + + + org.apache.logging.log4j + log4j-api + 2.24.1 + test + diff --git a/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/BukkitLoginSession.java b/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/BukkitLoginSession.java index 8194e4e13..a29ac8337 100644 --- a/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/BukkitLoginSession.java +++ b/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/BukkitLoginSession.java @@ -47,6 +47,7 @@ public class BukkitLoginSession extends LoginSession { private final ClientPublicKey clientPublicKey; private boolean verified; + private Boolean preJoinAutoLoginAllowed; private SkinProperty skinProperty; public BukkitLoginSession(String username, byte[] verifyToken, ClientPublicKey publicKey, boolean registered, @@ -120,4 +121,12 @@ public synchronized void setVerifiedPremium(boolean verified) { public synchronized boolean isVerifiedPremium() { return verified; } + + public synchronized Boolean getPreJoinAutoLoginAllowed() { + return preJoinAutoLoginAllowed; + } + + public synchronized void setPreJoinAutoLoginAllowed(boolean allowed) { + this.preJoinAutoLoginAllowed = allowed; + } } diff --git a/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHook.java b/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHook.java index 9a906eeba..527c4795a 100644 --- a/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHook.java +++ b/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHook.java @@ -28,6 +28,8 @@ import com.github.games647.fastlogin.bukkit.BukkitLoginSession; import com.github.games647.fastlogin.bukkit.FastLoginBukkit; import com.github.games647.fastlogin.core.hooks.AuthPlugin; +import com.github.games647.fastlogin.core.message.NamespaceKey; +import com.github.games647.fastlogin.core.message.PreJoinMessage; import fr.xephi.authme.api.v3.AuthMeApi; import fr.xephi.authme.events.RestoreSessionEvent; import fr.xephi.authme.process.Management; @@ -39,6 +41,7 @@ import org.bukkit.event.Listener; import java.lang.reflect.Field; +import java.lang.reflect.Method; /** * GitHub: ... @@ -56,6 +59,9 @@ public class AuthMeHook implements AuthPlugin, Listener { private final AuthMeApi authmeAPI; private Management authmeManagement; + // AuthMe 6.0+ pre-join dialog support: forceLoginFromProxy(String) on the API + private Method forceLoginFromProxyMethod; + public AuthMeHook(FastLoginBukkit plugin) { this.plugin = plugin; this.authmeAPI = AuthMeApi.getInstance(); @@ -69,6 +75,40 @@ public AuthMeHook(FastLoginBukkit plugin) { this.authmeManagement = null; } } + + // Detect AuthMe 6.0+ forceLoginFromProxy(String) API for pre-join dialog compatibility + try { + forceLoginFromProxyMethod = authmeAPI.getClass().getMethod("forceLoginFromProxy", String.class); + enablePreJoinSupport(); + } catch (NoSuchMethodException ignored) { + forceLoginFromProxyMethod = null; + } + } + + private void enablePreJoinSupport() { + try { + Class.forName("io.papermc.paper.event.connection.configuration.AsyncPlayerConnectionConfigureEvent"); + Class connection = Class.forName("io.papermc.paper.connection.PlayerConnection"); + connection.getMethod("isConnected"); + Class.forName("io.papermc.paper.connection.PlayerConfigurationConnection") + .getMethod("sendPluginMessage", org.bukkit.plugin.Plugin.class, String.class, byte[].class); + org.bukkit.plugin.messaging.PluginMessageListener.class + .getMethod("onPluginMessageReceived", String.class, connection, byte[].class); + AuthMePreJoinListener listener = new AuthMePreJoinListener(plugin, this); + plugin.getServer().getPluginManager().registerEvents(listener, plugin); + if (plugin.getBungeeManager().isEnabled()) { + String channel = NamespaceKey.getCombined(plugin.getName(), PreJoinMessage.CHANNEL); + plugin.getServer().getMessenger().registerIncomingPluginChannel(plugin, channel, listener); + plugin.getServer().getMessenger().registerOutgoingPluginChannel(plugin, channel); + } + plugin.getLog().info("Enabled AuthMe pre-join dialog compatibility"); + } catch (ClassNotFoundException | NoSuchMethodException ignored) { + // Older Paper and Spigot keep the ordinary post-join flow. + } + } + + public void forceLoginBeforeJoin(String playerName) throws ReflectiveOperationException { + forceLoginFromProxyMethod.invoke(authmeAPI, playerName); } @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) @@ -84,10 +124,24 @@ public void onSessionRestore(RestoreSessionEvent restoreSessionEvent) { @Override public boolean forceLogin(Player player) { if (authmeAPI.isAuthenticated(player)) { + // AuthMe may have consumed the pre-join request before our delayed task runs. + if (forceLoginFromProxyMethod != null) { + return true; + } plugin.getLog().warn(ALREADY_AUTHENTICATED, player); return false; } + if (plugin.getBungeeManager().isEnabled() && forceLoginFromProxyMethod != null) { + try { + forceLoginBeforeJoin(player.getName()); + return true; + } catch (ReflectiveOperationException ex) { + plugin.getLog().warn("Failed to invoke AuthMe forceLoginFromProxy for {}. " + + "Falling back to standard forceLogin.", player.getName(), ex); + } + } + //skips registration and login authmeAPI.forceLogin(player); return true; diff --git a/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListener.java b/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListener.java new file mode 100644 index 000000000..866ec111d --- /dev/null +++ b/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListener.java @@ -0,0 +1,182 @@ +/* + * SPDX-License-Identifier: MIT + * + * The MIT License (MIT) + * + * Copyright (c) 2015-2024 games647 and contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.github.games647.fastlogin.bukkit.hook; + +import com.github.games647.fastlogin.bukkit.BukkitLoginSession; +import com.github.games647.fastlogin.bukkit.FastLoginBukkit; +import com.github.games647.fastlogin.bukkit.event.BukkitFastLoginAutoLoginEvent; +import com.github.games647.fastlogin.core.message.LoginActionMessage; +import com.github.games647.fastlogin.core.message.NamespaceKey; +import com.github.games647.fastlogin.core.message.PreJoinMessage; +import com.google.common.io.ByteArrayDataOutput; +import com.google.common.io.ByteStreams; +import io.papermc.paper.connection.PlayerConfigurationConnection; +import io.papermc.paper.connection.PlayerConnection; +import io.papermc.paper.event.connection.configuration.AsyncPlayerConnectionConfigureEvent; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.plugin.messaging.PluginMessageListener; + +import java.net.InetSocketAddress; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Runs before AuthMe's blocking configuration dialog. Only loaded on Paper versions with this API. + */ +public class AuthMePreJoinListener implements Listener, PluginMessageListener { + + private final FastLoginBukkit plugin; + private final AuthMeHook hook; + private final String channel; + private final ConcurrentMap pending = new ConcurrentHashMap<>(); + + public AuthMePreJoinListener(FastLoginBukkit plugin, AuthMeHook hook) { + this.plugin = plugin; + this.hook = hook; + this.channel = NamespaceKey.getCombined(plugin.getName(), PreJoinMessage.CHANNEL); + } + + @EventHandler(priority = EventPriority.LOWEST) + public void onConfigure(AsyncPlayerConnectionConfigureEvent event) { + if (!plugin.getCore().getConfig().get("autoLogin", true)) { + return; + } + + PlayerConfigurationConnection connection = event.getConnection(); + String name = connection.getProfile().getName(); + if (name == null || !(connection.getAddress() instanceof InetSocketAddress)) { + return; + } + + InetSocketAddress address = (InetSocketAddress) connection.getAddress(); + BukkitLoginSession session; + if (plugin.getBungeeManager().isEnabled()) { + session = requestProxySession(connection); + } else { + session = plugin.getSession(address); + } + + if (session == null || !session.isVerifiedPremium() || !name.equals(session.getUsername()) + || !connection.isConnected()) { + return; + } + + try { + // A proxy REGISTER only establishes a new account. It must never authenticate + // an existing AuthMe account, even if the premium name is verified by the proxy. + if (session.needsRegistration() && hook.isRegistered(name)) { + return; + } + plugin.putSession(address, session); + boolean registration = session.needsRegistration() + || (plugin.getCore().getConfig().get("auto-register-unknown", false) && !hook.isRegistered(name)); + if (!registration) { + BukkitFastLoginAutoLoginEvent loginEvent = + new BukkitFastLoginAutoLoginEvent(session, session.getProfile()); + plugin.getServer().getPluginManager().callEvent(loginEvent); + session.setPreJoinAutoLoginAllowed(!loginEvent.isCancelled()); + if (loginEvent.isCancelled()) { + return; + } + } + + // Queue before AuthMe opens either the login or the registration dialog. Registration + // and its IP limits still run through the normal FastLogin task after the player joins. + hook.forceLoginBeforeJoin(name); + } catch (ReflectiveOperationException ex) { + plugin.getLog().warn("Could not prepare AuthMe pre-join login for {}", name, ex); + } + } + + private BukkitLoginSession requestProxySession(PlayerConfigurationConnection connection) { + PendingRequest request = new PendingRequest(); + pending.put(connection, request); + try { + ByteArrayDataOutput output = ByteStreams.newDataOutput(); + new PreJoinMessage(request.id, null).writeTo(output); + connection.sendPluginMessage(plugin, channel, output.toByteArray()); + LoginActionMessage action = request.response.get(5, TimeUnit.SECONDS); + if (action.getType() == LoginActionMessage.Type.CRACKED) { + return null; + } + BukkitLoginSession session = new BukkitLoginSession(action.getPlayerName(), + action.getType() == LoginActionMessage.Type.LOGIN); + session.setVerifiedPremium(true); + return session; + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } catch (ExecutionException | TimeoutException ex) { + plugin.getLog().warn("No pre-join response from FastLogin proxy for {}; keeping AuthMe authentication", + connection.getProfile().getName()); + } finally { + pending.remove(connection, request); + } + return null; + } + + @Override + public void onPluginMessageReceived(String channel, Player player, byte[] message) { + // This channel is only used during configuration. + } + + @Override + public void onPluginMessageReceived(String channel, PlayerConnection connection, byte[] message) { + if (!this.channel.equals(channel) || !(connection instanceof PlayerConfigurationConnection)) { + return; + } + PendingRequest request = pending.get(connection); + if (request == null) { + return; + } + try { + PreJoinMessage response = new PreJoinMessage(); + response.readFrom(ByteStreams.newDataInput(message)); + LoginActionMessage action = response.getLoginAction(); + String name = ((PlayerConfigurationConnection) connection).getProfile().getName(); + if (request.id.equals(response.getRequestId()) && action != null + && action.getPlayerName().equals(name) + && plugin.getBungeeManager().isProxyAllowed(action.getProxyId())) { + request.response.complete(action); + } + } catch (IllegalArgumentException | IllegalStateException | IndexOutOfBoundsException ex) { + plugin.getLog().debug("Ignoring malformed FastLogin pre-join response", ex); + } + } + + private static final class PendingRequest { + + private final UUID id = UUID.randomUUID(); + private final CompletableFuture response = new CompletableFuture<>(); + } +} diff --git a/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/listener/BungeeListener.java b/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/listener/BungeeListener.java index 14cc9fa73..5fa5400f9 100644 --- a/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/listener/BungeeListener.java +++ b/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/listener/BungeeListener.java @@ -123,6 +123,11 @@ private void onRegisterMessage(Player player, String playerName) { } private void startLoginTaskIfReady(Player player, BukkitLoginSession session) { + BukkitLoginSession existing = plugin.getSession(player.spigot().getRawAddress()); + if (existing != null && existing.getUsername().equals(session.getUsername()) + && existing.getPreJoinAutoLoginAllowed() != null) { + session = existing; + } session.setVerifiedPremium(true); plugin.putSession(player.spigot().getRawAddress(), session); diff --git a/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/task/ForceLoginTask.java b/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/task/ForceLoginTask.java index e5daaf55a..624a29f37 100644 --- a/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/task/ForceLoginTask.java +++ b/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/task/ForceLoginTask.java @@ -74,7 +74,12 @@ public void run() { @Override public FastLoginAutoLoginEvent callFastLoginAutoLoginEvent(LoginSession session, StoredProfile profile) { BukkitFastLoginAutoLoginEvent event = new BukkitFastLoginAutoLoginEvent(session, profile); - core.getPlugin().getServer().getPluginManager().callEvent(event); + Boolean preJoinAllowed = ((BukkitLoginSession) session).getPreJoinAutoLoginAllowed(); + if (preJoinAllowed == null) { + core.getPlugin().getServer().getPluginManager().callEvent(event); + } else { + event.setCancelled(!preJoinAllowed); + } return event; } diff --git a/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHookTest.java b/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHookTest.java new file mode 100644 index 000000000..284b9bad4 --- /dev/null +++ b/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHookTest.java @@ -0,0 +1,81 @@ +/* + * SPDX-License-Identifier: MIT + * + * The MIT License (MIT) + * + * Copyright (c) 2015-2024 games647 and contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.github.games647.fastlogin.bukkit.hook; + +import com.github.games647.fastlogin.bukkit.FastLoginBukkit; +import fr.xephi.authme.api.v3.AuthMeApi; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +class AuthMeHookTest { + + @Test + void olderAuthMeKeepsStandardLogin() { + FastLoginBukkit plugin = mock(FastLoginBukkit.class, RETURNS_DEEP_STUBS); + AuthMeApi api = mock(AuthMeApi.class); + Player player = mock(Player.class); + try (MockedStatic singleton = mockStatic(AuthMeApi.class)) { + singleton.when(AuthMeApi::getInstance).thenReturn(api); + AuthMeHook hook = new AuthMeHook(plugin); + assertTrue(hook.forceLogin(player)); + verify(api).forceLogin(player); + when(api.isAuthenticated(player)).thenReturn(true); + assertFalse(hook.forceLogin(player)); + } + } + + @Test + void modernAuthMeCanPrepareLoginBeforePlayerExists() throws ReflectiveOperationException { + FastLoginBukkit plugin = mock(FastLoginBukkit.class, RETURNS_DEEP_STUBS); + when(plugin.getName()).thenReturn("FastLogin"); + AuthMeApi api = mock(AuthMeApi.class, withSettings().extraInterfaces(ProxyLoginApi.class)); + try (MockedStatic singleton = mockStatic(AuthMeApi.class)) { + singleton.when(AuthMeApi::getInstance).thenReturn(api); + AuthMeHook hook = new AuthMeHook(plugin); + hook.forceLoginBeforeJoin("PremiumPlayer"); + verify((ProxyLoginApi) api).forceLoginFromProxy("PremiumPlayer"); + + Player player = mock(Player.class); + when(api.isAuthenticated(player)).thenReturn(true); + assertTrue(hook.forceLogin(player)); + } + } + + public interface ProxyLoginApi { + + void forceLoginFromProxy(String name); + } +} diff --git a/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListenerTest.java b/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListenerTest.java new file mode 100644 index 000000000..765860e06 --- /dev/null +++ b/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListenerTest.java @@ -0,0 +1,195 @@ +/* + * SPDX-License-Identifier: MIT + * + * The MIT License (MIT) + * + * Copyright (c) 2015-2024 games647 and contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.github.games647.fastlogin.bukkit.hook; + +import com.destroystokyo.paper.profile.PlayerProfile; +import com.github.games647.fastlogin.bukkit.BukkitLoginSession; +import com.github.games647.fastlogin.bukkit.FastLoginBukkit; +import com.github.games647.fastlogin.bukkit.event.BukkitFastLoginAutoLoginEvent; +import com.github.games647.fastlogin.core.message.LoginActionMessage; +import com.github.games647.fastlogin.core.message.PreJoinMessage; +import com.google.common.io.ByteStreams; +import io.papermc.paper.connection.PlayerConfigurationConnection; +import io.papermc.paper.event.connection.configuration.AsyncPlayerConnectionConfigureEvent; +import org.bukkit.event.Event; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.InetSocketAddress; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AuthMePreJoinListenerTest { + + private final FastLoginBukkit plugin = mock(FastLoginBukkit.class, RETURNS_DEEP_STUBS); + private final AuthMeHook hook = mock(AuthMeHook.class); + private final PlayerConfigurationConnection connection = mock(PlayerConfigurationConnection.class); + private final InetSocketAddress address = new InetSocketAddress("127.0.0.1", 12345); + private AuthMePreJoinListener listener; + private BukkitLoginSession session; + private final net.md_5.bungee.config.Configuration config = new net.md_5.bungee.config.Configuration(); + + @BeforeEach + void setUp() { + when(plugin.getName()).thenReturn("FastLogin"); + config.set("autoLogin", true); + when(plugin.getCore().getConfig()).thenReturn(config); + when(connection.getAddress()).thenReturn(address); + when(connection.isConnected()).thenReturn(true); + PlayerProfile profile = mock(PlayerProfile.class); + when(profile.getName()).thenReturn("PremiumPlayer"); + when(connection.getProfile()).thenReturn(profile); + session = new BukkitLoginSession("PremiumPlayer", true); + session.setVerifiedPremium(true); + when(plugin.getSession(address)).thenReturn(session); + listener = new AuthMePreJoinListener(plugin, hook); + } + + @Test + void verifiedPremiumSkipsDialogBeforeJoin() throws ReflectiveOperationException { + listener.onConfigure(new AsyncPlayerConnectionConfigureEvent(connection)); + verify(hook).forceLoginBeforeJoin("PremiumPlayer"); + } + + @Test + void unverifiedPlayerKeepsDialog() throws ReflectiveOperationException { + session.setVerifiedPremium(false); + listener.onConfigure(new AsyncPlayerConnectionConfigureEvent(connection)); + verify(hook, never()).forceLoginBeforeJoin(anyString()); + } + + @Test + void disabledAutoLoginKeepsDialog() throws ReflectiveOperationException { + config.set("autoLogin", false); + listener.onConfigure(new AsyncPlayerConnectionConfigureEvent(connection)); + verify(hook, never()).forceLoginBeforeJoin(anyString()); + } + + @Test + void differentSessionNameKeepsDialog() throws ReflectiveOperationException { + when(connection.getProfile().getName()).thenReturn("AnotherPlayer"); + listener.onConfigure(new AsyncPlayerConnectionConfigureEvent(connection)); + verify(hook, never()).forceLoginBeforeJoin(anyString()); + } + + @Test + void cancelledAutoLoginKeepsDialog() throws ReflectiveOperationException { + org.bukkit.plugin.PluginManager manager = plugin.getServer().getPluginManager(); + doAnswer(invocation -> { + ((BukkitFastLoginAutoLoginEvent) invocation.getArgument(0)).setCancelled(true); + return null; + }).when(manager).callEvent(any(Event.class)); + listener.onConfigure(new AsyncPlayerConnectionConfigureEvent(connection)); + verify(hook, never()).forceLoginBeforeJoin(anyString()); + } + + @Test + void newPremiumCanReachPostJoinRegistration() throws ReflectiveOperationException { + session = new BukkitLoginSession("PremiumPlayer", false); + session.setVerifiedPremium(true); + when(plugin.getSession(address)).thenReturn(session); + listener.onConfigure(new AsyncPlayerConnectionConfigureEvent(connection)); + verify(hook).forceLoginBeforeJoin("PremiumPlayer"); + verify(plugin.getServer().getPluginManager(), never()).callEvent(any(Event.class)); + } + + @Test + void proxyReplyMustMatchConnectionRequestAndAllowedProxy() throws ReflectiveOperationException { + when(plugin.getBungeeManager().isEnabled()).thenReturn(true); + UUID allowedProxy = UUID.randomUUID(); + when(plugin.getBungeeManager().isProxyAllowed(allowedProxy)).thenReturn(true); + PlayerConfigurationConnection otherConnection = mock(PlayerConfigurationConnection.class); + doAnswer(invocation -> { + PreJoinMessage request = new PreJoinMessage(); + request.readFrom(ByteStreams.newDataInput(invocation.getArgument(2))); + listener.onPluginMessageReceived("fastlogin:prejoin", connection, new byte[] {1}); + reply(connection, UUID.randomUUID(), allowedProxy, "PremiumPlayer"); + reply(otherConnection, request.getRequestId(), allowedProxy, "PremiumPlayer"); + reply(connection, request.getRequestId(), UUID.randomUUID(), "PremiumPlayer"); + reply(connection, request.getRequestId(), allowedProxy, "AnotherPlayer"); + verify(hook, never()).forceLoginBeforeJoin(anyString()); + reply(connection, request.getRequestId(), allowedProxy, "PremiumPlayer"); + return null; + }).when(connection).sendPluginMessage(eq(plugin), eq("fastlogin:prejoin"), any(byte[].class)); + + listener.onConfigure(new AsyncPlayerConnectionConfigureEvent(connection)); + + verify(hook).forceLoginBeforeJoin("PremiumPlayer"); + verify(plugin).putSession(eq(address), any(BukkitLoginSession.class)); + } + + @Test + void registrationCannotAuthenticateAnExistingAuthMeAccount() throws ReflectiveOperationException { + when(plugin.getBungeeManager().isEnabled()).thenReturn(true); + UUID allowedProxy = UUID.randomUUID(); + when(plugin.getBungeeManager().isProxyAllowed(allowedProxy)).thenReturn(true); + when(hook.isRegistered("PremiumPlayer")).thenReturn(true); + doAnswer(invocation -> { + PreJoinMessage request = new PreJoinMessage(); + request.readFrom(ByteStreams.newDataInput(invocation.getArgument(2))); + PreJoinMessage response = new PreJoinMessage(request.getRequestId(), + new LoginActionMessage(LoginActionMessage.Type.REGISTER, "PremiumPlayer", allowedProxy)); + com.google.common.io.ByteArrayDataOutput output = ByteStreams.newDataOutput(); + response.writeTo(output); + listener.onPluginMessageReceived("fastlogin:prejoin", connection, output.toByteArray()); + return null; + }).when(connection).sendPluginMessage(eq(plugin), eq("fastlogin:prejoin"), any(byte[].class)); + listener.onConfigure(new AsyncPlayerConnectionConfigureEvent(connection)); + verify(hook, never()).forceLoginBeforeJoin(anyString()); + verify(plugin, never()).putSession(eq(address), any(BukkitLoginSession.class)); + } + + private void reply(PlayerConfigurationConnection source, UUID requestId, UUID proxyId, String name) { + PreJoinMessage response = new PreJoinMessage(requestId, + new LoginActionMessage(LoginActionMessage.Type.LOGIN, name, proxyId)); + com.google.common.io.ByteArrayDataOutput output = ByteStreams.newDataOutput(); + response.writeTo(output); + listener.onPluginMessageReceived("fastlogin:prejoin", source, output.toByteArray()); + } + + @Test + void expiredProxyRequestCannotApproveALaterConnection() throws ReflectiveOperationException { + when(plugin.getBungeeManager().isEnabled()).thenReturn(true); + UUID allowedProxy = UUID.randomUUID(); + when(plugin.getBungeeManager().isProxyAllowed(allowedProxy)).thenReturn(true); + org.mockito.ArgumentCaptor request = org.mockito.ArgumentCaptor.forClass(byte[].class); + listener.onConfigure(new AsyncPlayerConnectionConfigureEvent(connection)); + verify(connection).sendPluginMessage(eq(plugin), eq("fastlogin:prejoin"), request.capture()); + PreJoinMessage message = new PreJoinMessage(); + message.readFrom(ByteStreams.newDataInput(request.getValue())); + reply(connection, message.getRequestId(), allowedProxy, "PremiumPlayer"); + verify(hook, never()).forceLoginBeforeJoin(anyString()); + } +} diff --git a/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/task/PreJoinLoginEventTest.java b/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/task/PreJoinLoginEventTest.java new file mode 100644 index 000000000..c3ee55ce0 --- /dev/null +++ b/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/task/PreJoinLoginEventTest.java @@ -0,0 +1,52 @@ +/* + * SPDX-License-Identifier: MIT + * + * The MIT License (MIT) + * + * Copyright (c) 2015-2024 games647 and contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.github.games647.fastlogin.bukkit.task; + +import com.github.games647.fastlogin.bukkit.BukkitLoginSession; +import com.github.games647.fastlogin.bukkit.FastLoginBukkit; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +class PreJoinLoginEventTest { + + @Test + void postJoinTaskRetainsEarlierCancellationWithoutFiringAgain() { + FastLoginBukkit plugin = mock(FastLoginBukkit.class, RETURNS_DEEP_STUBS); + BukkitLoginSession session = new BukkitLoginSession("PremiumPlayer", true); + session.setPreJoinAutoLoginAllowed(false); + ForceLoginTask task = new ForceLoginTask(plugin.getCore(), mock(Player.class), session); + assertTrue(task.callFastLoginAutoLoginEvent(session, null).isCancelled()); + session.setPreJoinAutoLoginAllowed(true); + assertFalse(task.callFastLoginAutoLoginEvent(session, null).isCancelled()); + verifyNoInteractions(plugin.getServer().getPluginManager()); + } +} diff --git a/bungee/src/main/java/com/github/games647/fastlogin/bungee/FastLoginBungee.java b/bungee/src/main/java/com/github/games647/fastlogin/bungee/FastLoginBungee.java index 770b36bd7..6805a6b16 100644 --- a/bungee/src/main/java/com/github/games647/fastlogin/bungee/FastLoginBungee.java +++ b/bungee/src/main/java/com/github/games647/fastlogin/bungee/FastLoginBungee.java @@ -37,6 +37,7 @@ import com.github.games647.fastlogin.core.message.ChannelMessage; import com.github.games647.fastlogin.core.message.NamespaceKey; import com.github.games647.fastlogin.core.message.SuccessMessage; +import com.github.games647.fastlogin.core.message.PreJoinMessage; import com.github.games647.fastlogin.core.scheduler.AsyncScheduler; import com.github.games647.fastlogin.core.shared.FastLoginCore; import com.github.games647.fastlogin.core.shared.PlatformPlugin; @@ -105,6 +106,7 @@ public void onEnable() { //this is required to listen to incoming messages from the server getProxy().registerChannel(NamespaceKey.getCombined(getName(), ChangePremiumMessage.CHANGE_CHANNEL)); getProxy().registerChannel(NamespaceKey.getCombined(getName(), SuccessMessage.SUCCESS_CHANNEL)); + getProxy().registerChannel(NamespaceKey.getCombined(getName(), PreJoinMessage.CHANNEL)); registerHook(); } diff --git a/bungee/src/main/java/com/github/games647/fastlogin/bungee/listener/PluginMessageListener.java b/bungee/src/main/java/com/github/games647/fastlogin/bungee/listener/PluginMessageListener.java index bb4ca89d8..3c24a69ad 100644 --- a/bungee/src/main/java/com/github/games647/fastlogin/bungee/listener/PluginMessageListener.java +++ b/bungee/src/main/java/com/github/games647/fastlogin/bungee/listener/PluginMessageListener.java @@ -32,6 +32,8 @@ import com.github.games647.fastlogin.core.message.ChangePremiumMessage; import com.github.games647.fastlogin.core.message.NamespaceKey; import com.github.games647.fastlogin.core.message.SuccessMessage; +import com.github.games647.fastlogin.core.message.LoginActionMessage; +import com.github.games647.fastlogin.core.message.PreJoinMessage; import com.github.games647.fastlogin.core.shared.FastLoginCore; import com.github.games647.fastlogin.core.storage.StoredProfile; import com.google.common.io.ByteArrayDataInput; @@ -45,6 +47,7 @@ import net.md_5.bungee.event.EventHandler; import java.util.Arrays; +import java.util.UUID; public class PluginMessageListener implements Listener { @@ -52,12 +55,14 @@ public class PluginMessageListener implements Listener { private final String successChannel; private final String changeChannel; + private final String preJoinChannel; public PluginMessageListener(FastLoginBungee plugin) { this.plugin = plugin; this.successChannel = new NamespaceKey(plugin.getName(), SuccessMessage.SUCCESS_CHANNEL).getCombinedName(); this.changeChannel = new NamespaceKey(plugin.getName(), ChangePremiumMessage.CHANGE_CHANNEL).getCombinedName(); + this.preJoinChannel = NamespaceKey.getCombined(plugin.getName(), PreJoinMessage.CHANNEL); } @EventHandler @@ -76,6 +81,12 @@ public void onPluginMessage(PluginMessageEvent pluginMessageEvent) { return; } + if (preJoinChannel.equals(channel)) { + onPreJoinRequest((Server) pluginMessageEvent.getSender(), + (ProxiedPlayer) pluginMessageEvent.getReceiver(), pluginMessageEvent.getData()); + return; + } + //so that we can safely process this in the background byte[] data = Arrays.copyOf(pluginMessageEvent.getData(), pluginMessageEvent.getData().length); ProxiedPlayer forPlayer = (ProxiedPlayer) pluginMessageEvent.getReceiver(); @@ -83,6 +94,26 @@ public void onPluginMessage(PluginMessageEvent pluginMessageEvent) { plugin.getScheduler().runAsync(() -> readMessage(forPlayer, channel, data)); } + private void onPreJoinRequest(Server source, ProxiedPlayer player, byte[] data) { + try { + PreJoinMessage request = new PreJoinMessage(); + request.readFrom(ByteStreams.newDataInput(data)); + if (request.getLoginAction() != null) { + return; + } + BungeeLoginSession session = plugin.getSession().get(player.getPendingConnection()); + LoginActionMessage.Type type = LoginActionMessage.Type.CRACKED; + if (session != null && player.getPendingConnection().isOnlineMode()) { + type = session.needsRegistration() ? LoginActionMessage.Type.REGISTER : LoginActionMessage.Type.LOGIN; + } + UUID proxyId = UUID.fromString(plugin.getProxy().getConfig().getUuid()); + LoginActionMessage action = new LoginActionMessage(type, player.getName(), proxyId); + plugin.sendPluginMessage(source, new PreJoinMessage(request.getRequestId(), action)); + } catch (IllegalArgumentException | IllegalStateException | IndexOutOfBoundsException ex) { + plugin.getLog().debug("Ignoring malformed FastLogin pre-join request", ex); + } + } + private void readMessage(ProxiedPlayer forPlayer, String channel, byte[] data) { FastLoginCore core = plugin.getCore(); diff --git a/bungee/src/test/java/com/github/games647/fastlogin/bungee/listener/PreJoinMessageListenerTest.java b/bungee/src/test/java/com/github/games647/fastlogin/bungee/listener/PreJoinMessageListenerTest.java new file mode 100644 index 000000000..c50af69fa --- /dev/null +++ b/bungee/src/test/java/com/github/games647/fastlogin/bungee/listener/PreJoinMessageListenerTest.java @@ -0,0 +1,115 @@ +/* + * SPDX-License-Identifier: MIT + * + * The MIT License (MIT) + * + * Copyright (c) 2015-2024 games647 and contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.github.games647.fastlogin.bungee.listener; + +import com.github.games647.fastlogin.bungee.BungeeLoginSession; +import com.github.games647.fastlogin.bungee.FastLoginBungee; +import com.github.games647.fastlogin.core.message.LoginActionMessage; +import com.github.games647.fastlogin.core.message.PreJoinMessage; +import com.google.common.io.ByteArrayDataOutput; +import com.google.common.io.ByteStreams; +import net.md_5.bungee.api.connection.PendingConnection; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.connection.Server; +import net.md_5.bungee.api.event.PluginMessageEvent; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class PreJoinMessageListenerTest { + + @Test + void premiumReplyUsesOriginatingConnectionWithoutCompletingLogin() { + assertReply(true, true, LoginActionMessage.Type.LOGIN); + } + + @Test + void unknownPremiumCanRegisterAfterConfiguration() { + assertReply(true, false, LoginActionMessage.Type.REGISTER); + } + + @Test + void offlinePlayerCannotClaimPremiumSession() { + assertReply(false, true, LoginActionMessage.Type.CRACKED); + } + + @Test + void clientCannotRequestAuthentication() { + FastLoginBungee plugin = mock(FastLoginBungee.class); + when(plugin.getName()).thenReturn("FastLogin"); + ProxiedPlayer player = mock(ProxiedPlayer.class); + Server server = mock(Server.class); + PluginMessageEvent event = new PluginMessageEvent(player, server, "fastlogin:prejoin", new byte[0]); + new PluginMessageListener(plugin).onPluginMessage(event); + assertTrue(event.isCancelled()); + verifyNoInteractions(server); + } + + private void assertReply(boolean onlineMode, boolean registered, LoginActionMessage.Type expected) { + FastLoginBungee plugin = mock(FastLoginBungee.class, RETURNS_DEEP_STUBS); + when(plugin.getName()).thenReturn("FastLogin"); + ProxiedPlayer player = mock(ProxiedPlayer.class); + when(player.getName()).thenReturn("PremiumPlayer"); + PendingConnection connection = mock(PendingConnection.class); + when(connection.isOnlineMode()).thenReturn(onlineMode); + when(player.getPendingConnection()).thenReturn(connection); + Server source = mock(Server.class); + ConcurrentHashMap sessions = new ConcurrentHashMap<>(); + BungeeLoginSession session = new BungeeLoginSession("PremiumPlayer", registered, null); + sessions.put(connection, session); + when(plugin.getSession()).thenReturn(sessions); + UUID proxyId = UUID.randomUUID(); + when(plugin.getProxy().getConfig().getUuid()).thenReturn(proxyId.toString()); + UUID requestId = UUID.randomUUID(); + ByteArrayDataOutput output = ByteStreams.newDataOutput(); + new PreJoinMessage(requestId, null).writeTo(output); + PluginMessageEvent event = new PluginMessageEvent(source, player, "fastlogin:prejoin", output.toByteArray()); + + new PluginMessageListener(plugin).onPluginMessage(event); + + ArgumentCaptor response = ArgumentCaptor.forClass(PreJoinMessage.class); + verify(plugin).sendPluginMessage(eq(source), response.capture()); + assertTrue(event.isCancelled()); + assertEquals(requestId, response.getValue().getRequestId()); + assertEquals("PremiumPlayer", response.getValue().getLoginAction().getPlayerName()); + assertEquals(expected, response.getValue().getLoginAction().getType()); + assertEquals(proxyId, response.getValue().getLoginAction().getProxyId()); + assertFalse(session.isAlreadyLogged()); + assertFalse(session.isAlreadySaved()); + } +} diff --git a/core/src/main/java/com/github/games647/fastlogin/core/message/PreJoinMessage.java b/core/src/main/java/com/github/games647/fastlogin/core/message/PreJoinMessage.java new file mode 100644 index 000000000..8e53f525b --- /dev/null +++ b/core/src/main/java/com/github/games647/fastlogin/core/message/PreJoinMessage.java @@ -0,0 +1,84 @@ +/* + * SPDX-License-Identifier: MIT + * + * The MIT License (MIT) + * + * Copyright (c) 2015-2024 games647 and contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.github.games647.fastlogin.core.message; + +import com.google.common.io.ByteArrayDataInput; +import com.google.common.io.ByteArrayDataOutput; + +import java.util.UUID; + +/** + * A connection-specific challenge and its proxy-authenticated response, before PlayerJoinEvent. + */ +public class PreJoinMessage implements ChannelMessage { + + public static final String CHANNEL = "prejoin"; + + private UUID requestId; + private LoginActionMessage loginAction; + + public PreJoinMessage() { + // Reading mode. + } + + public PreJoinMessage(UUID requestId, LoginActionMessage loginAction) { + this.requestId = requestId; + this.loginAction = loginAction; + } + + public UUID getRequestId() { + return requestId; + } + + public LoginActionMessage getLoginAction() { + return loginAction; + } + + @Override + public void readFrom(ByteArrayDataInput input) { + requestId = new UUID(input.readLong(), input.readLong()); + loginAction = null; + if (input.readBoolean()) { + loginAction = new LoginActionMessage(); + loginAction.readFrom(input); + } + } + + @Override + public void writeTo(ByteArrayDataOutput output) { + output.writeLong(requestId.getMostSignificantBits()); + output.writeLong(requestId.getLeastSignificantBits()); + output.writeBoolean(loginAction != null); + if (loginAction != null) { + loginAction.writeTo(output); + } + } + + @Override + public String getChannelName() { + return CHANNEL; + } +} diff --git a/core/src/test/java/com/github/games647/fastlogin/core/message/PreJoinMessageTest.java b/core/src/test/java/com/github/games647/fastlogin/core/message/PreJoinMessageTest.java new file mode 100644 index 000000000..b7271cb4a --- /dev/null +++ b/core/src/test/java/com/github/games647/fastlogin/core/message/PreJoinMessageTest.java @@ -0,0 +1,62 @@ +/* + * SPDX-License-Identifier: MIT + * + * The MIT License (MIT) + * + * Copyright (c) 2015-2024 games647 and contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.github.games647.fastlogin.core.message; + +import com.google.common.io.ByteArrayDataOutput; +import com.google.common.io.ByteStreams; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class PreJoinMessageTest { + + @Test + void requestAndResponseCarryConnectionChallenge() { + UUID requestId = UUID.randomUUID(); + PreJoinMessage request = roundTrip(new PreJoinMessage(requestId, null)); + assertEquals(requestId, request.getRequestId()); + assertNull(request.getLoginAction()); + + UUID proxyId = UUID.randomUUID(); + PreJoinMessage response = roundTrip(new PreJoinMessage(requestId, + new LoginActionMessage(LoginActionMessage.Type.REGISTER, "PremiumPlayer", proxyId))); + assertEquals(requestId, response.getRequestId()); + assertEquals("PremiumPlayer", response.getLoginAction().getPlayerName()); + assertEquals(LoginActionMessage.Type.REGISTER, response.getLoginAction().getType()); + assertEquals(proxyId, response.getLoginAction().getProxyId()); + } + + private PreJoinMessage roundTrip(PreJoinMessage message) { + ByteArrayDataOutput output = ByteStreams.newDataOutput(); + message.writeTo(output); + PreJoinMessage decoded = new PreJoinMessage(); + decoded.readFrom(ByteStreams.newDataInput(output.toByteArray())); + return decoded; + } +} diff --git a/velocity/src/main/java/com/github/games647/fastlogin/velocity/FastLoginVelocity.java b/velocity/src/main/java/com/github/games647/fastlogin/velocity/FastLoginVelocity.java index 26217b835..14a6bbdb6 100644 --- a/velocity/src/main/java/com/github/games647/fastlogin/velocity/FastLoginVelocity.java +++ b/velocity/src/main/java/com/github/games647/fastlogin/velocity/FastLoginVelocity.java @@ -31,6 +31,7 @@ import com.github.games647.fastlogin.core.message.ChangePremiumMessage; import com.github.games647.fastlogin.core.message.ChannelMessage; import com.github.games647.fastlogin.core.message.SuccessMessage; +import com.github.games647.fastlogin.core.message.PreJoinMessage; import com.github.games647.fastlogin.core.scheduler.AsyncScheduler; import com.github.games647.fastlogin.core.shared.FastLoginCore; import com.github.games647.fastlogin.core.shared.PlatformPlugin; @@ -115,6 +116,7 @@ public void onProxyInitialization(ProxyInitializeEvent event) { ChannelRegistrar channelRegistry = server.getChannelRegistrar(); channelRegistry.register(MinecraftChannelIdentifier.create(getName(), ChangePremiumMessage.CHANGE_CHANNEL)); channelRegistry.register(MinecraftChannelIdentifier.create(getName(), SuccessMessage.SUCCESS_CHANNEL)); + channelRegistry.register(MinecraftChannelIdentifier.create(getName(), PreJoinMessage.CHANNEL)); } @Subscribe diff --git a/velocity/src/main/java/com/github/games647/fastlogin/velocity/listener/PluginMessageListener.java b/velocity/src/main/java/com/github/games647/fastlogin/velocity/listener/PluginMessageListener.java index 64041fbf0..96b8f2106 100644 --- a/velocity/src/main/java/com/github/games647/fastlogin/velocity/listener/PluginMessageListener.java +++ b/velocity/src/main/java/com/github/games647/fastlogin/velocity/listener/PluginMessageListener.java @@ -28,6 +28,8 @@ import com.github.games647.fastlogin.core.hooks.bedrock.FloodgateService; import com.github.games647.fastlogin.core.message.ChangePremiumMessage; import com.github.games647.fastlogin.core.message.SuccessMessage; +import com.github.games647.fastlogin.core.message.LoginActionMessage; +import com.github.games647.fastlogin.core.message.PreJoinMessage; import com.github.games647.fastlogin.core.shared.FastLoginCore; import com.github.games647.fastlogin.core.storage.StoredProfile; import com.github.games647.fastlogin.velocity.FastLoginVelocity; @@ -51,6 +53,7 @@ public class PluginMessageListener { private final String successChannel; private final String changeChannel; + private final String preJoinChannel; public PluginMessageListener(FastLoginVelocity plugin) { this.plugin = plugin; @@ -58,6 +61,7 @@ public PluginMessageListener(FastLoginVelocity plugin) { String prefix = plugin.getName(); this.successChannel = MinecraftChannelIdentifier.create(prefix, SuccessMessage.SUCCESS_CHANNEL).getId(); this.changeChannel = MinecraftChannelIdentifier.create(prefix, ChangePremiumMessage.CHANGE_CHANNEL).getId(); + this.preJoinChannel = MinecraftChannelIdentifier.create(prefix, PreJoinMessage.CHANNEL).getId(); } @Subscribe @@ -76,6 +80,11 @@ public void onPluginMessage(PluginMessageEvent pluginMessageEvent) { return; } + if (preJoinChannel.equals(channel)) { + onPreJoinRequest((ServerConnection) pluginMessageEvent.getSource(), pluginMessageEvent.getData()); + return; + } + //so that we can safely process this in the background byte[] data = Arrays.copyOf(pluginMessageEvent.getData(), pluginMessageEvent.getData().length); Player forPlayer = (Player) pluginMessageEvent.getTarget(); @@ -83,6 +92,27 @@ public void onPluginMessage(PluginMessageEvent pluginMessageEvent) { plugin.getScheduler().runAsync(() -> readMessage(forPlayer, channel, data)); } + private void onPreJoinRequest(ServerConnection source, byte[] data) { + try { + PreJoinMessage request = new PreJoinMessage(); + request.readFrom(ByteStreams.newDataInput(data)); + if (request.getLoginAction() != null) { + return; + } + Player player = source.getPlayer(); + VelocityLoginSession session = plugin.getSession().get(player.getRemoteAddress()); + LoginActionMessage.Type type = LoginActionMessage.Type.CRACKED; + if (session != null && player.isOnlineMode()) { + type = session.needsRegistration() ? LoginActionMessage.Type.REGISTER : LoginActionMessage.Type.LOGIN; + } + LoginActionMessage action = new LoginActionMessage(type, player.getUsername(), plugin.getProxyId()); + // Reply on this connection, not a random player connected to the same RegisteredServer. + plugin.sendPluginMessage(source, new PreJoinMessage(request.getRequestId(), action)); + } catch (IllegalArgumentException | IllegalStateException | IndexOutOfBoundsException ex) { + plugin.getLog().debug("Ignoring malformed FastLogin pre-join request", ex); + } + } + private void readMessage(Player sender, String channel, byte[] data) { FastLoginCore core = plugin.getCore(); diff --git a/velocity/src/test/java/com/github/games647/fastlogin/velocity/listener/PreJoinMessageListenerTest.java b/velocity/src/test/java/com/github/games647/fastlogin/velocity/listener/PreJoinMessageListenerTest.java new file mode 100644 index 000000000..40339a596 --- /dev/null +++ b/velocity/src/test/java/com/github/games647/fastlogin/velocity/listener/PreJoinMessageListenerTest.java @@ -0,0 +1,118 @@ +/* + * SPDX-License-Identifier: MIT + * + * The MIT License (MIT) + * + * Copyright (c) 2015-2024 games647 and contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.github.games647.fastlogin.velocity.listener; + +import com.github.games647.fastlogin.core.message.LoginActionMessage; +import com.github.games647.fastlogin.core.message.PreJoinMessage; +import com.github.games647.fastlogin.velocity.FastLoginVelocity; +import com.github.games647.fastlogin.velocity.VelocityLoginSession; +import com.google.common.io.ByteArrayDataOutput; +import com.google.common.io.ByteStreams; +import com.velocitypowered.api.event.connection.PluginMessageEvent; +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.ServerConnection; +import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.net.InetSocketAddress; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class PreJoinMessageListenerTest { + + @Test + void premiumReplyUsesOriginatingConnectionWithoutCompletingLogin() { + assertReply(true, true, LoginActionMessage.Type.LOGIN); + } + + @Test + void unknownPremiumCanRegisterAfterConfiguration() { + assertReply(true, false, LoginActionMessage.Type.REGISTER); + } + + @Test + void offlinePlayerCannotClaimPremiumSession() { + assertReply(false, true, LoginActionMessage.Type.CRACKED); + } + + @Test + void clientCannotRequestAuthentication() { + FastLoginVelocity plugin = mock(FastLoginVelocity.class); + when(plugin.getName()).thenReturn("fastlogin"); + Player player = mock(Player.class); + ServerConnection server = mock(ServerConnection.class); + PluginMessageEvent event = new PluginMessageEvent(player, server, + MinecraftChannelIdentifier.from("fastlogin:prejoin"), new byte[0]); + new PluginMessageListener(plugin).onPluginMessage(event); + assertFalse(event.getResult().isAllowed()); + verifyNoInteractions(server); + } + + private void assertReply(boolean onlineMode, boolean registered, LoginActionMessage.Type expected) { + FastLoginVelocity plugin = mock(FastLoginVelocity.class, RETURNS_DEEP_STUBS); + when(plugin.getName()).thenReturn("fastlogin"); + Player player = mock(Player.class); + when(player.getUsername()).thenReturn("PremiumPlayer"); + when(player.isOnlineMode()).thenReturn(onlineMode); + InetSocketAddress address = new InetSocketAddress("127.0.0.1", 23456); + when(player.getRemoteAddress()).thenReturn(address); + ServerConnection source = mock(ServerConnection.class); + when(source.getPlayer()).thenReturn(player); + ConcurrentHashMap sessions = new ConcurrentHashMap<>(); + VelocityLoginSession session = new VelocityLoginSession("PremiumPlayer", registered, null); + sessions.put(address, session); + when(plugin.getSession()).thenReturn(sessions); + UUID proxyId = UUID.randomUUID(); + when(plugin.getProxyId()).thenReturn(proxyId); + UUID requestId = UUID.randomUUID(); + ByteArrayDataOutput output = ByteStreams.newDataOutput(); + new PreJoinMessage(requestId, null).writeTo(output); + PluginMessageEvent event = new PluginMessageEvent(source, player, + MinecraftChannelIdentifier.from("fastlogin:prejoin"), output.toByteArray()); + + new PluginMessageListener(plugin).onPluginMessage(event); + + ArgumentCaptor response = ArgumentCaptor.forClass(PreJoinMessage.class); + verify(plugin).sendPluginMessage(eq(source), response.capture()); + assertFalse(event.getResult().isAllowed()); + assertEquals(requestId, response.getValue().getRequestId()); + assertEquals("PremiumPlayer", response.getValue().getLoginAction().getPlayerName()); + assertEquals(expected, response.getValue().getLoginAction().getType()); + assertEquals(proxyId, response.getValue().getLoginAction().getProxyId()); + assertFalse(session.isAlreadyLogged()); + assertFalse(session.isAlreadySaved()); + } +} From b7d878e57337868ddaff3ccdc7ae56a575ba203f Mon Sep 17 00:00:00 2001 From: leoaxo098 Date: Fri, 30 Oct 2026 22:19:48 +0700 Subject: [PATCH 2/5] fix: support older AuthMe pre-join dialog builds Use AuthMe's configuration listener session queue when its public forceLoginFromProxy(String) API is unavailable, as in 6.0.0-b2734. Add fallback regression tests and an optional integration check against the official b2734 JAR's session queue and dialog skip predicate. --- .../fastlogin/bukkit/hook/AuthMeHook.java | 58 ++++++- .../fastlogin/bukkit/hook/AuthMeHookTest.java | 143 ++++++++++++++++++ 2 files changed, 197 insertions(+), 4 deletions(-) diff --git a/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHook.java b/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHook.java index 527c4795a..a1319cf19 100644 --- a/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHook.java +++ b/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHook.java @@ -38,7 +38,10 @@ import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; import org.bukkit.event.Listener; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.RegisteredListener; import java.lang.reflect.Field; import java.lang.reflect.Method; @@ -61,6 +64,8 @@ public class AuthMeHook implements AuthPlugin, Listener { // AuthMe 6.0+ pre-join dialog support: forceLoginFromProxy(String) on the API private Method forceLoginFromProxyMethod; + private Object preJoinLoginTarget; + private Method preJoinLoginMethod; public AuthMeHook(FastLoginBukkit plugin) { this.plugin = plugin; @@ -79,21 +84,27 @@ public AuthMeHook(FastLoginBukkit plugin) { // Detect AuthMe 6.0+ forceLoginFromProxy(String) API for pre-join dialog compatibility try { forceLoginFromProxyMethod = authmeAPI.getClass().getMethod("forceLoginFromProxy", String.class); - enablePreJoinSupport(); + preJoinLoginTarget = authmeAPI; + preJoinLoginMethod = forceLoginFromProxyMethod; } catch (NoSuchMethodException ignored) { forceLoginFromProxyMethod = null; } + enablePreJoinSupport(); } private void enablePreJoinSupport() { try { - Class.forName("io.papermc.paper.event.connection.configuration.AsyncPlayerConnectionConfigureEvent"); + Class configureEvent = Class.forName( + "io.papermc.paper.event.connection.configuration.AsyncPlayerConnectionConfigureEvent"); Class connection = Class.forName("io.papermc.paper.connection.PlayerConnection"); connection.getMethod("isConnected"); Class.forName("io.papermc.paper.connection.PlayerConfigurationConnection") .getMethod("sendPluginMessage", org.bukkit.plugin.Plugin.class, String.class, byte[].class); org.bukkit.plugin.messaging.PluginMessageListener.class .getMethod("onPluginMessageReceived", String.class, connection, byte[].class); + if (preJoinLoginMethod == null && !findLegacyPreJoinQueue(configureEvent)) { + return; + } AuthMePreJoinListener listener = new AuthMePreJoinListener(plugin, this); plugin.getServer().getPluginManager().registerEvents(listener, plugin); if (plugin.getBungeeManager().isEnabled()) { @@ -104,11 +115,50 @@ private void enablePreJoinSupport() { plugin.getLog().info("Enabled AuthMe pre-join dialog compatibility"); } catch (ClassNotFoundException | NoSuchMethodException ignored) { // Older Paper and Spigot keep the ordinary post-join flow. + } catch (ReflectiveOperationException | SecurityException ex) { + plugin.getLog().warn("Could not enable AuthMe pre-join dialog compatibility", ex); + } + } + + private boolean findLegacyPreJoinQueue(Class configureEvent) throws ReflectiveOperationException { + Plugin authme = plugin.getServer().getPluginManager().getPlugin("AuthMe"); + if (authme == null) { + return false; + } + + HandlerList handlers = (HandlerList) configureEvent.getMethod("getHandlerList").invoke(null); + boolean hasAuthMeListener = false; + for (RegisteredListener registration : handlers.getRegisteredListeners()) { + if (registration.getPlugin() != authme) { + continue; + } + hasAuthMeListener = true; + Listener listener = registration.getListener(); + try { + // Builds such as 6.0.0-b2734 check this queue before opening either dialog, + // but do not yet expose forceLoginFromProxy(String) on AuthMeApi. + Field sessionsField = listener.getClass().getDeclaredField("proxySessionManager"); + sessionsField.setAccessible(true); + Object sessions = sessionsField.get(listener); + if (sessions != null) { + Method queueLogin = sessions.getClass().getMethod("processProxySessionMessage", String.class); + preJoinLoginTarget = sessions; + preJoinLoginMethod = queueLogin; + plugin.getLog().info("Using AuthMe session queue for pre-join dialog compatibility"); + return true; + } + } catch (NoSuchFieldException | NoSuchMethodException ignored) { + // Only the dialog listener exposes the shared session queue. + } + } + if (hasAuthMeListener) { + plugin.getLog().warn("AuthMe configuration listener found, but no compatible pre-join login API or queue"); } + return false; } public void forceLoginBeforeJoin(String playerName) throws ReflectiveOperationException { - forceLoginFromProxyMethod.invoke(authmeAPI, playerName); + preJoinLoginMethod.invoke(preJoinLoginTarget, playerName); } @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) @@ -125,7 +175,7 @@ public void onSessionRestore(RestoreSessionEvent restoreSessionEvent) { public boolean forceLogin(Player player) { if (authmeAPI.isAuthenticated(player)) { // AuthMe may have consumed the pre-join request before our delayed task runs. - if (forceLoginFromProxyMethod != null) { + if (preJoinLoginMethod != null) { return true; } plugin.getLog().warn(ALREADY_AUTHENTICATED, player); diff --git a/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHookTest.java b/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHookTest.java index 284b9bad4..9cfcf1b83 100644 --- a/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHookTest.java +++ b/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHookTest.java @@ -27,15 +27,36 @@ import com.github.games647.fastlogin.bukkit.FastLoginBukkit; import fr.xephi.authme.api.v3.AuthMeApi; +import io.papermc.paper.connection.PlayerConfigurationConnection; +import io.papermc.paper.event.connection.configuration.AsyncPlayerConnectionConfigureEvent; import org.bukkit.entity.Player; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.RegisteredListener; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; import org.mockito.MockedStatic; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Paths; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.isA; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.same; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.Mockito.withSettings; @@ -74,6 +95,128 @@ void modernAuthMeCanPrepareLoginBeforePlayerExists() throws ReflectiveOperationE } } + @Test + void authMeWithoutNewApiQueuesVerifiedLoginBeforeDialog() throws ReflectiveOperationException { + FastLoginBukkit plugin = mock(FastLoginBukkit.class, RETURNS_DEEP_STUBS); + when(plugin.getName()).thenReturn("FastLogin"); + Plugin authme = mock(Plugin.class); + when(plugin.getServer().getPluginManager().getPlugin("AuthMe")).thenReturn(authme); + AuthMeApi api = mock(AuthMeApi.class); + LegacyDialogListener dialogListener = new LegacyDialogListener(); + RegisteredListener registration = new RegisteredListener(dialogListener, (listener, event) -> { }, + EventPriority.HIGHEST, authme, false); + HandlerList handlers = AsyncPlayerConnectionConfigureEvent.getHandlerList(); + handlers.register(registration); + try (MockedStatic singleton = mockStatic(AuthMeApi.class)) { + singleton.when(AuthMeApi::getInstance).thenReturn(api); + AuthMeHook hook = new AuthMeHook(plugin); + verify(plugin.getServer().getPluginManager()).registerEvents(isA(AuthMePreJoinListener.class), same(plugin)); + + hook.forceLoginBeforeJoin("PremiumPlayer"); + assertTrue(dialogListener.proxySessionManager.sessions.contains("premiumplayer")); + assertFalse(dialogListener.proxySessionManager.sessions.contains("crackedplayer")); + + Player player = mock(Player.class); + when(api.isAuthenticated(player)).thenReturn(true); + assertTrue(hook.forceLogin(player)); + } finally { + handlers.unregister(registration); + } + } + + @Test + void anotherPluginsSessionQueueCannotEnableAuthMeBypass() { + FastLoginBukkit plugin = mock(FastLoginBukkit.class, RETURNS_DEEP_STUBS); + Plugin authme = mock(Plugin.class); + when(plugin.getServer().getPluginManager().getPlugin("AuthMe")).thenReturn(authme); + RegisteredListener registration = new RegisteredListener(new LegacyDialogListener(), (listener, event) -> { }, + EventPriority.HIGHEST, mock(Plugin.class), false); + HandlerList handlers = AsyncPlayerConnectionConfigureEvent.getHandlerList(); + handlers.register(registration); + try (MockedStatic singleton = mockStatic(AuthMeApi.class)) { + singleton.when(AuthMeApi::getInstance).thenReturn(mock(AuthMeApi.class)); + new AuthMeHook(plugin); + verify(plugin.getServer().getPluginManager(), never()) + .registerEvents(isA(AuthMePreJoinListener.class), same(plugin)); + } finally { + handlers.unregister(registration); + } + } + + @Test + @EnabledIfSystemProperty(named = "authme.compatibilityJar", matches = ".+") + void authMe2734SkipsDialogUsingItsRealSessionQueue() throws Exception { + URL jar = Paths.get(System.getProperty("authme.compatibilityJar")).toUri().toURL(); + try (URLClassLoader loader = new URLClassLoader(new URL[]{jar}, getClass().getClassLoader()) { + @Override + protected synchronized Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (!name.startsWith("fr.xephi.authme.")) { + return super.loadClass(name, resolve); + } + Class type = findLoadedClass(name); + if (type == null) { + type = findClass(name); + } + if (resolve) { + resolveClass(type); + } + return type; + } + }) { + Class listenerType = loader.loadClass("fr.xephi.authme.listener.PaperDialogFlowListener"); + Listener dialogListener = (Listener) listenerType.getConstructor().newInstance(); + Object sessions = loader.loadClass("fr.xephi.authme.data.ProxySessionManager").getConstructor().newInstance(); + Field sessionField = listenerType.getDeclaredField("proxySessionManager"); + sessionField.setAccessible(true); + sessionField.set(dialogListener, sessions); + Constructor cacheConstructor = loader.loadClass("fr.xephi.authme.data.auth.PlayerCache") + .getDeclaredConstructor(); + cacheConstructor.setAccessible(true); + Field cacheField = listenerType.getDeclaredField("playerCache"); + cacheField.setAccessible(true); + cacheField.set(dialogListener, cacheConstructor.newInstance()); + + FastLoginBukkit plugin = mock(FastLoginBukkit.class, RETURNS_DEEP_STUBS); + when(plugin.getName()).thenReturn("FastLogin"); + Plugin authme = mock(Plugin.class); + when(plugin.getServer().getPluginManager().getPlugin("AuthMe")).thenReturn(authme); + RegisteredListener registration = new RegisteredListener(dialogListener, (listener, event) -> { }, + EventPriority.HIGHEST, authme, false); + HandlerList handlers = AsyncPlayerConnectionConfigureEvent.getHandlerList(); + handlers.register(registration); + try (MockedStatic singleton = mockStatic(AuthMeApi.class)) { + singleton.when(AuthMeApi::getInstance).thenReturn(mock(AuthMeApi.class)); + AuthMeHook hook = new AuthMeHook(plugin); + hook.forceLoginBeforeJoin("PremiumPlayer"); + Method shouldResume = sessions.getClass().getMethod("shouldResumeSession", String.class); + assertTrue((boolean) shouldResume.invoke(sessions, "premiumplayer")); + assertFalse((boolean) shouldResume.invoke(sessions, "crackedplayer")); + + Method skipDialog = listenerType.getDeclaredMethod("shouldSkipDialogs", String.class, + PlayerConfigurationConnection.class); + skipDialog.setAccessible(true); + assertTrue((boolean) skipDialog.invoke(dialogListener, "premiumplayer", + mock(PlayerConfigurationConnection.class))); + } finally { + handlers.unregister(registration); + } + } + } + + public static class LegacyDialogListener implements Listener { + + private final LegacyProxySessions proxySessionManager = new LegacyProxySessions(); + } + + public static class LegacyProxySessions { + + private final Set sessions = new HashSet<>(); + + public void processProxySessionMessage(String name) { + sessions.add(name.toLowerCase(Locale.ROOT)); + } + } + public interface ProxyLoginApi { void forceLoginFromProxy(String name); From 08d5452f1c20456222edddedd04ea2b505f2a8c4 Mon Sep 17 00:00:00 2001 From: leoaxo098 Date: Sat, 31 Oct 2026 12:38:47 +0700 Subject: [PATCH 3/5] fix: track pre-join requests and skip unresponsive proxy waits Track successful pre-join requests on the verified connection session before accepting an already authenticated player as a successful login. Skip subsequent pre-join waits after an unresponsive proxy times out. Cover failed queue attempts, repeated proxy responses, modern API login, and empty proxy session maps in the regression tests. Validation: full Maven test suite, 65 passed and 1 skipped. --- .../fastlogin/bukkit/BukkitLoginSession.java | 9 +++++ .../fastlogin/bukkit/hook/AuthMeHook.java | 5 ++- .../bukkit/hook/AuthMePreJoinListener.java | 10 +++++- .../fastlogin/bukkit/hook/AuthMeHookTest.java | 29 ++++++++++++--- .../hook/AuthMePreJoinListenerTest.java | 36 +++++++++++++++++++ .../listener/PreJoinMessageListenerTest.java | 27 ++++++++++++++ .../listener/PreJoinMessageListenerTest.java | 28 +++++++++++++++ 7 files changed, 138 insertions(+), 6 deletions(-) diff --git a/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/BukkitLoginSession.java b/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/BukkitLoginSession.java index a29ac8337..87969af6b 100644 --- a/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/BukkitLoginSession.java +++ b/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/BukkitLoginSession.java @@ -48,6 +48,7 @@ public class BukkitLoginSession extends LoginSession { private boolean verified; private Boolean preJoinAutoLoginAllowed; + private boolean preJoinLoginQueued; private SkinProperty skinProperty; public BukkitLoginSession(String username, byte[] verifyToken, ClientPublicKey publicKey, boolean registered, @@ -129,4 +130,12 @@ public synchronized Boolean getPreJoinAutoLoginAllowed() { public synchronized void setPreJoinAutoLoginAllowed(boolean allowed) { this.preJoinAutoLoginAllowed = allowed; } + + public synchronized boolean isPreJoinLoginQueued() { + return preJoinLoginQueued; + } + + public synchronized void setPreJoinLoginQueued(boolean queued) { + this.preJoinLoginQueued = queued; + } } diff --git a/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHook.java b/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHook.java index a1319cf19..beb5c383a 100644 --- a/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHook.java +++ b/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHook.java @@ -175,7 +175,10 @@ public void onSessionRestore(RestoreSessionEvent restoreSessionEvent) { public boolean forceLogin(Player player) { if (authmeAPI.isAuthenticated(player)) { // AuthMe may have consumed the pre-join request before our delayed task runs. - if (preJoinLoginMethod != null) { + BukkitLoginSession session = preJoinLoginMethod == null ? null + : plugin.getSession(player.spigot().getRawAddress()); + if (session != null && session.isVerifiedPremium() && session.isPreJoinLoginQueued() + && player.getName().equals(session.getUsername())) { return true; } plugin.getLog().warn(ALREADY_AUTHENTICATED, player); diff --git a/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListener.java b/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListener.java index 866ec111d..84144df12 100644 --- a/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListener.java +++ b/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListener.java @@ -60,6 +60,7 @@ public class AuthMePreJoinListener implements Listener, PluginMessageListener { private final AuthMeHook hook; private final String channel; private final ConcurrentMap pending = new ConcurrentHashMap<>(); + private volatile boolean proxyUnresponsive; public AuthMePreJoinListener(FastLoginBukkit plugin, AuthMeHook hook) { this.plugin = plugin; @@ -114,12 +115,16 @@ public void onConfigure(AsyncPlayerConnectionConfigureEvent event) { // Queue before AuthMe opens either the login or the registration dialog. Registration // and its IP limits still run through the normal FastLogin task after the player joins. hook.forceLoginBeforeJoin(name); + session.setPreJoinLoginQueued(true); } catch (ReflectiveOperationException ex) { plugin.getLog().warn("Could not prepare AuthMe pre-join login for {}", name, ex); } } private BukkitLoginSession requestProxySession(PlayerConfigurationConnection connection) { + if (proxyUnresponsive) { + return null; + } PendingRequest request = new PendingRequest(); pending.put(connection, request); try { @@ -137,7 +142,9 @@ private BukkitLoginSession requestProxySession(PlayerConfigurationConnection con } catch (InterruptedException ex) { Thread.currentThread().interrupt(); } catch (ExecutionException | TimeoutException ex) { - plugin.getLog().warn("No pre-join response from FastLogin proxy for {}; keeping AuthMe authentication", + proxyUnresponsive = true; + plugin.getLog().warn("No pre-join response from FastLogin proxy for {}; keeping AuthMe authentication " + + "and skipping further pre-join requests until FastLogin restarts", connection.getProfile().getName()); } finally { pending.remove(connection, request); @@ -167,6 +174,7 @@ public void onPluginMessageReceived(String channel, PlayerConnection connection, if (request.id.equals(response.getRequestId()) && action != null && action.getPlayerName().equals(name) && plugin.getBungeeManager().isProxyAllowed(action.getProxyId())) { + proxyUnresponsive = false; request.response.complete(action); } } catch (IllegalArgumentException | IllegalStateException | IndexOutOfBoundsException ex) { diff --git a/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHookTest.java b/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHookTest.java index 9cfcf1b83..3f7bba655 100644 --- a/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHookTest.java +++ b/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHookTest.java @@ -26,6 +26,7 @@ package com.github.games647.fastlogin.bukkit.hook; import com.github.games647.fastlogin.bukkit.FastLoginBukkit; +import com.github.games647.fastlogin.bukkit.BukkitLoginSession; import fr.xephi.authme.api.v3.AuthMeApi; import io.papermc.paper.connection.PlayerConfigurationConnection; import io.papermc.paper.event.connection.configuration.AsyncPlayerConnectionConfigureEvent; @@ -43,6 +44,7 @@ import java.lang.reflect.Field; import java.lang.reflect.Method; import java.net.URL; +import java.net.InetSocketAddress; import java.net.URLClassLoader; import java.nio.file.Paths; import java.util.HashSet; @@ -57,6 +59,7 @@ import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; import static org.mockito.Mockito.same; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.Mockito.withSettings; @@ -89,9 +92,15 @@ void modernAuthMeCanPrepareLoginBeforePlayerExists() throws ReflectiveOperationE hook.forceLoginBeforeJoin("PremiumPlayer"); verify((ProxyLoginApi) api).forceLoginFromProxy("PremiumPlayer"); - Player player = mock(Player.class); - when(api.isAuthenticated(player)).thenReturn(true); + Player player = authenticatedPlayer(plugin, api, false); + assertFalse(hook.forceLogin(player)); + plugin.getSession(player.spigot().getRawAddress()).setPreJoinLoginQueued(true); + assertTrue(hook.forceLogin(player)); + + when(api.isAuthenticated(player)).thenReturn(false); + when(plugin.getBungeeManager().isEnabled()).thenReturn(true); assertTrue(hook.forceLogin(player)); + verify((ProxyLoginApi) api, times(2)).forceLoginFromProxy("PremiumPlayer"); } } @@ -116,8 +125,7 @@ void authMeWithoutNewApiQueuesVerifiedLoginBeforeDialog() throws ReflectiveOpera assertTrue(dialogListener.proxySessionManager.sessions.contains("premiumplayer")); assertFalse(dialogListener.proxySessionManager.sessions.contains("crackedplayer")); - Player player = mock(Player.class); - when(api.isAuthenticated(player)).thenReturn(true); + Player player = authenticatedPlayer(plugin, api, true); assertTrue(hook.forceLogin(player)); } finally { handlers.unregister(registration); @@ -203,6 +211,19 @@ protected synchronized Class loadClass(String name, boolean resolve) throws C } } + private Player authenticatedPlayer(FastLoginBukkit plugin, AuthMeApi api, boolean queued) { + Player player = mock(Player.class, RETURNS_DEEP_STUBS); + when(player.getName()).thenReturn("PremiumPlayer"); + InetSocketAddress address = new InetSocketAddress("127.0.0.1", 12345); + when(player.spigot().getRawAddress()).thenReturn(address); + BukkitLoginSession session = new BukkitLoginSession("PremiumPlayer", true); + session.setVerifiedPremium(true); + session.setPreJoinLoginQueued(queued); + when(plugin.getSession(address)).thenReturn(session); + when(api.isAuthenticated(player)).thenReturn(true); + return player; + } + public static class LegacyDialogListener implements Listener { private final LegacyProxySessions proxySessionManager = new LegacyProxySessions(); diff --git a/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListenerTest.java b/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListenerTest.java index 765860e06..74a404a55 100644 --- a/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListenerTest.java +++ b/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListenerTest.java @@ -39,6 +39,7 @@ import org.junit.jupiter.api.Test; import java.net.InetSocketAddress; +import java.time.Duration; import java.util.UUID; import static org.mockito.ArgumentMatchers.any; @@ -50,6 +51,11 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; class AuthMePreJoinListenerTest { @@ -81,6 +87,15 @@ void setUp() { void verifiedPremiumSkipsDialogBeforeJoin() throws ReflectiveOperationException { listener.onConfigure(new AsyncPlayerConnectionConfigureEvent(connection)); verify(hook).forceLoginBeforeJoin("PremiumPlayer"); + assertTrue(session.isPreJoinLoginQueued()); + } + + @Test + void failedQueueDoesNotMarkSessionAsQueued() throws ReflectiveOperationException { + doThrow(new ReflectiveOperationException("queue unavailable")).when(hook) + .forceLoginBeforeJoin("PremiumPlayer"); + listener.onConfigure(new AsyncPlayerConnectionConfigureEvent(connection)); + assertFalse(session.isPreJoinLoginQueued()); } @Test @@ -150,6 +165,24 @@ void proxyReplyMustMatchConnectionRequestAndAllowedProxy() throws ReflectiveOper verify(plugin).putSession(eq(address), any(BukkitLoginSession.class)); } + @Test + void validProxyResponsesKeepPreJoinEnabled() throws ReflectiveOperationException { + when(plugin.getBungeeManager().isEnabled()).thenReturn(true); + UUID allowedProxy = UUID.randomUUID(); + when(plugin.getBungeeManager().isProxyAllowed(allowedProxy)).thenReturn(true); + doAnswer(invocation -> { + PreJoinMessage request = new PreJoinMessage(); + request.readFrom(ByteStreams.newDataInput(invocation.getArgument(2))); + reply(connection, request.getRequestId(), allowedProxy, "PremiumPlayer"); + return null; + }).when(connection).sendPluginMessage(eq(plugin), eq("fastlogin:prejoin"), any(byte[].class)); + + listener.onConfigure(new AsyncPlayerConnectionConfigureEvent(connection)); + listener.onConfigure(new AsyncPlayerConnectionConfigureEvent(connection)); + + verify(hook, times(2)).forceLoginBeforeJoin("PremiumPlayer"); + } + @Test void registrationCannotAuthenticateAnExistingAuthMeAccount() throws ReflectiveOperationException { when(plugin.getBungeeManager().isEnabled()).thenReturn(true); @@ -190,6 +223,9 @@ void expiredProxyRequestCannotApproveALaterConnection() throws ReflectiveOperati PreJoinMessage message = new PreJoinMessage(); message.readFrom(ByteStreams.newDataInput(request.getValue())); reply(connection, message.getRequestId(), allowedProxy, "PremiumPlayer"); + assertTimeoutPreemptively(Duration.ofSeconds(1), + () -> listener.onConfigure(new AsyncPlayerConnectionConfigureEvent(connection))); + verify(connection).sendPluginMessage(eq(plugin), eq("fastlogin:prejoin"), any(byte[].class)); verify(hook, never()).forceLoginBeforeJoin(anyString()); } } diff --git a/bungee/src/test/java/com/github/games647/fastlogin/bungee/listener/PreJoinMessageListenerTest.java b/bungee/src/test/java/com/github/games647/fastlogin/bungee/listener/PreJoinMessageListenerTest.java index c50af69fa..4b20d2d9f 100644 --- a/bungee/src/test/java/com/github/games647/fastlogin/bungee/listener/PreJoinMessageListenerTest.java +++ b/bungee/src/test/java/com/github/games647/fastlogin/bungee/listener/PreJoinMessageListenerTest.java @@ -80,6 +80,33 @@ void clientCannotRequestAuthentication() { verifyNoInteractions(server); } + @Test + void onlinePlayerWithoutSessionGetsCrackedReply() { + FastLoginBungee plugin = mock(FastLoginBungee.class, RETURNS_DEEP_STUBS); + when(plugin.getName()).thenReturn("FastLogin"); + ProxiedPlayer player = mock(ProxiedPlayer.class); + when(player.getName()).thenReturn("PremiumPlayer"); + PendingConnection connection = mock(PendingConnection.class); + when(connection.isOnlineMode()).thenReturn(true); + when(player.getPendingConnection()).thenReturn(connection); + when(plugin.getSession()).thenReturn(new ConcurrentHashMap<>()); + when(plugin.getProxy().getConfig().getUuid()).thenReturn(UUID.randomUUID().toString()); + Server source = mock(Server.class); + UUID requestId = UUID.randomUUID(); + ByteArrayDataOutput output = ByteStreams.newDataOutput(); + new PreJoinMessage(requestId, null).writeTo(output); + PluginMessageEvent event = new PluginMessageEvent(source, player, "fastlogin:prejoin", output.toByteArray()); + + new PluginMessageListener(plugin).onPluginMessage(event); + + ArgumentCaptor response = ArgumentCaptor.forClass(PreJoinMessage.class); + verify(plugin).sendPluginMessage(eq(source), response.capture()); + assertTrue(event.isCancelled()); + assertEquals(requestId, response.getValue().getRequestId()); + assertEquals(LoginActionMessage.Type.CRACKED, response.getValue().getLoginAction().getType()); + assertTrue(plugin.getSession().isEmpty()); + } + private void assertReply(boolean onlineMode, boolean registered, LoginActionMessage.Type expected) { FastLoginBungee plugin = mock(FastLoginBungee.class, RETURNS_DEEP_STUBS); when(plugin.getName()).thenReturn("FastLogin"); diff --git a/velocity/src/test/java/com/github/games647/fastlogin/velocity/listener/PreJoinMessageListenerTest.java b/velocity/src/test/java/com/github/games647/fastlogin/velocity/listener/PreJoinMessageListenerTest.java index 40339a596..bd6588885 100644 --- a/velocity/src/test/java/com/github/games647/fastlogin/velocity/listener/PreJoinMessageListenerTest.java +++ b/velocity/src/test/java/com/github/games647/fastlogin/velocity/listener/PreJoinMessageListenerTest.java @@ -81,6 +81,34 @@ void clientCannotRequestAuthentication() { verifyNoInteractions(server); } + @Test + void onlinePlayerWithoutSessionGetsCrackedReply() { + FastLoginVelocity plugin = mock(FastLoginVelocity.class, RETURNS_DEEP_STUBS); + when(plugin.getName()).thenReturn("fastlogin"); + Player player = mock(Player.class); + when(player.getUsername()).thenReturn("PremiumPlayer"); + when(player.isOnlineMode()).thenReturn(true); + when(player.getRemoteAddress()).thenReturn(new InetSocketAddress("127.0.0.1", 23456)); + when(plugin.getSession()).thenReturn(new ConcurrentHashMap<>()); + when(plugin.getProxyId()).thenReturn(UUID.randomUUID()); + ServerConnection source = mock(ServerConnection.class); + when(source.getPlayer()).thenReturn(player); + UUID requestId = UUID.randomUUID(); + ByteArrayDataOutput output = ByteStreams.newDataOutput(); + new PreJoinMessage(requestId, null).writeTo(output); + PluginMessageEvent event = new PluginMessageEvent(source, player, + MinecraftChannelIdentifier.from("fastlogin:prejoin"), output.toByteArray()); + + new PluginMessageListener(plugin).onPluginMessage(event); + + ArgumentCaptor response = ArgumentCaptor.forClass(PreJoinMessage.class); + verify(plugin).sendPluginMessage(eq(source), response.capture()); + assertFalse(event.getResult().isAllowed()); + assertEquals(requestId, response.getValue().getRequestId()); + assertEquals(LoginActionMessage.Type.CRACKED, response.getValue().getLoginAction().getType()); + assertEquals(0, plugin.getSession().size()); + } + private void assertReply(boolean onlineMode, boolean registered, LoginActionMessage.Type expected) { FastLoginVelocity plugin = mock(FastLoginVelocity.class, RETURNS_DEEP_STUBS); when(plugin.getName()).thenReturn("fastlogin"); From 690e1453b06c8e9ce7852919c2e54f7dab9dc79d Mon Sep 17 00:00:00 2001 From: leoaxo098 Date: Sat, 31 Oct 2026 15:50:14 +0700 Subject: [PATCH 4/5] fix: recover pre-join proxy requests safely after timeouts Retry an unresponsive proxy after a 30-second cooldown, allowing only one recovery probe while other connections fall back to AuthMe. Clear the cooldown only when the recovery request receives a valid response, and cancel timed-out futures so late replies cannot reset it. Cover cooldown boundaries, competing joins, recovery, and late replies. Validation: 66 tests passed, 1 skipped; CodeRabbit reported no findings. --- .../bukkit/hook/AuthMePreJoinListener.java | 44 ++++++++++-- .../hook/AuthMePreJoinListenerTest.java | 70 +++++++++++++++++++ 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListener.java b/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListener.java index 84144df12..51871372a 100644 --- a/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListener.java +++ b/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListener.java @@ -50,6 +50,7 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.function.LongSupplier; /** * Runs before AuthMe's blocking configuration dialog. Only loaded on Paper versions with this API. @@ -60,11 +61,18 @@ public class AuthMePreJoinListener implements Listener, PluginMessageListener { private final AuthMeHook hook; private final String channel; private final ConcurrentMap pending = new ConcurrentHashMap<>(); - private volatile boolean proxyUnresponsive; + private final LongSupplier nanoTime; + private Long proxyTimeoutNanos; + private PendingRequest proxyProbe; public AuthMePreJoinListener(FastLoginBukkit plugin, AuthMeHook hook) { + this(plugin, hook, System::nanoTime); + } + + AuthMePreJoinListener(FastLoginBukkit plugin, AuthMeHook hook, LongSupplier nanoTime) { this.plugin = plugin; this.hook = hook; + this.nanoTime = nanoTime; this.channel = NamespaceKey.getCombined(plugin.getName(), PreJoinMessage.CHANNEL); } @@ -121,17 +129,34 @@ public void onConfigure(AsyncPlayerConnectionConfigureEvent event) { } } - private BukkitLoginSession requestProxySession(PlayerConfigurationConnection connection) { - if (proxyUnresponsive) { + private synchronized PendingRequest createProxyRequest() { + if (proxyProbe != null || (proxyTimeoutNanos != null + && nanoTime.getAsLong() - proxyTimeoutNanos < TimeUnit.SECONDS.toNanos(30))) { return null; } PendingRequest request = new PendingRequest(); + if (proxyTimeoutNanos != null) { + proxyProbe = request; + } + return request; + } + + private BukkitLoginSession requestProxySession(PlayerConfigurationConnection connection) { + PendingRequest request = createProxyRequest(); + if (request == null) { + return null; + } pending.put(connection, request); try { ByteArrayDataOutput output = ByteStreams.newDataOutput(); new PreJoinMessage(request.id, null).writeTo(output); connection.sendPluginMessage(plugin, channel, output.toByteArray()); LoginActionMessage action = request.response.get(5, TimeUnit.SECONDS); + synchronized (this) { + if (proxyProbe == request) { + proxyTimeoutNanos = null; + } + } if (action.getType() == LoginActionMessage.Type.CRACKED) { return null; } @@ -142,12 +167,20 @@ private BukkitLoginSession requestProxySession(PlayerConfigurationConnection con } catch (InterruptedException ex) { Thread.currentThread().interrupt(); } catch (ExecutionException | TimeoutException ex) { - proxyUnresponsive = true; + request.response.cancel(false); + synchronized (this) { + proxyTimeoutNanos = nanoTime.getAsLong(); + } plugin.getLog().warn("No pre-join response from FastLogin proxy for {}; keeping AuthMe authentication " - + "and skipping further pre-join requests until FastLogin restarts", + + "and skipping further pre-join requests for 30 seconds", connection.getProfile().getName()); } finally { pending.remove(connection, request); + synchronized (this) { + if (proxyProbe == request) { + proxyProbe = null; + } + } } return null; } @@ -174,7 +207,6 @@ public void onPluginMessageReceived(String channel, PlayerConnection connection, if (request.id.equals(response.getRequestId()) && action != null && action.getPlayerName().equals(name) && plugin.getBungeeManager().isProxyAllowed(action.getProxyId())) { - proxyUnresponsive = false; request.response.complete(action); } } catch (IllegalArgumentException | IllegalStateException | IndexOutOfBoundsException ex) { diff --git a/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListenerTest.java b/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListenerTest.java index 74a404a55..74fadadc5 100644 --- a/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListenerTest.java +++ b/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListenerTest.java @@ -41,6 +41,12 @@ import java.net.InetSocketAddress; import java.time.Duration; import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicBoolean; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -212,11 +218,50 @@ private void reply(PlayerConfigurationConnection source, UUID requestId, UUID pr listener.onPluginMessageReceived("fastlogin:prejoin", source, output.toByteArray()); } + @Test + void replyAlreadyBeingValidatedCannotClearTimeoutCooldown() throws Exception { + when(plugin.getBungeeManager().isEnabled()).thenReturn(true); + UUID allowedProxy = UUID.randomUUID(); + CountDownLatch validating = new CountDownLatch(1); + CountDownLatch resume = new CountDownLatch(1); + AtomicReference> response = new AtomicReference<>(); + when(plugin.getBungeeManager().isProxyAllowed(allowedProxy)).thenAnswer(invocation -> { + validating.countDown(); + assertTrue(resume.await(10, TimeUnit.SECONDS)); + return true; + }); + doAnswer(invocation -> { + PreJoinMessage request = new PreJoinMessage(); + request.readFrom(ByteStreams.newDataInput(invocation.getArgument(2))); + response.set(CompletableFuture.runAsync( + () -> reply(connection, request.getRequestId(), allowedProxy, "PremiumPlayer"))); + assertTrue(validating.await(1, TimeUnit.SECONDS)); + return null; + }).when(connection).sendPluginMessage(eq(plugin), eq("fastlogin:prejoin"), any(byte[].class)); + + try { + listener.onConfigure(new AsyncPlayerConnectionConfigureEvent(connection)); + } finally { + resume.countDown(); + } + response.get().get(1, TimeUnit.SECONDS); + assertTimeoutPreemptively(Duration.ofSeconds(1), + () -> listener.onConfigure(new AsyncPlayerConnectionConfigureEvent(connection))); + verify(connection).sendPluginMessage(eq(plugin), eq("fastlogin:prejoin"), any(byte[].class)); + verify(hook, never()).forceLoginBeforeJoin(anyString()); + } + @Test void expiredProxyRequestCannotApproveALaterConnection() throws ReflectiveOperationException { + AtomicLong clock = new AtomicLong(); + listener = new AuthMePreJoinListener(plugin, hook, clock::get); when(plugin.getBungeeManager().isEnabled()).thenReturn(true); UUID allowedProxy = UUID.randomUUID(); when(plugin.getBungeeManager().isProxyAllowed(allowedProxy)).thenReturn(true); + doAnswer(invocation -> { + clock.set(TimeUnit.SECONDS.toNanos(10)); + return null; + }).when(connection).sendPluginMessage(eq(plugin), eq("fastlogin:prejoin"), any(byte[].class)); org.mockito.ArgumentCaptor request = org.mockito.ArgumentCaptor.forClass(byte[].class); listener.onConfigure(new AsyncPlayerConnectionConfigureEvent(connection)); verify(connection).sendPluginMessage(eq(plugin), eq("fastlogin:prejoin"), request.capture()); @@ -227,5 +272,30 @@ void expiredProxyRequestCannotApproveALaterConnection() throws ReflectiveOperati () -> listener.onConfigure(new AsyncPlayerConnectionConfigureEvent(connection))); verify(connection).sendPluginMessage(eq(plugin), eq("fastlogin:prejoin"), any(byte[].class)); verify(hook, never()).forceLoginBeforeJoin(anyString()); + clock.set(TimeUnit.SECONDS.toNanos(40) - 1); + assertTimeoutPreemptively(Duration.ofSeconds(1), + () -> listener.onConfigure(new AsyncPlayerConnectionConfigureEvent(connection))); + verify(connection).sendPluginMessage(eq(plugin), eq("fastlogin:prejoin"), any(byte[].class)); + + PlayerConfigurationConnection competing = mock(PlayerConfigurationConnection.class, RETURNS_DEEP_STUBS); + when(competing.getProfile().getName()).thenReturn("AnotherPlayer"); + when(competing.getAddress()).thenReturn(new InetSocketAddress("127.0.0.1", 12346)); + AtomicBoolean recoveryProbe = new AtomicBoolean(true); + doAnswer(invocation -> { + PreJoinMessage retry = new PreJoinMessage(); + retry.readFrom(ByteStreams.newDataInput(invocation.getArgument(2))); + if (recoveryProbe.getAndSet(false)) { + assertTimeoutPreemptively(Duration.ofSeconds(1), + () -> listener.onConfigure(new AsyncPlayerConnectionConfigureEvent(competing))); + verify(competing, never()).sendPluginMessage(eq(plugin), eq("fastlogin:prejoin"), any(byte[].class)); + } + reply(connection, retry.getRequestId(), allowedProxy, "PremiumPlayer"); + return null; + }).when(connection).sendPluginMessage(eq(plugin), eq("fastlogin:prejoin"), any(byte[].class)); + clock.set(TimeUnit.SECONDS.toNanos(40)); + listener.onConfigure(new AsyncPlayerConnectionConfigureEvent(connection)); + listener.onConfigure(new AsyncPlayerConnectionConfigureEvent(connection)); + verify(connection, times(3)).sendPluginMessage(eq(plugin), eq("fastlogin:prejoin"), any(byte[].class)); + verify(hook, times(2)).forceLoginBeforeJoin("PremiumPlayer"); } } From f46bac3c3f0c788070fb88c99bcf24dd207a7d8a Mon Sep 17 00:00:00 2001 From: Leoaxo098 <98978560+Leoaxo098@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:54:45 +0700 Subject: [PATCH 5/5] Update bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListener.java Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../games647/fastlogin/bukkit/hook/AuthMePreJoinListener.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListener.java b/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListener.java index 51871372a..f1fe5b39b 100644 --- a/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListener.java +++ b/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListener.java @@ -153,9 +153,7 @@ private BukkitLoginSession requestProxySession(PlayerConfigurationConnection con connection.sendPluginMessage(plugin, channel, output.toByteArray()); LoginActionMessage action = request.response.get(5, TimeUnit.SECONDS); synchronized (this) { - if (proxyProbe == request) { - proxyTimeoutNanos = null; - } + proxyTimeoutNanos = null; } if (action.getType() == LoginActionMessage.Type.CRACKED) { return null;