From 6bfb14db2b3b593e7cca9c0f8f8f57981e06469d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 14 Oct 2026 14:38:54 +0200 Subject: [PATCH] added LoginLog appliance recording login attempts with IP --- .../tooling/loginLog/LoginAttempt.java | 31 ++++ .../appliances/tooling/loginLog/LoginLog.java | 147 ++++++++++++++++++ .../tooling/loginLog/LoginLogListener.java | 34 ++++ .../loginLog/001_create_login_attempts.sql | 15 ++ core/src/main/resources/config.yml | 10 ++ 5 files changed, 237 insertions(+) create mode 100644 common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginAttempt.java create mode 100644 common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLog.java create mode 100644 common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLogListener.java create mode 100644 common/src/main/resources/migrations/loginLog/001_create_login_attempts.sql diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginAttempt.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginAttempt.java new file mode 100644 index 0000000..65ac0f1 --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginAttempt.java @@ -0,0 +1,31 @@ +package eu.mhsl.craftattack.spawn.common.appliances.tooling.loginLog; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.net.InetAddress; +import java.time.Instant; +import java.util.UUID; + +record LoginAttempt( + @NotNull Instant at, + @NotNull UUID uuid, + @NotNull String name, + @NotNull String ip, + boolean bedrock, + @NotNull String stage, + @NotNull String result, + @Nullable String reason +) { + static final String STAGE_PRE_LOGIN = "PRE_LOGIN"; + static final String STAGE_LOGIN = "LOGIN"; + static final String RESULT_ABORTED = "ABORTED"; + + static String ipOf(@Nullable InetAddress address) { + return address == null ? "unknown" : address.getHostAddress(); + } + + LoginAttempt withOutcome(@NotNull String stage, @NotNull String result, @Nullable String reason) { + return new LoginAttempt(this.at, this.uuid, this.name, this.ip, this.bedrock, stage, result, reason); + } +} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLog.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLog.java new file mode 100644 index 0000000..ed39f5f --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLog.java @@ -0,0 +1,147 @@ +package eu.mhsl.craftattack.spawn.common.appliances.tooling.loginLog; + +import eu.mhsl.craftattack.spawn.core.Main; +import eu.mhsl.craftattack.spawn.core.appliance.Appliance; +import eu.mhsl.craftattack.spawn.core.database.Database; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Bukkit; +import org.bukkit.event.Listener; +import org.bukkit.scheduler.BukkitTask; +import org.geysermc.floodgate.api.FloodgateApi; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.net.InetAddress; +import java.sql.PreparedStatement; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Records every login attempt (allowed or not) with its IP address in the table login_attempts + * (schema: resources/migrations/loginLog). Basis for detecting ban evasion. + * Never blocks or delays a login: writes run on the database thread, failures are only logged. + */ +public class LoginLog extends Appliance { + private static final int MAX_REASON_LENGTH = 255; + private static final Duration PENDING_TIMEOUT = Duration.ofSeconds(60); + private static final long TICKS_PER_MINUTE = 20L * 60; + + private final Map pendingAttempts = new ConcurrentHashMap<>(); + private final List tasks = new ArrayList<>(); + private Database database; + private int retentionMonths; + + public LoginLog() { + super("loginLog"); + } + + @Override + public void onEnable() { + this.database = Main.instance().getDatabase(); + if(!this.database.isAvailable()) { + Main.logger().warning("LoginLog: no database configured, login attempts are not recorded"); + return; + } + this.retentionMonths = this.localConfig().getInt("retentionMonths", 16); + + this.tasks.add(Bukkit.getScheduler().runTaskTimerAsynchronously( + Main.instance(), this::flushStaleAttempts, TICKS_PER_MINUTE, TICKS_PER_MINUTE + )); + this.tasks.add(Bukkit.getScheduler().runTaskTimerAsynchronously( + Main.instance(), this::deleteExpired, TICKS_PER_MINUTE, TICKS_PER_MINUTE * 60 * 24 + )); + } + + @Override + public void onDisable() { + this.tasks.forEach(BukkitTask::cancel); + } + + void onPreLogin(UUID uuid, String name, @Nullable InetAddress address, String result, @Nullable Component kickMessage) { + if(!this.database.isAvailable()) return; + LoginAttempt attempt = new LoginAttempt( + Instant.now(), uuid, name, LoginAttempt.ipOf(address), this.isBedrock(uuid), + LoginAttempt.STAGE_PRE_LOGIN, result, plainText(kickMessage) + ); + + // allowed attempts are written after the login event, so they carry the final result + if(kickMessage == null) this.pendingAttempts.put(uuid, attempt); + else this.write(attempt); + } + + void onLogin(UUID uuid, String name, @Nullable InetAddress address, String result, @Nullable Component kickMessage) { + if(!this.database.isAvailable()) return; + LoginAttempt pending = this.pendingAttempts.remove(uuid); + LoginAttempt attempt = pending != null + ? pending + : new LoginAttempt(Instant.now(), uuid, name, LoginAttempt.ipOf(address), this.isBedrock(uuid), "", "", null); + + this.write(attempt.withOutcome(LoginAttempt.STAGE_LOGIN, result, plainText(kickMessage))); + } + + private void flushStaleAttempts() { + Instant threshold = Instant.now().minus(PENDING_TIMEOUT); + this.pendingAttempts.values().removeIf(attempt -> { + if(attempt.at().isAfter(threshold)) return false; + this.write(attempt.withOutcome(LoginAttempt.STAGE_PRE_LOGIN, LoginAttempt.RESULT_ABORTED, null)); + return true; + }); + } + + private void write(LoginAttempt attempt) { + String sql = "INSERT INTO login_attempts (at, uuid, name, ip, bedrock, stage, result, reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; + this.database.execute("recording login attempt of " + attempt.name(), connection -> { + try(PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setObject(1, LocalDateTime.ofInstant(attempt.at(), ZoneOffset.UTC)); + statement.setString(2, attempt.uuid().toString()); + statement.setString(3, attempt.name()); + statement.setString(4, attempt.ip()); + statement.setBoolean(5, attempt.bedrock()); + statement.setString(6, attempt.stage()); + statement.setString(7, attempt.result()); + statement.setString(8, attempt.reason()); + statement.executeUpdate(); + } + }); + } + + private void deleteExpired() { + String sql = "DELETE FROM login_attempts WHERE at < UTC_TIMESTAMP() - INTERVAL ? MONTH"; + this.database.execute("deleting expired login attempts", connection -> { + try(PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setInt(1, this.retentionMonths); + int deleted = statement.executeUpdate(); + if(deleted > 0) Main.logger().info(String.format("LoginLog: deleted %d expired login attempts", deleted)); + } + }); + } + + private boolean isBedrock(UUID uuid) { + try { + return FloodgateApi.getInstance().isFloodgatePlayer(uuid); + } catch(Throwable e) { + return false; + } + } + + private static @Nullable String plainText(@Nullable Component component) { + if(component == null) return null; + String text = PlainTextComponentSerializer.plainText().serialize(component) + .strip() + .replaceAll("\\s*\\n\\s*", " | "); + return text.length() > MAX_REASON_LENGTH ? text.substring(0, MAX_REASON_LENGTH) : text; + } + + @Override + protected @NotNull List listeners() { + return List.of(new LoginLogListener()); + } +} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLogListener.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLogListener.java new file mode 100644 index 0000000..bf13108 --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLogListener.java @@ -0,0 +1,34 @@ +package eu.mhsl.craftattack.spawn.common.appliances.tooling.loginLog; + +import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.player.AsyncPlayerPreLoginEvent; +import org.bukkit.event.player.PlayerLoginEvent; + +/** + * Listens on MONITOR priority, so the final result of all other checks (whitelist, bans, maintenance, ...) is recorded. + */ +class LoginLogListener extends ApplianceListener { + @EventHandler(priority = EventPriority.MONITOR) + public void onPreLogin(AsyncPlayerPreLoginEvent event) { + this.getAppliance().onPreLogin( + event.getUniqueId(), + event.getName(), + event.getAddress(), + event.getLoginResult().name(), + event.getLoginResult() == AsyncPlayerPreLoginEvent.Result.ALLOWED ? null : event.kickMessage() + ); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onLogin(PlayerLoginEvent event) { + this.getAppliance().onLogin( + event.getPlayer().getUniqueId(), + event.getPlayer().getName(), + event.getAddress(), + event.getResult().name(), + event.getResult() == PlayerLoginEvent.Result.ALLOWED ? null : event.kickMessage() + ); + } +} diff --git a/common/src/main/resources/migrations/loginLog/001_create_login_attempts.sql b/common/src/main/resources/migrations/loginLog/001_create_login_attempts.sql new file mode 100644 index 0000000..9440545 --- /dev/null +++ b/common/src/main/resources/migrations/loginLog/001_create_login_attempts.sql @@ -0,0 +1,15 @@ +-- Every login attempt (allowed or not) with the player's IP, basis for detecting ban evasion. +CREATE TABLE login_attempts ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + at DATETIME(3) NOT NULL COMMENT 'UTC', + uuid CHAR(36) NOT NULL, + name VARCHAR(64) NOT NULL, + ip VARCHAR(45) NOT NULL, + bedrock BOOLEAN NOT NULL, + stage VARCHAR(16) NOT NULL COMMENT 'PRE_LOGIN or LOGIN', + result VARCHAR(32) NOT NULL COMMENT 'Bukkit login result, or ABORTED', + reason VARCHAR(255) NULL COMMENT 'kick message as plain text', + KEY idx_at (at), + KEY idx_uuid (uuid), + KEY idx_ip (ip) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index f254c42..59ca5b2 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -7,6 +7,16 @@ api: secret: secret baseurl: https://mhsl.eu/craftattack/api/ +database: + host: + port: 3306 + name: craftattack + user: + password: + +loginLog: + retentionMonths: 16 + worldMuseum: uuid: connect-server-name: worldmuseum