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 8945d9059..024edc204 100644 --- a/authme-bungee/src/main/java/fr/xephi/authme/bungee/BungeeProxyBridge.java +++ b/authme-bungee/src/main/java/fr/xephi/authme/bungee/BungeeProxyBridge.java @@ -3,6 +3,8 @@ import com.google.common.io.ByteArrayDataInput; import com.google.common.io.ByteArrayDataOutput; import com.google.common.io.ByteStreams; +import fr.xephi.authme.bungee.events.AuthMeBungeeLoginEvent; +import fr.xephi.authme.bungee.events.AuthMeBungeeLogoutEvent; import fr.xephi.authme.bungee.premium.BungeePremiumOnlineModeHandler; import fr.xephi.authme.bungee.premium.BungeePremiumVerificationManager; import net.md_5.bungee.api.ChatColor; @@ -244,11 +246,21 @@ public void onPluginMessage(PluginMessageEvent event) { return; } + String playerName = normalizeName(parsedMessage.playerName()); + if (LOGIN_MESSAGE.equals(parsedMessage.typeId())) { if (configuration.isAuthServer(server.getInfo())) { logger.info("Player " + parsedMessage.playerName() + " authenticated on auth server '" + server.getInfo().getName() + "'"); + + ProxiedPlayer player = proxyServer.getPlayer(playerName); + if (player != null) { + boolean premium = requiresPremiumVerification(parsedMessage.playerName()); + proxyServer.getPluginManager().callEvent(new AuthMeBungeeLoginEvent(player, premium)); + } + authenticationStore.markAuthenticated(parsedMessage.playerName()); + sendAutoLoginIfAlreadySwitched(parsedMessage.playerName(), server.getInfo()); redirectToLoginServer(parsedMessage.playerName()); } else if (pendingAutoLogins.containsKey(parsedMessage.playerName())) { @@ -259,6 +271,11 @@ public void onPluginMessage(PluginMessageEvent event) { } } else if (LOGOUT_MESSAGE.equals(parsedMessage.typeId())) { authenticationStore.markLoggedOut(parsedMessage.playerName()); + ProxiedPlayer player = proxyServer.getPlayer(playerName); + if (player != null) { + proxyServer.getPluginManager().callEvent(new AuthMeBungeeLogoutEvent(player)); + } + redirectLoggedOutPlayer(parsedMessage.playerName()); } else if (PERFORM_LOGIN_ACK_MESSAGE.equals(parsedMessage.typeId())) { logger.info("Auto-login ACK received for " + parsedMessage.playerName() @@ -350,7 +367,6 @@ public void onServerSwitch(ServerSwitchEvent event) { logger.fine("PacketEvents-verified premium player " + normalizedName + " joining auth server — sending perform.login immediately"); } - String serverName = currentServer.getInfo().getName(); logger.info("Sending auto-login request to server '" + serverName + "' for player " + normalizedName); currentServer.getInfo().sendData( @@ -447,6 +463,7 @@ private void sendAutoLoginIfAlreadySwitched(String normalizedName, ServerInfo au // Still on auth server — normal flow, ServerSwitchEvent will handle it on switch return; } + String currentServerName = currentConn.getInfo().getName(); logger.info("Player " + normalizedName + " already on server '" + currentServerName + "' when login message arrived — sending auto-login immediately"); @@ -508,6 +525,7 @@ private void scheduleRetry(String normalizedName) { logger.fine("Auto-login retry cancelled for " + normalizedName + " (player has no active server)"); return; } + String serverName = server.getInfo().getName(); logger.fine("Retrying auto-login for " + normalizedName + " on server '" + serverName + "' (attempt " + (current + 1) + "/" + MAX_RETRIES + ")"); diff --git a/authme-bungee/src/main/java/fr/xephi/authme/bungee/events/AuthMeBungeeLoginEvent.java b/authme-bungee/src/main/java/fr/xephi/authme/bungee/events/AuthMeBungeeLoginEvent.java new file mode 100644 index 000000000..8795554e4 --- /dev/null +++ b/authme-bungee/src/main/java/fr/xephi/authme/bungee/events/AuthMeBungeeLoginEvent.java @@ -0,0 +1,34 @@ +package fr.xephi.authme.bungee.events; + +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.plugin.Event; + +public class AuthMeBungeeLoginEvent extends Event { + + private final ProxiedPlayer player; + + private final boolean premium; + + public AuthMeBungeeLoginEvent(ProxiedPlayer player, boolean premium) { + this.player = player; + this.premium = premium; + } + + /** + * Return whether this player required Premium verification for login + * + * @return if the player required premium verification + */ + public boolean isPremium() { + return premium; + } + + /** + * Return the player concerned by this event. + * + * @return The player who logged in correctly in the backend + */ + public ProxiedPlayer getPlayer() { + return player; + } +} diff --git a/authme-bungee/src/main/java/fr/xephi/authme/bungee/events/AuthMeBungeeLogoutEvent.java b/authme-bungee/src/main/java/fr/xephi/authme/bungee/events/AuthMeBungeeLogoutEvent.java new file mode 100644 index 000000000..f1687f5b8 --- /dev/null +++ b/authme-bungee/src/main/java/fr/xephi/authme/bungee/events/AuthMeBungeeLogoutEvent.java @@ -0,0 +1,22 @@ +package fr.xephi.authme.bungee.events; + +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.plugin.Event; + +public class AuthMeBungeeLogoutEvent extends Event { + + private final ProxiedPlayer player; + + public AuthMeBungeeLogoutEvent(ProxiedPlayer player) { + this.player = player; + } + + /** + * Return the player concerned by this event. + * + * @return The player who logged out correctly in the backend + */ + public ProxiedPlayer getPlayer() { + return player; + } +} 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 51732e092..c01371562 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 @@ -2,6 +2,8 @@ import com.google.common.io.ByteArrayDataOutput; import com.google.common.io.ByteStreams; +import fr.xephi.authme.bungee.events.AuthMeBungeeLoginEvent; +import fr.xephi.authme.bungee.events.AuthMeBungeeLogoutEvent; import net.md_5.bungee.api.ProxyServer; import net.md_5.bungee.api.config.ServerInfo; import net.md_5.bungee.api.connection.PendingConnection; @@ -13,6 +15,7 @@ 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.PluginManager; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -29,6 +32,8 @@ import java.util.logging.Logger; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -85,6 +90,9 @@ class BungeeProxyBridgeTest { @Mock private PendingConnection pendingConnection; + @Mock + private PluginManager pluginManager; + @Captor private ArgumentCaptor payloadCaptor; @@ -160,6 +168,7 @@ void shouldRedirectPlayerOnLogoutWhenConfigured() { given(proxyServer.getPlayer("alice")).willReturn(player); given(proxyServer.getServerInfo("limbo")).willReturn(nonAuthServerInfo); + stubEventsAllowed(); BungeeProxyBridge bridge = new BungeeProxyBridge( proxyServer, logger, new BungeeProxyConfiguration( Set.of("lobby"), false, true, Set.of("/login"), true, true, @@ -204,8 +213,8 @@ void shouldCancelPendingLoginOnExplicitAck() { given(pluginMessageEvent.getData()).willReturn(createAuthMePayload("perform.login.ack", "Alice")); bridge.onPluginMessage(pluginMessageEvent); - // getPlayer called exactly once by sendAutoLoginIfAlreadySwitched (on login), not by any retry - verify(proxyServer, org.mockito.Mockito.times(1)).getPlayer("alice"); + // getPlayer called twice: once by login event lookup, once by sendAutoLoginIfAlreadySwitched — not by any retry + verify(proxyServer, org.mockito.Mockito.times(2)).getPlayer("alice"); } @Test @@ -233,8 +242,8 @@ void shouldCancelPendingLoginOnImplicitAckFromNonAuthServer() { given(sourceServer.getInfo()).willReturn(nonAuthServerInfo); bridge.onPluginMessage(pluginMessageEvent); - // getPlayer called exactly once by sendAutoLoginIfAlreadySwitched (on login from auth server), not by retries - verify(proxyServer, org.mockito.Mockito.times(1)).getPlayer("alice"); + // getPlayer called twice: once by login event lookup, once by sendAutoLoginIfAlreadySwitched — not by retries + verify(proxyServer, org.mockito.Mockito.times(2)).getPlayer("alice"); } @Test @@ -349,7 +358,6 @@ void shouldNotForwardPerformLoginToNonAuthServers() { given(sourceServer.getInfo()).willReturn(authServerInfo); given(authServerInfo.getName()).willReturn("lobby"); given(serverSwitchEvent.getPlayer()).willReturn(player); - given(player.getName()).willReturn("Alice"); given(player.getServer()).willReturn(currentServer); given(currentServer.getInfo()).willReturn(nonAuthServerInfo); given(nonAuthServerInfo.getName()).willReturn("survival"); @@ -439,6 +447,7 @@ void shouldSendAutoLoginImmediatelyWhenPlayerAlreadySwitchedBeforeLoginMessage() given(currentServer.getInfo()).willReturn(nonAuthServerInfo); given(nonAuthServerInfo.getName()).willReturn("survival"); + stubEventsAllowed(); BungeeProxyBridge bridge = new BungeeProxyBridge(proxyServer, logger, createConfiguration(), new BungeeAuthenticationStore(), null); bridge.onPluginMessage(pluginMessageEvent); @@ -480,6 +489,47 @@ void shouldForceOnlineModeForPremiumHandshakeAfterChunkedPremiumListResync() { verify(pendingConnection).setOnlineMode(true); } + @Test + void shouldFireLoginEventWhenPlayerAuthenticatesOnAuthServer() { + given(pluginMessageEvent.isCancelled()).willReturn(false); + given(pluginMessageEvent.getTag()).willReturn(BungeeProxyBridge.AUTHME_CHANNEL); + given(pluginMessageEvent.getSender()).willReturn(sourceServer); + given(pluginMessageEvent.getData()).willReturn(createAuthMePayload("login", "Alice")); + given(sourceServer.getInfo()).willReturn(authServerInfo); + given(authServerInfo.getName()).willReturn("lobby"); + given(proxyServer.getPlayer("alice")).willReturn(player); + given(player.getServer()).willReturn(currentServer); + given(currentServer.getInfo()).willReturn(authServerInfo); + + stubEventsAllowed(); + + BungeeProxyBridge bridge = new BungeeProxyBridge(proxyServer, logger, createConfiguration(), new BungeeAuthenticationStore(), null); + bridge.onPluginMessage(pluginMessageEvent); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(AuthMeBungeeLoginEvent.class); + verify(pluginManager).callEvent(eventCaptor.capture()); + assertSame(player, eventCaptor.getValue().getPlayer()); + assertFalse(eventCaptor.getValue().isPremium()); + } + + @Test + void shouldFireLogoutEventWhenPlayerLogsOut() { + given(pluginMessageEvent.isCancelled()).willReturn(false); + given(pluginMessageEvent.getTag()).willReturn(BungeeProxyBridge.AUTHME_CHANNEL); + given(pluginMessageEvent.getSender()).willReturn(sourceServer); + given(pluginMessageEvent.getData()).willReturn(createAuthMePayload("logout", "Alice")); + given(proxyServer.getPlayer("alice")).willReturn(player); + + stubEventsAllowed(); + + BungeeProxyBridge bridge = new BungeeProxyBridge(proxyServer, logger, createConfiguration(), new BungeeAuthenticationStore(), null); + bridge.onPluginMessage(pluginMessageEvent); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(AuthMeBungeeLogoutEvent.class); + verify(pluginManager).callEvent(eventCaptor.capture()); + assertSame(player, eventCaptor.getValue().getPlayer()); + } + private static byte[] createChunkPayload(int seq, boolean last, String csv) { ByteArrayDataOutput output = ByteStreams.newDataOutput(); output.writeUTF("premium.list.chunk"); @@ -487,6 +537,11 @@ private static byte[] createChunkPayload(int seq, boolean last, String csv) { return output.toByteArray(); } + private void stubEventsAllowed() { + given(proxyServer.getPluginManager()).willReturn(pluginManager); + given(pluginManager.callEvent(any())).willAnswer(inv -> inv.getArgument(0)); + } + private static BungeeProxyConfiguration createConfiguration() { return new BungeeProxyConfiguration( Set.of("lobby"), false, true, Set.of("/login", "/register", "/l", "/reg", "/email", "/captcha", "/2fa", "/totp", "/log"), 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 e2fc5ec6f..2177a41dc 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 @@ -19,6 +19,8 @@ import com.velocitypowered.api.proxy.messages.LegacyChannelIdentifier; import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier; import com.velocitypowered.api.proxy.server.RegisteredServer; +import fr.xephi.authme.velocity.events.AuthMeVelocityLoginEvent; +import fr.xephi.authme.velocity.events.AuthMeVelocityLogoutEvent; import fr.xephi.authme.velocity.premium.VelocityPremiumVerificationManager; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; @@ -242,11 +244,20 @@ void onPluginMessage(PluginMessageEvent event) { return; } + String normalizedName = normalizeName(parsedMessage.playerName()); String serverName = serverConnection.getServer().getServerInfo().getName(); if (LOGIN_MESSAGE.equals(parsedMessage.typeId())) { if (configuration.isAuthServer(serverConnection.getServer())) { logger.info("Player {} authenticated on auth server '{}'", parsedMessage.playerName(), serverName); + + proxyServer.getPlayer(normalizedName).ifPresent(player -> { + boolean premium = requiresPremiumVerification(normalizeName(parsedMessage.playerName())); + AuthMeVelocityLoginEvent loginEvent = new AuthMeVelocityLoginEvent(player, premium); + + proxyServer.getEventManager().fireAndForget(loginEvent); + }); + authenticationStore.markAuthenticated(parsedMessage.playerName()); sendAutoLoginIfAlreadySwitched(parsedMessage.playerName(), serverConnection.getServer()); redirectToLoginServer(parsedMessage.playerName()); @@ -262,6 +273,11 @@ void onPluginMessage(PluginMessageEvent event) { } else if (LOGOUT_MESSAGE.equals(parsedMessage.typeId())) { logger.info("Player {} logged out (notified by server '{}')", parsedMessage.playerName(), serverName); authenticationStore.markLoggedOut(parsedMessage.playerName()); + proxyServer.getPlayer(normalizedName).ifPresent(player -> { + AuthMeVelocityLogoutEvent loginEvent = new AuthMeVelocityLogoutEvent(player); + proxyServer.getEventManager().fireAndForget(loginEvent); + }); + redirectLoggedOutPlayer(parsedMessage.playerName()); } else if (PERFORM_LOGIN_ACK_MESSAGE.equals(parsedMessage.typeId())) { logger.info("Auto-login ACK received for {} from server '{}'", @@ -567,9 +583,11 @@ private void scheduleRetry(String normalizedName) { String serverName = serverOpt.get().getServer().getServerInfo().getName(); logger.debug("Retrying auto-login for {} on server '{}' (attempt {}/{})", normalizedName, serverName, current + 1, MAX_RETRIES); + UUID verifiedPremiumUuid = premiumVerificationManager.getVerifiedPremiumUuid(normalizedName); serverOpt.get().sendPluginMessage(AUTHME_CHANNEL, createPerformLoginMessage(normalizedName, verifiedPremiumUuid)); + scheduleRetry(normalizedName); }, 1, TimeUnit.SECONDS); } diff --git a/authme-velocity/src/main/java/fr/xephi/authme/velocity/events/AuthMeVelocityLoginEvent.java b/authme-velocity/src/main/java/fr/xephi/authme/velocity/events/AuthMeVelocityLoginEvent.java new file mode 100644 index 000000000..c41141295 --- /dev/null +++ b/authme-velocity/src/main/java/fr/xephi/authme/velocity/events/AuthMeVelocityLoginEvent.java @@ -0,0 +1,34 @@ +package fr.xephi.authme.velocity.events; + +import com.velocitypowered.api.proxy.Player; + +public class AuthMeVelocityLoginEvent { + + + private final Player player; + private final boolean premium; + + public AuthMeVelocityLoginEvent(Player player, boolean premium) { + this.player = player; + this.premium = premium; + } + + /** + * Return the player concerned by this event. + * + * @return The player who logged in correctly in the backend + */ + public Player getPlayer() { + return player; + } + + + /** + * Return whether this player required Premium verification for login + * + * @return if the player required premium verification + */ + public boolean isPremium() { + return premium; + } +} diff --git a/authme-velocity/src/main/java/fr/xephi/authme/velocity/events/AuthMeVelocityLogoutEvent.java b/authme-velocity/src/main/java/fr/xephi/authme/velocity/events/AuthMeVelocityLogoutEvent.java new file mode 100644 index 000000000..d6e157900 --- /dev/null +++ b/authme-velocity/src/main/java/fr/xephi/authme/velocity/events/AuthMeVelocityLogoutEvent.java @@ -0,0 +1,21 @@ +package fr.xephi.authme.velocity.events; + +import com.velocitypowered.api.proxy.Player; + +public class AuthMeVelocityLogoutEvent { + + private final Player player; + + public AuthMeVelocityLogoutEvent(Player player) { + this.player = player; + } + + /** + * Return the player concerned by this event. + * + * @return The player who logged out correctly in the backend + */ + public Player getPlayer() { + return player; + } +} 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 975869161..9be849168 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 @@ -3,6 +3,7 @@ import com.google.common.io.ByteArrayDataOutput; import com.google.common.io.ByteStreams; import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.event.EventManager; import com.velocitypowered.api.event.command.CommandExecuteEvent; import com.velocitypowered.api.event.connection.PreLoginEvent; import com.velocitypowered.api.event.connection.DisconnectEvent; @@ -18,6 +19,8 @@ import com.velocitypowered.api.proxy.messages.ChannelRegistrar; import com.velocitypowered.api.proxy.server.RegisteredServer; import com.velocitypowered.api.proxy.server.ServerInfo; +import fr.xephi.authme.velocity.events.AuthMeVelocityLoginEvent; +import fr.xephi.authme.velocity.events.AuthMeVelocityLogoutEvent; import net.kyori.adventure.text.Component; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -92,6 +95,9 @@ class VelocityProxyBridgeTest { @Mock private CommandSource consoleSource; + @Mock + private EventManager eventManager; + @Captor private ArgumentCaptor payloadCaptor; @@ -195,8 +201,10 @@ proxyServer, logger, new VelocityProxyConfiguration(Set.of("lobby"), false, true "Authentication required.", true, true, "limbo", true, Set.of("/login", "/register"), true, "", "", false), new VelocityAuthenticationStore(), null); + given(proxyServer.getEventManager()).willReturn(eventManager); bridge.onPluginMessage(pluginMessageEvent); + verify(eventManager).fireAndForget(any(AuthMeVelocityLogoutEvent.class)); verify(connectionRequest).fireAndForget(); } @@ -229,6 +237,7 @@ void shouldCancelPendingLoginOnExplicitAck() { given(proxyServer.getPlayer("alice")).willReturn(Optional.of(player)); VelocityProxyBridge bridge = new VelocityProxyBridge(proxyServer, logger, createConfiguration(), new VelocityAuthenticationStore(), null); + given(proxyServer.getEventManager()).willReturn(eventManager); bridge.onPluginMessage(pluginMessageEvent); bridge.onServerConnected(new ServerConnectedEvent(player, authServer, null)); @@ -236,9 +245,11 @@ void shouldCancelPendingLoginOnExplicitAck() { given(pluginMessageEvent.getData()).willReturn(createAuthMePayload("perform.login.ack", "Alice")); bridge.onPluginMessage(pluginMessageEvent); - // After ACK, proxyServer.getPlayer should have been called exactly once (by sendAutoLoginIfAlreadySwitched, + // After ACK, proxyServer.getPlayer should have been called twice + // (one for the AuthMeVelocityLoginEvent and the other by sendAutoLoginIfAlreadySwitched, // not by any retry) — the pending login was cancelled before any retry could fire. - verify(proxyServer, org.mockito.Mockito.times(1)).getPlayer("alice"); + verify(proxyServer, org.mockito.Mockito.times(2)).getPlayer("alice"); + verify(eventManager).fireAndForget(any(AuthMeVelocityLoginEvent.class)); } @Test @@ -262,6 +273,7 @@ void shouldCancelPendingLoginOnImplicitAckFromNonAuthServer() { // Mark authenticated via auth server login given(pluginMessageEvent.getData()).willReturn(createAuthMePayload("login", "Alice")); given(sourceConnection.getServer()).willReturn(authServer); + given(proxyServer.getEventManager()).willReturn(eventManager); bridge.onPluginMessage(pluginMessageEvent); bridge.onServerConnected(new ServerConnectedEvent(player, authServer, null)); @@ -270,9 +282,10 @@ void shouldCancelPendingLoginOnImplicitAckFromNonAuthServer() { given(sourceConnection.getServer()).willReturn(nonAuthServer); bridge.onPluginMessage(pluginMessageEvent); - // Pending is now cancelled; proxyServer.getPlayer was called exactly once (by sendAutoLoginIfAlreadySwitched), + // Pending is now cancelled; proxyServer.getPlayer was called twice + // (one for the AuthMeVelocityLoginEvent calling and the other by sendAutoLoginIfAlreadySwitched), // not again by any retry. - verify(proxyServer, org.mockito.Mockito.times(1)).getPlayer("alice"); + verify(proxyServer, org.mockito.Mockito.times(2)).getPlayer("alice"); } @Test