From 8e810e2e80eea7aa69ebec149516d6b0e0fb59e4 Mon Sep 17 00:00:00 2001 From: linsaftw Date: Thu, 6 Aug 2026 20:35:02 -0300 Subject: [PATCH 1/3] feat(api): add forceLoginFromProxy(String) for dialog-safe proxy pre-join login Bypasses Paper/Folia pre-join dialogs when called before Player creation. Queue-first ordering ensures race-safe convergence with AsynchronousJoin. Caller must authenticate the proxy signal; completion is async. Fixes #3118 --- .../fr/xephi/authme/api/v3/AuthMeApi.java | 21 +++++ .../fr/xephi/authme/process/Management.java | 21 +++++ .../fr/xephi/authme/api/v3/AuthMeApiTest.java | 12 +++ .../xephi/authme/process/ManagementTest.java | 84 +++++++++++++++++++ .../bungeecord/BungeeReceiverTest.java | 6 +- 5 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 authme-core/src/test/java/fr/xephi/authme/process/ManagementTest.java diff --git a/authme-core/src/main/java/fr/xephi/authme/api/v3/AuthMeApi.java b/authme-core/src/main/java/fr/xephi/authme/api/v3/AuthMeApi.java index 3d8ab194f8..764d6a24cd 100644 --- a/authme-core/src/main/java/fr/xephi/authme/api/v3/AuthMeApi.java +++ b/authme-core/src/main/java/fr/xephi/authme/api/v3/AuthMeApi.java @@ -265,6 +265,27 @@ public void forceLogin(Player player) { management.forceLogin(player); } + /** + * Force a player to login by name, bypassing password verification. + * This is intended for trusted proxy integrations where the proxy has already + * authenticated the player. + * + *

The request is queued for up to 15 seconds. If the player is already online, + * the existing quiet force-login path is used. If the player is in a pre-join + * login dialog, the dialog is closed and the player is marked for force-login on join. + * If the player is offline, the queued request is consumed when the player joins. + * + *

