diff --git a/.gitignore b/.gitignore index 63ea4bd9cb..5f998cf26f 100644 --- a/.gitignore +++ b/.gitignore @@ -115,3 +115,5 @@ nb-configuration.xml # Don't exclude the .gitignore itself !.gitignore /samples/ + +.factorypath diff --git a/pom.xml b/pom.xml index c4565feb56..f1109c7adb 100644 --- a/pom.xml +++ b/pom.xml @@ -79,6 +79,7 @@ sgdc3, games647, Hex3l, krusic22 + 6.1.0 1.0 1.0.0 4.2.0 @@ -174,6 +175,11 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.5 + org.apache.maven.plugins @@ -552,6 +558,13 @@ + + org.junit.jupiter + junit-jupiter + ${dependencies.junit.version} + test + + org.geysermc.floodgate diff --git a/src/main/java/fr/xephi/authme/api/v3/AuthMeApi.java b/src/main/java/fr/xephi/authme/api/v3/AuthMeApi.java index 81f55863f7..62d3a66149 100644 --- a/src/main/java/fr/xephi/authme/api/v3/AuthMeApi.java +++ b/src/main/java/fr/xephi/authme/api/v3/AuthMeApi.java @@ -95,7 +95,7 @@ public String getPluginVersion() { * @return true if the player is authenticated */ public boolean isAuthenticated(Player player) { - return playerCache.isAuthenticated(player.getName()); + return playerCache.isAuthenticated(player); } /** diff --git a/src/main/java/fr/xephi/authme/data/auth/PlayerCache.java b/src/main/java/fr/xephi/authme/data/auth/PlayerCache.java index e617f20ec5..e5a440389d 100644 --- a/src/main/java/fr/xephi/authme/data/auth/PlayerCache.java +++ b/src/main/java/fr/xephi/authme/data/auth/PlayerCache.java @@ -1,74 +1,277 @@ package fr.xephi.authme.data.auth; +import org.bukkit.entity.Player; +import java.util.Collections; +import java.util.HashMap; import java.util.Locale; import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; /** - * Used to manage player's Authenticated status + * Connection-bound authentication state machine. + *

+ * Live authentication is owned by one exact {@link Player} object representing one server connection. + * Only that connection may transition itself {@code CONNECTED_UNAUTHENTICATED -> AUTHENTICATED}. + * Only that connection may transition itself {@code AUTHENTICATED -> CONNECTED_UNAUTHENTICATED} for logout. + * Only that connection may transition itself to disconnected on quit. + *

