diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d4e0db1..208fc748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ 0.25.0 - RegEx name validation - Add RegEx name validation -- Allow to retrieve the plugin version from LibreLoginProvider \ No newline at end of file +- Allow to retrieve the plugin version from LibreLoginProvider +- Skip the login flow for players already authenticated by Minekube Connect \ No newline at end of file diff --git a/Plugin/src/main/java/xyz/kyngs/librelogin/bungeecord/BungeeCordListener.java b/Plugin/src/main/java/xyz/kyngs/librelogin/bungeecord/BungeeCordListener.java index 2af1e2f2..1fb6e323 100644 --- a/Plugin/src/main/java/xyz/kyngs/librelogin/bungeecord/BungeeCordListener.java +++ b/Plugin/src/main/java/xyz/kyngs/librelogin/bungeecord/BungeeCordListener.java @@ -6,6 +6,7 @@ package xyz.kyngs.librelogin.bungeecord; +import io.netty.channel.Channel; import net.md_5.bungee.api.config.ServerInfo; import net.md_5.bungee.api.connection.PendingConnection; import net.md_5.bungee.api.connection.ProxiedPlayer; @@ -15,6 +16,7 @@ import net.md_5.bungee.event.EventPriority; import xyz.kyngs.librelogin.api.event.exception.EventCancelledException; import xyz.kyngs.librelogin.common.config.ConfigurationKeys; +import xyz.kyngs.librelogin.common.integration.ConnectIntegration; import xyz.kyngs.librelogin.common.listener.AuthenticListeners; import xyz.kyngs.librelogin.common.util.GeneralUtil; @@ -56,6 +58,12 @@ public void onDisconnect(PlayerDisconnectEvent event) { public void onPreLogin(PreLoginEvent event) { if (plugin.fromFloodgate(event.getConnection().getUniqueId())) return; + // Minekube Connect authenticated the player at its own edge, there is no Mojang session left + // for this proxy to verify. Setting online mode would make the proxy send an encryption + // request that can never be answered, so the login flow has to be skipped, just like it is + // for Floodgate players above. + if (fromConnect(event.getConnection())) return; + runAsyncEvent(event, () -> { var result = onPreLogin(event.getConnection().getName(), event.getConnection().getAddress().getAddress()); @@ -93,10 +101,51 @@ private void setField(PendingConnection connection, String fieldName, Object val } } + /** + * Reads the connection channel out of the {@link PendingConnection} implementation, so that the + * attributes other systems put on it can be looked at. BungeeCord exposes neither the field nor + * its type through its API, hence the reflection. + * + * @return the channel, or null if it could not be read + */ + private Channel getChannel(PendingConnection connection) { + try { + Field field = connection.getClass().getDeclaredField("ch"); + field.setAccessible(true); + + Object wrapper = field.get(connection); + if (wrapper == null) return null; + + return (Channel) wrapper.getClass().getMethod("getHandle").invoke(wrapper); + } catch (ReflectiveOperationException | ClassCastException e) { + plugin.getLogger().debug("Failed to read the channel of a pending connection.", e); + return null; + } + } + + /** + * Checks whether the connection was already authenticated by Minekube Connect, which marks such + * connections with the {@code connect-player} channel attribute before any login event fires. + *

+ * This fails open: if the channel cannot be read the connection is treated as if Connect was not + * installed, which is exactly what happened before this check existed. + */ + private boolean fromConnect(PendingConnection connection) { + return ConnectIntegration.isConnectChannel(getChannel(connection)); + } + @EventHandler(priority = EventPriority.LOWEST) public void onProfileRequest(LoginEvent event) { if (plugin.fromFloodgate(event.getConnection().getUniqueId())) return; + if (fromConnect(event.getConnection())) { + // Connect has already put the player's real uuid on the connection, rewriting it here + // would replace it with LibreLogin's own. Remember the uuid, as the channel is no longer + // reachable once the player is online. + plugin.getConnectIntegration().addPlayer(event.getConnection().getUniqueId()); + return; + } + // Note to future self: NEVER EVER RUN THIS ASYNC, IT WILL BREAK PLUGINS var profile = plugin.getDatabaseProvider().getByName(event.getConnection().getName()); diff --git a/Plugin/src/main/java/xyz/kyngs/librelogin/common/AuthenticLibreLogin.java b/Plugin/src/main/java/xyz/kyngs/librelogin/common/AuthenticLibreLogin.java index 5c873072..faa090de 100644 --- a/Plugin/src/main/java/xyz/kyngs/librelogin/common/AuthenticLibreLogin.java +++ b/Plugin/src/main/java/xyz/kyngs/librelogin/common/AuthenticLibreLogin.java @@ -56,6 +56,7 @@ import xyz.kyngs.librelogin.common.database.provider.LibreLoginSQLiteDatabaseProvider; import xyz.kyngs.librelogin.common.event.AuthenticEventProvider; import xyz.kyngs.librelogin.common.image.AuthenticImageProjector; +import xyz.kyngs.librelogin.common.integration.ConnectIntegration; import xyz.kyngs.librelogin.common.integration.FloodgateIntegration; import xyz.kyngs.librelogin.common.integration.luckperms.LuckPermsIntegration; import xyz.kyngs.librelogin.common.listener.LoginTryListener; @@ -98,6 +99,7 @@ public abstract class AuthenticLibreLogin implements LibreLoginPlugin cancelOnExit; private final PlatformHandle platformHandle; private final Set forbiddenPasswords; + private final ConnectIntegration connectApi; protected Logger logger; private AuthenticPremiumProvider premiumProvider; private AuthenticEventProvider eventProvider; @@ -123,6 +125,7 @@ protected AuthenticLibreLogin() { platformHandle = providePlatformHandle(); forbiddenPasswords = new HashSet<>(); cancelOnExit = HashMultimap.create(); + connectApi = new ConnectIntegration(); } public Map, DatabaseConnectorRegistration> getDatabaseConnectors() { @@ -814,6 +817,26 @@ public boolean fromFloodgate(UUID uuid) { return floodgateApi != null && uuid != null && floodgateApi.isFloodgateId(uuid); } + public ConnectIntegration getConnectIntegration() { + return connectApi; + } + + public boolean fromConnect(UUID uuid) { + return connectApi.isConnectId(uuid); + } + + /** + * Whether the player has already been authenticated before the connection reached this proxy, + * either by Floodgate or by Minekube Connect. LibreLogin must not run its own login flow for + * such players. + * + * @param uuid the player's uuid + * @return whether the player has been authenticated externally + */ + public boolean externallyAuthenticated(UUID uuid) { + return fromFloodgate(uuid) || fromConnect(uuid); + } + protected void shutdownProxy(int code) { //noinspection finally try { diff --git a/Plugin/src/main/java/xyz/kyngs/librelogin/common/integration/ConnectIntegration.java b/Plugin/src/main/java/xyz/kyngs/librelogin/common/integration/ConnectIntegration.java new file mode 100644 index 00000000..a6f07036 --- /dev/null +++ b/Plugin/src/main/java/xyz/kyngs/librelogin/common/integration/ConnectIntegration.java @@ -0,0 +1,61 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package xyz.kyngs.librelogin.common.integration; + +import io.netty.channel.Channel; +import io.netty.util.AttributeKey; + +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Recognizes players that have already been authenticated by Minekube Connect. + *

+ * Connect terminates the player's connection at its own edge, performs the Mojang handshake there, + * and then relays the player into the proxy over a local channel which it marks with the + * {@code connect-player} attribute. There is no second Mojang session left for the proxy to verify, + * so forcing online mode on such a connection makes the proxy send an encryption request that can + * never be answered and the login never completes. + *

+ * Reading the marker needs no compile time dependency on Connect: Netty interns attribute keys by + * name, so the attribute is simply absent when Connect is not installed. This is the same approach + * the listeners already use for Floodgate's {@code floodgate-player} attribute. + *

+ * The channel is only reachable while the player is logging in, so the UUIDs recognized during + * login are remembered here and dropped again when the player disconnects. That gives the rest of + * the plugin a UUID based check with the same shape as {@link FloodgateIntegration#isFloodgateId}. + */ +public class ConnectIntegration { + + private static final AttributeKey CONNECT_ATTR = AttributeKey.valueOf("connect-player"); + + private final Set players = ConcurrentHashMap.newKeySet(); + + /** + * Checks whether the given connection channel has been marked by Connect. + * + * @param channel the connection channel, may be null if it could not be read + * @return whether the player behind the channel has already been authenticated by Connect + */ + public static boolean isConnectChannel(Channel channel) { + return channel != null && channel.hasAttr(CONNECT_ATTR) && channel.attr(CONNECT_ATTR).get() != null; + } + + public void addPlayer(UUID uuid) { + if (uuid != null) players.add(uuid); + } + + public void removePlayer(UUID uuid) { + if (uuid != null) players.remove(uuid); + } + + public boolean isConnectId(UUID uuid) { + return uuid != null && players.contains(uuid); + } + +} diff --git a/Plugin/src/main/java/xyz/kyngs/librelogin/common/listener/AuthenticListeners.java b/Plugin/src/main/java/xyz/kyngs/librelogin/common/listener/AuthenticListeners.java index d013e882..2d21acd3 100644 --- a/Plugin/src/main/java/xyz/kyngs/librelogin/common/listener/AuthenticListeners.java +++ b/Plugin/src/main/java/xyz/kyngs/librelogin/common/listener/AuthenticListeners.java @@ -38,7 +38,7 @@ public AuthenticListeners(Plugin plugin) { protected void onPostLogin(P player, User user) { var ip = platformHandle.getIP(player); var uuid = platformHandle.getUUIDForPlayer(player); - if (plugin.fromFloodgate(uuid)) return; + if (plugin.externallyAuthenticated(uuid)) return; if (user == null) { user = plugin.getDatabaseProvider().getByUUID(uuid); @@ -63,6 +63,7 @@ protected void onPostLogin(P player, User user) { } protected void onPlayerDisconnect(P player) { + plugin.getConnectIntegration().removePlayer(platformHandle.getUUIDForPlayer(player)); plugin.onExit(player); plugin.getAuthorizationProvider().onExit(player); } @@ -268,11 +269,11 @@ private User checkAndValidateByName(String username, @Nullable PremiumUser premi protected BiHolder chooseServer(P player, @Nullable String ip, @Nullable User user) { var id = platformHandle.getUUIDForPlayer(player); - var fromFloodgate = plugin.fromFloodgate(id); + var externallyAuthenticated = plugin.externallyAuthenticated(id); var sessionTime = Duration.ofSeconds(plugin.getConfiguration().get(ConfigurationKeys.SESSION_TIMEOUT)); - if (fromFloodgate) { + if (externallyAuthenticated) { user = null; } else if (user == null) { user = plugin.getDatabaseProvider().getByUUID(id); @@ -282,7 +283,7 @@ protected BiHolder chooseServer(P player, @Nullable String ip, @Null ip = platformHandle.getIP(player); } - if (fromFloodgate || user.autoLoginEnabled() || (sessionTime != null && user.getLastAuthentication() != null && ip.equals(user.getIp()) && user.getLastAuthentication().toLocalDateTime().plus(sessionTime).isAfter(LocalDateTime.now()))) { + if (externallyAuthenticated || user.autoLoginEnabled() || (sessionTime != null && user.getLastAuthentication() != null && ip.equals(user.getIp()) && user.getLastAuthentication().toLocalDateTime().plus(sessionTime).isAfter(LocalDateTime.now()))) { return new BiHolder<>(true, plugin.getServerHandler().chooseLobbyServer(user, player, true, false)); } else { return new BiHolder<>(false, plugin.getServerHandler().chooseLimboServer(user, player)); diff --git a/Plugin/src/main/java/xyz/kyngs/librelogin/velocity/VelocityListeners.java b/Plugin/src/main/java/xyz/kyngs/librelogin/velocity/VelocityListeners.java index 9c1c3e32..869278b3 100644 --- a/Plugin/src/main/java/xyz/kyngs/librelogin/velocity/VelocityListeners.java +++ b/Plugin/src/main/java/xyz/kyngs/librelogin/velocity/VelocityListeners.java @@ -23,6 +23,7 @@ import net.kyori.adventure.text.Component; import xyz.kyngs.librelogin.api.event.exception.EventCancelledException; import xyz.kyngs.librelogin.common.config.ConfigurationKeys; +import xyz.kyngs.librelogin.common.integration.ConnectIntegration; import xyz.kyngs.librelogin.common.listener.AuthenticListeners; import xyz.kyngs.librelogin.common.util.GeneralUtil; @@ -78,6 +79,33 @@ public VelocityListeners(VelocityLibreLogin plugin) { super(plugin); } + private static Channel getChannel(InboundConnection connection) throws IllegalAccessException { + if (INITIAL_CONNECTION_DELEGATE != null) { + connection = (InboundConnection) INITIAL_CONNECTION_DELEGATE.get(connection); + } + + Object mcConnection = INITIAL_MINECRAFT_CONNECTION.get(connection); + + return (Channel) CHANNEL.get(mcConnection); + } + + /** + * Checks whether the connection was already authenticated by Minekube Connect, which marks such + * connections with the {@code connect-player} channel attribute before any login event fires. + *

+ * Unlike the Floodgate check this one fails open: if the channel cannot be read we simply + * proceed as if Connect was not installed, which is exactly what happened before this check + * existed. + */ + private boolean fromConnect(InboundConnection connection) { + try { + return ConnectIntegration.isConnectChannel(getChannel(connection)); + } catch (Exception e) { + plugin.getLogger().debug("Failed to check if player is coming from Connect.", e); + return false; + } + } + @Subscribe(order = PostOrder.LAST) public void onPostLogin(PostLoginEvent event) { onPostLogin(event.getPlayer(), null); @@ -94,6 +122,14 @@ public void onProfileRequest(GameProfileRequestEvent event) { if (existing != null && plugin.fromFloodgate(existing.getId())) return; + if (existing != null && fromConnect(event.getConnection())) { + // Connect has already put the player's real uuid and skin properties into the profile, + // rebuilding it from the original one would throw both away. Remember the uuid, as the + // channel is no longer reachable once the player is online. + plugin.getConnectIntegration().addPlayer(existing.getId()); + return; + } + var profile = plugin.getDatabaseProvider().getByName(event.getUsername()); var gProfile = event.getOriginalProfile(); @@ -109,15 +145,8 @@ public void onPreLogin(PreLoginEvent event) { // If floodgate is present, attempt to extract the floodgate player from the connection channel. if (plugin.floodgateEnabled()) { - Channel channel; - InboundConnection connection = event.getConnection(); try { - if (INITIAL_CONNECTION_DELEGATE != null) { - connection = (InboundConnection) INITIAL_CONNECTION_DELEGATE.get(connection); - } - - Object mcConnection = INITIAL_MINECRAFT_CONNECTION.get(connection); - channel = (Channel) CHANNEL.get(mcConnection); + Channel channel = getChannel(event.getConnection()); if (channel.attr(FLOODGATE_ATTR).get() != null) { return; // Player is coming from Floodgate @@ -130,6 +159,14 @@ public void onPreLogin(PreLoginEvent event) { } } + // Minekube Connect authenticated the player at its own edge, there is no Mojang session + // left for this proxy to verify. Forcing online mode would make the proxy send an encryption + // request that can never be answered, so the login flow has to be skipped, just like it is + // for Floodgate players above. + if (fromConnect(event.getConnection())) { + return; // Player has already been authenticated by Connect + } + var result = onPreLogin(event.getUsername(), event.getConnection().getRemoteAddress().getAddress()); event.setResult(