added LoginLog appliance recording login attempts with IP
This commit is contained in:
1 parent
e3de258124
commit
6bfb14db2b
5 files changed
+237
No files matched your search
+31
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+147
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -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;
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user