added LoginLog appliance recording login attempts with IP

This commit is contained in:
MineTec committed 2026-10-14 14:38:54 +02:00
1 parent e3de258124
commit 6bfb14db2b
5 files changed
+237

No files matched your search

@@ -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);
}
}
@@ -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<UUID, LoginAttempt> pendingAttempts = new ConcurrentHashMap<>();
private final List<BukkitTask> 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<Listener> listeners() {
return List.of(new LoginLogListener());
}
}
@@ -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<LoginLog> {
@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()
);
}
}
@@ -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;
+10
View File
@@ -7,6 +7,16 @@ api:
secret: secret secret: secret
baseurl: https://mhsl.eu/craftattack/api/ baseurl: https://mhsl.eu/craftattack/api/
database:
host:
port: 3306
name: craftattack
user:
password:
loginLog:
retentionMonths: 16
worldMuseum: worldMuseum:
uuid: uuid:
connect-server-name: worldmuseum connect-server-name: worldmuseum