Authentication completion is asynchronous. Observe {@link LoginEvent}, + * {@link RestoreSessionEvent}, or {@link AuthMeApi#isAuthenticated(Player)} later; + * do not assume this method returns after authentication. + * + * @param playerName the player name (case-insensitive) + * @throws IllegalArgumentException if playerName is null + */ + public void forceLoginFromProxy(String playerName) { + management.forceLoginFromProxy(playerName); + } + /** * Force a player to logout. * diff --git a/authme-core/src/main/java/fr/xephi/authme/process/Management.java b/authme-core/src/main/java/fr/xephi/authme/process/Management.java index b87c759c53..603041eb52 100644 --- a/authme-core/src/main/java/fr/xephi/authme/process/Management.java +++ b/authme-core/src/main/java/fr/xephi/authme/process/Management.java @@ -19,6 +19,10 @@ import javax.inject.Inject; +import fr.xephi.authme.data.ProxySessionManager; +import fr.xephi.authme.service.PreJoinDialogService; +import java.util.Locale; + /** * Performs auth actions, e.g. when a player joins, registers or wants to change his password. */ @@ -27,6 +31,12 @@ public class Management { @Inject private BukkitService bukkitService; + @Inject + private ProxySessionManager proxySessionManager; + + @Inject + private PreJoinDialogService preJoinDialogService; + // Processes @Inject private AsyncAddEmail asyncAddEmail; @@ -65,6 +75,17 @@ public void forceLoginFromProxy(Player player) { runTask(() -> asynchronousLogin.forceLoginFromProxy(player)); } + public void forceLoginFromProxy(String playerName) { + String normalizedName = playerName.toLowerCase(Locale.ROOT); + proxySessionManager.processProxySessionMessage(playerName); + Player player = bukkitService.getPlayerExact(playerName); + if (player != null && player.isOnline()) { + forceLoginFromProxy(player); + } else { + preJoinDialogService.approvePreJoinForceLogin(normalizedName); + } + } + public void performLogout(Player player) { // Capture location on the main thread before handing off to the async task. Location quitLocation = player.getLocation(); diff --git a/authme-core/src/test/java/fr/xephi/authme/api/v3/AuthMeApiTest.java b/authme-core/src/test/java/fr/xephi/authme/api/v3/AuthMeApiTest.java index 938b8b22bd..e5c006b377 100644 --- a/authme-core/src/test/java/fr/xephi/authme/api/v3/AuthMeApiTest.java +++ b/authme-core/src/test/java/fr/xephi/authme/api/v3/AuthMeApiTest.java @@ -415,6 +415,18 @@ void shouldForceLogin() { verify(management).forceLogin(player); } + @Test + void shouldForceLoginFromProxyByName() { + // given + String playerName = "Connor"; + + // when + api.forceLoginFromProxy(playerName); + + // then + verify(management).forceLoginFromProxy(playerName); + } + @Test void shouldForceLogout() { // given diff --git a/authme-core/src/test/java/fr/xephi/authme/process/ManagementTest.java b/authme-core/src/test/java/fr/xephi/authme/process/ManagementTest.java new file mode 100644 index 0000000000..37432228f8 --- /dev/null +++ b/authme-core/src/test/java/fr/xephi/authme/process/ManagementTest.java @@ -0,0 +1,84 @@ +package fr.xephi.authme.process; + +import fr.xephi.authme.data.ProxySessionManager; +import fr.xephi.authme.process.login.AsynchronousLogin; +import fr.xephi.authme.service.BukkitService; +import fr.xephi.authme.service.PreJoinDialogService; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Test for {@link Management}. + */ +@ExtendWith(MockitoExtension.class) +class ManagementTest { + + @InjectMocks + private Management management; + + @Mock + private BukkitService bukkitService; + @Mock + private AsynchronousLogin asynchronousLogin; + @Mock + private ProxySessionManager proxySessionManager; + @Mock + private PreJoinDialogService preJoinDialogService; + + @Test + void shouldForceLoginFromProxyByNameQueuesBeforeOnlineForceLogin() { + // given + String playerName = "Connor"; + Player player = mock(Player.class); + when(bukkitService.getPlayerExact(playerName)).thenReturn(player); + when(player.isOnline()).thenReturn(true); + + // when + management.forceLoginFromProxy(playerName); + + // then + verify(proxySessionManager).processProxySessionMessage(playerName); + verify(bukkitService).runTaskOptionallyAsync(any(Runnable.class)); + } + + @Test + void shouldForceLoginFromProxyByNameApprovesBlockingPreJoinDialog() { + // given + String playerName = "ConNor"; + when(bukkitService.getPlayerExact(playerName)).thenReturn(null); + + // when + management.forceLoginFromProxy(playerName); + + // then + verify(proxySessionManager).processProxySessionMessage(playerName); + verify(preJoinDialogService).approvePreJoinForceLogin("connor"); + verify(bukkitService, never()).runTaskOptionallyAsync(any()); + } + + @Test + void shouldForceLoginFromProxyByNameKeepsQueueWhenNoDialogFutureExists() { + // given + String playerName = "OfflinePlayer"; + when(bukkitService.getPlayerExact(playerName)).thenReturn(null); + when(preJoinDialogService.approvePreJoinForceLogin("offlineplayer")).thenReturn(false); + + // when + management.forceLoginFromProxy(playerName); + + // then + verify(proxySessionManager).processProxySessionMessage(playerName); + verify(preJoinDialogService).approvePreJoinForceLogin("offlineplayer"); + verify(bukkitService, never()).runTaskOptionallyAsync(any()); + } +} \ No newline at end of file diff --git a/authme-core/src/test/java/fr/xephi/authme/service/bungeecord/BungeeReceiverTest.java b/authme-core/src/test/java/fr/xephi/authme/service/bungeecord/BungeeReceiverTest.java index 17a0256d67..d5de39460c 100644 --- a/authme-core/src/test/java/fr/xephi/authme/service/bungeecord/BungeeReceiverTest.java +++ b/authme-core/src/test/java/fr/xephi/authme/service/bungeecord/BungeeReceiverTest.java @@ -165,7 +165,7 @@ void shouldOnlyQueueSessionWhenPerformLoginReceivedForOfflinePlayer() { // then verify(proxySessionManager).processProxySessionMessage(playerName, null); - verify(management, never()).forceLoginFromProxy(any()); + verify(management, never()).forceLoginFromProxy(any(Player.class)); verify(bungeeSender, never()).sendAuthMeBungeecordMessage(any(), any()); } @@ -234,7 +234,7 @@ void shouldRemoveQueuedRequestWhenPremiumValidateRejects() { // then verify(proxySessionManager).removeLoginRequest(playerName); - verify(management, never()).forceLoginFromProxy(any()); + verify(management, never()).forceLoginFromProxy(any(Player.class)); verify(bungeeSender, never()).sendAuthMeBungeecordMessage(any(), any()); } @@ -261,7 +261,7 @@ void shouldValidateAndQueueConfigPhasePerformLogin() { // then assertThat(result, equalTo("bobby")); verify(proxySessionManager).processProxySessionMessage(playerName, null); - verify(management, never()).forceLoginFromProxy(any()); + verify(management, never()).forceLoginFromProxy(any(Player.class)); } @Test From 047ed6c0410184a66b41c92df7a4b526e9d89701 Mon Sep 17 00:00:00 2001 From: linsaftw Date: Wed, 23 Sep 2026 13:46:40 -0300 Subject: [PATCH 2/3] option to delay register/login messages --- .../authme/bungee/AuthMeBungeePlugin.java | 5 +- .../authme/bungee/BungeeProxyBridge.java | 13 +- .../BungeePremiumVerificationManager.java | 7 +- .../DisabledPremiumVerificationManager.java | 31 +++ .../premium/PremiumVerificationManager.java | 38 ++++ .../premium/PremiumVerificationManagers.java | 40 ++++ .../authme/bungee/BungeeProxyBridgeTest.java | 44 +++- .../PremiumVerificationManagersTest.java | 29 +++ .../data/limbo/LimboPlayerTaskManager.java | 95 +++++++- .../xephi/authme/listener/OnJoinVerifier.java | 7 +- .../authme/process/join/AsynchronousJoin.java | 15 +- .../authme/service/PreJoinDialogService.java | 202 +++++++++++------- .../service/ProxyLoginRequestValidator.java | 13 ++ .../service/bungeecord/BungeeReceiver.java | 36 ++-- .../properties/RegistrationSettings.java | 6 + .../limbo/LimboPlayerTaskManagerTest.java | 116 +++++++++- .../authme/listener/OnJoinVerifierTest.java | 8 +- .../process/join/AsynchronousJoinTest.java | 28 +-- .../service/PreJoinDialogServiceTest.java | 117 +++++++--- .../ProxyLoginRequestValidatorTest.java | 23 ++ .../bungeecord/BungeeReceiverTest.java | 56 +++++ .../listener/PaperDialogFlowListener.java | 130 ++++++----- .../PaperLoginValidationListener.java | 112 ++++++++-- .../service/PendingConnectionRegistry.java | 80 +++++++ .../listener/PaperDialogFlowListenerTest.java | 157 +++++++------- .../PaperLoginValidationListenerTest.java | 163 ++++++++++++-- .../PendingConnectionRegistryTest.java | 161 ++++++++++++++ .../authme/velocity/AuthMeVelocityPlugin.java | 6 + .../authme/velocity/VelocityProxyBridge.java | 43 ++++ .../VelocityPremiumVerificationManager.java | 16 ++ .../velocity/VelocityProxyBridgeTest.java | 57 +++++ ...elocityPremiumVerificationManagerTest.java | 84 +++++++- 32 files changed, 1601 insertions(+), 337 deletions(-) create mode 100644 authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/DisabledPremiumVerificationManager.java create mode 100644 authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/PremiumVerificationManager.java create mode 100644 authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/PremiumVerificationManagers.java create mode 100644 authme-bungee/src/test/java/fr/xephi/authme/bungee/premium/PremiumVerificationManagersTest.java create mode 100644 authme-paper-common/src/main/java/fr/xephi/authme/service/PendingConnectionRegistry.java create mode 100644 authme-paper-common/src/test/java/fr/xephi/authme/service/PendingConnectionRegistryTest.java diff --git a/authme-bungee/src/main/java/fr/xephi/authme/bungee/AuthMeBungeePlugin.java b/authme-bungee/src/main/java/fr/xephi/authme/bungee/AuthMeBungeePlugin.java index ca0164e13c..19f6b8d88f 100644 --- a/authme-bungee/src/main/java/fr/xephi/authme/bungee/AuthMeBungeePlugin.java +++ b/authme-bungee/src/main/java/fr/xephi/authme/bungee/AuthMeBungeePlugin.java @@ -19,6 +19,9 @@ public void onEnable() { @Override public void onDisable() { - proxyBridge.shutdown(); + // BungeeCord also calls onDisable when onEnable failed, so proxyBridge may be null + if (proxyBridge != null) { + proxyBridge.shutdown(); + } } } diff --git a/authme-bungee/src/main/java/fr/xephi/authme/bungee/BungeeProxyBridge.java b/authme-bungee/src/main/java/fr/xephi/authme/bungee/BungeeProxyBridge.java index 8945d9059e..a6e8369f1f 100644 --- a/authme-bungee/src/main/java/fr/xephi/authme/bungee/BungeeProxyBridge.java +++ b/authme-bungee/src/main/java/fr/xephi/authme/bungee/BungeeProxyBridge.java @@ -4,7 +4,8 @@ import com.google.common.io.ByteArrayDataOutput; import com.google.common.io.ByteStreams; import fr.xephi.authme.bungee.premium.BungeePremiumOnlineModeHandler; -import fr.xephi.authme.bungee.premium.BungeePremiumVerificationManager; +import fr.xephi.authme.bungee.premium.PremiumVerificationManager; +import fr.xephi.authme.bungee.premium.PremiumVerificationManagers; import net.md_5.bungee.api.ChatColor; import net.md_5.bungee.api.chat.TextComponent; import net.md_5.bungee.api.ProxyServer; @@ -13,7 +14,7 @@ import net.md_5.bungee.api.connection.Server; import net.md_5.bungee.api.event.ChatEvent; import net.md_5.bungee.api.event.PlayerDisconnectEvent; -import net.md_5.bungee.api.event.PlayerHandshakeEvent; +import net.md_5.bungee.api.event.PreLoginEvent; import net.md_5.bungee.api.event.PluginMessageEvent; import net.md_5.bungee.api.event.ServerConnectEvent; import net.md_5.bungee.api.event.ServerSwitchEvent; @@ -71,7 +72,7 @@ public final class BungeeProxyBridge implements Listener { // Players with a pending premium verification (ran /premium but not yet confirmed via reconnect) private volatile Set pendingPremiumUsernames = ConcurrentHashMap.newKeySet(); private final BungeePremiumOnlineModeHandler premiumOnlineModeHandler; - private final BungeePremiumVerificationManager premiumVerificationManager; + private final PremiumVerificationManager premiumVerificationManager; private final ScheduledExecutorService retryScheduler = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "authme-bungee-retry"); t.setDaemon(true); @@ -87,7 +88,7 @@ public final class BungeeProxyBridge implements Listener { this.authenticationStore = authenticationStore; this.premiumOnlineModeHandler = new BungeePremiumOnlineModeHandler(this::requiresPremiumVerification); this.premiumVerificationManager = - new BungeePremiumVerificationManager(proxyServer, logger, + PremiumVerificationManagers.create(proxyServer, logger, this::requiresPremiumVerification, this::isPendingPremiumVerification, this::clearPendingPremiumVerification, () -> this.configuration.keepOfflineUuidCompatibility()); @@ -174,8 +175,10 @@ void registerChannels() { } @EventHandler(priority = EventPriority.HIGHEST) - public void onPlayerHandshake(PlayerHandshakeEvent event) { + public void onPreLogin(PreLoginEvent event) { if (!configuration.keepOfflineUuidCompatibility()) { + // PlayerHandshakeEvent runs before LOGIN_START, so the connection name is still null there. + // PreLoginEvent runs after LOGIN_START and before Bungee chooses the encryption path. premiumOnlineModeHandler.enableOnlineModeIfRequired(event.getConnection()); } } diff --git a/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/BungeePremiumVerificationManager.java b/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/BungeePremiumVerificationManager.java index d26c7e4712..8e355e50d6 100644 --- a/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/BungeePremiumVerificationManager.java +++ b/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/BungeePremiumVerificationManager.java @@ -9,7 +9,7 @@ import java.util.function.Predicate; import java.util.logging.Logger; -public final class BungeePremiumVerificationManager { +public final class BungeePremiumVerificationManager implements PremiumVerificationManager { private final ProxyServer proxyServer; private final Logger logger; @@ -35,10 +35,12 @@ public BungeePremiumVerificationManager(ProxyServer proxyServer, Logger logger, this.loginVerifier = new ProxyPremiumLoginVerifier("authme-bungee-premium", this.logger::warning); } + @Override public void register() { refreshRegistration(); } + @Override public void refreshRegistration() { if (!keepOfflineUuidCompatibility.getAsBoolean()) { unregisterPacketListener(); @@ -58,14 +60,17 @@ public void refreshRegistration() { logger.info("Registered PacketEvents premium verification on the Bungee proxy"); } + @Override public UUID getVerifiedPremiumUuid(String normalizedName) { return loginVerifier.getVerifiedUuid(normalizedName); } + @Override public void clearVerifiedPremium(String normalizedName) { loginVerifier.clearVerified(normalizedName); } + @Override public void shutdown() { unregisterPacketListener(); loginVerifier.shutdown(); diff --git a/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/DisabledPremiumVerificationManager.java b/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/DisabledPremiumVerificationManager.java new file mode 100644 index 0000000000..a3ad5c14c3 --- /dev/null +++ b/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/DisabledPremiumVerificationManager.java @@ -0,0 +1,31 @@ +package fr.xephi.authme.bungee.premium; + +import java.util.UUID; + +/** + * No-op implementation used when PacketEvents is not available on the proxy: the AuthMeBungee + * plugin keeps working, but players are never marked as proxy-verified premium players. + */ +final class DisabledPremiumVerificationManager implements PremiumVerificationManager { + + @Override + public void register() { + } + + @Override + public void refreshRegistration() { + } + + @Override + public UUID getVerifiedPremiumUuid(String normalizedName) { + return null; + } + + @Override + public void clearVerifiedPremium(String normalizedName) { + } + + @Override + public void shutdown() { + } +} diff --git a/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/PremiumVerificationManager.java b/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/PremiumVerificationManager.java new file mode 100644 index 0000000000..5f254e8094 --- /dev/null +++ b/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/PremiumVerificationManager.java @@ -0,0 +1,38 @@ +package fr.xephi.authme.bungee.premium; + +import java.util.UUID; + +/** + * Proxy-side premium verification. Implementations that use PacketEvents are only instantiated + * (and therefore only loaded) when the PacketEvents plugin is installed on the proxy. + */ +public interface PremiumVerificationManager { + + /** + * Registers the premium verification listeners. + */ + void register(); + + /** + * Re-evaluates whether the premium verification listeners should be active. + */ + void refreshRegistration(); + + /** + * @param normalizedName lower-case player name + * @return the verified premium UUID, or {@code null} if the player was not verified + */ + UUID getVerifiedPremiumUuid(String normalizedName); + + /** + * Discards the verification result of the given player. + * + * @param normalizedName lower-case player name + */ + void clearVerifiedPremium(String normalizedName); + + /** + * Unregisters all listeners and releases resources. + */ + void shutdown(); +} diff --git a/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/PremiumVerificationManagers.java b/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/PremiumVerificationManagers.java new file mode 100644 index 0000000000..1990fd850b --- /dev/null +++ b/authme-bungee/src/main/java/fr/xephi/authme/bungee/premium/PremiumVerificationManagers.java @@ -0,0 +1,40 @@ +package fr.xephi.authme.bungee.premium; + +import net.md_5.bungee.api.ProxyServer; + +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.logging.Logger; + +/** + * Creates the {@link PremiumVerificationManager} for the Bungee proxy. The PacketEvents-backed + * implementation is only instantiated when PacketEvents is installed, so that classes referencing + * the PacketEvents library are never loaded on proxies without it. + */ +public final class PremiumVerificationManagers { + + private static final String PACKET_EVENTS_PLUGIN_NAME = "packetevents"; + + private PremiumVerificationManagers() { + } + + public static PremiumVerificationManager create(ProxyServer proxyServer, Logger logger, + Predicate requiresVerification, + Predicate isPendingVerification, + Consumer pendingVerificationFailureHandler, + BooleanSupplier keepOfflineUuidCompatibility) { + if (proxyServer.getPluginManager().getPlugin(PACKET_EVENTS_PLUGIN_NAME) == null) { + logger.warning("PacketEvents is not installed on the proxy; premium proxy verification stays disabled"); + return new DisabledPremiumVerificationManager(); + } + try { + return new BungeePremiumVerificationManager(proxyServer, logger, requiresVerification, + isPendingVerification, pendingVerificationFailureHandler, keepOfflineUuidCompatibility); + } catch (LinkageError e) { + logger.warning("PacketEvents is installed but its classes could not be loaded; premium proxy " + + "verification stays disabled: " + e); + return new DisabledPremiumVerificationManager(); + } + } +} diff --git a/authme-bungee/src/test/java/fr/xephi/authme/bungee/BungeeProxyBridgeTest.java b/authme-bungee/src/test/java/fr/xephi/authme/bungee/BungeeProxyBridgeTest.java index 51732e0928..ea713537f6 100644 --- a/authme-bungee/src/test/java/fr/xephi/authme/bungee/BungeeProxyBridgeTest.java +++ b/authme-bungee/src/test/java/fr/xephi/authme/bungee/BungeeProxyBridgeTest.java @@ -9,10 +9,13 @@ import net.md_5.bungee.api.connection.Server; import net.md_5.bungee.api.event.ChatEvent; import net.md_5.bungee.api.event.PlayerDisconnectEvent; -import net.md_5.bungee.api.event.PlayerHandshakeEvent; +import net.md_5.bungee.api.event.PreLoginEvent; import net.md_5.bungee.api.event.PluginMessageEvent; import net.md_5.bungee.api.event.ServerConnectEvent; import net.md_5.bungee.api.event.ServerSwitchEvent; +import net.md_5.bungee.api.plugin.Plugin; +import net.md_5.bungee.api.plugin.PluginManager; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -80,14 +83,26 @@ class BungeeProxyBridgeTest { private ServerConnectEvent serverConnectEvent; @Mock - private PlayerHandshakeEvent playerHandshakeEvent; + private PreLoginEvent preLoginEvent; @Mock private PendingConnection pendingConnection; + @Mock + private PluginManager pluginManager; + + @Mock + private Plugin packetEventsPlugin; + @Captor private ArgumentCaptor payloadCaptor; + @BeforeEach + void registerPacketEventsOnTheProxy() { + given(proxyServer.getPluginManager()).willReturn(pluginManager); + given(pluginManager.getPlugin("packetevents")).willReturn(packetEventsPlugin); + } + @Test void shouldTrackAuthenticatedPlayerAndForwardPerformLoginOnServerSwitch() { given(pluginMessageEvent.isCancelled()).willReturn(false); @@ -452,13 +467,30 @@ void shouldForceOnlineModeForPremiumHandshakeWhenOfflineCompatibilityDisabled() given(pluginMessageEvent.getTag()).willReturn(BungeeProxyBridge.AUTHME_CHANNEL); given(pluginMessageEvent.getSender()).willReturn(sourceServer); given(pluginMessageEvent.getData()).willReturn(createAuthMePayload("premium.set", "Alice")); - given(playerHandshakeEvent.getConnection()).willReturn(pendingConnection); + given(preLoginEvent.getConnection()).willReturn(pendingConnection); + given(pendingConnection.getName()).willReturn("Alice"); + given(pendingConnection.isOnlineMode()).willReturn(false); + + BungeeProxyBridge bridge = new BungeeProxyBridge(proxyServer, logger, createConfiguration(), new BungeeAuthenticationStore(), null); + bridge.onPluginMessage(pluginMessageEvent); + bridge.onPreLogin(preLoginEvent); + + verify(pendingConnection).setOnlineMode(true); + } + + @Test + void shouldForceOnlineModeForPendingPremiumEnrollmentAtPreLogin() { + given(pluginMessageEvent.isCancelled()).willReturn(false); + given(pluginMessageEvent.getTag()).willReturn(BungeeProxyBridge.AUTHME_CHANNEL); + given(pluginMessageEvent.getSender()).willReturn(sourceServer); + given(pluginMessageEvent.getData()).willReturn(createAuthMePayload("premium.pending.set", "Alice")); + given(preLoginEvent.getConnection()).willReturn(pendingConnection); given(pendingConnection.getName()).willReturn("Alice"); given(pendingConnection.isOnlineMode()).willReturn(false); BungeeProxyBridge bridge = new BungeeProxyBridge(proxyServer, logger, createConfiguration(), new BungeeAuthenticationStore(), null); bridge.onPluginMessage(pluginMessageEvent); - bridge.onPlayerHandshake(playerHandshakeEvent); + bridge.onPreLogin(preLoginEvent); verify(pendingConnection).setOnlineMode(true); } @@ -469,13 +501,13 @@ void shouldForceOnlineModeForPremiumHandshakeAfterChunkedPremiumListResync() { given(pluginMessageEvent.getTag()).willReturn(BungeeProxyBridge.AUTHME_CHANNEL); given(pluginMessageEvent.getSender()).willReturn(sourceServer); given(pluginMessageEvent.getData()).willReturn(createChunkPayload(0, true, "Alice")); - given(playerHandshakeEvent.getConnection()).willReturn(pendingConnection); + given(preLoginEvent.getConnection()).willReturn(pendingConnection); given(pendingConnection.getName()).willReturn("Alice"); given(pendingConnection.isOnlineMode()).willReturn(false); BungeeProxyBridge bridge = new BungeeProxyBridge(proxyServer, logger, createConfiguration(), new BungeeAuthenticationStore(), null); bridge.onPluginMessage(pluginMessageEvent); - bridge.onPlayerHandshake(playerHandshakeEvent); + bridge.onPreLogin(preLoginEvent); verify(pendingConnection).setOnlineMode(true); } diff --git a/authme-bungee/src/test/java/fr/xephi/authme/bungee/premium/PremiumVerificationManagersTest.java b/authme-bungee/src/test/java/fr/xephi/authme/bungee/premium/PremiumVerificationManagersTest.java new file mode 100644 index 0000000000..271875c7b9 --- /dev/null +++ b/authme-bungee/src/test/java/fr/xephi/authme/bungee/premium/PremiumVerificationManagersTest.java @@ -0,0 +1,29 @@ +package fr.xephi.authme.bungee.premium; + +import net.md_5.bungee.api.ProxyServer; +import net.md_5.bungee.api.plugin.PluginManager; +import org.junit.jupiter.api.Test; + +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +class PremiumVerificationManagersTest { + + @Test + void shouldUseDisabledManagerWhenPacketEventsIsNotInstalled() { + ProxyServer proxyServer = mock(ProxyServer.class); + given(proxyServer.getPluginManager()).willReturn(mock(PluginManager.class)); + + PremiumVerificationManager manager = PremiumVerificationManagers.create( + proxyServer, mock(Logger.class), name -> true, name -> false, name -> { }, () -> true); + + assertNull(manager.getVerifiedPremiumUuid("alice")); + manager.register(); + manager.refreshRegistration(); + manager.clearVerifiedPremium("alice"); + manager.shutdown(); + } +} diff --git a/authme-core/src/main/java/fr/xephi/authme/data/limbo/LimboPlayerTaskManager.java b/authme-core/src/main/java/fr/xephi/authme/data/limbo/LimboPlayerTaskManager.java index 33abdfadd4..9d127ac821 100644 --- a/authme-core/src/main/java/fr/xephi/authme/data/limbo/LimboPlayerTaskManager.java +++ b/authme-core/src/main/java/fr/xephi/authme/data/limbo/LimboPlayerTaskManager.java @@ -3,6 +3,7 @@ import ch.jalu.configme.properties.Property; import fr.xephi.authme.data.auth.PlayerCache; import fr.xephi.authme.data.captcha.RegistrationCaptchaManager; +import fr.xephi.authme.datasource.DataSource; import fr.xephi.authme.message.MessageKey; import fr.xephi.authme.message.Messages; import fr.xephi.authme.service.BukkitService; @@ -15,6 +16,8 @@ import org.bukkit.entity.Player; import javax.inject.Inject; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import static fr.xephi.authme.service.BukkitService.TICKS_PER_SECOND; @@ -38,6 +41,9 @@ class LimboPlayerTaskManager { @Inject private RegistrationCaptchaManager registrationCaptchaManager; + @Inject + private DataSource dataSource; + LimboPlayerTaskManager() { } @@ -54,12 +60,24 @@ void registerMessageTask(Player player, LimboPlayer limbo, LimboMessageType mess if (interval > 0) { String[] joinMessage = messages.retrieveSingle(player, result.messageKey, result.args).split("\n"); MessageTask messageTask = new MessageTask(player, joinMessage); - CancellableTask taskHandle = - bukkitService.runTaskTimer(player, messageTask, 2 * TICKS_PER_SECOND, interval * TICKS_PER_SECOND); + CancellableTask taskHandle = messageType == LimboMessageType.REGISTER + ? scheduleRegistrationMessageTask(player, messageTask, interval) + : bukkitService.runTaskTimer(player, messageTask, 2 * TICKS_PER_SECOND, + interval * TICKS_PER_SECOND); limbo.setMessageTask(messageTask, taskHandle); } } + private CancellableTask scheduleRegistrationMessageTask(Player player, MessageTask messageTask, int interval) { + int configuredDelay = settings.getProperty(RegistrationSettings.REGISTER_MESSAGE_DELAY); + long delayTicks = Math.max(0, configuredDelay) * (long) TICKS_PER_SECOND; + long intervalTicks = interval * (long) TICKS_PER_SECOND; + RegistrationMessageTaskHandle taskHandle = + new RegistrationMessageTaskHandle(player, messageTask, intervalTicks); + taskHandle.scheduleInitialCheck(delayTicks); + return taskHandle; + } + /** * Registers a {@link TimeoutTask} for the given player according to the configuration. * @@ -111,6 +129,79 @@ private MessageResult getMessageKey(String name, LimboMessageType messageType) { } } + private final class RegistrationMessageTaskHandle implements CancellableTask { + private final Player player; + private final MessageTask messageTask; + private final long intervalTicks; + private final AtomicBoolean cancelled = new AtomicBoolean(); + private final AtomicReference delayedTask = new AtomicReference<>(); + private final AtomicReference lookupTask = new AtomicReference<>(); + private final AtomicReference repeatingTask = new AtomicReference<>(); + + private RegistrationMessageTaskHandle(Player player, MessageTask messageTask, long intervalTicks) { + this.player = player; + this.messageTask = messageTask; + this.intervalTicks = intervalTicks; + } + + private void scheduleInitialCheck(long delayTicks) { + CancellableTask task = bukkitService.runTaskLater(player, this::checkRegistrationAsync, delayTicks); + setTask(delayedTask, task); + } + + private void checkRegistrationAsync() { + if (cancelled.get() || !player.isOnline() || playerCache.isAuthenticated(player.getName())) { + return; + } + + CancellableTask task = bukkitService.runTaskAsynchronously(() -> { + boolean isRegistered = dataSource.isAuthAvailable(player.getName()); + if (cancelled.get() || isRegistered || playerCache.isAuthenticated(player.getName())) { + return; + } + + bukkitService.scheduleSyncTaskFromOptionallyAsyncTask(player, () -> { + if (cancelled.get() || !player.isOnline() || playerCache.isAuthenticated(player.getName())) { + return; + } + + messageTask.run(); + CancellableTask repeating = bukkitService.runTaskTimer( + player, messageTask, intervalTicks, intervalTicks); + setTask(repeatingTask, repeating); + }); + }); + setTask(lookupTask, task); + } + + private void setTask(AtomicReference taskReference, CancellableTask task) { + if (cancelled.get()) { + task.cancel(); + } else { + taskReference.set(task); + if (cancelled.get() && taskReference.compareAndSet(task, null)) { + task.cancel(); + } + } + } + + @Override + public void cancel() { + if (cancelled.compareAndSet(false, true)) { + cancelTask(delayedTask); + cancelTask(lookupTask); + cancelTask(repeatingTask); + } + } + + private void cancelTask(AtomicReference taskReference) { + CancellableTask task = taskReference.getAndSet(null); + if (task != null) { + task.cancel(); + } + } + } + private static final class MessageResult { private final MessageKey messageKey; private final String[] args; diff --git a/authme-core/src/main/java/fr/xephi/authme/listener/OnJoinVerifier.java b/authme-core/src/main/java/fr/xephi/authme/listener/OnJoinVerifier.java index bec01cde82..0a7f50aa5f 100644 --- a/authme-core/src/main/java/fr/xephi/authme/listener/OnJoinVerifier.java +++ b/authme-core/src/main/java/fr/xephi/authme/listener/OnJoinVerifier.java @@ -234,9 +234,10 @@ public void checkSingleSession(String name) throws FailedVerificationException { return; } - Player onlinePlayer = bukkitService.getPlayerExact(name); - if (onlinePlayer != null) { - throw new FailedVerificationException(MessageKey.USERNAME_ALREADY_ONLINE_ERROR); + for (Player player : bukkitService.getOnlinePlayers()) { + if (player.getName().equalsIgnoreCase(name)) { + throw new FailedVerificationException(MessageKey.USERNAME_ALREADY_ONLINE_ERROR); + } } } diff --git a/authme-core/src/main/java/fr/xephi/authme/process/join/AsynchronousJoin.java b/authme-core/src/main/java/fr/xephi/authme/process/join/AsynchronousJoin.java index 17f858bdaf..fbd9556c30 100644 --- a/authme-core/src/main/java/fr/xephi/authme/process/join/AsynchronousJoin.java +++ b/authme-core/src/main/java/fr/xephi/authme/process/join/AsynchronousJoin.java @@ -152,14 +152,13 @@ public class AsynchronousJoin implements AsynchronousProcess { public void processJoin(Player player) { String name = player.getName().toLowerCase(Locale.ROOT); String ip = PlayerUtils.getPlayerIp(player); - UUID playerId = player.getUniqueId(); - String pendingLoginPassword = preJoinDialogService.consumePendingLoginPassword(playerId); - String pendingRecoveryEmail = preJoinDialogService.consumePendingRecoveryEmail(playerId); - PreJoinDialogService.PendingRegistration pendingRegistration = - preJoinDialogService.consumePendingRegistration(playerId); - boolean shouldSkipPostJoinDialog = preJoinDialogService.consumeSkipPostJoinDialog(playerId); - boolean pendingForceLogin = preJoinDialogService.consumePendingForceLogin(playerId); - String pendingKick = preJoinDialogService.consumePendingKickMessage(playerId); + PreJoinDialogService.PendingDialogState dialogState = preJoinDialogService.consumeSession(name); + String pendingLoginPassword = dialogState.loginPassword(); + String pendingRecoveryEmail = dialogState.recoveryEmail(); + PreJoinDialogService.PendingRegistration pendingRegistration = dialogState.registration(); + boolean shouldSkipPostJoinDialog = dialogState.skipPostJoinDialog(); + boolean pendingForceLogin = dialogState.forceLogin(); + String pendingKick = dialogState.kickMessage(); // pendingKick is applied below, after proxy/premium/session checks, which take priority: // a Velocity perform.login arriving just after the player cancelled the pre-join dialog // must win over the dialog cancel kick. diff --git a/authme-core/src/main/java/fr/xephi/authme/service/PreJoinDialogService.java b/authme-core/src/main/java/fr/xephi/authme/service/PreJoinDialogService.java index 219ac7b5dc..306e0205e2 100644 --- a/authme-core/src/main/java/fr/xephi/authme/service/PreJoinDialogService.java +++ b/authme-core/src/main/java/fr/xephi/authme/service/PreJoinDialogService.java @@ -1,139 +1,181 @@ package fr.xephi.authme.service; import java.util.Map; -import java.util.Set; -import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; /** * Stores transient dialog state between Paper/Folia's configuration phase and the actual join. + *

+ * State belongs to a session opened for one configuration-phase connection, never to a player name + * or profile id: those are shared by every connection authenticating as the same player on an + * offline-mode server, so a later connection could otherwise consume or wipe the state of the one + * that is still authenticating. */ public class PreJoinDialogService { - private final Map pendingLoginPasswords = new ConcurrentHashMap<>(); - private final Map pendingRecoveryEmails = new ConcurrentHashMap<>(); - private final Map pendingRegistrations = new ConcurrentHashMap<>(); - private final Set skipPostJoinDialogs = ConcurrentHashMap.newKeySet(); - private final Map pendingKickMessages = new ConcurrentHashMap<>(); - - // Pre-join force-login: tracks players blocked in the pre-join login dialog so that - // ForceLoginCommand can unblock them without requiring the player to be in PLAY state. - private final Map pendingPreJoinByName = new ConcurrentHashMap<>(); - private final Map> pendingPreJoinFutures = new ConcurrentHashMap<>(); - private final Set pendingForceLogins = ConcurrentHashMap.newKeySet(); + private final AtomicLong nextSessionId = new AtomicLong(); + private final Map sessionByName = new ConcurrentHashMap<>(); + private final Map sessions = new ConcurrentHashMap<>(); public PreJoinDialogService() { } - public void storePendingLoginPassword(UUID playerId, String password) { - pendingLoginPasswords.put(playerId, password); - } + /** + * Opens a session for a connection entering the configuration phase, dropping the state of any + * earlier session for that name so it can never be inherited. + * + * @param normalizedName the player name in lowercase + * @return the id identifying this session in every other call + */ + public long openSession(String normalizedName) { + long sessionId = nextSessionId.incrementAndGet(); + sessions.put(sessionId, new DialogSession(normalizedName)); - public String consumePendingLoginPassword(UUID playerId) { - return pendingLoginPasswords.remove(playerId); + Long previousId = sessionByName.put(normalizedName, sessionId); + if (previousId != null) { + sessions.remove(previousId); + } + return sessionId; } - public void storePendingRecoveryEmail(UUID playerId, String email) { - pendingRecoveryEmails.put(playerId, email); + public void retireSession(long sessionId) { + DialogSession session = sessions.remove(sessionId); + if (session != null) { + sessionByName.remove(session.name, sessionId); + } } - public String consumePendingRecoveryEmail(UUID playerId) { - return pendingRecoveryEmails.remove(playerId); - } + /** + * Consumes everything the player's session holds, at once so that nothing can be left half-read. + * + * @param normalizedName the player name in lowercase + * @return the pending state, or {@link PendingDialogState#NONE} if the player has no session + */ + public PendingDialogState consumeSession(String normalizedName) { + Long sessionId = sessionByName.remove(normalizedName); + if (sessionId == null) { + return PendingDialogState.NONE; + } - public void storePendingPasswordRegistration(UUID playerId, String password, String email) { - pendingRegistrations.put(playerId, new PendingRegistration(password, email, false)); + DialogSession session = sessions.remove(sessionId); + return session == null ? PendingDialogState.NONE : session.toPendingState(); } - public void storePendingEmailRegistration(UUID playerId, String email) { - pendingRegistrations.put(playerId, new PendingRegistration(email, null, true)); + public void storePendingLoginPassword(long sessionId, String password) { + DialogSession session = sessions.get(sessionId); + if (session != null) { + session.loginPassword = password; + } } - public PendingRegistration consumePendingRegistration(UUID playerId) { - return pendingRegistrations.remove(playerId); + public void storePendingRecoveryEmail(long sessionId, String email) { + DialogSession session = sessions.get(sessionId); + if (session != null) { + session.recoveryEmail = email; + } } - public void markSkipPostJoinDialog(UUID playerId) { - skipPostJoinDialogs.add(playerId); + public void storePendingPasswordRegistration(long sessionId, String password, String email) { + DialogSession session = sessions.get(sessionId); + if (session != null) { + session.registration = new PendingRegistration(password, email, false); + } } - public boolean consumeSkipPostJoinDialog(UUID playerId) { - return skipPostJoinDialogs.remove(playerId); + public void storePendingEmailRegistration(long sessionId, String email) { + DialogSession session = sessions.get(sessionId); + if (session != null) { + session.registration = new PendingRegistration(email, null, true); + } } - public void storePendingKickMessage(UUID playerId, String message) { - pendingKickMessages.put(playerId, message); + public void markSkipPostJoinDialog(long sessionId) { + DialogSession session = sessions.get(sessionId); + if (session != null) { + session.skipPostJoinDialog = true; + } } - public String consumePendingKickMessage(UUID playerId) { - return pendingKickMessages.remove(playerId); + public void storePendingKickMessage(long sessionId, String message) { + DialogSession session = sessions.get(sessionId); + if (session != null) { + session.kickMessage = message; + } } /** - * Registers the blocking {@link CompletableFuture} used by the pre-join login dialog so that + * Registers the blocking {@link CompletableFuture} used by the pre-join dialog so that * {@link #approvePreJoinForceLogin} can resolve it from outside the event handler thread. * - * @param normalizedName the player name in lowercase - * @param uuid the player's UUID + * @param sessionId the session waiting on the dialog * @param future the future that blocks the configuration-phase thread */ - public void registerPreJoinFuture(String normalizedName, UUID uuid, CompletableFuture future) { - pendingPreJoinByName.put(normalizedName, uuid); - pendingPreJoinFutures.put(uuid, future); + public void registerPreJoinFuture(long sessionId, CompletableFuture future) { + DialogSession session = sessions.get(sessionId); + if (session != null) { + session.future = future; + } } - /** - * Removes the pre-join future registration once the blocking wait is over. - * - * @param uuid the player's UUID - */ - public void unregisterPreJoinFuture(UUID uuid) { - pendingPreJoinFutures.remove(uuid); - pendingPreJoinByName.values().remove(uuid); + public void unregisterPreJoinFuture(long sessionId) { + DialogSession session = sessions.get(sessionId); + if (session != null) { + session.future = null; + } } /** - * Approves a force-login for a player currently blocked in the pre-join login dialog. - * Completes the blocking future with {@code null} (no kick message), allowing the player to - * proceed to PLAY state where {@link #consumePendingForceLogin} will trigger a force-login. + * Approves a force-login for a player currently blocked in the pre-join dialog, letting him + * proceed to the play state where the force-login is performed. Resolved by name because a proxy + * or the console only knows the name; unambiguous because a name has at most one session. * * @param normalizedName the player name in lowercase - * @return {@code true} if the player was in the pre-join dialog and the approval was registered, - * {@code false} if no such player was found (e.g. already joined or not in dialog) + * @return {@code true} if the player was in the pre-join dialog and the approval was registered */ public boolean approvePreJoinForceLogin(String normalizedName) { - UUID uuid = pendingPreJoinByName.get(normalizedName); - if (uuid == null) { - return false; - } - CompletableFuture future = pendingPreJoinFutures.get(uuid); + Long sessionId = sessionByName.get(normalizedName); + DialogSession session = sessionId == null ? null : sessions.get(sessionId); + CompletableFuture future = session == null ? null : session.future; if (future == null) { return false; } - pendingForceLogins.add(uuid); + + session.forceLogin = true; future.complete(null); return true; } - /** - * Consumes the force-login flag for the given player. - * - * @param playerId the player's UUID - * @return {@code true} if a force-login was approved for this player (flag is cleared) - */ - public boolean consumePendingForceLogin(UUID playerId) { - return pendingForceLogins.remove(playerId); - } - - public void clear(UUID playerId) { - pendingLoginPasswords.remove(playerId); - pendingRecoveryEmails.remove(playerId); - pendingRegistrations.remove(playerId); - skipPostJoinDialogs.remove(playerId); - pendingForceLogins.remove(playerId); - pendingKickMessages.remove(playerId); - unregisterPreJoinFuture(playerId); + private static final class DialogSession { + + private final String name; + + // Written on the configuration-phase thread, read on the join thread + private volatile String loginPassword; + private volatile String recoveryEmail; + private volatile PendingRegistration registration; + private volatile boolean skipPostJoinDialog; + private volatile boolean forceLogin; + private volatile String kickMessage; + private volatile CompletableFuture future; + + DialogSession(String name) { + this.name = name; + } + + PendingDialogState toPendingState() { + return new PendingDialogState(loginPassword, recoveryEmail, registration, + skipPostJoinDialog, forceLogin, kickMessage); + } + } + + public record PendingDialogState(String loginPassword, String recoveryEmail, + PendingRegistration registration, boolean skipPostJoinDialog, + boolean forceLogin, String kickMessage) { + + public static final PendingDialogState NONE = + new PendingDialogState(null, null, null, false, false, null); } public record PendingRegistration(String primaryValue, String secondaryValue, boolean isEmailRegistration) { diff --git a/authme-core/src/main/java/fr/xephi/authme/service/ProxyLoginRequestValidator.java b/authme-core/src/main/java/fr/xephi/authme/service/ProxyLoginRequestValidator.java index 213588daa2..c83eb88e0f 100644 --- a/authme-core/src/main/java/fr/xephi/authme/service/ProxyLoginRequestValidator.java +++ b/authme-core/src/main/java/fr/xephi/authme/service/ProxyLoginRequestValidator.java @@ -8,6 +8,8 @@ import fr.xephi.authme.message.Messages; import fr.xephi.authme.output.ConsoleLoggerFactory; import fr.xephi.authme.service.bungeecord.BungeeSender; +import fr.xephi.authme.settings.Settings; +import fr.xephi.authme.settings.properties.PremiumSettings; import org.bukkit.entity.Player; import javax.inject.Inject; @@ -43,6 +45,9 @@ public class ProxyLoginRequestValidator { @Inject private Messages messages; + @Inject + private Settings settings; + ProxyLoginRequestValidator() { } @@ -60,6 +65,14 @@ public boolean validate(Player player, UUID verifiedPremiumUuid) { } String playerName = player.getName(); + if (!settings.getProperty(PremiumSettings.ENABLE_PREMIUM)) { + // The proxy still holds this name in its premium cache; tell it to drop the entry so the + // player is not forced through Mojang verification again while the feature is disabled. + logger.info("Rejected proxy premium login for " + playerName + ": premium is disabled"); + bungeeSender.sendPremiumUnset(playerName); + return false; + } + PlayerAuth auth = playerCache.getAuth(playerName); if (auth == null) { auth = dataSource.getAuth(playerName.toLowerCase(Locale.ROOT)); diff --git a/authme-core/src/main/java/fr/xephi/authme/service/bungeecord/BungeeReceiver.java b/authme-core/src/main/java/fr/xephi/authme/service/bungeecord/BungeeReceiver.java index 6540238686..56f0d93a8a 100644 --- a/authme-core/src/main/java/fr/xephi/authme/service/bungeecord/BungeeReceiver.java +++ b/authme-core/src/main/java/fr/xephi/authme/service/bungeecord/BungeeReceiver.java @@ -14,6 +14,7 @@ import fr.xephi.authme.service.ProxyLoginRequestValidator; import fr.xephi.authme.settings.Settings; import fr.xephi.authme.settings.properties.HooksSettings; +import fr.xephi.authme.settings.properties.PremiumSettings; import fr.xephi.authme.util.UuidUtils; import org.bukkit.entity.Player; import org.bukkit.plugin.messaging.Messenger; @@ -42,6 +43,7 @@ public class BungeeReceiver implements PluginMessageListener, SettingsDependent private static final long MAX_AGE_MILLIS = 30_000L; private boolean isEnabled; + private boolean premiumEnabled; private String proxySharedSecret; private boolean channelRegistered; @@ -63,6 +65,7 @@ public class BungeeReceiver implements PluginMessageListener, SettingsDependent public void reload(Settings settings) { this.proxySharedSecret = settings.getProperty(HooksSettings.PROXY_SHARED_SECRET); this.isEnabled = settings.getProperty(HooksSettings.BUNGEECORD); + this.premiumEnabled = settings.getProperty(PremiumSettings.ENABLE_PREMIUM); final Messenger messenger = plugin.getServer().getMessenger(); if (messenger == null) { return; @@ -112,22 +115,23 @@ public void onPluginMessageReceived(String channel, Player player, byte[] data) logger.info("Proxy plugin '" + argument + "' has started and registered the authme:main channel"); final String proxyName = argument; bukkitService.runTaskAsynchronously(() -> { - List premiumNames = dataSource.getPremiumUsernames(); - if (!premiumNames.isEmpty()) { - bukkitService.scheduleSyncTaskFromOptionallyAsyncTask(() -> { - // Re-fetch a carrier at send-time: the original player may have gone offline - // during the async DB query. - Player freshCarrier = bukkitService.getOnlinePlayers().stream() - .findFirst().orElse(null); - if (freshCarrier != null) { - bungeeSender.sendPremiumList(freshCarrier, premiumNames); - logger.info("Sent premium list (" + premiumNames.size() + " player(s)) to proxy '" + proxyName + "'"); - } else { - logger.warning("Cannot send premium list to proxy '" + proxyName - + "': no online player available as carrier."); - } - }); - } + // Always send the list, even when it is empty: an empty list is authoritative and + // lets the proxy replace a stale premium cache. With premium disabled, stored + // premium names must not reach the proxy, or it keeps verifying those players. + List premiumNames = premiumEnabled ? dataSource.getPremiumUsernames() : List.of(); + bukkitService.scheduleSyncTaskFromOptionallyAsyncTask(() -> { + // Re-fetch a carrier at send-time: the original player may have gone offline + // during the async DB query. + Player freshCarrier = bukkitService.getOnlinePlayers().stream() + .findFirst().orElse(null); + if (freshCarrier != null) { + bungeeSender.sendPremiumList(freshCarrier, premiumNames); + logger.info("Sent premium list (" + premiumNames.size() + " player(s)) to proxy '" + proxyName + "'"); + } else { + logger.warning("Cannot send premium list to proxy '" + proxyName + + "': no online player available as carrier."); + } + }); }); return; } diff --git a/authme-core/src/main/java/fr/xephi/authme/settings/properties/RegistrationSettings.java b/authme-core/src/main/java/fr/xephi/authme/settings/properties/RegistrationSettings.java index 479d18d068..a62aa16668 100644 --- a/authme-core/src/main/java/fr/xephi/authme/settings/properties/RegistrationSettings.java +++ b/authme-core/src/main/java/fr/xephi/authme/settings/properties/RegistrationSettings.java @@ -20,6 +20,12 @@ public final class RegistrationSettings implements SettingsHolder { public static final Property MESSAGE_INTERVAL = newProperty("settings.registration.messageInterval", 5); + @Comment({ + "Delay in seconds before showing the first registration reminder.", + "When the delay expires, AuthMe checks whether the player is still unregistered before sending it."}) + public static final Property REGISTER_MESSAGE_DELAY = + newProperty("settings.registration.registerMessageDelay", 1); + @Comment({ "Only registered and logged in players can play.", "See restrictions for exceptions"}) diff --git a/authme-core/src/test/java/fr/xephi/authme/data/limbo/LimboPlayerTaskManagerTest.java b/authme-core/src/test/java/fr/xephi/authme/data/limbo/LimboPlayerTaskManagerTest.java index 3977b181fd..9f16a7c45c 100644 --- a/authme-core/src/test/java/fr/xephi/authme/data/limbo/LimboPlayerTaskManagerTest.java +++ b/authme-core/src/test/java/fr/xephi/authme/data/limbo/LimboPlayerTaskManagerTest.java @@ -3,6 +3,7 @@ import fr.xephi.authme.TestHelper; import fr.xephi.authme.data.auth.PlayerCache; import fr.xephi.authme.data.captcha.RegistrationCaptchaManager; +import fr.xephi.authme.datasource.DataSource; import fr.xephi.authme.message.MessageKey; import fr.xephi.authme.message.Messages; import fr.xephi.authme.service.BukkitService; @@ -16,6 +17,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -32,7 +34,9 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +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.verifyNoInteractions; @@ -60,6 +64,9 @@ class LimboPlayerTaskManagerTest { @Mock private RegistrationCaptchaManager registrationCaptchaManager; + @Mock + private DataSource dataSource; + @BeforeAll static void setupLogger() { TestHelper.setupLogger(); @@ -74,7 +81,8 @@ void shouldRegisterMessageTask() { given(messages.retrieveSingle(player, key)).willReturn("Please register!"); int interval = 12; given(settings.getProperty(RegistrationSettings.MESSAGE_INTERVAL)).willReturn(interval); - given(bukkitService.runTaskTimer(eq(player), any(MessageTask.class), anyLong(), anyLong())) + given(settings.getProperty(RegistrationSettings.REGISTER_MESSAGE_DELAY)).willReturn(1); + given(bukkitService.runTaskLater(eq(player), any(Runnable.class), anyLong())) .willReturn(mock(CancellableTask.class)); // when @@ -83,8 +91,104 @@ void shouldRegisterMessageTask() { // then verify(limboPlayer).setMessageTask(any(MessageTask.class), any(CancellableTask.class)); verify(messages).retrieveSingle(player, key); - verify(bukkitService).runTaskTimer(eq(player), - any(MessageTask.class), eq(2L * TICKS_PER_SECOND), eq((long) interval * TICKS_PER_SECOND)); + verify(bukkitService).runTaskLater(eq(player), any(Runnable.class), eq(1L * TICKS_PER_SECOND)); + } + + @Test + void shouldSuppressDelayedRegistrationMessageWhenPlayerWasRegistered() { + // given + String name = "registered_during_delay"; + Player player = mock(Player.class); + given(player.getName()).willReturn(name); + given(player.isOnline()).willReturn(true); + LimboPlayer limboPlayer = mock(LimboPlayer.class); + given(settings.getProperty(RegistrationSettings.MESSAGE_INTERVAL)).willReturn(5); + given(settings.getProperty(RegistrationSettings.REGISTER_MESSAGE_DELAY)).willReturn(1); + given(messages.retrieveSingle(player, MessageKey.REGISTER_MESSAGE)).willReturn("Please register!"); + CancellableTask delayedTask = mock(CancellableTask.class); + given(bukkitService.runTaskLater(eq(player), any(Runnable.class), eq(1L * TICKS_PER_SECOND))) + .willReturn(delayedTask); + CancellableTask lookupTask = mock(CancellableTask.class); + doAnswer(invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return lookupTask; + }).when(bukkitService).runTaskAsynchronously(any(Runnable.class)); + given(dataSource.isAuthAvailable(name)).willReturn(true); + ArgumentCaptor delayedAction = ArgumentCaptor.forClass(Runnable.class); + + // when + limboPlayerTaskManager.registerMessageTask(player, limboPlayer, LimboMessageType.REGISTER); + verify(bukkitService).runTaskLater(eq(player), delayedAction.capture(), eq(1L * TICKS_PER_SECOND)); + delayedAction.getValue().run(); + + // then + verify(dataSource).isAuthAvailable(name); + verify(player, never()).sendMessage(any(String[].class)); + verify(bukkitService, never()).runTaskTimer(eq(player), any(MessageTask.class), anyLong(), anyLong()); + } + + @Test + void shouldSendDelayedRegistrationMessageWhenPlayerIsStillUnregistered() { + // given + String name = "still_unregistered"; + Player player = mock(Player.class); + given(player.getName()).willReturn(name); + given(player.isOnline()).willReturn(true); + LimboPlayer limboPlayer = mock(LimboPlayer.class); + int interval = 5; + given(settings.getProperty(RegistrationSettings.MESSAGE_INTERVAL)).willReturn(interval); + given(settings.getProperty(RegistrationSettings.REGISTER_MESSAGE_DELAY)).willReturn(1); + given(messages.retrieveSingle(player, MessageKey.REGISTER_MESSAGE)).willReturn("Please register!"); + given(bukkitService.runTaskLater(eq(player), any(Runnable.class), eq(1L * TICKS_PER_SECOND))) + .willReturn(mock(CancellableTask.class)); + CancellableTask lookupTask = mock(CancellableTask.class); + doAnswer(invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return lookupTask; + }).when(bukkitService).runTaskAsynchronously(any(Runnable.class)); + doAnswer(invocation -> { + ((Runnable) invocation.getArgument(1)).run(); + return null; + }).when(bukkitService).scheduleSyncTaskFromOptionallyAsyncTask(eq(player), any(Runnable.class)); + given(dataSource.isAuthAvailable(name)).willReturn(false); + given(bukkitService.runTaskTimer(eq(player), any(MessageTask.class), anyLong(), anyLong())) + .willReturn(mock(CancellableTask.class)); + ArgumentCaptor delayedAction = ArgumentCaptor.forClass(Runnable.class); + + // when + limboPlayerTaskManager.registerMessageTask(player, limboPlayer, LimboMessageType.REGISTER); + verify(bukkitService).runTaskLater(eq(player), delayedAction.capture(), eq(1L * TICKS_PER_SECOND)); + delayedAction.getValue().run(); + + // then + verify(bukkitService).runTaskTimer(eq(player), any(MessageTask.class), + eq((long) interval * TICKS_PER_SECOND), eq((long) interval * TICKS_PER_SECOND)); + } + + @Test + void shouldSuppressDelayedRegistrationMessageWhenPlayerWasForceLoggedIn() { + // given + String name = "force_logged_in"; + Player player = mock(Player.class); + given(player.getName()).willReturn(name); + given(player.isOnline()).willReturn(true); + LimboPlayer limboPlayer = mock(LimboPlayer.class); + given(settings.getProperty(RegistrationSettings.MESSAGE_INTERVAL)).willReturn(5); + given(settings.getProperty(RegistrationSettings.REGISTER_MESSAGE_DELAY)).willReturn(1); + given(messages.retrieveSingle(player, MessageKey.REGISTER_MESSAGE)).willReturn("Please register!"); + given(bukkitService.runTaskLater(eq(player), any(Runnable.class), eq(1L * TICKS_PER_SECOND))) + .willReturn(mock(CancellableTask.class)); + given(playerCache.isAuthenticated(name)).willReturn(true); + ArgumentCaptor delayedAction = ArgumentCaptor.forClass(Runnable.class); + + // when + limboPlayerTaskManager.registerMessageTask(player, limboPlayer, LimboMessageType.REGISTER); + verify(bukkitService).runTaskLater(eq(player), delayedAction.capture(), eq(1L * TICKS_PER_SECOND)); + delayedAction.getValue().run(); + + // then + verifyNoInteractions(dataSource); + verify(player, never()).sendMessage(any(String[].class)); } @Test @@ -112,6 +216,9 @@ void shouldCancelExistingMessageTask() { MessageTask existingMessageTask = mock(MessageTask.class); limboPlayer.setMessageTask(existingMessageTask); given(settings.getProperty(RegistrationSettings.MESSAGE_INTERVAL)).willReturn(8); + given(settings.getProperty(RegistrationSettings.REGISTER_MESSAGE_DELAY)).willReturn(1); + given(bukkitService.runTaskLater(eq(player), any(Runnable.class), eq(1L * TICKS_PER_SECOND))) + .willReturn(mock(CancellableTask.class)); given(messages.retrieveSingle(player, MessageKey.REGISTER_MESSAGE)).willReturn("Please register!"); // when @@ -133,6 +240,9 @@ void shouldInitializeMessageTaskWithCaptchaMessage() { given(player.getName()).willReturn(name); LimboPlayer limboPlayer = new LimboPlayer(null, true, Collections.singletonList(new UserGroup("grp")), false, 0.1f, 0.0f); given(settings.getProperty(RegistrationSettings.MESSAGE_INTERVAL)).willReturn(12); + given(settings.getProperty(RegistrationSettings.REGISTER_MESSAGE_DELAY)).willReturn(1); + given(bukkitService.runTaskLater(eq(player), any(Runnable.class), eq(1L * TICKS_PER_SECOND))) + .willReturn(mock(CancellableTask.class)); given(registrationCaptchaManager.isCaptchaRequired(name)).willReturn(true); String captcha = "M032"; given(registrationCaptchaManager.getCaptchaCodeOrGenerateNew(name)).willReturn(captcha); diff --git a/authme-core/src/test/java/fr/xephi/authme/listener/OnJoinVerifierTest.java b/authme-core/src/test/java/fr/xephi/authme/listener/OnJoinVerifierTest.java index 56898c4412..07075f3fc8 100644 --- a/authme-core/src/test/java/fr/xephi/authme/listener/OnJoinVerifierTest.java +++ b/authme-core/src/test/java/fr/xephi/authme/listener/OnJoinVerifierTest.java @@ -340,13 +340,13 @@ void shouldAcceptNameThatIsNotOnline() throws FailedVerificationException { // given String name = "bobby"; given(settings.getProperty(RestrictionSettings.FORCE_SINGLE_SESSION)).willReturn(true); - given(bukkitService.getPlayerExact("bobby")).willReturn(null); + given(bukkitService.getOnlinePlayers()).willReturn(Collections.emptyList()); // when onJoinVerifier.checkSingleSession(name); // then - verify(bukkitService).getPlayerExact(name); + verify(bukkitService).getOnlinePlayers(); } @Test @@ -355,8 +355,8 @@ void shouldRejectNameAlreadyOnline() { String name = "Charlie"; Player onlinePlayer = mock(Player.class); - - given(bukkitService.getPlayerExact("Charlie")).willReturn(onlinePlayer); + given(onlinePlayer.getName()).willReturn("CHARLIE"); + given(bukkitService.getOnlinePlayers()).willReturn(Collections.singletonList(onlinePlayer)); given(settings.getProperty(RestrictionSettings.FORCE_SINGLE_SESSION)).willReturn(true); // when / then diff --git a/authme-core/src/test/java/fr/xephi/authme/process/join/AsynchronousJoinTest.java b/authme-core/src/test/java/fr/xephi/authme/process/join/AsynchronousJoinTest.java index 33d6a33dbc..d0b0e769f2 100644 --- a/authme-core/src/test/java/fr/xephi/authme/process/join/AsynchronousJoinTest.java +++ b/authme-core/src/test/java/fr/xephi/authme/process/join/AsynchronousJoinTest.java @@ -48,6 +48,7 @@ import static fr.xephi.authme.service.BukkitServiceTestHelper.setBukkitServiceToRunTaskOptionallyAsync; import static fr.xephi.authme.service.BukkitServiceTestHelper.setBukkitServiceToScheduleSyncEntityTaskFromOptionallyAsyncTask; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.doAnswer; @@ -118,6 +119,8 @@ public void setUp() { setBukkitServiceToRunTaskOptionallyAsync(bukkitService); setBukkitServiceToRunTaskLater(bukkitService); given(service.getProperty(PremiumSettings.ENABLE_PREMIUM)).willReturn(false); + given(preJoinDialogService.consumeSession(anyString())) + .willReturn(PreJoinDialogService.PendingDialogState.NONE); } @Test @@ -231,9 +234,8 @@ public void shouldAutoLoginFromProxySessionEvenIfPreJoinDialogWasCancelled() { // but by the time processJoin() runs the proxy session is available; the kick must be discarded. Player player = mockPlayer("Bobby"); setUpRegisteredJoin(player); - UUID playerId = UUID.randomUUID(); - given(player.getUniqueId()).willReturn(playerId); - given(preJoinDialogService.consumePendingKickMessage(playerId)).willReturn("You have canceled the login."); + givenPendingDialogState(new PreJoinDialogService.PendingDialogState( + null, null, null, false, false, "You have canceled the login.")); given(proxySessionManager.consumeLoginRequest("bobby")) .willReturn(new ProxySessionManager.ProxyLoginRequest("bobby", null)); given(proxyLoginRequestValidator.validate(player, null)).willReturn(true); @@ -252,10 +254,8 @@ public void shouldProcessPendingPreJoinLoginInsteadOfShowingDialog() { // given Player player = mockPlayer("Bobby"); setUpRegisteredJoin(player); - java.util.UUID playerId = java.util.UUID.randomUUID(); - given(player.getUniqueId()).willReturn(playerId); - given(preJoinDialogService.consumePendingLoginPassword(playerId)).willReturn("hunter2"); - given(preJoinDialogService.consumeSkipPostJoinDialog(playerId)).willReturn(false); + givenPendingDialogState(new PreJoinDialogService.PendingDialogState( + "hunter2", null, null, false, false, null)); given(playerCache.isAuthenticated("Bobby")).willReturn(false); // when @@ -350,9 +350,8 @@ public void shouldForceLoginPlayerApprovedViaPreJoinDialog() { // given Player player = mockPlayer("Bobby"); setUpRegisteredJoin(player); - java.util.UUID playerId = java.util.UUID.randomUUID(); - given(player.getUniqueId()).willReturn(playerId); - given(preJoinDialogService.consumePendingForceLogin(playerId)).willReturn(true); + givenPendingDialogState(new PreJoinDialogService.PendingDialogState( + null, null, null, false, true, null)); // when asynchronousJoin.processJoin(player); @@ -369,9 +368,8 @@ public void shouldSkipPostJoinDialogWhenPreJoinDialogWasDeferred() { // given Player player = mockPlayer("Bobby"); setUpRegisteredJoin(player); - java.util.UUID playerId = java.util.UUID.randomUUID(); - given(player.getUniqueId()).willReturn(playerId); - given(preJoinDialogService.consumeSkipPostJoinDialog(playerId)).willReturn(true); + givenPendingDialogState(new PreJoinDialogService.PendingDialogState( + null, null, null, true, false, null)); given(playerCache.isAuthenticated("Bobby")).willReturn(false); given(service.getProperty(RegistrationSettings.USE_DIALOG_UI)).willReturn(true); given(dialogAdapter.isDialogSupported()).willReturn(true); @@ -384,6 +382,10 @@ public void shouldSkipPostJoinDialogWhenPreJoinDialogWasDeferred() { verify(dialogAdapter, never()).showLoginDialog(eq(player), any(DialogWindowSpec.class)); } + private void givenPendingDialogState(PreJoinDialogService.PendingDialogState state) { + given(preJoinDialogService.consumeSession("bobby")).willReturn(state); + } + private void setUpRegisteredJoin(Player player) { String normalizedName = player.getName().toLowerCase(); given(validationService.fulfillsNameRestrictions(player)).willReturn(true); diff --git a/authme-core/src/test/java/fr/xephi/authme/service/PreJoinDialogServiceTest.java b/authme-core/src/test/java/fr/xephi/authme/service/PreJoinDialogServiceTest.java index fc829bed55..c0816ef7d0 100644 --- a/authme-core/src/test/java/fr/xephi/authme/service/PreJoinDialogServiceTest.java +++ b/authme-core/src/test/java/fr/xephi/authme/service/PreJoinDialogServiceTest.java @@ -1,8 +1,8 @@ package fr.xephi.authme.service; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.util.UUID; import java.util.concurrent.CompletableFuture; import static org.hamcrest.MatcherAssert.assertThat; @@ -14,54 +14,117 @@ */ class PreJoinDialogServiceTest { + private PreJoinDialogService service; + + @BeforeEach + void createService() { + service = new PreJoinDialogService(); + } + @Test void shouldStoreAndConsumePendingLoginPassword() { - PreJoinDialogService service = new PreJoinDialogService(); - UUID uuid = UUID.randomUUID(); + long sessionId = service.openSession("bobby"); + + service.storePendingLoginPassword(sessionId, "s3cr3t"); + + assertThat(service.consumeSession("bobby").loginPassword(), is("s3cr3t")); + assertThat(service.consumeSession("bobby").loginPassword(), nullValue()); + } + + @Test + void shouldConsumeWholeSessionAtOnce() { + long sessionId = service.openSession("bobby"); + service.storePendingLoginPassword(sessionId, "s3cr3t"); + service.storePendingRecoveryEmail(sessionId, "bob@example.com"); + service.storePendingPasswordRegistration(sessionId, "pw", "reg@example.com"); + service.markSkipPostJoinDialog(sessionId); + service.storePendingKickMessage(sessionId, "bye"); + + PreJoinDialogService.PendingDialogState state = service.consumeSession("bobby"); + + assertThat(state.loginPassword(), is("s3cr3t")); + assertThat(state.recoveryEmail(), is("bob@example.com")); + assertThat(state.registration(), is(new PreJoinDialogService.PendingRegistration("pw", "reg@example.com", false))); + assertThat(state.skipPostJoinDialog(), is(true)); + assertThat(state.kickMessage(), is("bye")); + } + + @Test + void shouldReturnEmptyStateForPlayerWithoutSession() { + PreJoinDialogService.PendingDialogState state = service.consumeSession("nobody"); + + assertThat(state, is(PreJoinDialogService.PendingDialogState.NONE)); + assertThat(state.loginPassword(), nullValue()); + assertThat(state.forceLogin(), is(false)); + } - service.storePendingLoginPassword(uuid, "s3cr3t"); + @Test + void shouldStoreEmailRegistration() { + long sessionId = service.openSession("bobby"); + + service.storePendingEmailRegistration(sessionId, "bob@example.com"); + + assertThat(service.consumeSession("bobby").registration(), + is(new PreJoinDialogService.PendingRegistration("bob@example.com", null, true))); + } + + @Test + void shouldNotLetLaterSessionConsumeStateOfEarlierOne() { + long firstSession = service.openSession("bobby"); + service.storePendingLoginPassword(firstSession, "s3cr3t"); + + service.openSession("bobby"); - assertThat(service.consumePendingLoginPassword(uuid), is("s3cr3t")); - assertThat(service.consumePendingLoginPassword(uuid), nullValue()); + assertThat(service.consumeSession("bobby").loginPassword(), nullValue()); + // The earlier session is gone, so writing to it must not resurrect it either + service.storePendingLoginPassword(firstSession, "s3cr3t"); + assertThat(service.consumeSession("bobby").loginPassword(), nullValue()); + } + + @Test + void shouldIgnoreStoresForRetiredSession() { + long sessionId = service.openSession("bobby"); + service.retireSession(sessionId); + + service.storePendingLoginPassword(sessionId, "s3cr3t"); + service.markSkipPostJoinDialog(sessionId); + + assertThat(service.consumeSession("bobby"), is(PreJoinDialogService.PendingDialogState.NONE)); } @Test void shouldApprovePreJoinForceLoginAndCompleteFuture() { - PreJoinDialogService service = new PreJoinDialogService(); - UUID uuid = UUID.randomUUID(); + long sessionId = service.openSession("bobby"); CompletableFuture future = new CompletableFuture<>(); - service.registerPreJoinFuture("bobby", uuid, future); + service.registerPreJoinFuture(sessionId, future); boolean result = service.approvePreJoinForceLogin("bobby"); assertThat(result, is(true)); assertThat(future.isDone(), is(true)); assertThat(future.getNow("sentinel"), is(nullValue())); - assertThat(service.consumePendingForceLogin(uuid), is(true)); - assertThat(service.consumePendingForceLogin(uuid), is(false)); + assertThat(service.consumeSession("bobby").forceLogin(), is(true)); + assertThat(service.consumeSession("bobby").forceLogin(), is(false)); } @Test void shouldReturnFalseForApproveWhenNoPreJoinDialogPending() { - PreJoinDialogService service = new PreJoinDialogService(); - assertThat(service.approvePreJoinForceLogin("nobody"), is(false)); } @Test - void shouldReturnFalseForConsumeForceLoginWhenNotApproved() { - PreJoinDialogService service = new PreJoinDialogService(); + void shouldReturnFalseForApproveWhenSessionHasNoDialog() { + service.openSession("bobby"); - assertThat(service.consumePendingForceLogin(UUID.randomUUID()), is(false)); + assertThat(service.approvePreJoinForceLogin("bobby"), is(false)); } @Test void shouldNotApproveAfterUnregister() { - PreJoinDialogService service = new PreJoinDialogService(); - UUID uuid = UUID.randomUUID(); + long sessionId = service.openSession("alice"); CompletableFuture future = new CompletableFuture<>(); - service.registerPreJoinFuture("alice", uuid, future); - service.unregisterPreJoinFuture(uuid); + service.registerPreJoinFuture(sessionId, future); + service.unregisterPreJoinFuture(sessionId); boolean result = service.approvePreJoinForceLogin("alice"); @@ -70,17 +133,15 @@ void shouldNotApproveAfterUnregister() { } @Test - void shouldClearAllStateForPlayer() { - PreJoinDialogService service = new PreJoinDialogService(); - UUID uuid = UUID.randomUUID(); + void shouldNotApproveAfterSessionRetired() { + long sessionId = service.openSession("charlie"); CompletableFuture future = new CompletableFuture<>(); - service.storePendingLoginPassword(uuid, "pw"); - service.registerPreJoinFuture("charlie", uuid, future); + service.storePendingLoginPassword(sessionId, "pw"); + service.registerPreJoinFuture(sessionId, future); - service.clear(uuid); + service.retireSession(sessionId); - assertThat(service.consumePendingLoginPassword(uuid), is(nullValue())); assertThat(service.approvePreJoinForceLogin("charlie"), is(false)); - assertThat(service.consumePendingForceLogin(uuid), is(false)); + assertThat(service.consumeSession("charlie"), is(PreJoinDialogService.PendingDialogState.NONE)); } } diff --git a/authme-core/src/test/java/fr/xephi/authme/service/ProxyLoginRequestValidatorTest.java b/authme-core/src/test/java/fr/xephi/authme/service/ProxyLoginRequestValidatorTest.java index 1465bf0e2a..f00d454030 100644 --- a/authme-core/src/test/java/fr/xephi/authme/service/ProxyLoginRequestValidatorTest.java +++ b/authme-core/src/test/java/fr/xephi/authme/service/ProxyLoginRequestValidatorTest.java @@ -5,6 +5,8 @@ import fr.xephi.authme.message.MessageKey; import fr.xephi.authme.message.Messages; import fr.xephi.authme.service.bungeecord.BungeeSender; +import fr.xephi.authme.settings.Settings; +import fr.xephi.authme.settings.properties.PremiumSettings; import org.bukkit.entity.Player; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -19,6 +21,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; /** * Test for {@link ProxyLoginRequestValidator}. @@ -47,6 +50,9 @@ class ProxyLoginRequestValidatorTest { @Mock private Messages messages; + @Mock + private Settings settings; + @Mock private Player player; @@ -55,6 +61,7 @@ void shouldAcceptStoredPremiumUuidFromProxy() { UUID premiumUuid = UUID.randomUUID(); PlayerAuth auth = PlayerAuth.builder().name("bobby").premiumUuid(premiumUuid).build(); given(player.getName()).willReturn("Bobby"); + given(settings.getProperty(PremiumSettings.ENABLE_PREMIUM)).willReturn(true); given(playerCache.getAuth("Bobby")).willReturn(auth); assertTrue(validator.validate(player, premiumUuid)); @@ -67,6 +74,7 @@ void shouldRejectStoredPremiumUuidMismatch() { UUID forwardedUuid = UUID.randomUUID(); PlayerAuth auth = PlayerAuth.builder().name("bobby").premiumUuid(storedUuid).build(); given(player.getName()).willReturn("Bobby"); + given(settings.getProperty(PremiumSettings.ENABLE_PREMIUM)).willReturn(true); given(playerCache.getAuth("Bobby")).willReturn(auth); assertFalse(validator.validate(player, forwardedUuid)); @@ -78,6 +86,7 @@ void shouldFinalizeMatchingPendingPremiumUuid() { UUID pendingUuid = UUID.randomUUID(); PlayerAuth auth = PlayerAuth.builder().name("bobby").build(); given(player.getName()).willReturn("Bobby"); + given(settings.getProperty(PremiumSettings.ENABLE_PREMIUM)).willReturn(true); given(playerCache.getAuth("Bobby")).willReturn(auth); given(pendingPremiumCache.removePending("Bobby")).willReturn(pendingUuid); @@ -92,6 +101,7 @@ void shouldStaySilentWhenPendingAlreadyConsumed() { UUID forwardedUuid = UUID.randomUUID(); PlayerAuth auth = PlayerAuth.builder().name("bobby").build(); given(player.getName()).willReturn("Bobby"); + given(settings.getProperty(PremiumSettings.ENABLE_PREMIUM)).willReturn(true); given(playerCache.getAuth("Bobby")).willReturn(auth); given(pendingPremiumCache.removePending("Bobby")).willReturn(null); @@ -107,6 +117,7 @@ void shouldRejectPendingPremiumUuidMismatchAndNotifyPlayer() { UUID forwardedUuid = UUID.randomUUID(); PlayerAuth auth = PlayerAuth.builder().name("bobby").build(); given(player.getName()).willReturn("Bobby"); + given(settings.getProperty(PremiumSettings.ENABLE_PREMIUM)).willReturn(true); given(playerCache.getAuth("Bobby")).willReturn(auth); given(pendingPremiumCache.removePending("Bobby")).willReturn(pendingUuid); @@ -115,4 +126,16 @@ void shouldRejectPendingPremiumUuidMismatchAndNotifyPlayer() { verify(messages).send(player, MessageKey.PREMIUM_PENDING_FAIL); verify(premiumService, never()).finalizePendingPremium(player, forwardedUuid); } + + @Test + void shouldRejectProxyPremiumClaimAndNotifyProxyWhenPremiumIsDisabled() { + UUID forwardedUuid = UUID.randomUUID(); + given(player.getName()).willReturn("Bobby"); + given(settings.getProperty(PremiumSettings.ENABLE_PREMIUM)).willReturn(false); + + assertFalse(validator.validate(player, forwardedUuid)); + verify(bungeeSender).sendPremiumUnset("Bobby"); + verify(messages, never()).send(player, MessageKey.PREMIUM_PENDING_FAIL); + verifyNoInteractions(playerCache, dataSource, pendingPremiumCache, premiumService); + } } diff --git a/authme-core/src/test/java/fr/xephi/authme/service/bungeecord/BungeeReceiverTest.java b/authme-core/src/test/java/fr/xephi/authme/service/bungeecord/BungeeReceiverTest.java index d5de39460c..faec092100 100644 --- a/authme-core/src/test/java/fr/xephi/authme/service/bungeecord/BungeeReceiverTest.java +++ b/authme-core/src/test/java/fr/xephi/authme/service/bungeecord/BungeeReceiverTest.java @@ -12,6 +12,7 @@ import fr.xephi.authme.service.ProxyLoginRequestValidator; import fr.xephi.authme.settings.Settings; import fr.xephi.authme.settings.properties.HooksSettings; +import fr.xephi.authme.settings.properties.PremiumSettings; import org.bukkit.Server; import org.bukkit.entity.Player; import org.bukkit.plugin.messaging.Messenger; @@ -24,10 +25,12 @@ import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import java.util.List; import java.util.UUID; import static fr.xephi.authme.service.BukkitServiceTestHelper.setBukkitServiceToRunTaskAsynchronously; import static fr.xephi.authme.service.BukkitServiceTestHelper.setBukkitServiceToScheduleSyncEntityTaskFromOptionallyAsyncTask; +import static fr.xephi.authme.service.BukkitServiceTestHelper.setBukkitServiceToScheduleSyncTaskFromOptionallyAsyncTask; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.nullValue; @@ -81,6 +84,7 @@ static void initLogger() { void setUp() { given(plugin.getServer()).willReturn(server); given(server.getMessenger()).willReturn(messenger); + given(settings.getProperty(PremiumSettings.ENABLE_PREMIUM)).willReturn(true); } @Test @@ -288,6 +292,58 @@ void shouldRejectConfigPhasePerformLoginWithInvalidHmac() { verify(proxySessionManager, never()).processProxySessionMessage(any(), any()); } + @Test + void shouldSendEmptyPremiumListOnProxyStartedWhenPremiumIsDisabled() { + // given + given(settings.getProperty(HooksSettings.BUNGEECORD)).willReturn(true); + given(settings.getProperty(PremiumSettings.ENABLE_PREMIUM)).willReturn(false); + setBukkitServiceToRunTaskAsynchronously(bukkitService); + setBukkitServiceToScheduleSyncTaskFromOptionallyAsyncTask(bukkitService); + + Player carrier = mock(Player.class); + given(bukkitService.getOnlinePlayers()).willReturn(List.of(carrier)); + + BungeeReceiver receiver = + new BungeeReceiver(plugin, bukkitService, proxySessionManager, management, bungeeSender, dataSource, + proxyLoginRequestValidator, settings); + + // when + receiver.onPluginMessageReceived("authme:main", carrier, buildProxyStartedPayload("velocity")); + + // then + verify(dataSource, never()).getPremiumUsernames(); + verify(bungeeSender).sendPremiumList(carrier, List.of()); + } + + @Test + void shouldSendEmptyPremiumListOnProxyStartedWhenNoPremiumUsersAreStored() { + // given + given(settings.getProperty(HooksSettings.BUNGEECORD)).willReturn(true); + given(dataSource.getPremiumUsernames()).willReturn(List.of()); + setBukkitServiceToRunTaskAsynchronously(bukkitService); + setBukkitServiceToScheduleSyncTaskFromOptionallyAsyncTask(bukkitService); + + Player carrier = mock(Player.class); + given(bukkitService.getOnlinePlayers()).willReturn(List.of(carrier)); + + BungeeReceiver receiver = + new BungeeReceiver(plugin, bukkitService, proxySessionManager, management, bungeeSender, dataSource, + proxyLoginRequestValidator, settings); + + // when + receiver.onPluginMessageReceived("authme:main", carrier, buildProxyStartedPayload("velocity")); + + // then + verify(bungeeSender).sendPremiumList(carrier, List.of()); + } + + private static byte[] buildProxyStartedPayload(String proxyName) { + ByteArrayDataOutput out = ByteStreams.newDataOutput(); + out.writeUTF(MessageType.PROXY_STARTED.getId()); + out.writeUTF(proxyName); + return out.toByteArray(); + } + private static byte[] buildPerformLoginPayload(String playerName, long timestamp, String hmac) { return buildPerformLoginPayload(playerName, timestamp, "", hmac); } diff --git a/authme-paper-common/src/main/java/fr/xephi/authme/listener/PaperDialogFlowListener.java b/authme-paper-common/src/main/java/fr/xephi/authme/listener/PaperDialogFlowListener.java index dd8b095e72..e39fda0ea4 100644 --- a/authme-paper-common/src/main/java/fr/xephi/authme/listener/PaperDialogFlowListener.java +++ b/authme-paper-common/src/main/java/fr/xephi/authme/listener/PaperDialogFlowListener.java @@ -52,8 +52,9 @@ */ public class PaperDialogFlowListener implements Listener { - private final ConcurrentMap> pendingLoginResponses = new ConcurrentHashMap<>(); - private final ConcurrentMap> pendingRegisterResponses = new ConcurrentHashMap<>(); + private final ConcurrentMap> pendingLoginResponses = new ConcurrentHashMap<>(); + private final ConcurrentMap> pendingRegisterResponses = new ConcurrentHashMap<>(); + private final ConcurrentMap connectionSessions = new ConcurrentHashMap<>(); @Inject private CommonService commonService; @@ -105,9 +106,7 @@ public void onPlayerConfigure(AsyncPlayerConnectionConfigureEvent event) { return; } - pendingLoginResponses.remove(playerId); - pendingRegisterResponses.remove(playerId); - preJoinDialogService.clear(playerId); + retireSession(connection); String normalizedName = playerName.toLowerCase(Locale.ROOT); Set unrestrictedNames = commonService.getProperty(RestrictionSettings.UNRESTRICTED_NAMES); @@ -118,18 +117,21 @@ public void onPlayerConfigure(AsyncPlayerConnectionConfigureEvent event) { return; } + long sessionId = preJoinDialogService.openSession(normalizedName); + connectionSessions.put(connection, sessionId); + PlayerAuth auth = dataSource.getAuth(normalizedName); if (auth != null) { if (shouldSkipPreJoinDialogForPremium(auth, playerName, playerId)) { - preJoinDialogService.markSkipPostJoinDialog(playerId); + preJoinDialogService.markSkipPostJoinDialog(sessionId); return; } - handleBlockingLoginDialog(connection, playerId, playerName); + handleBlockingLoginDialog(connection, sessionId, playerName); } else if (commonService.getProperty(RegistrationSettings.FORCE)) { RegistrationType registrationType = commonService.getProperty(RegistrationSettings.REGISTRATION_TYPE); RegisterSecondaryArgument secondArg = commonService.getProperty(RegistrationSettings.REGISTER_SECOND_ARGUMENT); - handleBlockingRegisterDialog(connection, playerId, playerName, PaperDialogHelper.createPreJoinRegisterDialog( + handleBlockingRegisterDialog(connection, sessionId, playerName, PaperDialogHelper.createPreJoinRegisterDialog( dialogWindowService.createPreJoinRegisterDialog(playerName, registrationType, secondArg))); } } @@ -140,9 +142,9 @@ public void onPlayerCustomClick(PlayerCustomClickEvent event) { return; } - UUID playerId = connection.getProfile().getId(); String playerName = connection.getProfile().getName(); - if (playerId == null || playerName == null) { + Long sessionId = connectionSessions.get(connection); + if (playerName == null || sessionId == null) { return; } @@ -150,17 +152,17 @@ public void onPlayerCustomClick(PlayerCustomClickEvent event) { String kickMessage = commonService.getProperty(RegistrationSettings.PRE_JOIN_LOGIN_CANCEL_KICKS) ? messages.retrieveSingle(playerName, MessageKey.DIALOG_LOGIN_CANCELED) : null; - completeLoginResponse(playerId, kickMessage); + completeLoginResponse(sessionId, kickMessage); return; } if (PaperDialogActionKeys.PRE_JOIN_LOGIN_RECOVERY.equals(event.getIdentifier())) { - processPreJoinLoginRecovery(playerId, playerName, connection); + processPreJoinLoginRecovery(playerName, connection); return; } if (PaperDialogActionKeys.PRE_JOIN_RECOVERY_SUBMIT.equals(event.getIdentifier())) { - processPreJoinRecoverySubmit(playerId, playerName, event.getDialogResponseView()); + processPreJoinRecoverySubmit(sessionId, event.getDialogResponseView()); return; } @@ -168,17 +170,17 @@ public void onPlayerCustomClick(PlayerCustomClickEvent event) { String kickMessage = commonService.getProperty(RegistrationSettings.PRE_JOIN_LOGIN_CANCEL_KICKS) ? messages.retrieveSingle(playerName, MessageKey.DIALOG_LOGIN_CANCELED) : null; - completeLoginResponse(playerId, kickMessage); + completeLoginResponse(sessionId, kickMessage); return; } if (PaperDialogActionKeys.PRE_JOIN_LOGIN_SUBMIT.equals(event.getIdentifier())) { - processPreJoinLogin(playerId, playerName, event.getDialogResponseView()); + processPreJoinLogin(sessionId, playerName, event.getDialogResponseView()); return; } if (PaperDialogActionKeys.PRE_JOIN_REGISTER_SUBMIT.equals(event.getIdentifier())) { - storePendingRegistration(connection, playerId, playerName, event.getDialogResponseView()); + storePendingRegistration(connection, sessionId, playerName, event.getDialogResponseView()); return; } @@ -186,26 +188,38 @@ public void onPlayerCustomClick(PlayerCustomClickEvent event) { String kickMessage = commonService.getProperty(RegistrationSettings.PRE_JOIN_REGISTER_CANCEL_KICKS) ? messages.retrieveSingle(playerName, MessageKey.DIALOG_REGISTER_CANCELED) : null; - completeRegisterResponse(playerId, kickMessage); + completeRegisterResponse(sessionId, kickMessage); } } @EventHandler(priority = EventPriority.MONITOR) public void onPlayerConnectionClose(PlayerConnectionCloseEvent event) { - UUID playerId = event.getPlayerUniqueId(); - pendingLoginResponses.remove(playerId); - pendingRegisterResponses.remove(playerId); - preJoinDialogService.clear(playerId); + // The event only carries a name and profile id, both shared by every connection using that + // name, so retire by connection instead: the ones that are gone, whichever player they were + for (PlayerConfigurationConnection connection : connectionSessions.keySet()) { + if (!connection.isConnected()) { + retireSession(connection); + } + } + } + + private void retireSession(PlayerConfigurationConnection connection) { + Long sessionId = connectionSessions.remove(connection); + if (sessionId != null) { + pendingLoginResponses.remove(sessionId); + pendingRegisterResponses.remove(sessionId); + preJoinDialogService.retireSession(sessionId); + } } - private void handleBlockingLoginDialog(PlayerConfigurationConnection connection, UUID playerId, String playerName) { + private void handleBlockingLoginDialog(PlayerConfigurationConnection connection, long sessionId, String playerName) { CompletableFuture loginResponse = new CompletableFuture<>(); long timeoutSeconds = Math.max(commonService.getProperty(RestrictionSettings.LOGIN_TIMEOUT), 1); loginResponse.completeOnTimeout( messages.retrieveSingle(playerName, MessageKey.LOGIN_TIMEOUT_ERROR), timeoutSeconds, TimeUnit.SECONDS); String normalizedName = playerName.toLowerCase(Locale.ROOT); - pendingLoginResponses.put(playerId, loginResponse); - preJoinDialogService.registerPreJoinFuture(normalizedName, playerId, loginResponse); + pendingLoginResponses.put(sessionId, loginResponse); + preJoinDialogService.registerPreJoinFuture(sessionId, loginResponse); // Close the race with a proxy auto-login (perform.login) that arrived during the configuration // phase between the shouldSkipDialogs() check and now: if a proxy session has been queued, @@ -223,72 +237,72 @@ private void handleBlockingLoginDialog(PlayerConfigurationConnection connection, PaperDialogHelper.createPreJoinLoginDialog(dialogWindowService.createPreJoinLoginDialog(playerName))); } String kickMessage = loginResponse.join(); - pendingLoginResponses.remove(playerId); - preJoinDialogService.unregisterPreJoinFuture(playerId); + pendingLoginResponses.remove(sessionId); + preJoinDialogService.unregisterPreJoinFuture(sessionId); if (kickMessage != null) { - preJoinDialogService.storePendingKickMessage(playerId, kickMessage); + preJoinDialogService.storePendingKickMessage(sessionId, kickMessage); } connection.getAudience().closeDialog(); } - private void processPreJoinLogin(UUID playerId, String playerName, DialogResponseView dialogResponseView) { + private void processPreJoinLogin(long sessionId, String playerName, DialogResponseView dialogResponseView) { String password = dialogResponseView == null ? null : dialogResponseView.getText("password"); if (password == null || password.isBlank()) { - completeLoginResponse(playerId, messages.retrieveSingle(playerName, MessageKey.LOGIN_TIMEOUT_ERROR)); + completeLoginResponse(sessionId, messages.retrieveSingle(playerName, MessageKey.LOGIN_TIMEOUT_ERROR)); return; } PlayerAuth auth = dataSource.getAuth(playerName.toLowerCase(Locale.ROOT)); if (auth == null) { - completeLoginResponse(playerId, messages.retrieveSingle(playerName, MessageKey.UNKNOWN_USER)); + completeLoginResponse(sessionId, messages.retrieveSingle(playerName, MessageKey.UNKNOWN_USER)); return; } if (passwordSecurity.comparePassword(password, auth.getPassword(), playerName)) { - preJoinDialogService.storePendingLoginPassword(playerId, password); - completeLoginResponse(playerId, null); + preJoinDialogService.storePendingLoginPassword(sessionId, password); + completeLoginResponse(sessionId, null); } else { - completeLoginResponse(playerId, messages.retrieveSingle(playerName, MessageKey.WRONG_PASSWORD)); + completeLoginResponse(sessionId, messages.retrieveSingle(playerName, MessageKey.WRONG_PASSWORD)); } } - private void processPreJoinLoginRecovery(UUID playerId, String playerName, - PlayerConfigurationConnection connection) { + private void processPreJoinLoginRecovery(String playerName, PlayerConfigurationConnection connection) { DialogWindowSpec recoverySpec = dialogWindowService.createPreJoinRecoveryDialog(playerName); connection.getAudience().showDialog(PaperDialogHelper.createPreJoinRecoveryDialog(recoverySpec)); } - private void processPreJoinRecoverySubmit(UUID playerId, String playerName, DialogResponseView dialogResponseView) { + private void processPreJoinRecoverySubmit(long sessionId, DialogResponseView dialogResponseView) { String email = dialogResponseView == null ? null : dialogResponseView.getText("email"); if (email != null && !email.isBlank()) { - preJoinDialogService.storePendingRecoveryEmail(playerId, email); + preJoinDialogService.storePendingRecoveryEmail(sessionId, email); } // Let the player join; AsynchronousJoin will execute the recovery and kick on failure - completeLoginResponse(playerId, null); + completeLoginResponse(sessionId, null); } - private void handleBlockingRegisterDialog(PlayerConfigurationConnection connection, UUID playerId, String playerName, Dialog dialog) { + private void handleBlockingRegisterDialog(PlayerConfigurationConnection connection, long sessionId, + String playerName, Dialog dialog) { CompletableFuture registerResponse = new CompletableFuture<>(); long timeoutSeconds = Math.max(commonService.getProperty(RestrictionSettings.REGISTER_TIMEOUT), 1); registerResponse.completeOnTimeout( messages.retrieveSingle(playerName, MessageKey.LOGIN_TIMEOUT_ERROR), timeoutSeconds, TimeUnit.SECONDS); - pendingRegisterResponses.put(playerId, registerResponse); + pendingRegisterResponses.put(sessionId, registerResponse); connection.getAudience().showDialog(dialog); String kickMessage = registerResponse.join(); - pendingRegisterResponses.remove(playerId); + pendingRegisterResponses.remove(sessionId); if (kickMessage != null) { - preJoinDialogService.storePendingKickMessage(playerId, kickMessage); + preJoinDialogService.storePendingKickMessage(sessionId, kickMessage); } connection.getAudience().closeDialog(); } - private void storePendingRegistration(PlayerConfigurationConnection connection, UUID playerId, String playerName, - DialogResponseView dialogResponseView) { + private void storePendingRegistration(PlayerConfigurationConnection connection, long sessionId, + String playerName, DialogResponseView dialogResponseView) { if (dialogResponseView == null) { - completeRegisterResponse(playerId, null); + completeRegisterResponse(sessionId, null); return; } @@ -302,7 +316,7 @@ private void storePendingRegistration(PlayerConfigurationConnection connection, String kickMessage = messages.retrieveSingle(playerName, MessageKey.MAX_REGISTER_EXCEEDED, Integer.toString(maxRegPerIp), Integer.toString(otherAccounts.size()), String.join(", ", otherAccounts)); - completeRegisterResponse(playerId, kickMessage); + completeRegisterResponse(sessionId, kickMessage); return; } } @@ -323,8 +337,8 @@ private void storePendingRegistration(PlayerConfigurationConnection connection, messages.retrieveSingle(playerName, MessageKey.PASSWORD_MATCH_ERROR)); return; } - preJoinDialogService.storePendingEmailRegistration(playerId, email); - completeRegisterResponse(playerId, null); + preJoinDialogService.storePendingEmailRegistration(sessionId, email); + completeRegisterResponse(sessionId, null); return; } @@ -349,8 +363,8 @@ private void storePendingRegistration(PlayerConfigurationConnection connection, return; } if (secondArg == RegisterSecondaryArgument.CONFIRMATION) { - preJoinDialogService.storePendingPasswordRegistration(playerId, password, null); - completeRegisterResponse(playerId, null); + preJoinDialogService.storePendingPasswordRegistration(sessionId, password, null); + completeRegisterResponse(sessionId, null); return; } @@ -367,13 +381,13 @@ private void storePendingRegistration(PlayerConfigurationConnection connection, messages.retrieveSingle(playerName, MessageKey.INVALID_EMAIL)); return; } - preJoinDialogService.storePendingPasswordRegistration(playerId, password, email); - completeRegisterResponse(playerId, null); + preJoinDialogService.storePendingPasswordRegistration(sessionId, password, email); + completeRegisterResponse(sessionId, null); return; } - preJoinDialogService.storePendingPasswordRegistration(playerId, password, null); - completeRegisterResponse(playerId, null); + preJoinDialogService.storePendingPasswordRegistration(sessionId, password, null); + completeRegisterResponse(sessionId, null); } private void showRegisterDialogWithError(PlayerConfigurationConnection connection, String playerName, @@ -384,15 +398,15 @@ private void showRegisterDialogWithError(PlayerConfigurationConnection connectio connection.getAudience().showDialog(PaperDialogHelper.createPreJoinRegisterDialog(spec.withBody(errorMessage))); } - private void completeLoginResponse(UUID playerId, String kickMessage) { - CompletableFuture loginResponse = pendingLoginResponses.get(playerId); + private void completeLoginResponse(long sessionId, String kickMessage) { + CompletableFuture loginResponse = pendingLoginResponses.get(sessionId); if (loginResponse != null) { loginResponse.complete(kickMessage); } } - private void completeRegisterResponse(UUID playerId, String kickMessage) { - CompletableFuture registerResponse = pendingRegisterResponses.get(playerId); + private void completeRegisterResponse(long sessionId, String kickMessage) { + CompletableFuture registerResponse = pendingRegisterResponses.get(sessionId); if (registerResponse != null) { registerResponse.complete(kickMessage); } diff --git a/authme-paper-common/src/main/java/fr/xephi/authme/listener/PaperLoginValidationListener.java b/authme-paper-common/src/main/java/fr/xephi/authme/listener/PaperLoginValidationListener.java index a507163ae3..df71ac5f62 100644 --- a/authme-paper-common/src/main/java/fr/xephi/authme/listener/PaperLoginValidationListener.java +++ b/authme-paper-common/src/main/java/fr/xephi/authme/listener/PaperLoginValidationListener.java @@ -1,7 +1,14 @@ package fr.xephi.authme.listener; +import com.destroystokyo.paper.event.player.PlayerConnectionCloseEvent; import com.destroystokyo.paper.profile.PlayerProfile; +import fr.xephi.authme.message.MessageKey; import fr.xephi.authme.message.Messages; +import fr.xephi.authme.service.PendingConnectionRegistry; +import fr.xephi.authme.settings.Settings; +import fr.xephi.authme.settings.properties.RestrictionSettings; +import io.papermc.paper.connection.PlayerConfigurationConnection; +import io.papermc.paper.connection.PlayerConnection; import io.papermc.paper.connection.PlayerLoginConnection; import io.papermc.paper.event.connection.PlayerConnectionValidateLoginEvent; import io.papermc.paper.event.player.PlayerServerFullCheckEvent; @@ -9,8 +16,10 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; import javax.inject.Inject; +import java.util.concurrent.TimeUnit; /** * Shared login validation listener for Paper-derived platforms. @@ -19,28 +28,29 @@ public class PaperLoginValidationListener implements Listener { private static final LegacyComponentSerializer LEGACY_SERIALIZER = LegacyComponentSerializer.legacySection(); + /** Safety net for a connection that never reports itself as gone; not the release mechanism. */ + private static final long CLAIM_GRACE_MILLIS = TimeUnit.SECONDS.toMillis(60); + @Inject private OnJoinVerifier onJoinVerifier; @Inject private Messages messages; - @EventHandler(priority = EventPriority.LOW) - public void onPlayerConnectionValidateLogin(PlayerConnectionValidateLoginEvent event) { - if (!(event.getConnection() instanceof PlayerLoginConnection loginConnection)) { - return; - } + @Inject + private PendingConnectionRegistry pendingConnectionRegistry; - String playerName = getPlayerName(loginConnection); - if (playerName == null) { - return; - } + @Inject + private Settings settings; - try { - onJoinVerifier.checkSingleSession(playerName); - } catch (FailedVerificationException e) { - String kickMessage = messages.retrieveSingle(playerName, e.getReason(), e.getArgs()); - event.kickMessage(LEGACY_SERIALIZER.deserialize(kickMessage)); + // Paper fires this twice: in the login phase, and again once the connection is configured + @EventHandler(priority = EventPriority.LOW) + public void onPlayerConnectionValidateLogin(PlayerConnectionValidateLoginEvent event) { + PlayerConnection connection = event.getConnection(); + if (connection instanceof PlayerLoginConnection loginConnection) { + verifyLoginPhase(event, loginConnection); + } else if (connection instanceof PlayerConfigurationConnection configurationConnection) { + verifyConfigurationPhase(event, configurationConnection); } } @@ -63,6 +73,80 @@ public void onPlayerServerFullCheck(PlayerServerFullCheckEvent event) { } } + // From the play state on, checkSingleSession sees the player itself + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerJoin(PlayerJoinEvent event) { + pendingConnectionRegistry.release(event.getPlayer().getName()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerConnectionClose(PlayerConnectionCloseEvent event) { + String playerName = event.getPlayerName(); + if (playerName != null) { + pendingConnectionRegistry.releaseIfStale(playerName); + } + } + + private void verifyLoginPhase(PlayerConnectionValidateLoginEvent event, PlayerLoginConnection connection) { + String playerName = getPlayerName(connection); + if (playerName == null) { + return; + } + + try { + onJoinVerifier.checkSingleSession(playerName); + } catch (FailedVerificationException e) { + denyConnection(event, playerName, e.getReason(), e.getArgs()); + return; + } + + // A concurrent connection on the same name would take over its pre-join dialog state + if (!pendingConnectionRegistry.tryClaim(playerName, connection, getClaimTtlMillis())) { + denyConnection(event, playerName, MessageKey.USERNAME_ALREADY_ONLINE_ERROR); + } + } + + private void verifyConfigurationPhase(PlayerConnectionValidateLoginEvent event, + PlayerConfigurationConnection connection) { + PlayerProfile profile = connection.getProfile(); + String playerName = profile == null ? null : profile.getName(); + if (playerName == null) { + return; + } + + // This connection held the name to get here, so losing it means another one took it over + if (pendingConnectionRegistry.isClaimedByOtherConnection(playerName, connection)) { + denyConnection(event, playerName, MessageKey.USERNAME_ALREADY_ONLINE_ERROR); + return; + } + + // No claim at all means an online player being reconfigured, never checked against itself + if (!pendingConnectionRegistry.holdsClaim(playerName, connection)) { + return; + } + + try { + onJoinVerifier.checkSingleSession(playerName); + } catch (FailedVerificationException e) { + denyConnection(event, playerName, e.getReason(), e.getArgs()); + return; + } + + // Renew so the claim covers the remaining wait until the player is placed in the world + pendingConnectionRegistry.tryClaim(playerName, connection, getClaimTtlMillis()); + } + + private void denyConnection(PlayerConnectionValidateLoginEvent event, String playerName, + MessageKey reason, String... args) { + event.kickMessage(LEGACY_SERIALIZER.deserialize(messages.retrieveSingle(playerName, reason, args))); + } + + private long getClaimTtlMillis() { + int timeoutSeconds = Math.max(settings.getProperty(RestrictionSettings.LOGIN_TIMEOUT), + settings.getProperty(RestrictionSettings.REGISTER_TIMEOUT)); + return TimeUnit.SECONDS.toMillis(Math.max(timeoutSeconds, 0)) + CLAIM_GRACE_MILLIS; + } + private static String getPlayerName(PlayerLoginConnection connection) { PlayerProfile profile = connection.getAuthenticatedProfile(); if (profile != null && profile.getName() != null) { diff --git a/authme-paper-common/src/main/java/fr/xephi/authme/service/PendingConnectionRegistry.java b/authme-paper-common/src/main/java/fr/xephi/authme/service/PendingConnectionRegistry.java new file mode 100644 index 0000000000..225687cf29 --- /dev/null +++ b/authme-paper-common/src/main/java/fr/xephi/authme/service/PendingConnectionRegistry.java @@ -0,0 +1,80 @@ +package fr.xephi.authme.service; + +import io.papermc.paper.connection.PlayerConnection; + +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Reserves a player name while its connection is still being established, before the play state. + */ +public class PendingConnectionRegistry { + + private final Map claims = new ConcurrentHashMap<>(); + + public PendingConnectionRegistry() { + } + + public boolean tryClaim(String name, PlayerConnection connection, long ttlMillis) { + String connectionKey = connectionKey(connection); + long expiresAt = System.currentTimeMillis() + ttlMillis; + Claim claim = claims.compute(normalize(name), (ignored, existing) -> { + if (existing == null || existing.isStale() || existing.isHeldBy(connectionKey)) { + return new Claim(connectionKey, connection, expiresAt); + } + return existing; + }); + return claim.isHeldBy(connectionKey); + } + + public boolean holdsClaim(String name, PlayerConnection connection) { + Claim claim = claims.get(normalize(name)); + return claim != null && !claim.isStale() && claim.isHeldBy(connectionKey(connection)); + } + + public boolean isClaimedByOtherConnection(String name, PlayerConnection connection) { + Claim claim = claims.get(normalize(name)); + return claim != null && !claim.isStale() && !claim.isHeldBy(connectionKey(connection)); + } + + public void release(String name) { + claims.remove(normalize(name)); + } + + // A refused duplicate connection closes too, so releasing by name alone would hand it the name + public void releaseIfStale(String name) { + claims.computeIfPresent(normalize(name), (ignored, claim) -> claim.isStale() ? null : claim); + } + + private static String normalize(String name) { + return name.toLowerCase(Locale.ROOT); + } + + // The ephemeral port makes this unique per connection and unchanged across the phase switch + private static String connectionKey(PlayerConnection connection) { + return String.valueOf(connection.getAddress()); + } + + private static final class Claim { + + private final String connectionKey; + private final PlayerConnection connection; + private final long expiresAt; + + Claim(String connectionKey, PlayerConnection connection, long expiresAt) { + this.connectionKey = connectionKey; + this.connection = connection; + this.expiresAt = expiresAt; + } + + boolean isHeldBy(String otherConnectionKey) { + return connectionKey.equals(otherConnectionKey); + } + + // The TTL guarantees a claim can never lock a name out for good + boolean isStale() { + return !connection.isConnected() || System.currentTimeMillis() > expiresAt; + } + } +} diff --git a/authme-paper-common/src/test/java/fr/xephi/authme/listener/PaperDialogFlowListenerTest.java b/authme-paper-common/src/test/java/fr/xephi/authme/listener/PaperDialogFlowListenerTest.java index e5335e68a4..313ee35c25 100644 --- a/authme-paper-common/src/test/java/fr/xephi/authme/listener/PaperDialogFlowListenerTest.java +++ b/authme-paper-common/src/test/java/fr/xephi/authme/listener/PaperDialogFlowListenerTest.java @@ -1,5 +1,6 @@ package fr.xephi.authme.listener; +import com.destroystokyo.paper.event.player.PlayerConnectionCloseEvent; import com.destroystokyo.paper.profile.PlayerProfile; import fr.xephi.authme.data.ProxySessionManager; import fr.xephi.authme.data.auth.PlayerCache; @@ -45,6 +46,8 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; @@ -54,6 +57,8 @@ public class PaperDialogFlowListenerTest { + private static final long SESSION_ID = 42L; + @Test public void shouldListenLateOnPlayerConfigure() throws Exception { Method method = PaperDialogFlowListener.class @@ -65,7 +70,6 @@ public void shouldListenLateOnPlayerConfigure() throws Exception { } @Test - @SuppressWarnings("unchecked") public void shouldShowErrorDialogAndKeepFutureOpenForEmptyPasswordSubmission() throws Exception { PaperDialogFlowListener listener = new PaperDialogFlowListener(); CommonService commonService = mock(CommonService.class); @@ -88,22 +92,10 @@ public void shouldShowErrorDialogAndKeepFutureOpenForEmptyPasswordSubmission() t new DialogWindowSpec("Register", List.of(new DialogInputSpec("password", "Password", 100)), "Register", "Cancel", false, false, null)); - UUID playerId = UUID.randomUUID(); CompletableFuture future = new CompletableFuture<>(); - Field pendingField = PaperDialogFlowListener.class.getDeclaredField("pendingRegisterResponses"); - pendingField.setAccessible(true); - ConcurrentMap> pendingRegisterResponses = - (ConcurrentMap>) pendingField.get(listener); - pendingRegisterResponses.put(playerId, future); - - PlayerProfile profile = mock(PlayerProfile.class); - given(profile.getId()).willReturn(playerId); - given(profile.getName()).willReturn("Bobby"); - - Audience audience = mock(Audience.class); - PlayerConfigurationConnection connection = mock(PlayerConfigurationConnection.class); - given(connection.getProfile()).willReturn(profile); - given(connection.getAudience()).willReturn(audience); + PlayerConfigurationConnection connection = mockConnection("Bobby"); + given(connection.getAudience()).willReturn(mock(Audience.class)); + seedSession(listener, connection, "pendingRegisterResponses", future); DialogResponseView responseView = mock(DialogResponseView.class); given(responseView.getText("password")).willReturn(""); @@ -122,32 +114,20 @@ public void shouldShowErrorDialogAndKeepFutureOpenForEmptyPasswordSubmission() t assertThat("future must stay open so the player can retry", future.isDone(), is(false)); verify(connection).getAudience(); - verify(preJoinDialogService, never()).storePendingPasswordRegistration(any(), any(), any()); + verify(preJoinDialogService, never()).storePendingPasswordRegistration(anyLong(), any(), any()); } } @Test - @SuppressWarnings("unchecked") public void shouldFallbackToPostJoinDialogWhenPreJoinLoginIsCancelled() throws Exception { PaperDialogFlowListener listener = new PaperDialogFlowListener(); CommonService commonService = mock(CommonService.class); setField(listener, "commonService", commonService); given(commonService.getProperty(RegistrationSettings.PRE_JOIN_LOGIN_CANCEL_KICKS)).willReturn(false); - UUID playerId = UUID.randomUUID(); CompletableFuture future = new CompletableFuture<>(); - Field pendingField = PaperDialogFlowListener.class.getDeclaredField("pendingLoginResponses"); - pendingField.setAccessible(true); - ConcurrentMap> pendingLoginResponses = - (ConcurrentMap>) pendingField.get(listener); - pendingLoginResponses.put(playerId, future); - - PlayerProfile profile = mock(PlayerProfile.class); - given(profile.getId()).willReturn(playerId); - given(profile.getName()).willReturn("Bobby"); - - PlayerConfigurationConnection connection = mock(PlayerConfigurationConnection.class); - given(connection.getProfile()).willReturn(profile); + PlayerConfigurationConnection connection = mockConnection("Bobby"); + seedSession(listener, connection, "pendingLoginResponses", future); PlayerCustomClickEvent event = mock(PlayerCustomClickEvent.class); given(event.getCommonConnection()).willReturn(connection); @@ -160,7 +140,6 @@ public void shouldFallbackToPostJoinDialogWhenPreJoinLoginIsCancelled() throws E } @Test - @SuppressWarnings("unchecked") public void shouldKickWhenPreJoinLoginIsCancelledAndSettingEnabled() throws Exception { PaperDialogFlowListener listener = new PaperDialogFlowListener(); CommonService commonService = mock(CommonService.class); @@ -170,20 +149,9 @@ public void shouldKickWhenPreJoinLoginIsCancelledAndSettingEnabled() throws Exce given(commonService.getProperty(RegistrationSettings.PRE_JOIN_LOGIN_CANCEL_KICKS)).willReturn(true); given(messages.retrieveSingle("Bobby", MessageKey.DIALOG_LOGIN_CANCELED)).willReturn("Canceled!"); - UUID playerId = UUID.randomUUID(); CompletableFuture future = new CompletableFuture<>(); - Field pendingField = PaperDialogFlowListener.class.getDeclaredField("pendingLoginResponses"); - pendingField.setAccessible(true); - ConcurrentMap> pendingLoginResponses = - (ConcurrentMap>) pendingField.get(listener); - pendingLoginResponses.put(playerId, future); - - PlayerProfile profile = mock(PlayerProfile.class); - given(profile.getId()).willReturn(playerId); - given(profile.getName()).willReturn("Bobby"); - - PlayerConfigurationConnection connection = mock(PlayerConfigurationConnection.class); - given(connection.getProfile()).willReturn(profile); + PlayerConfigurationConnection connection = mockConnection("Bobby"); + seedSession(listener, connection, "pendingLoginResponses", future); PlayerCustomClickEvent event = mock(PlayerCustomClickEvent.class); given(event.getCommonConnection()).willReturn(connection); @@ -196,27 +164,15 @@ public void shouldKickWhenPreJoinLoginIsCancelledAndSettingEnabled() throws Exce } @Test - @SuppressWarnings("unchecked") public void shouldFallbackToPostJoinDialogWhenPreJoinRegisterIsCancelled() throws Exception { PaperDialogFlowListener listener = new PaperDialogFlowListener(); CommonService commonService = mock(CommonService.class); setField(listener, "commonService", commonService); given(commonService.getProperty(RegistrationSettings.PRE_JOIN_REGISTER_CANCEL_KICKS)).willReturn(false); - UUID playerId = UUID.randomUUID(); CompletableFuture future = new CompletableFuture<>(); - Field pendingField = PaperDialogFlowListener.class.getDeclaredField("pendingRegisterResponses"); - pendingField.setAccessible(true); - ConcurrentMap> pendingRegisterResponses = - (ConcurrentMap>) pendingField.get(listener); - pendingRegisterResponses.put(playerId, future); - - PlayerProfile profile = mock(PlayerProfile.class); - given(profile.getId()).willReturn(playerId); - given(profile.getName()).willReturn("Bobby"); - - PlayerConfigurationConnection connection = mock(PlayerConfigurationConnection.class); - given(connection.getProfile()).willReturn(profile); + PlayerConfigurationConnection connection = mockConnection("Bobby"); + seedSession(listener, connection, "pendingRegisterResponses", future); PlayerCustomClickEvent event = mock(PlayerCustomClickEvent.class); given(event.getCommonConnection()).willReturn(connection); @@ -229,7 +185,6 @@ public void shouldFallbackToPostJoinDialogWhenPreJoinRegisterIsCancelled() throw } @Test - @SuppressWarnings("unchecked") public void shouldKickWhenPreJoinRegisterIsCancelledAndSettingEnabled() throws Exception { PaperDialogFlowListener listener = new PaperDialogFlowListener(); CommonService commonService = mock(CommonService.class); @@ -239,20 +194,9 @@ public void shouldKickWhenPreJoinRegisterIsCancelledAndSettingEnabled() throws E given(commonService.getProperty(RegistrationSettings.PRE_JOIN_REGISTER_CANCEL_KICKS)).willReturn(true); given(messages.retrieveSingle("Bobby", MessageKey.DIALOG_REGISTER_CANCELED)).willReturn("Canceled!"); - UUID playerId = UUID.randomUUID(); CompletableFuture future = new CompletableFuture<>(); - Field pendingField = PaperDialogFlowListener.class.getDeclaredField("pendingRegisterResponses"); - pendingField.setAccessible(true); - ConcurrentMap> pendingRegisterResponses = - (ConcurrentMap>) pendingField.get(listener); - pendingRegisterResponses.put(playerId, future); - - PlayerProfile profile = mock(PlayerProfile.class); - given(profile.getId()).willReturn(playerId); - given(profile.getName()).willReturn("Bobby"); - - PlayerConfigurationConnection connection = mock(PlayerConfigurationConnection.class); - given(connection.getProfile()).willReturn(profile); + PlayerConfigurationConnection connection = mockConnection("Bobby"); + seedSession(listener, connection, "pendingRegisterResponses", future); PlayerCustomClickEvent event = mock(PlayerCustomClickEvent.class); given(event.getCommonConnection()).willReturn(connection); @@ -298,7 +242,7 @@ public void shouldSkipPreJoinDialogsForAuthenticatedPlayerEvenIfPostJoinDialogsA listener.onPlayerConfigure(event); - verify(preJoinDialogService).clear(playerId); + verify(preJoinDialogService, never()).openSession(anyString()); verifyNoInteractions(audience); } @@ -339,7 +283,7 @@ public void shouldSkipPreJoinDialogsForPlayerWithValidSession() throws Exception listener.onPlayerConfigure(event); - verify(preJoinDialogService).clear(playerId); + verify(preJoinDialogService, never()).openSession(anyString()); verifyNoInteractions(audience); } @@ -379,7 +323,7 @@ public void shouldSkipPreJoinDialogsForProxyAutoLogin() throws Exception { listener.onPlayerConfigure(event); - verify(preJoinDialogService).clear(playerId); + verify(preJoinDialogService, never()).openSession(anyString()); verifyNoInteractions(audience); verifyNoInteractions(sessionService); } @@ -419,6 +363,7 @@ public void shouldSkipPreJoinDialogsForVerifiedPremiumPlayer() throws Exception given(sessionService.hasValidSession("bobby", null)).willReturn(false); given(dataSource.getAuth("bobby")).willReturn(auth); given(premiumLoginVerifier.getVerifiedUuid("Bobby")).willReturn(premiumUuid); + given(preJoinDialogService.openSession("bobby")).willReturn(SESSION_ID); PlayerProfile profile = mock(PlayerProfile.class); given(profile.getId()).willReturn(playerId); @@ -434,7 +379,7 @@ public void shouldSkipPreJoinDialogsForVerifiedPremiumPlayer() throws Exception listener.onPlayerConfigure(event); - verify(preJoinDialogService).markSkipPostJoinDialog(playerId); + verify(preJoinDialogService).markSkipPostJoinDialog(SESSION_ID); verifyNoInteractions(audience); } @@ -470,7 +415,7 @@ public void shouldNotSkipPreJoinDialogsForUnverifiedPremiumPlayer() throws Excep // UUID v4 that doesn't match stored premium UUID → must return false (impostor or wrong account) assertThat(invokeShouldSkipPreJoinDialogForPremium(listener, auth, "Bobby", playerId), is(false)); - verify(preJoinDialogService, never()).markSkipPostJoinDialog(playerId); + verify(preJoinDialogService, never()).markSkipPostJoinDialog(anyLong()); } @Test @@ -548,6 +493,62 @@ private static boolean invokeShouldSkipPreJoinDialogForPremium(PaperDialogFlowLi return (boolean) method.invoke(listener, auth, playerName, playerId); } + @Test + public void shouldRetireSessionOfClosedConnectionOnly() throws Exception { + PaperDialogFlowListener listener = new PaperDialogFlowListener(); + PreJoinDialogService preJoinDialogService = mock(PreJoinDialogService.class); + setField(listener, "preJoinDialogService", preJoinDialogService); + + PlayerConfigurationConnection gone = mockConnection("Bobby"); + given(gone.isConnected()).willReturn(false); + PlayerConfigurationConnection live = mockConnection("Bobby"); + given(live.isConnected()).willReturn(true); + CompletableFuture liveFuture = new CompletableFuture<>(); + connectionSessions(listener).put(gone, SESSION_ID); + connectionSessions(listener).put(live, SESSION_ID + 1); + pendingResponses(listener, "pendingLoginResponses").put(SESSION_ID, new CompletableFuture<>()); + pendingResponses(listener, "pendingLoginResponses").put(SESSION_ID + 1, liveFuture); + + listener.onPlayerConnectionClose(new PlayerConnectionCloseEvent( + UUID.randomUUID(), "Bobby", InetAddress.getLoopbackAddress(), false)); + + verify(preJoinDialogService).retireSession(SESSION_ID); + verify(preJoinDialogService, never()).retireSession(SESSION_ID + 1); + assertThat(connectionSessions(listener).containsKey(gone), is(false)); + assertThat(connectionSessions(listener).get(live), is(SESSION_ID + 1)); + assertThat(pendingResponses(listener, "pendingLoginResponses").get(SESSION_ID + 1), is(liveFuture)); + } + + private static PlayerConfigurationConnection mockConnection(String playerName) { + PlayerProfile profile = mock(PlayerProfile.class); + given(profile.getName()).willReturn(playerName); + PlayerConfigurationConnection connection = mock(PlayerConfigurationConnection.class); + given(connection.getProfile()).willReturn(profile); + return connection; + } + + private static void seedSession(PaperDialogFlowListener listener, PlayerConfigurationConnection connection, + String responseField, CompletableFuture future) throws Exception { + connectionSessions(listener).put(connection, SESSION_ID); + pendingResponses(listener, responseField).put(SESSION_ID, future); + } + + @SuppressWarnings("unchecked") + private static ConcurrentMap connectionSessions( + PaperDialogFlowListener listener) throws ReflectiveOperationException { + Field field = PaperDialogFlowListener.class.getDeclaredField("connectionSessions"); + field.setAccessible(true); + return (ConcurrentMap) field.get(listener); + } + + @SuppressWarnings("unchecked") + private static ConcurrentMap> pendingResponses( + PaperDialogFlowListener listener, String responseField) throws ReflectiveOperationException { + Field field = PaperDialogFlowListener.class.getDeclaredField(responseField); + field.setAccessible(true); + return (ConcurrentMap>) field.get(listener); + } + private static void setField(Object target, String fieldName, Object value) throws ReflectiveOperationException { Field field = target.getClass().getDeclaredField(fieldName); field.setAccessible(true); diff --git a/authme-paper-common/src/test/java/fr/xephi/authme/listener/PaperLoginValidationListenerTest.java b/authme-paper-common/src/test/java/fr/xephi/authme/listener/PaperLoginValidationListenerTest.java index 99c908761b..7cbb047d0e 100644 --- a/authme-paper-common/src/test/java/fr/xephi/authme/listener/PaperLoginValidationListenerTest.java +++ b/authme-paper-common/src/test/java/fr/xephi/authme/listener/PaperLoginValidationListenerTest.java @@ -1,14 +1,20 @@ package fr.xephi.authme.listener; +import com.destroystokyo.paper.event.player.PlayerConnectionCloseEvent; import com.destroystokyo.paper.profile.PlayerProfile; import fr.xephi.authme.message.MessageKey; import fr.xephi.authme.message.Messages; +import fr.xephi.authme.service.PendingConnectionRegistry; +import fr.xephi.authme.settings.Settings; +import fr.xephi.authme.settings.properties.RestrictionSettings; import io.papermc.paper.connection.PlayerConfigurationConnection; import io.papermc.paper.connection.PlayerLoginConnection; import io.papermc.paper.event.connection.PlayerConnectionValidateLoginEvent; import io.papermc.paper.event.player.PlayerServerFullCheckEvent; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerJoinEvent; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -17,10 +23,17 @@ import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import java.net.InetAddress; +import java.util.UUID; + import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -37,33 +50,132 @@ public class PaperLoginValidationListenerTest { @Mock private Messages messages; + @Mock + private PendingConnectionRegistry pendingConnectionRegistry; + + @Mock + private Settings settings; + + private void givenConfiguredTimeouts() { + given(settings.getProperty(RestrictionSettings.LOGIN_TIMEOUT)).willReturn(30); + given(settings.getProperty(RestrictionSettings.REGISTER_TIMEOUT)).willReturn(30); + } + @Test - public void shouldIgnoreConfigurationStageValidationEvent() { - PlayerConfigurationConnection connection = mock(PlayerConfigurationConnection.class); + public void shouldClaimNameDuringLoginPhase() { + givenConfiguredTimeouts(); + PlayerLoginConnection connection = newLoginConnection("Bobby"); + given(pendingConnectionRegistry.tryClaim(eq("Bobby"), eq(connection), anyLong())).willReturn(true); PlayerConnectionValidateLoginEvent event = new PlayerConnectionValidateLoginEvent(connection, null); listener.onPlayerConnectionValidateLogin(event); - verifyNoInteractions(onJoinVerifier, messages); assertThat(event.isAllowed(), is(true)); + verify(pendingConnectionRegistry).tryClaim(eq("Bobby"), eq(connection), anyLong()); } @Test public void shouldKickDuplicateSessionOnValidationEvent() throws FailedVerificationException { - PlayerLoginConnection connection = mock(PlayerLoginConnection.class); - PlayerProfile profile = mock(PlayerProfile.class); - given(connection.getAuthenticatedProfile()).willReturn(profile); - given(profile.getName()).willReturn("Bobby"); - given(messages.retrieveSingle("Bobby", MessageKey.USERNAME_ALREADY_ONLINE_ERROR)) - .willReturn("&cAlready online"); - FailedVerificationException exception = new FailedVerificationException(MessageKey.USERNAME_ALREADY_ONLINE_ERROR); - org.mockito.BDDMockito.willThrow(exception).given(onJoinVerifier).checkSingleSession("Bobby"); + PlayerLoginConnection connection = newLoginConnection("Bobby"); + givenAlreadyOnlineMessage(); + willThrow(new FailedVerificationException(MessageKey.USERNAME_ALREADY_ONLINE_ERROR)) + .given(onJoinVerifier).checkSingleSession("Bobby"); + PlayerConnectionValidateLoginEvent event = new PlayerConnectionValidateLoginEvent(connection, null); + + listener.onPlayerConnectionValidateLogin(event); + + assertThat(event.isAllowed(), is(false)); + assertThat(serialize(event.getKickMessage()), is("&cAlready online")); + verify(pendingConnectionRegistry, never()).tryClaim(eq("Bobby"), eq(connection), anyLong()); + } + + @Test + public void shouldRefuseSecondConnectionUsingSameNameDuringLoginPhase() { + givenConfiguredTimeouts(); + PlayerLoginConnection connection = newLoginConnection("Bobby"); + given(pendingConnectionRegistry.tryClaim(eq("Bobby"), eq(connection), anyLong())).willReturn(false); + givenAlreadyOnlineMessage(); + PlayerConnectionValidateLoginEvent event = new PlayerConnectionValidateLoginEvent(connection, null); + + listener.onPlayerConnectionValidateLogin(event); + + assertThat(event.isAllowed(), is(false)); + assertThat(serialize(event.getKickMessage()), is("&cAlready online")); + } + + @Test + public void shouldIgnoreReconfigurationOfPlayerWithoutClaim() { + PlayerConfigurationConnection connection = newConfigurationConnection("Bobby"); + given(pendingConnectionRegistry.holdsClaim("Bobby", connection)).willReturn(false); + PlayerConnectionValidateLoginEvent event = new PlayerConnectionValidateLoginEvent(connection, null); + + listener.onPlayerConnectionValidateLogin(event); + + verifyNoInteractions(onJoinVerifier, messages); + assertThat(event.isAllowed(), is(true)); + } + + @Test + public void shouldKickWhenClaimWasTakenOverDuringConfiguration() { + PlayerConfigurationConnection connection = newConfigurationConnection("Bobby"); + given(pendingConnectionRegistry.isClaimedByOtherConnection("Bobby", connection)).willReturn(true); + givenAlreadyOnlineMessage(); + PlayerConnectionValidateLoginEvent event = new PlayerConnectionValidateLoginEvent(connection, null); + + listener.onPlayerConnectionValidateLogin(event); + + assertThat(event.isAllowed(), is(false)); + assertThat(serialize(event.getKickMessage()), is("&cAlready online")); + verifyNoInteractions(onJoinVerifier); + } + + @Test + public void shouldVerifySingleSessionWhenFinishingConfiguration() throws FailedVerificationException { + givenConfiguredTimeouts(); + PlayerConfigurationConnection connection = newConfigurationConnection("Bobby"); + given(pendingConnectionRegistry.holdsClaim("Bobby", connection)).willReturn(true); + PlayerConnectionValidateLoginEvent event = new PlayerConnectionValidateLoginEvent(connection, null); + + listener.onPlayerConnectionValidateLogin(event); + + assertThat(event.isAllowed(), is(true)); + verify(onJoinVerifier).checkSingleSession("Bobby"); + verify(pendingConnectionRegistry).tryClaim(eq("Bobby"), eq(connection), anyLong()); + } + + @Test + public void shouldKickWhenNameWasTakenDuringConfiguration() throws FailedVerificationException { + PlayerConfigurationConnection connection = newConfigurationConnection("Bobby"); + given(pendingConnectionRegistry.holdsClaim("Bobby", connection)).willReturn(true); + givenAlreadyOnlineMessage(); + willThrow(new FailedVerificationException(MessageKey.USERNAME_ALREADY_ONLINE_ERROR)) + .given(onJoinVerifier).checkSingleSession("Bobby"); PlayerConnectionValidateLoginEvent event = new PlayerConnectionValidateLoginEvent(connection, null); listener.onPlayerConnectionValidateLogin(event); assertThat(event.isAllowed(), is(false)); - assertThat(LegacyComponentSerializer.legacySection().serialize(event.getKickMessage()), is("&cAlready online")); + assertThat(serialize(event.getKickMessage()), is("&cAlready online")); + } + + @Test + public void shouldReleaseClaimOnJoin() { + Player player = mock(Player.class); + given(player.getName()).willReturn("Bobby"); + + listener.onPlayerJoin(new PlayerJoinEvent(player, Component.text("joined"))); + + verify(pendingConnectionRegistry).release("Bobby"); + } + + @Test + public void shouldReleaseStaleClaimOnConnectionClose() { + PlayerConnectionCloseEvent event = new PlayerConnectionCloseEvent( + UUID.randomUUID(), "Bobby", InetAddress.getLoopbackAddress(), false); + + listener.onPlayerConnectionClose(event); + + verify(pendingConnectionRegistry).releaseIfStale("Bobby"); } @Test @@ -88,7 +200,32 @@ public void shouldDenyServerFullEventWithVerifierMessage() { listener.onPlayerServerFullCheck(event); assertThat(event.isAllowed(), is(false)); - assertThat(LegacyComponentSerializer.legacySection().serialize(event.kickMessage()), is("&cServer full")); + assertThat(serialize(event.kickMessage()), is("&cServer full")); verify(onJoinVerifier).getServerFullKickMessageIfDenied("RegularPlayer"); } + + private void givenAlreadyOnlineMessage() { + given(messages.retrieveSingle("Bobby", MessageKey.USERNAME_ALREADY_ONLINE_ERROR)) + .willReturn("&cAlready online"); + } + + private static PlayerLoginConnection newLoginConnection(String name) { + PlayerLoginConnection connection = mock(PlayerLoginConnection.class); + PlayerProfile profile = mock(PlayerProfile.class); + given(connection.getAuthenticatedProfile()).willReturn(profile); + given(profile.getName()).willReturn(name); + return connection; + } + + private static PlayerConfigurationConnection newConfigurationConnection(String name) { + PlayerConfigurationConnection connection = mock(PlayerConfigurationConnection.class); + PlayerProfile profile = mock(PlayerProfile.class); + given(connection.getProfile()).willReturn(profile); + given(profile.getName()).willReturn(name); + return connection; + } + + private static String serialize(Component component) { + return LegacyComponentSerializer.legacySection().serialize(component); + } } diff --git a/authme-paper-common/src/test/java/fr/xephi/authme/service/PendingConnectionRegistryTest.java b/authme-paper-common/src/test/java/fr/xephi/authme/service/PendingConnectionRegistryTest.java new file mode 100644 index 0000000000..33fe128d00 --- /dev/null +++ b/authme-paper-common/src/test/java/fr/xephi/authme/service/PendingConnectionRegistryTest.java @@ -0,0 +1,161 @@ +package fr.xephi.authme.service; + +import io.papermc.paper.connection.PlayerConnection; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.InetSocketAddress; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Test for {@link PendingConnectionRegistry}. + */ +public class PendingConnectionRegistryTest { + + private static final long TTL = 30_000L; + + private PendingConnectionRegistry registry; + + @BeforeEach + public void setUpRegistry() { + registry = new PendingConnectionRegistry(); + } + + @Test + public void shouldGrantClaimForFreeName() { + PlayerConnection connection = newConnection(50_000); + + assertThat(registry.tryClaim("Bobby", connection, TTL), is(true)); + assertThat(registry.holdsClaim("Bobby", connection), is(true)); + } + + @Test + public void shouldMatchClaimCaseInsensitively() { + PlayerConnection connection = newConnection(50_000); + registry.tryClaim("Bobby", connection, TTL); + + assertThat(registry.holdsClaim("bOBBy", connection), is(true)); + assertThat(registry.tryClaim("BOBBY", newConnection(50_001), TTL), is(false)); + } + + @Test + public void shouldRefuseSecondLiveConnectionWithSameName() { + PlayerConnection first = newConnection(50_000); + PlayerConnection second = newConnection(50_001); + registry.tryClaim("Bobby", first, TTL); + + assertThat(registry.tryClaim("Bobby", second, TTL), is(false)); + assertThat(registry.holdsClaim("Bobby", second), is(false)); + assertThat(registry.holdsClaim("Bobby", first), is(true)); + } + + @Test + public void shouldReportNameClaimedByAnotherLiveConnection() { + PlayerConnection holder = newConnection(50_000); + PlayerConnection other = newConnection(50_001); + registry.tryClaim("Bobby", holder, TTL); + + assertThat(registry.isClaimedByOtherConnection("Bobby", other), is(true)); + assertThat(registry.isClaimedByOtherConnection("Bobby", holder), is(false)); + } + + @Test + public void shouldNotReportStaleOrMissingClaimAsHeldByAnotherConnection() { + PlayerConnection other = newConnection(50_001); + + assertThat(registry.isClaimedByOtherConnection("Bobby", other), is(false)); + + PlayerConnection gone = newConnection(50_000); + registry.tryClaim("Bobby", gone, TTL); + given(gone.isConnected()).willReturn(false); + assertThat(registry.isClaimedByOtherConnection("Bobby", other), is(false)); + + registry.tryClaim("Alice", newConnection(50_002), -1L); + assertThat(registry.isClaimedByOtherConnection("Alice", other), is(false)); + } + + @Test + public void shouldAllowSameConnectionToRenewItsClaim() { + PlayerConnection connection = newConnection(50_000); + registry.tryClaim("Bobby", connection, TTL); + + assertThat(registry.tryClaim("Bobby", connection, TTL), is(true)); + } + + @Test + public void shouldTakeOverClaimOfDisconnectedConnection() { + PlayerConnection gone = newConnection(50_000); + registry.tryClaim("Bobby", gone, TTL); + given(gone.isConnected()).willReturn(false); + + assertThat(registry.tryClaim("Bobby", newConnection(50_001), TTL), is(true)); + } + + @Test + public void shouldTakeOverExpiredClaim() { + registry.tryClaim("Bobby", newConnection(50_000), -1L); + + assertThat(registry.tryClaim("Bobby", newConnection(50_001), TTL), is(true)); + } + + @Test + public void shouldNotConsiderExpiredClaimAsHeld() { + PlayerConnection connection = newConnection(50_000); + registry.tryClaim("Bobby", connection, -1L); + + assertThat(registry.holdsClaim("Bobby", connection), is(false)); + } + + @Test + public void shouldReleaseClaim() { + PlayerConnection connection = newConnection(50_000); + registry.tryClaim("Bobby", connection, TTL); + + registry.release("Bobby"); + + assertThat(registry.holdsClaim("Bobby", connection), is(false)); + assertThat(registry.tryClaim("Bobby", newConnection(50_001), TTL), is(true)); + } + + @Test + public void shouldKeepClaimOfLiveConnectionWhenAnotherOneCloses() { + PlayerConnection holder = newConnection(50_000); + registry.tryClaim("Bobby", holder, TTL); + + // The refused duplicate connection closes and triggers the same close event + registry.releaseIfStale("Bobby"); + + assertThat(registry.holdsClaim("Bobby", holder), is(true)); + assertThat(registry.tryClaim("Bobby", newConnection(50_001), TTL), is(false)); + } + + @Test + public void shouldReleaseClaimOfClosedConnection() { + PlayerConnection holder = newConnection(50_000); + registry.tryClaim("Bobby", holder, TTL); + given(holder.isConnected()).willReturn(false); + + registry.releaseIfStale("Bobby"); + + assertThat(registry.tryClaim("Bobby", newConnection(50_001), TTL), is(true)); + } + + @Test + public void shouldHandleUnknownNameOnRelease() { + registry.release("Bobby"); + registry.releaseIfStale("Bobby"); + + assertThat(registry.holdsClaim("Bobby", newConnection(50_000)), is(false)); + } + + private static PlayerConnection newConnection(int port) { + PlayerConnection connection = mock(PlayerConnection.class); + given(connection.getAddress()).willReturn(new InetSocketAddress("127.0.0.1", port)); + given(connection.isConnected()).willReturn(true); + return connection; + } +} diff --git a/authme-velocity/src/main/java/fr/xephi/authme/velocity/AuthMeVelocityPlugin.java b/authme-velocity/src/main/java/fr/xephi/authme/velocity/AuthMeVelocityPlugin.java index 068494a002..b4a9d95529 100644 --- a/authme-velocity/src/main/java/fr/xephi/authme/velocity/AuthMeVelocityPlugin.java +++ b/authme-velocity/src/main/java/fr/xephi/authme/velocity/AuthMeVelocityPlugin.java @@ -11,6 +11,7 @@ import com.velocitypowered.api.event.player.PlayerChatEvent; import com.velocitypowered.api.event.player.ServerConnectedEvent; import com.velocitypowered.api.event.player.ServerPreConnectEvent; +import com.velocitypowered.api.event.player.configuration.PlayerEnterConfigurationEvent; import com.velocitypowered.api.event.player.configuration.PlayerEnteredConfigurationEvent; import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; import com.velocitypowered.api.event.proxy.ProxyShutdownEvent; @@ -72,6 +73,11 @@ public void onGameProfileRequest(GameProfileRequestEvent event) { proxyBridge.onGameProfileRequest(event); } + @Subscribe + public void onPlayerEnterConfiguration(PlayerEnterConfigurationEvent event) { + proxyBridge.onPlayerEnterConfiguration(event); + } + @Subscribe public void onPlayerEnteredConfiguration(PlayerEnteredConfigurationEvent event) { proxyBridge.onPlayerEnteredConfiguration(event); diff --git a/authme-velocity/src/main/java/fr/xephi/authme/velocity/VelocityProxyBridge.java b/authme-velocity/src/main/java/fr/xephi/authme/velocity/VelocityProxyBridge.java index 89da15f6e1..f7280145e5 100644 --- a/authme-velocity/src/main/java/fr/xephi/authme/velocity/VelocityProxyBridge.java +++ b/authme-velocity/src/main/java/fr/xephi/authme/velocity/VelocityProxyBridge.java @@ -11,6 +11,7 @@ import com.velocitypowered.api.event.player.GameProfileRequestEvent; import com.velocitypowered.api.event.player.ServerConnectedEvent; import com.velocitypowered.api.event.player.ServerPreConnectEvent; +import com.velocitypowered.api.event.player.configuration.PlayerEnterConfigurationEvent; import com.velocitypowered.api.event.player.configuration.PlayerEnteredConfigurationEvent; import com.velocitypowered.api.proxy.ConnectionRequestBuilder; import com.velocitypowered.api.proxy.Player; @@ -92,6 +93,7 @@ final class VelocityProxyBridge { this.premiumVerificationManager = new VelocityPremiumVerificationManager(logger, this::requiresPremiumVerification, this::isPendingPremiumVerification, + this::clearPendingPremiumVerification, () -> this.configuration.keepOfflineUuidCompatibility()); this.cacheFile = dataDirectory != null ? dataDirectory.resolve("premium_names.cache") : null; if (cacheFile != null) { @@ -322,6 +324,47 @@ void onPluginMessage(PluginMessageEvent event) { } } + /** + * Sends the auto-login {@code perform.login} as soon as an already-authenticated player is about to enter + * the configuration phase during a server switch, before the backend decides whether to show its blocking + * pre-join login dialog. The regular {@link #onServerConnected(ServerConnectedEvent)} path fires only after + * the configuration phase completes — too late to suppress that dialog. + *

+ * During a switch the player is in the "null window" (neither the old nor the new server is set as the + * current one), so {@link PlayerEnteredConfigurationEvent} cannot reach the target backend. This event + * fires earlier with the in-flight target connection. + *

+ * This is sent to all servers (not just auth servers) so that a non-auth server running AuthMe + * also receives the auto-login signal early enough to suppress its login dialog. + */ + void onPlayerEnterConfiguration(PlayerEnterConfigurationEvent event) { + if (!configuration.autoLoginEnabled()) { + return; + } + ServerConnection server = event.server(); + if (server == null) { + return; + } + + String normalizedName = normalizeName(event.player().getUsername()); + UUID verifiedPremiumUuid = premiumVerificationManager.getVerifiedPremiumUuid(normalizedName); + boolean isPremiumJoin = verifiedPremiumUuid != null; + if (!authenticationStore.isAuthenticated(normalizedName) && !isPremiumJoin) { + return; + } + + boolean sent = server.sendPluginMessage( + AUTHME_CHANNEL, createPerformLoginMessage(normalizedName, verifiedPremiumUuid)); + if (sent) { + logger.info("Sent config-phase auto-login to server '{}' for player {} (verifiedPremiumUuid={})", + server.getServer().getServerInfo().getName(), normalizedName, verifiedPremiumUuid); + initiatePendingLogin(normalizedName); + } else { + logger.debug("Config-phase auto-login send to '{}' for {} returned false; onServerConnected will retry", + server.getServer().getServerInfo().getName(), normalizedName); + } + } + /** * Sends the auto-login {@code perform.login} as soon as an already-authenticated player enters the * configuration phase on an auth server, before the backend decides whether to show its blocking diff --git a/authme-velocity/src/main/java/fr/xephi/authme/velocity/premium/VelocityPremiumVerificationManager.java b/authme-velocity/src/main/java/fr/xephi/authme/velocity/premium/VelocityPremiumVerificationManager.java index 849b279e9d..b8d753422b 100644 --- a/authme-velocity/src/main/java/fr/xephi/authme/velocity/premium/VelocityPremiumVerificationManager.java +++ b/authme-velocity/src/main/java/fr/xephi/authme/velocity/premium/VelocityPremiumVerificationManager.java @@ -6,8 +6,11 @@ import org.slf4j.Logger; import java.util.Locale; +import java.util.Set; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.BooleanSupplier; +import java.util.function.Consumer; import java.util.function.Predicate; public final class VelocityPremiumVerificationManager { @@ -15,17 +18,21 @@ public final class VelocityPremiumVerificationManager { private final Logger logger; private final Predicate requiresVerification; private final Predicate isPendingVerification; + private final Consumer pendingVerificationFailureHandler; private final BooleanSupplier keepOfflineUuidCompatibility; + private final Set pendingVerificationAttempted = ConcurrentHashMap.newKeySet(); private final ProxyPremiumLoginVerifier loginVerifier; private boolean registered; public VelocityPremiumVerificationManager(Logger logger, Predicate requiresVerification, Predicate isPendingVerification, + Consumer pendingVerificationFailureHandler, BooleanSupplier keepOfflineUuidCompatibility) { this.logger = logger; this.requiresVerification = requiresVerification; this.isPendingVerification = isPendingVerification; + this.pendingVerificationFailureHandler = pendingVerificationFailureHandler; this.keepOfflineUuidCompatibility = keepOfflineUuidCompatibility; this.loginVerifier = new ProxyPremiumLoginVerifier("authme-velocity-premium", message -> this.logger.warn(message)); @@ -41,6 +48,13 @@ public void register() { public void onPreLogin(PreLoginEvent event) { String normalizedName = normalize(event.getUsername()); + if (isPendingVerification.test(normalizedName) && !pendingVerificationAttempted.add(normalizedName)) { + // Velocity fires no event when Mojang rejects a forced online-mode login, so a pending + // player showing up again means the previous attempt failed: cancel the enrollment and + // let them log in with their password instead of forcing online mode forever. + pendingVerificationAttempted.remove(normalizedName); + pendingVerificationFailureHandler.accept(normalizedName); + } if (requiresVerification.test(normalizedName)) { event.setResult(PreLoginEvent.PreLoginComponentResult.forceOnlineMode()); } @@ -54,6 +68,7 @@ public void onGameProfileRequest(GameProfileRequestEvent event) { UUID verifiedPremiumUuid = event.getOriginalProfile().getId(); loginVerifier.storeVerified(normalizedName, verifiedPremiumUuid); + pendingVerificationAttempted.remove(normalizedName); boolean rewrite = keepOfflineUuidCompatibility.getAsBoolean(); logger.info("onGameProfileRequest: stored verified premium UUID {} for '{}' (keepOfflineUuidCompatibility={})", verifiedPremiumUuid, normalizedName, rewrite); @@ -74,6 +89,7 @@ public UUID getVerifiedPremiumUuid(String normalizedName) { public void clearVerifiedPremium(String normalizedName) { loginVerifier.clearVerified(normalizedName); + pendingVerificationAttempted.remove(normalizedName); } public void shutdown() { diff --git a/authme-velocity/src/test/java/fr/xephi/authme/velocity/VelocityProxyBridgeTest.java b/authme-velocity/src/test/java/fr/xephi/authme/velocity/VelocityProxyBridgeTest.java index 7dbd88ecbd..8eb49b5ccd 100644 --- a/authme-velocity/src/test/java/fr/xephi/authme/velocity/VelocityProxyBridgeTest.java +++ b/authme-velocity/src/test/java/fr/xephi/authme/velocity/VelocityProxyBridgeTest.java @@ -10,6 +10,7 @@ import com.velocitypowered.api.event.player.PlayerChatEvent; import com.velocitypowered.api.event.player.ServerConnectedEvent; import com.velocitypowered.api.event.player.ServerPreConnectEvent; +import com.velocitypowered.api.event.player.configuration.PlayerEnterConfigurationEvent; import com.velocitypowered.api.event.player.configuration.PlayerEnteredConfigurationEvent; import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.ProxyServer; @@ -414,6 +415,36 @@ void shouldNotSendConfigPhaseAutoLoginWhenEnteringNonAuthServer() { verify(currentServer, never()).sendPluginMessage(any(), any(byte[].class)); } + @Test + void shouldSendConfigPhaseAutoLoginWhenEnteringNonAuthServerOnServerSwitch() { + given(player.getUsername()).willReturn("Alice"); + given(currentServer.getServer()).willReturn(nonAuthServer); + given(nonAuthServer.getServerInfo()).willReturn(nonAuthServerInfo); + given(nonAuthServerInfo.getName()).willReturn("survival"); + given(currentServer.sendPluginMessage(eq(VelocityProxyBridge.AUTHME_CHANNEL), any(byte[].class))) + .willReturn(true); + + VelocityAuthenticationStore store = new VelocityAuthenticationStore(); + store.markAuthenticated("alice"); + + VelocityProxyBridge bridge = new VelocityProxyBridge(proxyServer, logger, createConfiguration(), store, null); + bridge.onPlayerEnterConfiguration(new PlayerEnterConfigurationEvent(player, currentServer)); + + verify(currentServer).sendPluginMessage(eq(VelocityProxyBridge.AUTHME_CHANNEL), payloadCaptor.capture()); + assertPerformLoginPayload(payloadCaptor.getValue(), "alice", "test-secret"); + } + + @Test + void shouldNotSendConfigPhaseAutoLoginWhenEnteredConfigurationWithoutServerConnection() { + VelocityAuthenticationStore store = new VelocityAuthenticationStore(); + store.markAuthenticated("alice"); + + VelocityProxyBridge bridge = new VelocityProxyBridge(proxyServer, logger, createConfiguration(), store, null); + bridge.onPlayerEnteredConfiguration(new PlayerEnteredConfigurationEvent(player, null)); + + verify(currentServer, never()).sendPluginMessage(any(), any(byte[].class)); + } + // --- Command blocking tests --- @Test @@ -604,6 +635,32 @@ void shouldForceOnlineModeAfterChunkedPremiumListResync() { assertEquals(PreLoginEvent.PreLoginComponentResult.forceOnlineMode().toString(), event.getResult().toString()); } + @Test + void shouldCancelPendingPremiumVerificationWhenForcedOnlineLoginNeverCompletes() { + given(pluginMessageEvent.getResult()).willReturn(PluginMessageEvent.ForwardResult.forward()); + given(pluginMessageEvent.getIdentifier()).willReturn(VelocityProxyBridge.AUTHME_CHANNEL); + given(pluginMessageEvent.getSource()).willReturn(sourceConnection); + given(pluginMessageEvent.getData()).willReturn(createAuthMePayload("premium.pending.set", "Alice")); + given(sourceConnection.getServer()).willReturn(authServer); + given(authServer.getServerInfo()).willReturn(authServerInfo); + given(authServerInfo.getName()).willReturn("lobby"); + + VelocityProxyBridge bridge = new VelocityProxyBridge(proxyServer, logger, createConfiguration(), new VelocityAuthenticationStore(), null); + bridge.onPluginMessage(pluginMessageEvent); + + PreLoginEvent firstAttempt = new PreLoginEvent(mock(InboundConnection.class), "Alice", null); + bridge.onPreLogin(firstAttempt); + // Mojang rejected the unlicensed client; Velocity fires no event for that, the player just reconnects + PreLoginEvent secondAttempt = new PreLoginEvent(mock(InboundConnection.class), "Alice", null); + bridge.onPreLogin(secondAttempt); + PreLoginEvent thirdAttempt = new PreLoginEvent(mock(InboundConnection.class), "Alice", null); + bridge.onPreLogin(thirdAttempt); + + assertEquals(PreLoginEvent.PreLoginComponentResult.forceOnlineMode().toString(), firstAttempt.getResult().toString()); + assertEquals(PreLoginEvent.PreLoginComponentResult.allowed().toString(), secondAttempt.getResult().toString()); + assertEquals(PreLoginEvent.PreLoginComponentResult.allowed().toString(), thirdAttempt.getResult().toString()); + } + private static byte[] createChunkPayload(int seq, boolean last, String csv) { ByteArrayDataOutput output = ByteStreams.newDataOutput(); output.writeUTF("premium.list.chunk"); diff --git a/authme-velocity/src/test/java/fr/xephi/authme/velocity/premium/VelocityPremiumVerificationManagerTest.java b/authme-velocity/src/test/java/fr/xephi/authme/velocity/premium/VelocityPremiumVerificationManagerTest.java index 0246461ded..e460bbfe4d 100644 --- a/authme-velocity/src/test/java/fr/xephi/authme/velocity/premium/VelocityPremiumVerificationManagerTest.java +++ b/authme-velocity/src/test/java/fr/xephi/authme/velocity/premium/VelocityPremiumVerificationManagerTest.java @@ -8,11 +8,16 @@ import org.junit.jupiter.api.Test; import org.slf4j.Logger; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.UUID; +import java.util.function.Predicate; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; class VelocityPremiumVerificationManagerTest { @@ -20,7 +25,7 @@ class VelocityPremiumVerificationManagerTest { @Test void shouldForceOnlineModeForPremiumUser() { VelocityPremiumVerificationManager manager = new VelocityPremiumVerificationManager( - mock(Logger.class), "alice"::equals, normalizedName -> false, () -> false); + mock(Logger.class), "alice"::equals, normalizedName -> false, name -> { }, () -> false); PreLoginEvent event = new PreLoginEvent(mock(InboundConnection.class), "Alice", null); manager.onPreLogin(event); @@ -32,7 +37,7 @@ void shouldForceOnlineModeForPremiumUser() { @Test void shouldRewriteVerifiedProfileToOfflineUuid() { VelocityPremiumVerificationManager manager = new VelocityPremiumVerificationManager( - mock(Logger.class), "alice"::equals, normalizedName -> false, () -> true); + mock(Logger.class), "alice"::equals, normalizedName -> false, name -> { }, () -> true); UUID mojangUuid = UUID.fromString("8d6d0684-d8b4-4d40-8d2d-0dd4df5555c8"); GameProfile originalProfile = new GameProfile(mojangUuid, "Alice", List.of()); GameProfileRequestEvent event = new GameProfileRequestEvent( @@ -47,7 +52,7 @@ void shouldRewriteVerifiedProfileToOfflineUuid() { @Test void shouldIgnoreOfflineModeProfileRequest() { VelocityPremiumVerificationManager manager = new VelocityPremiumVerificationManager( - mock(Logger.class), "alice"::equals, normalizedName -> false, () -> true); + mock(Logger.class), "alice"::equals, normalizedName -> false, name -> { }, () -> true); UUID mojangUuid = UUID.fromString("8d6d0684-d8b4-4d40-8d2d-0dd4df5555c8"); GameProfile originalProfile = new GameProfile(mojangUuid, "Alice", List.of()); GameProfileRequestEvent event = new GameProfileRequestEvent( @@ -62,7 +67,7 @@ void shouldIgnoreOfflineModeProfileRequest() { @Test void shouldKeepMojangUuidWhenOfflineCompatibilityDisabled() { VelocityPremiumVerificationManager manager = new VelocityPremiumVerificationManager( - mock(Logger.class), "alice"::equals, normalizedName -> false, () -> false); + mock(Logger.class), "alice"::equals, normalizedName -> false, name -> { }, () -> false); UUID mojangUuid = UUID.fromString("8d6d0684-d8b4-4d40-8d2d-0dd4df5555c8"); GameProfile originalProfile = new GameProfile(mojangUuid, "Alice", List.of()); GameProfileRequestEvent event = new GameProfileRequestEvent( @@ -73,4 +78,75 @@ void shouldKeepMojangUuidWhenOfflineCompatibilityDisabled() { assertEquals(mojangUuid, event.getGameProfile().getId()); assertEquals(mojangUuid, manager.getVerifiedPremiumUuid("alice")); } + + @Test + void shouldForceOnlineModeOnFirstPendingAttemptThenCancelOnSecond() { + Set pending = new HashSet<>(Set.of("alice")); + VelocityPremiumVerificationManager manager = new VelocityPremiumVerificationManager( + mock(Logger.class), pending::contains, pending::contains, pending::remove, () -> false); + + PreLoginEvent firstAttempt = new PreLoginEvent(mock(InboundConnection.class), "Alice", null); + manager.onPreLogin(firstAttempt); + // Mojang rejected the session: no profile request, no login and no disconnect event follow + PreLoginEvent secondAttempt = new PreLoginEvent(mock(InboundConnection.class), "Alice", null); + manager.onPreLogin(secondAttempt); + + assertEquals(PreLoginEvent.PreLoginComponentResult.forceOnlineMode().toString(), + firstAttempt.getResult().toString()); + assertEquals(PreLoginEvent.PreLoginComponentResult.allowed().toString(), + secondAttempt.getResult().toString()); + assertFalse(pending.contains("alice")); + } + + @Test + void shouldKeepForcingOnlineModeForPendingPlayerAfterSuccessfulVerification() { + Set pending = new HashSet<>(Set.of("alice")); + VelocityPremiumVerificationManager manager = new VelocityPremiumVerificationManager( + mock(Logger.class), pending::contains, pending::contains, pending::remove, () -> false); + GameProfile profile = new GameProfile( + UUID.fromString("8d6d0684-d8b4-4d40-8d2d-0dd4df5555c8"), "Alice", List.of()); + + manager.onPreLogin(new PreLoginEvent(mock(InboundConnection.class), "Alice", null)); + manager.onGameProfileRequest(new GameProfileRequestEvent(mock(InboundConnection.class), profile, true)); + // The backend has not finalized the enrollment yet, so the player is still pending on reconnect + PreLoginEvent reconnect = new PreLoginEvent(mock(InboundConnection.class), "Alice", null); + manager.onPreLogin(reconnect); + + assertEquals(PreLoginEvent.PreLoginComponentResult.forceOnlineMode().toString(), + reconnect.getResult().toString()); + assertTrue(pending.contains("alice")); + } + + @Test + void shouldStillForceOnlineModeForEnrolledPremiumPlayerWhenPendingAttemptFails() { + Set pending = new HashSet<>(Set.of("alice")); + Predicate requiresVerification = name -> "alice".equals(name) || pending.contains(name); + VelocityPremiumVerificationManager manager = new VelocityPremiumVerificationManager( + mock(Logger.class), requiresVerification, pending::contains, pending::remove, () -> false); + + manager.onPreLogin(new PreLoginEvent(mock(InboundConnection.class), "Alice", null)); + PreLoginEvent secondAttempt = new PreLoginEvent(mock(InboundConnection.class), "Alice", null); + manager.onPreLogin(secondAttempt); + + assertEquals(PreLoginEvent.PreLoginComponentResult.forceOnlineMode().toString(), + secondAttempt.getResult().toString()); + assertFalse(pending.contains("alice")); + } + + @Test + void shouldResetAttemptWhenPendingVerificationIsRearmed() { + Set pending = new HashSet<>(Set.of("alice")); + VelocityPremiumVerificationManager manager = new VelocityPremiumVerificationManager( + mock(Logger.class), pending::contains, pending::contains, pending::remove, () -> false); + + manager.onPreLogin(new PreLoginEvent(mock(InboundConnection.class), "Alice", null)); + // A new /premium run sends premium.pending.set again, which clears the verification state + manager.clearVerifiedPremium("alice"); + PreLoginEvent reconnect = new PreLoginEvent(mock(InboundConnection.class), "Alice", null); + manager.onPreLogin(reconnect); + + assertEquals(PreLoginEvent.PreLoginComponentResult.forceOnlineMode().toString(), + reconnect.getResult().toString()); + assertTrue(pending.contains("alice")); + } } From 572b1489916f8ca1b24faeac4cc3f387be0c7bc5 Mon Sep 17 00:00:00 2001 From: linsaftw Date: Wed, 23 Sep 2026 13:51:19 -0300 Subject: [PATCH 3/3] fix comments --- .../xephi/authme/settings/properties/RegistrationSettings.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/authme-core/src/main/java/fr/xephi/authme/settings/properties/RegistrationSettings.java b/authme-core/src/main/java/fr/xephi/authme/settings/properties/RegistrationSettings.java index a62aa16668..b6d6c37612 100644 --- a/authme-core/src/main/java/fr/xephi/authme/settings/properties/RegistrationSettings.java +++ b/authme-core/src/main/java/fr/xephi/authme/settings/properties/RegistrationSettings.java @@ -22,7 +22,8 @@ public final class RegistrationSettings implements SettingsHolder { @Comment({ "Delay in seconds before showing the first registration reminder.", - "When the delay expires, AuthMe checks whether the player is still unregistered before sending it."}) + "When the delay expires, AuthMe checks whether the player is still unregistered", + "before sending it."}) public static final Property REGISTER_MESSAGE_DELAY = newProperty("settings.registration.registerMessageDelay", 1);