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..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
@@ -47,6 +47,8 @@ public class BukkitLoginSession extends LoginSession {
private final ClientPublicKey clientPublicKey;
private boolean verified;
+ private Boolean preJoinAutoLoginAllowed;
+ private boolean preJoinLoginQueued;
private SkinProperty skinProperty;
public BukkitLoginSession(String username, byte[] verifyToken, ClientPublicKey publicKey, boolean registered,
@@ -120,4 +122,20 @@ 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;
+ }
+
+ 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 9a906eeba..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
@@ -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;
@@ -36,9 +38,13 @@
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;
/**
* GitHub: ...
@@ -56,6 +62,11 @@ 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;
+ private Object preJoinLoginTarget;
+ private Method preJoinLoginMethod;
+
public AuthMeHook(FastLoginBukkit plugin) {
this.plugin = plugin;
this.authmeAPI = AuthMeApi.getInstance();
@@ -69,6 +80,85 @@ 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);
+ preJoinLoginTarget = authmeAPI;
+ preJoinLoginMethod = forceLoginFromProxyMethod;
+ } catch (NoSuchMethodException ignored) {
+ forceLoginFromProxyMethod = null;
+ }
+ enablePreJoinSupport();
+ }
+
+ private void enablePreJoinSupport() {
+ try {
+ 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()) {
+ 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.
+ } 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 {
+ preJoinLoginMethod.invoke(preJoinLoginTarget, playerName);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
@@ -84,10 +174,27 @@ 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.
+ 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);
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..f1fe5b39b
--- /dev/null
+++ b/bukkit/src/main/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListener.java
@@ -0,0 +1,220 @@
+/*
+ * 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;
+import java.util.function.LongSupplier;
+
+/**
+ * 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<>();
+ 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);
+ }
+
+ @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);
+ session.setPreJoinLoginQueued(true);
+ } catch (ReflectiveOperationException ex) {
+ plugin.getLog().warn("Could not prepare AuthMe pre-join login for {}", name, ex);
+ }
+ }
+
+ 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) {
+ proxyTimeoutNanos = null;
+ }
+ 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) {
+ 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 for 30 seconds",
+ connection.getProfile().getName());
+ } finally {
+ pending.remove(connection, request);
+ synchronized (this) {
+ if (proxyProbe == request) {
+ proxyProbe = null;
+ }
+ }
+ }
+ 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..3f7bba655
--- /dev/null
+++ b/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMeHookTest.java
@@ -0,0 +1,245 @@
+/*
+ * 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 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;
+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.InetSocketAddress;
+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.times;
+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 = 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");
+ }
+ }
+
+ @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 = authenticatedPlayer(plugin, api, 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);
+ }
+ }
+ }
+
+ 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();
+ }
+
+ 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);
+ }
+}
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..74fadadc5
--- /dev/null
+++ b/bukkit/src/test/java/com/github/games647/fastlogin/bukkit/hook/AuthMePreJoinListenerTest.java
@@ -0,0 +1,301 @@
+/*
+ * 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.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;
+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;
+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 {
+
+ 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");
+ 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
+ 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 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);
+ 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 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());
+ 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());
+ 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");
+ }
+}
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..4b20d2d9f
--- /dev/null
+++ b/bungee/src/test/java/com/github/games647/fastlogin/bungee/listener/PreJoinMessageListenerTest.java
@@ -0,0 +1,142 @@
+/*
+ * 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);
+ }
+
+ @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");
+ 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..bd6588885
--- /dev/null
+++ b/velocity/src/test/java/com/github/games647/fastlogin/velocity/listener/PreJoinMessageListenerTest.java
@@ -0,0 +1,146 @@
+/*
+ * 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);
+ }
+
+ @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");
+ 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());
+ }
+}