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;
+ }
+ }
+ });
+ }
+}