+ * Name-based APIs are preserved for compatibility/offline/admin paths only. + * They must not authorize a live {@link Player}. */ public class PlayerCache { - private final Map cache = new ConcurrentHashMap<>(); + private final ConcurrentMap connections = new ConcurrentHashMap<>(); PlayerCache() { } - /** - * Adds the given auth object to the player cache (for the name defined in the PlayerAuth). - * - * @param auth the player auth object to save - */ + public void registerConnection(Player player) { + Objects.requireNonNull(player, "player must not be null"); + String name = normalizeName(player.getName()); + UUID uuid = player.getUniqueId(); + connections.compute(name, (key, oldState) -> + ConnectionState.unauthenticated(player, uuid)); + } + + public boolean authenticate(Player player, PlayerAuth auth) { + Objects.requireNonNull(player, "player must not be null"); + Objects.requireNonNull(auth, "auth must not be null"); + String name = normalizeName(player.getName()); + UUID uuid = player.getUniqueId(); + + for (;;) { + ConnectionState current = connections.get(name); + if (current == null) { + return false; + } + if (!current.belongsTo(player)) { + return false; + } + if (current.isAuthenticated()) { + return true; + } + ConnectionState updated = current.withAuth(auth); + if (connections.replace(name, current, updated)) { + return true; + } + } + } + + public Optional deauthenticate(Player player) { + Objects.requireNonNull(player, "player must not be null"); + String name = normalizeName(player.getName()); + + for (;;) { + ConnectionState current = connections.get(name); + if (current == null) { + return Optional.empty(); + } + if (!current.belongsTo(player)) { + return Optional.empty(); + } + if (!current.isAuthenticated()) { + return Optional.empty(); + } + ConnectionState updated = current.withoutAuth(); + if (connections.replace(name, current, updated)) { + return Optional.ofNullable(current.getAuth().orElse(null)); + } + } + } + + public Optional disconnect(Player player) { + Objects.requireNonNull(player, "player must not be null"); + String name = normalizeName(player.getName()); + + for (;;) { + ConnectionState current = connections.get(name); + if (current == null) { + return Optional.empty(); + } + if (!current.belongsTo(player)) { + return Optional.empty(); + } + if (connections.remove(name, current)) { + return Optional.of(current.snapshot(name)); + } + } + } + + public AuthenticationState getAuthenticationState(Player player) { + Objects.requireNonNull(player, "player must not be null"); + String name = normalizeName(player.getName()); + ConnectionState state = connections.get(name); + if (state == null) { + return AuthenticationState.NO_CONNECTION; + } + if (!state.belongsTo(player)) { + return AuthenticationState.CONNECTION_MISMATCH; + } + return state.isAuthenticated() + ? AuthenticationState.AUTHENTICATED + : AuthenticationState.CONNECTED_UNAUTHENTICATED; + } + + public boolean isAuthenticated(Player player) { + return getAuthenticationState(player) == AuthenticationState.AUTHENTICATED; + } + + public Optional getAuth(Player player) { + Objects.requireNonNull(player, "player must not be null"); + String name = normalizeName(player.getName()); + ConnectionState state = connections.get(name); + if (state == null || !state.belongsTo(player)) { + return Optional.empty(); + } + return state.getAuth(); + } + public void updatePlayer(PlayerAuth auth) { - cache.put(auth.getNickname().toLowerCase(Locale.ROOT), auth); + if (auth == null) { + return; + } + String name = normalizeName(auth.getNickname()); + connections.computeIfPresent(name, (key, state) -> { + if (state.isAuthenticated()) { + return state.withAuth(auth); + } + return state; + }); } - /** - * Removes a player from the player cache. - * - * @param user name of the player to remove - */ public void removePlayer(String user) { - cache.remove(user.toLowerCase(Locale.ROOT)); + connections.remove(normalizeName(user)); } - /** - * Get whether a player is authenticated (i.e. whether he is present in the player cache). - * - * @param user player's name - * - * @return true if player is logged in, false otherwise. - */ public boolean isAuthenticated(String user) { - return cache.containsKey(user.toLowerCase(Locale.ROOT)); + String name = normalizeName(user); + ConnectionState state = connections.get(name); + return state != null && state.isAuthenticated(); } - /** - * Returns the PlayerAuth associated with the given user, if available. - * - * @param user name of the player - * - * @return the associated auth object, or null if not available - */ public PlayerAuth getAuth(String user) { - return cache.get(user.toLowerCase(Locale.ROOT)); + String name = normalizeName(user); + ConnectionState state = connections.get(name); + if (state == null) { + return null; + } + return state.getAuth().orElse(null); } - /** - * @return number of logged in players - */ public int getLogged() { - return cache.size(); + return (int) connections.values().stream().filter(ConnectionState::isAuthenticated).count(); } - /** - * Returns the player cache data. - * - * @return all player auths inside the player cache - */ public Map getCache() { - return this.cache; + Map snapshot = new HashMap<>(); + connections.forEach((name, state) -> { + state.getAuth().ifPresent(auth -> snapshot.put(name, auth)); + }); + return Collections.unmodifiableMap(snapshot); + } + + private static String normalizeName(String name) { + return name.toLowerCase(Locale.ROOT); + } + + public enum AuthenticationState { + NO_CONNECTION, + CONNECTED_UNAUTHENTICATED, + AUTHENTICATED, + CONNECTION_MISMATCH + } + + public static final class ConnectionSnapshot { + private final String normalizedName; + private final UUID uniqueId; + private final PlayerAuth auth; + + ConnectionSnapshot(String normalizedName, UUID uniqueId, PlayerAuth auth) { + this.normalizedName = normalizedName; + this.uniqueId = uniqueId; + this.auth = auth; + } + + public String getNormalizedName() { + return normalizedName; + } + + public UUID getUniqueId() { + return uniqueId; + } + + public Optional getAuth() { + return Optional.ofNullable(auth); + } + + public boolean wasAuthenticated() { + return auth != null; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof ConnectionSnapshot)) { + return false; + } + ConnectionSnapshot that = (ConnectionSnapshot) o; + return Objects.equals(normalizedName, that.normalizedName) + && Objects.equals(uniqueId, that.uniqueId) + && Objects.equals(auth, that.auth); + } + + @Override + public int hashCode() { + return Objects.hash(normalizedName, uniqueId, auth); + } + + @Override + public String toString() { + return "ConnectionSnapshot{name=" + normalizedName + ",uuid=" + uniqueId + ",auth=" + (auth != null) + "}"; + } } + private static final class ConnectionState { + private final Player owner; + private final UUID uniqueId; + private final PlayerAuth auth; + + ConnectionState(Player owner, UUID uniqueId, PlayerAuth auth) { + this.owner = owner; + this.uniqueId = uniqueId; + this.auth = auth; + } + + static ConnectionState unauthenticated(Player owner, UUID uniqueId) { + return new ConnectionState(owner, uniqueId, null); + } + + ConnectionState withAuth(PlayerAuth auth) { + return new ConnectionState(this.owner, this.uniqueId, auth); + } + + ConnectionState withoutAuth() { + return new ConnectionState(this.owner, this.uniqueId, null); + } + + boolean belongsTo(Player candidate) { + return this.owner == candidate; + } + + boolean isAuthenticated() { + return auth != null; + } + + Optional getAuth() { + return Optional.ofNullable(auth); + } + + ConnectionSnapshot snapshot(String normalizedName) { + return new ConnectionSnapshot(normalizedName, uniqueId, auth); + } + } } diff --git a/src/main/java/fr/xephi/authme/data/limbo/LimboPlayerTaskManager.java b/src/main/java/fr/xephi/authme/data/limbo/LimboPlayerTaskManager.java index ca5eaa1f6d..c40480ce7b 100644 --- a/src/main/java/fr/xephi/authme/data/limbo/LimboPlayerTaskManager.java +++ b/src/main/java/fr/xephi/authme/data/limbo/LimboPlayerTaskManager.java @@ -5,6 +5,7 @@ import fr.xephi.authme.data.captcha.RegistrationCaptchaManager; import fr.xephi.authme.message.MessageKey; import fr.xephi.authme.message.Messages; +import fr.xephi.authme.process.login.ForceLoginRequestService; import fr.xephi.authme.service.BukkitService; import fr.xephi.authme.settings.Settings; import fr.xephi.authme.settings.properties.RegistrationSettings; @@ -34,6 +35,9 @@ class LimboPlayerTaskManager { @Inject private PlayerCache playerCache; + @Inject + private ForceLoginRequestService forceLoginRequestService; + @Inject private RegistrationCaptchaManager registrationCaptchaManager; @@ -52,7 +56,7 @@ void registerMessageTask(Player player, LimboPlayer limbo, LimboMessageType mess MessageResult result = getMessageKey(player.getName(), messageType); if (interval > 0) { String[] joinMessage = messages.retrieveSingle(player, result.messageKey, result.args).split("\n"); - MessageTask messageTask = new MessageTask(player, joinMessage); + MessageTask messageTask = new MessageTask(player, joinMessage, playerCache, forceLoginRequestService); bukkitService.runTaskTimer(messageTask, 2 * TICKS_PER_SECOND, (long) interval * TICKS_PER_SECOND); limbo.setMessageTask(messageTask); } diff --git a/src/main/java/fr/xephi/authme/data/limbo/LimboService.java b/src/main/java/fr/xephi/authme/data/limbo/LimboService.java index c8b469224e..f933a5d8d5 100644 --- a/src/main/java/fr/xephi/authme/data/limbo/LimboService.java +++ b/src/main/java/fr/xephi/authme/data/limbo/LimboService.java @@ -116,7 +116,7 @@ public void restoreData(Player player) { LimboPlayer limbo = entries.remove(lowerName); if (limbo == null) { - logger.debug("No LimboPlayer found for `{0}` - cannot restore", lowerName); + logger.debug("No LimboPlayer found for `{0}` - only clearing persisted limbo state", lowerName); } else { player.setOp(limbo.isOperator()); settings.getProperty(RESTORE_ALLOW_FLIGHT).restoreAllowFlight(player, limbo); @@ -124,8 +124,8 @@ public void restoreData(Player player) { settings.getProperty(RESTORE_WALK_SPEED).restoreWalkSpeed(player, limbo); limbo.clearTasks(); logger.debug("Restored LimboPlayer stats for `{0}`", lowerName); - persistence.removeLimboPlayer(player); } + persistence.removeLimboPlayer(player); authGroupHandler.setGroup(player, limbo, AuthGroupType.LOGGED_IN); } diff --git a/src/main/java/fr/xephi/authme/initialization/OnShutdownPlayerSaver.java b/src/main/java/fr/xephi/authme/initialization/OnShutdownPlayerSaver.java index 261e7dd59f..ef02353af2 100644 --- a/src/main/java/fr/xephi/authme/initialization/OnShutdownPlayerSaver.java +++ b/src/main/java/fr/xephi/authme/initialization/OnShutdownPlayerSaver.java @@ -58,7 +58,7 @@ private void savePlayer(Player player) { } else { saveLoggedinPlayer(player); } - playerCache.removePlayer(name); + playerCache.disconnect(player); } private void saveLoggedinPlayer(Player player) { diff --git a/src/main/java/fr/xephi/authme/listener/BedrockAutoLoginListener.java b/src/main/java/fr/xephi/authme/listener/BedrockAutoLoginListener.java index 4f63e61020..973e583c44 100644 --- a/src/main/java/fr/xephi/authme/listener/BedrockAutoLoginListener.java +++ b/src/main/java/fr/xephi/authme/listener/BedrockAutoLoginListener.java @@ -26,8 +26,6 @@ public class BedrockAutoLoginListener implements Listener { private BukkitService bukkitService; @Inject private AuthMe plugin; - @Inject - private Messages messages; @Inject private Settings settings; @@ -47,7 +45,6 @@ public void onPlayerJoin(PlayerJoinEvent event) { bukkitService.runTaskLater(player, () -> { if (isBedrockPlayer(uuid) && !authmeApi.isAuthenticated(player) && authmeApi.isRegistered(name)) { authmeApi.forceLogin(player, true); - messages.send(player, MessageKey.BEDROCK_AUTO_LOGGED_IN); } },20L); } diff --git a/src/main/java/fr/xephi/authme/listener/InventoryRestrictionNotifier.java b/src/main/java/fr/xephi/authme/listener/InventoryRestrictionNotifier.java new file mode 100644 index 0000000000..d0e04a9f59 --- /dev/null +++ b/src/main/java/fr/xephi/authme/listener/InventoryRestrictionNotifier.java @@ -0,0 +1,62 @@ +package fr.xephi.authme.listener; + +import fr.xephi.authme.data.auth.PlayerCache; +import fr.xephi.authme.message.MessageKey; +import fr.xephi.authme.message.Messages; +import fr.xephi.authme.ConsoleLogger; +import fr.xephi.authme.output.ConsoleLoggerFactory; +import fr.xephi.authme.util.expiring.ExpiringSet; +import org.bukkit.entity.Player; + +import javax.inject.Inject; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +public final class InventoryRestrictionNotifier { + + private static final long MESSAGE_COOLDOWN_SECONDS = 2L; + + private final ConsoleLogger logger = ConsoleLoggerFactory.get(InventoryRestrictionNotifier.class); + private final Messages messages; + private final PlayerCache playerCache; + private final ExpiringSet notifiedPlayers = new ExpiringSet<>(MESSAGE_COOLDOWN_SECONDS, TimeUnit.SECONDS); + + @Inject + InventoryRestrictionNotifier(Messages messages, PlayerCache playerCache) { + this.messages = messages; + this.playerCache = playerCache; + } + + public void notifyDenied(Player player, String action) { + Objects.requireNonNull(player, "player must not be null"); + Objects.requireNonNull(action, "action must not be null"); + + UUID uuid = player.getUniqueId(); + if (notifiedPlayers.contains(uuid)) { + return; + } + notifiedPlayers.add(uuid); + + messages.send(player, MessageKey.DENIED_INVENTORY); + + PlayerCache.AuthenticationState state = playerCache.getAuthenticationState(player); + if (state == PlayerCache.AuthenticationState.CONNECTION_MISMATCH) { + logger.warning("INVENTORY_ACTION_DENIED action='" + action + + "' player='" + player.getName() + + "' uuid=" + uuid + + " state=" + state); + } else { + logger.fine("INVENTORY_ACTION_DENIED action='" + action + + "' player='" + player.getName() + + "' uuid=" + uuid + + " state=" + state); + } + } + + public void clear(Player player) { + if (player != null) { + notifiedPlayers.remove(player.getUniqueId()); + } + } +} diff --git a/src/main/java/fr/xephi/authme/listener/ListenerService.java b/src/main/java/fr/xephi/authme/listener/ListenerService.java index d90c6f2d99..8ad22e8e20 100644 --- a/src/main/java/fr/xephi/authme/listener/ListenerService.java +++ b/src/main/java/fr/xephi/authme/listener/ListenerService.java @@ -17,7 +17,7 @@ /** * Service class for the AuthMe listeners to determine whether an event should be canceled. */ -class ListenerService implements SettingsDependent { +public class ListenerService implements SettingsDependent { private final DataSource dataSource; private final PlayerCache playerCache; private final ValidationService validationService; @@ -76,7 +76,7 @@ public boolean shouldCancelEvent(PlayerEvent event) { * @return true if the associated event should be canceled, false otherwise */ public boolean shouldCancelEvent(Player player) { - return player != null && !checkAuth(player.getName()) && !PlayerUtils.isNpc(player); + return player != null && !checkAuth(player) && !PlayerUtils.isNpc(player); } @Override public void reload(Settings settings) { @@ -87,11 +87,12 @@ public void reload(Settings settings) { * Checks whether the player is allowed to perform actions (i.e. whether he is logged in * or if other settings permit playing). * - * @param name the name of the player to verify + * @param player the player to verify * @return true if the player may play, false otherwise */ - private boolean checkAuth(String name) { - if (validationService.isUnrestricted(name) || playerCache.isAuthenticated(name)){ + private boolean checkAuth(Player player) { + String name = player.getName(); + if (validationService.isUnrestricted(name) || playerCache.isAuthenticated(player)) { return true; } if (!isRegistrationForced && !dataSource.isAuthAvailable(name)) { diff --git a/src/main/java/fr/xephi/authme/listener/PlayerListener.java b/src/main/java/fr/xephi/authme/listener/PlayerListener.java index 60aa74015e..87691a8c81 100644 --- a/src/main/java/fr/xephi/authme/listener/PlayerListener.java +++ b/src/main/java/fr/xephi/authme/listener/PlayerListener.java @@ -2,6 +2,7 @@ import fr.xephi.authme.data.QuickCommandsProtectionManager; import fr.xephi.authme.data.auth.PlayerAuth; +import fr.xephi.authme.data.auth.PlayerCache; import fr.xephi.authme.datasource.DataSource; import fr.xephi.authme.message.MessageKey; import fr.xephi.authme.message.Messages; @@ -32,6 +33,7 @@ import org.bukkit.event.block.SignChangeEvent; import org.bukkit.event.entity.EntityDamageByEntityEvent; import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; import org.bukkit.event.inventory.InventoryOpenEvent; import org.bukkit.event.player.AsyncPlayerChatEvent; import org.bukkit.event.player.AsyncPlayerPreLoginEvent; @@ -73,6 +75,8 @@ public class PlayerListener implements Listener { @Inject private DataSource dataSource; @Inject + private PlayerCache playerCache; + @Inject private AntiBotService antiBotService; @Inject private Management management; @@ -94,6 +98,8 @@ public class PlayerListener implements Listener { private PermissionsManager permissionsManager; @Inject private QuickCommandsProtectionManager quickCommandsProtectionManager; + @Inject + private InventoryRestrictionNotifier inventoryRestrictionNotifier; // Lowest priority to apply fast protection checks @EventHandler(priority = EventPriority.LOWEST) @@ -194,6 +200,10 @@ public void onPlayerJoin(PlayerJoinEvent event) { quickCommandsProtectionManager.processJoin(player); + if (!validationService.isUnrestricted(player.getName())) { + playerCache.registerConnection(player); + } + management.performJoin(player); teleportationService.teleportNewPlayerToFirstSpawn(player); @@ -240,6 +250,8 @@ public void onJoinMessage(PlayerJoinEvent event) { public void onPlayerQuit(PlayerQuitEvent event) { Player player = event.getPlayer(); + inventoryRestrictionNotifier.clear(player); + // Note: quit message can be null, despite api documentation says not if (settings.getProperty(RegistrationSettings.REMOVE_LEAVE_MESSAGE)) { event.setQuitMessage(null); @@ -255,6 +267,7 @@ public void onPlayerQuit(PlayerQuitEvent event) { } if (antiBotService.wasPlayerKicked(player.getName())) { + playerCache.disconnect(player); return; } @@ -270,11 +283,6 @@ public void onPlayerKick(PlayerKickEvent event) { event.setCancelled(true); return; } - - final Player player = event.getPlayer(); - if (!antiBotService.wasPlayerKicked(player.getName())) { - management.performQuit(player); - } } /* @@ -478,6 +486,7 @@ public void onSignChange(SignChangeEvent event) { public void onPlayerDropItem(PlayerDropItemEvent event) { if (listenerService.shouldCancelEvent(event)) { event.setCancelled(true); + inventoryRestrictionNotifier.notifyDenied(event.getPlayer(), "drop_item"); } } @@ -485,6 +494,7 @@ public void onPlayerDropItem(PlayerDropItemEvent event) { public void onPlayerHeldItem(PlayerItemHeldEvent event) { if (listenerService.shouldCancelEvent(event)) { event.setCancelled(true); + inventoryRestrictionNotifier.notifyDenied(event.getPlayer(), "change_held_item"); } } @@ -492,6 +502,7 @@ public void onPlayerHeldItem(PlayerItemHeldEvent event) { public void onPlayerConsumeItem(PlayerItemConsumeEvent event) { if (listenerService.shouldCancelEvent(event)) { event.setCancelled(true); + inventoryRestrictionNotifier.notifyDenied(event.getPlayer(), "consume_item"); } } @@ -517,26 +528,48 @@ private boolean isInventoryWhitelisted(InventoryView inventory) { return false; } + private boolean shouldCancelInventoryInteraction(HumanEntity humanEntity, InventoryView inventory) { + if (!(humanEntity instanceof Player)) { + return false; + } + final Player player = (Player) humanEntity; + return listenerService.shouldCancelEvent(player) && !isInventoryWhitelisted(inventory); + } + + private void closeInventoryIfStillRestricted(HumanEntity player) { + if (shouldCancelInventoryInteraction(player, player.getOpenInventory())) { + player.closeInventory(); + } + } + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) public void onPlayerInventoryOpen(InventoryOpenEvent event) { final HumanEntity player = event.getPlayer(); - if (listenerService.shouldCancelEvent(player) - && !isInventoryWhitelisted(event.getView())) { + if (shouldCancelInventoryInteraction(player, event.getView())) { event.setCancelled(true); + inventoryRestrictionNotifier.notifyDenied((Player) player, "inventory_open"); /* * @note little hack cause InventoryOpenEvent cannot be cancelled for * real, cause no packet is sent to server by client for the main inv */ - bukkitService.scheduleSyncDelayedTask(player::closeInventory, 1); + bukkitService.runTaskLater((Player) player, () -> closeInventoryIfStillRestricted(player), 1); } } @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) public void onPlayerInventoryClick(InventoryClickEvent event) { - if (listenerService.shouldCancelEvent(event.getWhoClicked()) - && !isInventoryWhitelisted(event.getView())) { + if (shouldCancelInventoryInteraction(event.getWhoClicked(), event.getView())) { + event.setCancelled(true); + inventoryRestrictionNotifier.notifyDenied((Player) event.getWhoClicked(), "inventory_click"); + } + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) + public void onPlayerInventoryDrag(InventoryDragEvent event) { + if (shouldCancelInventoryInteraction(event.getWhoClicked(), event.getView())) { event.setCancelled(true); + inventoryRestrictionNotifier.notifyDenied((Player) event.getWhoClicked(), "inventory_drag"); } } } diff --git a/src/main/java/fr/xephi/authme/listener/PlayerListener19.java b/src/main/java/fr/xephi/authme/listener/PlayerListener19.java index aeb116dccb..c7ce0939f1 100644 --- a/src/main/java/fr/xephi/authme/listener/PlayerListener19.java +++ b/src/main/java/fr/xephi/authme/listener/PlayerListener19.java @@ -15,10 +15,14 @@ public class PlayerListener19 implements Listener { @Inject private ListenerService listenerService; + @Inject + private InventoryRestrictionNotifier inventoryRestrictionNotifier; + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) public void onPlayerSwapHandItems(PlayerSwapHandItemsEvent event) { if (listenerService.shouldCancelEvent(event)) { event.setCancelled(true); + inventoryRestrictionNotifier.notifyDenied(event.getPlayer(), "swap_hands"); } } } diff --git a/src/main/java/fr/xephi/authme/listener/PlayerListenerHigherThan18.java b/src/main/java/fr/xephi/authme/listener/PlayerListenerHigherThan18.java index bd845ce0b1..d85b8bc04d 100644 --- a/src/main/java/fr/xephi/authme/listener/PlayerListenerHigherThan18.java +++ b/src/main/java/fr/xephi/authme/listener/PlayerListenerHigherThan18.java @@ -1,6 +1,7 @@ package fr.xephi.authme.listener; import fr.xephi.authme.settings.Settings; +import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; @@ -15,10 +16,16 @@ public class PlayerListenerHigherThan18 implements Listener { @Inject private Settings settings; + @Inject + private InventoryRestrictionNotifier inventoryRestrictionNotifier; + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) public void onPlayerPickupItem(EntityPickupItemEvent event) { if (listenerService.shouldCancelEvent(event)) { event.setCancelled(true); + if (event.getEntity() instanceof Player) { + inventoryRestrictionNotifier.notifyDenied((Player) event.getEntity(), "pickup_item"); + } } } diff --git a/src/main/java/fr/xephi/authme/listener/protocollib/InventoryPacketAdapter.java b/src/main/java/fr/xephi/authme/listener/protocollib/InventoryPacketAdapter.java index 9a73a5128a..c68430f356 100644 --- a/src/main/java/fr/xephi/authme/listener/protocollib/InventoryPacketAdapter.java +++ b/src/main/java/fr/xephi/authme/listener/protocollib/InventoryPacketAdapter.java @@ -26,8 +26,7 @@ import com.comphenix.protocol.reflect.StructureModifier; import fr.xephi.authme.AuthMe; import fr.xephi.authme.ConsoleLogger; -import fr.xephi.authme.data.auth.PlayerCache; -import fr.xephi.authme.datasource.DataSource; +import fr.xephi.authme.listener.ListenerService; import fr.xephi.authme.output.ConsoleLoggerFactory; import fr.xephi.authme.service.BukkitService; import org.bukkit.Material; @@ -48,13 +47,11 @@ class InventoryPacketAdapter extends PacketAdapter { private static final int HOTBAR_SIZE = 9; private final ConsoleLogger logger = ConsoleLoggerFactory.get(InventoryPacketAdapter.class); - private final PlayerCache playerCache; - private final DataSource dataSource; + private final ListenerService listenerService; - InventoryPacketAdapter(AuthMe plugin, PlayerCache playerCache, DataSource dataSource) { + InventoryPacketAdapter(AuthMe plugin, ListenerService listenerService) { super(plugin, PacketType.Play.Server.SET_SLOT, PacketType.Play.Server.WINDOW_ITEMS); - this.playerCache = playerCache; - this.dataSource = dataSource; + this.listenerService = listenerService; } @Override @@ -63,7 +60,7 @@ public void onPacketSending(PacketEvent packetEvent) { PacketContainer packet = packetEvent.getPacket(); int windowId = packet.getIntegers().read(0); - if (windowId == PLAYER_INVENTORY && shouldHideInventory(player.getName())) { + if (windowId == PLAYER_INVENTORY && shouldHideInventory(player)) { packetEvent.setCancelled(true); } } @@ -77,12 +74,12 @@ public void register(BukkitService bukkitService) { ProtocolLibrary.getProtocolManager().addPacketListener(this); bukkitService.getOnlinePlayers().stream() - .filter(player -> shouldHideInventory(player.getName())) + .filter(this::shouldHideInventory) .forEach(this::sendBlankInventoryPacket); } - private boolean shouldHideInventory(String playerName) { - return !playerCache.isAuthenticated(playerName) && dataSource.isAuthAvailable(playerName); + private boolean shouldHideInventory(Player player) { + return listenerService.shouldCancelEvent(player); } public void unregister() { diff --git a/src/main/java/fr/xephi/authme/listener/protocollib/ProtocolLibService.java b/src/main/java/fr/xephi/authme/listener/protocollib/ProtocolLibService.java index f0297b3387..d35d31fa24 100644 --- a/src/main/java/fr/xephi/authme/listener/protocollib/ProtocolLibService.java +++ b/src/main/java/fr/xephi/authme/listener/protocollib/ProtocolLibService.java @@ -4,9 +4,9 @@ import fr.xephi.authme.AuthMe; import fr.xephi.authme.ConsoleLogger; import fr.xephi.authme.data.auth.PlayerCache; -import fr.xephi.authme.datasource.DataSource; import fr.xephi.authme.initialization.SettingsDependent; import fr.xephi.authme.output.ConsoleLoggerFactory; +import fr.xephi.authme.listener.ListenerService; import fr.xephi.authme.service.BukkitService; import fr.xephi.authme.settings.Settings; import fr.xephi.authme.settings.properties.PluginSettings; @@ -36,15 +36,15 @@ public class ProtocolLibService implements SettingsDependent { private final AuthMe plugin; private final BukkitService bukkitService; private final PlayerCache playerCache; - private final DataSource dataSource; + private final ListenerService listenerService; @Inject ProtocolLibService(AuthMe plugin, Settings settings, BukkitService bukkitService, PlayerCache playerCache, - DataSource dataSource) { + ListenerService listenerService) { this.plugin = plugin; this.bukkitService = bukkitService; this.playerCache = playerCache; - this.dataSource = dataSource; + this.listenerService = listenerService; reload(settings); } @@ -74,7 +74,7 @@ public void setup() { if (protectInvBeforeLogin) { if (inventoryPacketAdapter == null) { // register the packet listener and start hiding it for all already online players (reload) - inventoryPacketAdapter = new InventoryPacketAdapter(plugin, playerCache, dataSource); + inventoryPacketAdapter = new InventoryPacketAdapter(plugin, listenerService); inventoryPacketAdapter.register(bukkitService); } } else if (inventoryPacketAdapter != null) { diff --git a/src/main/java/fr/xephi/authme/message/MessageKey.java b/src/main/java/fr/xephi/authme/message/MessageKey.java index ab05738dfc..ae64547dd8 100644 --- a/src/main/java/fr/xephi/authme/message/MessageKey.java +++ b/src/main/java/fr/xephi/authme/message/MessageKey.java @@ -287,6 +287,9 @@ public enum MessageKey { /** Error: not all required settings are set for sending emails. Please contact an admin. */ INCOMPLETE_EMAIL_SETTINGS("email.incomplete_settings"), + /** Inventory action blocked because this connection is not authenticated. */ + DENIED_INVENTORY("error.denied_inventory"), + /** The email could not be sent. Please contact an administrator. */ EMAIL_SEND_FAILURE("email.send_failure"), diff --git a/src/main/java/fr/xephi/authme/process/Management.java b/src/main/java/fr/xephi/authme/process/Management.java index 454c0c1859..553f0b164f 100644 --- a/src/main/java/fr/xephi/authme/process/Management.java +++ b/src/main/java/fr/xephi/authme/process/Management.java @@ -1,10 +1,12 @@ package fr.xephi.authme.process; +import fr.xephi.authme.data.auth.PlayerCache; import fr.xephi.authme.process.changepassword.AsyncChangePassword; import fr.xephi.authme.process.email.AsyncAddEmail; import fr.xephi.authme.process.email.AsyncChangeEmail; import fr.xephi.authme.process.join.AsynchronousJoin; import fr.xephi.authme.process.login.AsynchronousLogin; +import fr.xephi.authme.process.login.ForceLoginRequestService; import fr.xephi.authme.process.logout.AsynchronousLogout; import fr.xephi.authme.process.quit.AsynchronousQuit; import fr.xephi.authme.process.register.AsyncRegister; @@ -25,6 +27,9 @@ public class Management { @Inject private BukkitService bukkitService; + @Inject + private PlayerCache playerCache; + // Processes @Inject private AsyncAddEmail asyncAddEmail; @@ -41,6 +46,8 @@ public class Management { @Inject private AsynchronousLogin asynchronousLogin; @Inject + private ForceLoginRequestService forceLoginRequestService; + @Inject private AsynchronousUnregister asynchronousUnregister; @Inject private AsyncChangePassword asyncChangePassword; @@ -54,10 +61,26 @@ public void performLogin(Player player, String password) { } public void forceLogin(Player player) { + if (player == null) { + return; + } + if (playerCache.isAuthenticated(player)) { + forceLoginRequestService.clear(player); + return; + } + forceLoginRequestService.markPending(player); runTask(() -> asynchronousLogin.forceLogin(player)); } public void forceLogin(Player player, boolean quiet) { + if (player == null) { + return; + } + if (playerCache.isAuthenticated(player)) { + forceLoginRequestService.clear(player); + return; + } + forceLoginRequestService.markPending(player); runTask(() -> asynchronousLogin.forceLogin(player, quiet)); } @@ -82,7 +105,11 @@ public void performJoin(Player player) { } public void performQuit(Player player) { - runTask(() -> asynchronousQuit.processQuit(player)); + var disconnected = playerCache.disconnect(player); + if (disconnected.isEmpty()) { + return; + } + runTask(() -> asynchronousQuit.processQuit(player, disconnected.get())); } public void performAddEmail(Player player, String newEmail) { diff --git a/src/main/java/fr/xephi/authme/process/join/AsynchronousJoin.java b/src/main/java/fr/xephi/authme/process/join/AsynchronousJoin.java index 424c49896b..14da5892af 100644 --- a/src/main/java/fr/xephi/authme/process/join/AsynchronousJoin.java +++ b/src/main/java/fr/xephi/authme/process/join/AsynchronousJoin.java @@ -1,6 +1,7 @@ package fr.xephi.authme.process.join; import fr.xephi.authme.ConsoleLogger; +import fr.xephi.authme.data.auth.PlayerCache; import fr.xephi.authme.data.ProxySessionManager; import fr.xephi.authme.data.limbo.LimboService; import fr.xephi.authme.datasource.DataSource; @@ -52,6 +53,9 @@ public class AsynchronousJoin implements AsynchronousProcess { @Inject private DataSource database; + @Inject + private PlayerCache playerCache; + @Inject private CommonService service; @@ -122,6 +126,10 @@ public void processJoin(Player player) { boolean isAuthAvailable = database.isAuthAvailable(name); + if (playerCache.isAuthenticated(player)) { + return; + } + if (isAuthAvailable) { // Protect inventory if (service.getProperty(PROTECT_INVENTORY_BEFORE_LOGIN)) { @@ -195,6 +203,9 @@ private void processJoinSync(Player player, boolean isAuthAvailable) { int registrationTimeout = service.getProperty(RestrictionSettings.TIMEOUT) * TICKS_PER_SECOND; bukkitService.scheduleSyncTaskFromOptionallyAsyncTask(() -> { + if (playerCache.isAuthenticated(player)) { + return; + } limboService.createLimboPlayer(player, isAuthAvailable); player.setNoDamageTicks(registrationTimeout); diff --git a/src/main/java/fr/xephi/authme/process/login/AsynchronousLogin.java b/src/main/java/fr/xephi/authme/process/login/AsynchronousLogin.java index 988124d9a4..eb78fe538c 100644 --- a/src/main/java/fr/xephi/authme/process/login/AsynchronousLogin.java +++ b/src/main/java/fr/xephi/authme/process/login/AsynchronousLogin.java @@ -60,6 +60,9 @@ public class AsynchronousLogin implements AsynchronousProcess { @Inject private PlayerCache playerCache; + @Inject + private ForceLoginRequestService forceLoginRequestService; + @Inject private SyncProcessManager syncProcessManager; @@ -118,10 +121,7 @@ public void login(Player player, String password) { * @param player the player to log in */ public synchronized void forceLogin(Player player) { - PlayerAuth auth = getPlayerAuth(player); - if (auth != null) { - performLogin(player, auth); - } + forceLoginInternal(player, false); } /** @@ -130,11 +130,101 @@ public synchronized void forceLogin(Player player) { * @param player the player to log in * @param quiet if true no messages will be sent */ - public void forceLogin(Player player, boolean quiet) { - PlayerAuth auth = getPlayerAuth(player, quiet); - if (auth != null) { - performLogin(player, auth); + public synchronized void forceLogin(Player player, boolean quiet) { + forceLoginInternal(player, quiet); + } + + private void forceLoginInternal(Player player, boolean quiet) { + if (player == null) { + return; + } + + if (playerCache.isAuthenticated(player)) { + forceLoginRequestService.clear(player); + return; + } + + forceLoginRequestService.markPending(player); + if (!forceLoginRequestService.beginAttempt(player)) { + return; + } + + boolean keepPendingForRetry = false; + try { + forceLoginRequestService.incrementAttempt(player); + ForceLoginAttemptResult result = performForceLoginAttempt(player, quiet); + if (result == ForceLoginAttemptResult.SUCCESS + || result == ForceLoginAttemptResult.ALREADY_AUTHENTICATED) { + return; + } + if (result == ForceLoginAttemptResult.RETRYABLE_NOT_READY + && forceLoginRequestService.canRetry(player)) { + keepPendingForRetry = true; + scheduleForceLoginRetry(player, quiet); + return; + } + } finally { + forceLoginRequestService.finishAttempt(player); + if (!keepPendingForRetry || playerCache.isAuthenticated(player)) { + forceLoginRequestService.clear(player); + } + } + } + + private ForceLoginAttemptResult performForceLoginAttempt(Player player, boolean quiet) { + if (!player.isOnline()) { + return ForceLoginAttemptResult.RETRYABLE_NOT_READY; + } + + String name = player.getName().toLowerCase(Locale.ROOT); + if (playerCache.isAuthenticated(player)) { + return ForceLoginAttemptResult.ALREADY_AUTHENTICATED; + } + + PlayerAuth auth = dataSource.getAuth(name); + if (auth == null) { + return ForceLoginAttemptResult.RETRYABLE_NOT_READY; + } + + if (!service.getProperty(DatabaseSettings.MYSQL_COL_GROUP).isEmpty() + && auth.getGroupId() == service.getProperty(HooksSettings.NON_ACTIVATED_USERS_GROUP)) { + if (!quiet) { + service.send(player, MessageKey.ACCOUNT_NOT_ACTIVATED); + } + return ForceLoginAttemptResult.FAILED; + } + + String ip = PlayerUtils.getPlayerIp(player); + if (hasReachedMaxLoggedInPlayersForIp(player, ip)) { + if (!quiet) { + service.send(player, MessageKey.ALREADY_LOGGED_IN_ERROR); + } + return ForceLoginAttemptResult.FAILED; + } + + boolean isAsync = service.getProperty(PluginSettings.USE_ASYNC_TASKS); + AuthMeAsyncPreLoginEvent event = new AuthMeAsyncPreLoginEvent(player, isAsync); + bukkitService.callEvent(event); + if (!event.canLogin()) { + return ForceLoginAttemptResult.FAILED; } + + return performLogin(player, auth) + ? ForceLoginAttemptResult.SUCCESS + : ForceLoginAttemptResult.FAILED; + } + + private void scheduleForceLoginRetry(Player player, boolean quiet) { + bukkitService.runTaskLater(player, () -> + bukkitService.runTaskOptionallyAsync(() -> forceLogin(player, quiet)), + forceLoginRequestService.retryDelayTicks()); + } + + private enum ForceLoginAttemptResult { + SUCCESS, + ALREADY_AUTHENTICATED, + RETRYABLE_NOT_READY, + FAILED } /** @@ -160,7 +250,7 @@ private PlayerAuth getPlayerAuth(Player player) { */ private PlayerAuth getPlayerAuth(Player player, boolean quiet) { String name = player.getName().toLowerCase(Locale.ROOT); - if (playerCache.isAuthenticated(name)) { + if (playerCache.isAuthenticated(player)) { if (!quiet) { service.send(player, MessageKey.ALREADY_LOGGED_IN_ERROR); } @@ -269,59 +359,69 @@ private void handleWrongPassword(Player player, PlayerAuth auth, String ip) { * * @param player the player to log in * @param auth the associated PlayerAuth object + * @return true if authentication succeeded, false if stale connection rejected the login */ - public void performLogin(Player player, PlayerAuth auth) { - if (player.isOnline()) { - boolean isFirstLogin = (auth.getLastLogin() == null); - - // Update auth to reflect this new login - String ip = PlayerUtils.getPlayerIp(player); - auth.setRealName(player.getName()); - auth.setLastLogin(System.currentTimeMillis()); - auth.setLastIp(ip); - dataSource.updateSession(auth); - - // TODO: send an update when a messaging service will be implemented (SESSION) - - // Successful login, so reset the captcha & temp ban count - String name = player.getName(); - loginCaptchaManager.resetLoginFailureCount(name); - tempbanManager.resetCount(ip, name); - player.setNoDamageTicks(0); - - service.send(player, MessageKey.LOGIN_SUCCESS); - - // Other auths - List auths = dataSource.getAllAuthsByIp(auth.getLastIp()); - displayOtherAccounts(auths, player); - - String email = auth.getEmail(); - if (service.getProperty(EmailSettings.RECALL_PLAYERS) && Utils.isEmailEmpty(email)) { - service.send(player, MessageKey.ADD_EMAIL_MESSAGE); - } + public boolean performLogin(Player player, PlayerAuth auth) { + if (!player.isOnline()) { + logger.warning("Player '" + player.getName() + "' wasn't online during login process, aborted..."); + return false; + } - logger.fine(player.getName() + " logged in " + ip); + boolean isFirstLogin = (auth.getLastLogin() == null); - // makes player loggedin - playerCache.updatePlayer(auth); - dataSource.setLogged(name); - sessionService.grantSession(name); + // Update auth to reflect this new login + String ip = PlayerUtils.getPlayerIp(player); + auth.setRealName(player.getName()); + auth.setLastLogin(System.currentTimeMillis()); + auth.setLastIp(ip); + dataSource.updateSession(auth); - if (bungeeSender.isEnabled()) { - // As described at https://www.spigotmc.org/wiki/bukkit-bungee-plugin-messaging-channel/ - // "Keep in mind that you can't send plugin messages directly after a player joins." - bukkitService.scheduleSyncDelayedTask(() -> - bungeeSender.sendAuthMeBungeecordMessage(player, MessageType.LOGIN), settings.getProperty(HooksSettings.PROXY_SEND_DELAY)); - } + // TODO: send an update when a messaging service will be implemented (SESSION) - // As the scheduling executes the Task most likely after the current - // task, we schedule it in the end - // so that we can be sure, and have not to care if it might be - // processed in other order. - syncProcessManager.processSyncPlayerLogin(player, isFirstLogin, auths); - } else { - logger.warning("Player '" + player.getName() + "' wasn't online during login process, aborted..."); + // Successful login, so reset the captcha & temp ban count + String name = player.getName(); + loginCaptchaManager.resetLoginFailureCount(name); + tempbanManager.resetCount(ip, name); + player.setNoDamageTicks(0); + + // Authenticate the exact current connection before sending success + if (!playerCache.authenticate(player, auth)) { + logger.warning("AUTH_STALE_LOGIN_REJECTED player='" + player.getName() + + "' uuid=" + player.getUniqueId() + + " state=" + playerCache.getAuthenticationState(player)); + forceLoginRequestService.clear(player); + return false; } + + service.send(player, MessageKey.LOGIN_SUCCESS); + + // Other auths + List auths = dataSource.getAllAuthsByIp(auth.getLastIp()); + displayOtherAccounts(auths, player); + + String email = auth.getEmail(); + if (service.getProperty(EmailSettings.RECALL_PLAYERS) && Utils.isEmailEmpty(email)) { + service.send(player, MessageKey.ADD_EMAIL_MESSAGE); + } + + logger.fine(player.getName() + " logged in " + ip); + + dataSource.setLogged(name); + sessionService.grantSession(name); + + if (bungeeSender.isEnabled()) { + // As described at https://www.spigotmc.org/wiki/bukkit-bungee-plugin-messaging-channel/ + // "Keep in mind that you can't send plugin messages directly after a player joins." + bukkitService.scheduleSyncDelayedTask(() -> + bungeeSender.sendAuthMeBungeecordMessage(player, MessageType.LOGIN), settings.getProperty(HooksSettings.PROXY_SEND_DELAY)); + } + + // As the scheduling executes the Task most likely after the current + // task, we schedule it in the end + // so that we can be sure, and have not to care if it might be + // processed in other order. + syncProcessManager.processSyncPlayerLogin(player, isFirstLogin, auths); + return true; } /** diff --git a/src/main/java/fr/xephi/authme/process/login/ForceLoginRequestService.java b/src/main/java/fr/xephi/authme/process/login/ForceLoginRequestService.java new file mode 100644 index 0000000000..16fe9a3fef --- /dev/null +++ b/src/main/java/fr/xephi/authme/process/login/ForceLoginRequestService.java @@ -0,0 +1,108 @@ +package fr.xephi.authme.process.login; + +import org.bukkit.entity.Player; + +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * Tracks trusted force-login requests before the async login process updates PlayerCache. + */ +public class ForceLoginRequestService { + + private static final int MAX_ATTEMPTS = 20; + private static final long RETRY_DELAY_TICKS = 5L; + + private final Set pendingNames = ConcurrentHashMap.newKeySet(); + private final Set runningNames = ConcurrentHashMap.newKeySet(); + private final ConcurrentMap attemptCounts = new ConcurrentHashMap<>(); + + ForceLoginRequestService() { + } + + public void markPending(Player player) { + if (player == null) { + return; + } + markPending(player.getName()); + } + + public void markPending(String name) { + normalize(name).ifPresent(normalizedName -> { + pendingNames.add(normalizedName); + attemptCounts.putIfAbsent(normalizedName, 0); + }); + } + + public boolean beginAttempt(Player player) { + if (player == null) { + return false; + } + return normalize(player.getName()) + .filter(pendingNames::contains) + .filter(runningNames::add) + .isPresent(); + } + + public int incrementAttempt(Player player) { + if (player == null) { + return 0; + } + return normalize(player.getName()) + .map(name -> attemptCounts.merge(name, 1, Integer::sum)) + .orElse(0); + } + + public boolean canRetry(Player player) { + if (player == null) { + return false; + } + return normalize(player.getName()) + .map(name -> attemptCounts.getOrDefault(name, 0) < MAX_ATTEMPTS) + .orElse(false); + } + + public void finishAttempt(Player player) { + if (player == null) { + return; + } + normalize(player.getName()).ifPresent(runningNames::remove); + } + + public void clear(Player player) { + if (player == null) { + return; + } + clear(player.getName()); + } + + public void clear(String name) { + normalize(name).ifPresent(normalizedName -> { + pendingNames.remove(normalizedName); + runningNames.remove(normalizedName); + attemptCounts.remove(normalizedName); + }); + } + + public boolean isPending(String name) { + return normalize(name).filter(pendingNames::contains).isPresent(); + } + + public long retryDelayTicks() { + return RETRY_DELAY_TICKS; + } + + private Optional normalize(String name) { + if (name == null) { + return Optional.empty(); + } + String normalized = name.trim().toLowerCase(Locale.ROOT); + if (normalized.isEmpty()) { + return Optional.empty(); + } + return Optional.of(normalized); + } +} diff --git a/src/main/java/fr/xephi/authme/process/login/ProcessSyncPlayerLogin.java b/src/main/java/fr/xephi/authme/process/login/ProcessSyncPlayerLogin.java index 30d6861995..7506e067a9 100644 --- a/src/main/java/fr/xephi/authme/process/login/ProcessSyncPlayerLogin.java +++ b/src/main/java/fr/xephi/authme/process/login/ProcessSyncPlayerLogin.java @@ -67,10 +67,15 @@ private void restoreInventory(Player player) { RestoreInventoryEvent event = new RestoreInventoryEvent(player); bukkitService.callEvent(event); if (!event.isCancelled()) { - player.updateInventory(); + resyncInventoryAfterLogin(player); } } + private void resyncInventoryAfterLogin(Player player) { + player.updateInventory(); + bukkitService.runTaskLater(player, player::updateInventory, 1L); + } + /** * Performs operations in sync mode for a player that has just logged in. * @@ -80,15 +85,20 @@ private void restoreInventory(Player player) { */ public void processPlayerLogin(Player player, boolean isFirstLogin, List authsWithSameIp) { final String name = player.getName().toLowerCase(Locale.ROOT); - final LimboPlayer limbo = limboService.getLimboPlayer(name); - // Limbo contains the State of the Player before /login - if (limbo != null) { - limboService.restoreData(player); + if (playerCache.getAuth(player).isEmpty()) { + return; } + final LimboPlayer limbo = limboService.getLimboPlayer(name); + + // Limbo contains the State of the Player before /login. Successful login is also the cleanup boundary for stale persisted limbo. + limboService.restoreData(player); + if (commonService.getProperty(PROTECT_INVENTORY_BEFORE_LOGIN)) { restoreInventory(player); + } else { + resyncInventoryAfterLogin(player); } final PlayerAuth auth = playerCache.getAuth(name); diff --git a/src/main/java/fr/xephi/authme/process/logout/AsynchronousLogout.java b/src/main/java/fr/xephi/authme/process/logout/AsynchronousLogout.java index 4b6af1e5a8..887c396497 100644 --- a/src/main/java/fr/xephi/authme/process/logout/AsynchronousLogout.java +++ b/src/main/java/fr/xephi/authme/process/logout/AsynchronousLogout.java @@ -55,13 +55,13 @@ public class AsynchronousLogout implements AsynchronousProcess { * @param player the player wanting to log out */ public void logout(Player player) { - String name = player.getName().toLowerCase(Locale.ROOT); - if (!playerCache.isAuthenticated(name)) { + var removedAuth = playerCache.deauthenticate(player); + if (removedAuth.isEmpty()) { service.send(player, MessageKey.NOT_LOGGED_IN); return; } - PlayerAuth auth = playerCache.getAuth(name); + PlayerAuth auth = removedAuth.get(); database.updateSession(auth); // TODO: send an update when a messaging service will be implemented (SESSION) //if (service.getProperty(RestrictionSettings.SAVE_QUIT_LOCATION)) { @@ -70,7 +70,7 @@ public void logout(Player player) { // TODO: send an update when a messaging service will be implemented (QUITLOC) //} AuthMeReReloaded - Always save quit location - playerCache.removePlayer(name); + String name = player.getName().toLowerCase(Locale.ROOT); codeManager.unverify(name); database.setUnlogged(name); sessionService.revokeSession(name); diff --git a/src/main/java/fr/xephi/authme/process/quit/AsynchronousQuit.java b/src/main/java/fr/xephi/authme/process/quit/AsynchronousQuit.java index 31bb707877..388736e4ef 100644 --- a/src/main/java/fr/xephi/authme/process/quit/AsynchronousQuit.java +++ b/src/main/java/fr/xephi/authme/process/quit/AsynchronousQuit.java @@ -7,6 +7,7 @@ import fr.xephi.authme.datasource.DataSource; import fr.xephi.authme.process.AsynchronousProcess; import fr.xephi.authme.process.SyncProcessManager; +import fr.xephi.authme.process.login.ForceLoginRequestService; import fr.xephi.authme.service.CommonService; import fr.xephi.authme.service.SessionService; import fr.xephi.authme.service.ValidationService; @@ -34,7 +35,7 @@ public class AsynchronousQuit implements AsynchronousProcess { private CommonService service; @Inject - private PlayerCache playerCache; + private ForceLoginRequestService forceLoginRequestService; @Inject private SyncProcessManager syncProcessManager; @@ -58,13 +59,18 @@ public class AsynchronousQuit implements AsynchronousProcess { * Processes that the given player has quit the server. * * @param player the player who left + * @param connection the connection snapshot taken at disconnect */ - public void processQuit(Player player) { - if (player == null || validationService.isUnrestricted(player.getName())) { + public void processQuit(Player player, PlayerCache.ConnectionSnapshot connection) { + if (player == null) { return; } - String name = player.getName().toLowerCase(Locale.ROOT); - boolean wasLoggedIn = playerCache.isAuthenticated(name); + forceLoginRequestService.clear(player); + if (validationService.isUnrestricted(player.getName())) { + return; + } + String name = connection.getNormalizedName(); + boolean wasLoggedIn = connection.wasAuthenticated(); if (wasLoggedIn) { //if (service.getProperty(RestrictionSettings.SAVE_QUIT_LOCATION)) { @@ -90,8 +96,6 @@ public void processQuit(Player player) { // TODO: send an update when a messaging service will be implemented (QUITLOC) } - //always unauthenticate the player - use session only for auto logins on the same ip - playerCache.removePlayer(name); codeManager.unverify(name); //always update the database when the player quit the game (if sessions are disabled) diff --git a/src/main/java/fr/xephi/authme/process/unregister/AsynchronousUnregister.java b/src/main/java/fr/xephi/authme/process/unregister/AsynchronousUnregister.java index caecd295e3..74532ebe13 100644 --- a/src/main/java/fr/xephi/authme/process/unregister/AsynchronousUnregister.java +++ b/src/main/java/fr/xephi/authme/process/unregister/AsynchronousUnregister.java @@ -121,10 +121,14 @@ public void adminUnregister(CommandSender initiator, String name, Player player) * @param player the according Player object (nullable) */ private void performPostUnregisterActions(String name, Player player) { - if (player != null && playerCache.isAuthenticated(name)) { - bungeeSender.sendAuthMeBungeecordMessage(player, MessageType.LOGOUT); + if (player != null) { + if (playerCache.isAuthenticated(player)) { + bungeeSender.sendAuthMeBungeecordMessage(player, MessageType.LOGOUT); + } + playerCache.deauthenticate(player); + } else { + playerCache.removePlayer(name); } - playerCache.removePlayer(name); // TODO: send an update when a messaging service will be implemented (UNREGISTER) diff --git a/src/main/java/fr/xephi/authme/task/MessageTask.java b/src/main/java/fr/xephi/authme/task/MessageTask.java index dd11e8efeb..1fdf90f8a7 100644 --- a/src/main/java/fr/xephi/authme/task/MessageTask.java +++ b/src/main/java/fr/xephi/authme/task/MessageTask.java @@ -1,6 +1,8 @@ package fr.xephi.authme.task; import com.github.Anon8281.universalScheduler.UniversalRunnable; +import fr.xephi.authme.data.auth.PlayerCache; +import fr.xephi.authme.process.login.ForceLoginRequestService; import org.bukkit.entity.Player; /** @@ -10,14 +12,19 @@ public class MessageTask extends UniversalRunnable { private final Player player; private final String[] message; + private final PlayerCache playerCache; + private final ForceLoginRequestService forceLoginRequestService; private boolean isMuted; /* * Constructor. */ - public MessageTask(Player player, String[] lines) { + public MessageTask(Player player, String[] lines, PlayerCache playerCache, + ForceLoginRequestService forceLoginRequestService) { this.player = player; this.message = lines; + this.playerCache = playerCache; + this.forceLoginRequestService = forceLoginRequestService; isMuted = false; } @@ -27,8 +34,12 @@ public void setMuted(boolean isMuted) { @Override public void run() { - if (!isMuted) { - player.sendMessage(message); + if (!player.isOnline() + || isMuted + || playerCache.isAuthenticated(player.getName()) + || forceLoginRequestService.isPending(player.getName())) { + return; } + player.sendMessage(message); } } diff --git a/src/main/resources/messages/messages_en.yml b/src/main/resources/messages/messages_en.yml index 40ed034b44..c18b8f5c98 100644 --- a/src/main/resources/messages/messages_en.yml +++ b/src/main/resources/messages/messages_en.yml @@ -43,6 +43,7 @@ error: kick_for_vip: '&3A VIP player has joined the server when it was full!' kick_unresolved_hostname: '&cAn error occurred: unresolved player hostname!' tempban_max_logins: '&cYou have been temporarily banned for failing to log in too many times.' + denied_inventory: '&cAuthMe blocked this inventory action because this Minecraft connection is not authenticated. Use /login or /register first.' # AntiBot antibot: diff --git a/src/test/java/fr/xephi/authme/data/auth/PlayerCacheTest.java b/src/test/java/fr/xephi/authme/data/auth/PlayerCacheTest.java new file mode 100644 index 0000000000..6a7d520bbf --- /dev/null +++ b/src/test/java/fr/xephi/authme/data/auth/PlayerCacheTest.java @@ -0,0 +1,185 @@ +package fr.xephi.authme.data.auth; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class PlayerCacheTest { + + @Test + void authenticatesOnlyRegisteredCurrentConnection() { + PlayerCache cache = new PlayerCache(); + Player player = createPlayerProxy("TestPlayer", UUID.randomUUID()); + cache.registerConnection(player); + + PlayerAuth auth = PlayerAuth.builder() + .name("TestPlayer") + .realName("TestPlayer") + .uuid(UUID.randomUUID()) + .build(); + + assertTrue(cache.authenticate(player, auth)); + assertTrue(cache.isAuthenticated(player)); + } + + @Test + void rejectsAuthenticationFromReplacedConnection() { + PlayerCache cache = new PlayerCache(); + UUID uuid = UUID.randomUUID(); + Player oldPlayer = createPlayerProxy("TestPlayer", uuid); + Player newPlayer = createPlayerProxy("TestPlayer", uuid); + + cache.registerConnection(oldPlayer); + + PlayerAuth auth = PlayerAuth.builder() + .name("TestPlayer") + .realName("TestPlayer") + .uuid(uuid) + .build(); + + assertFalse(cache.authenticate(newPlayer, auth)); + } + + @Test + void staleDisconnectCannotRemoveCurrentAuthenticatedConnection() { + PlayerCache cache = new PlayerCache(); + UUID uuid = UUID.randomUUID(); + Player oldPlayer = createPlayerProxy("TestPlayer", uuid); + Player newPlayer = createPlayerProxy("TestPlayer", uuid); + + cache.registerConnection(oldPlayer); + cache.disconnect(oldPlayer); + + cache.registerConnection(newPlayer); + + PlayerAuth auth = PlayerAuth.builder() + .name("TestPlayer") + .realName("TestPlayer") + .uuid(uuid) + .build(); + + assertTrue(cache.authenticate(newPlayer, auth)); + } + + @Test + void duplicateDisconnectIsIdempotent() { + PlayerCache cache = new PlayerCache(); + Player player = createPlayerProxy("TestPlayer", UUID.randomUUID()); + + cache.registerConnection(player); + + var disconnect1 = cache.disconnect(player); + var disconnect2 = cache.disconnect(player); + + assertTrue(disconnect1.isPresent()); + assertFalse(disconnect2.isPresent()); + } + + @Test + void deauthenticateKeepsConnectionButRemovesAuthentication() { + PlayerCache cache = new PlayerCache(); + Player player = createPlayerProxy("TestPlayer", UUID.randomUUID()); + + cache.registerConnection(player); + + PlayerAuth auth = PlayerAuth.builder() + .name("TestPlayer") + .realName("TestPlayer") + .uuid(UUID.randomUUID()) + .build(); + + cache.authenticate(player, auth); + + var removed = cache.deauthenticate(player); + + assertTrue(removed.isPresent()); + assertEquals( + PlayerCache.AuthenticationState.CONNECTED_UNAUTHENTICATED, + cache.getAuthenticationState(player)); + } + + @Test + void updatePlayerCannotCreateAuthentication() { + PlayerCache cache = new PlayerCache(); + Player player = createPlayerProxy("TestPlayer", UUID.randomUUID()); + + cache.registerConnection(player); + + PlayerAuth auth = PlayerAuth.builder() + .name("TestPlayer") + .realName("TestPlayer") + .uuid(UUID.randomUUID()) + .build(); + + cache.updatePlayer(auth); + + assertEquals( + PlayerCache.AuthenticationState.CONNECTED_UNAUTHENTICATED, + cache.getAuthenticationState(player)); + } + + @Test + void cacheExposureIsImmutableSnapshot() { + PlayerCache cache = new PlayerCache(); + Player player = createPlayerProxy("TestPlayer", UUID.randomUUID()); + + cache.registerConnection(player); + + PlayerAuth auth = PlayerAuth.builder() + .name("TestPlayer") + .realName("TestPlayer") + .uuid(UUID.randomUUID()) + .build(); + + cache.authenticate(player, auth); + + var map = cache.getCache(); + assertThrows(UnsupportedOperationException.class, () -> map.put("key", auth)); + } + + private static Player createPlayerProxy(String name, UUID uuid) { + return (Player) Proxy.newProxyInstance( + Player.class.getClassLoader(), + new Class[]{Player.class}, + new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + switch (method.getName()) { + case "getName": + return name; + case "getUniqueId": + return uuid; + case "equals": + return proxy == args[0]; + case "hashCode": + return System.identityHashCode(proxy); + case "toString": + return "PlayerProxy{" + name + "}"; + default: + if (method.getReturnType() == boolean.class) { + return false; + } + if (method.getReturnType() == int.class) { + return 0; + } + if (method.getReturnType() == long.class) { + return 0L; + } + if (method.getReturnType() == float.class) { + return 0f; + } + if (method.getReturnType() == double.class) { + return 0d; + } + return null; + } + } + }); + } +}