From 86ae261c60dd6e3568de7afbb5f3f161bec54a80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 14 Oct 2026 17:00:11 +0200 Subject: [PATCH] added connection key and recorded event to LoginLog --- .../appliances/tooling/loginLog/LoginLog.java | 27 +++++-- .../migrations/loginLog/002_add_network.sql | 6 ++ .../core/event/LoginAttemptRecordedEvent.java | 49 +++++++++++++ .../spawn/core/util/network/Networks.java | 72 +++++++++++++++++++ 4 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 common/src/main/resources/migrations/loginLog/002_add_network.sql create mode 100644 core/src/main/java/eu/mhsl/craftattack/spawn/core/event/LoginAttemptRecordedEvent.java create mode 100644 core/src/main/java/eu/mhsl/craftattack/spawn/core/util/network/Networks.java 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 index 0535378..e1f05b6 100644 --- 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 @@ -5,6 +5,8 @@ import eu.mhsl.craftattack.spawn.core.Main; import eu.mhsl.craftattack.spawn.core.api.server.HttpServer; import eu.mhsl.craftattack.spawn.core.appliance.Appliance; import eu.mhsl.craftattack.spawn.core.database.Database; +import eu.mhsl.craftattack.spawn.core.event.LoginAttemptRecordedEvent; +import eu.mhsl.craftattack.spawn.core.util.network.Networks; import eu.mhsl.craftattack.spawn.core.util.server.Floodgate; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; @@ -16,6 +18,8 @@ import org.jetbrains.annotations.Nullable; import java.net.InetAddress; import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; import java.time.Duration; import java.time.Instant; import java.time.LocalDateTime; @@ -30,6 +34,7 @@ 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. + * After each insert a LoginAttemptRecordedEvent is fired on the database thread. * The website reports signups and logins of its users via POST /api/loginlog/website. * Never blocks or delays a login: writes run on the database thread, failures are only logged. */ @@ -101,19 +106,29 @@ public class LoginLog extends Appliance { } private void write(LoginAttempt attempt) { - String sql = "INSERT INTO login_attempts (at, uuid, name, ip, bedrock, stage, result, reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; + String network = Networks.networkOf(attempt.ip()); + String sql = "INSERT INTO login_attempts (at, uuid, name, ip, network, bedrock, stage, result, reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; this.database.execute("recording login attempt of " + attempt.name(), connection -> { - try(PreparedStatement statement = connection.prepareStatement(sql)) { + long id; + try(PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { 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.setString(5, network); + statement.setBoolean(6, attempt.bedrock()); + statement.setString(7, attempt.stage()); + statement.setString(8, attempt.result()); + statement.setString(9, attempt.reason()); statement.executeUpdate(); + try(ResultSet keys = statement.getGeneratedKeys()) { + if(!keys.next()) return; + id = keys.getLong(1); + } } + Bukkit.getPluginManager().callEvent(new LoginAttemptRecordedEvent(new LoginAttemptRecordedEvent.RecordedAttempt( + id, attempt.at(), attempt.uuid(), attempt.name(), attempt.ip(), network, attempt.stage(), attempt.result() + ))); }); } diff --git a/common/src/main/resources/migrations/loginLog/002_add_network.sql b/common/src/main/resources/migrations/loginLog/002_add_network.sql new file mode 100644 index 0000000..fa8c100 --- /dev/null +++ b/common/src/main/resources/migrations/loginLog/002_add_network.sql @@ -0,0 +1,6 @@ +-- Connection key per attempt: the IPv4 address or the IPv6 /64 prefix. Groups accounts by internet connection. +ALTER TABLE login_attempts + ADD COLUMN network VARCHAR(45) NULL COMMENT 'IPv4 address or IPv6 /64 prefix, NULL if unknown' AFTER ip, + ADD KEY idx_network_at (network, at); + +UPDATE login_attempts SET network = ip WHERE ip REGEXP '^[0-9]{1,3}([.][0-9]{1,3}){3}$'; diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/event/LoginAttemptRecordedEvent.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/event/LoginAttemptRecordedEvent.java new file mode 100644 index 0000000..3646f30 --- /dev/null +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/event/LoginAttemptRecordedEvent.java @@ -0,0 +1,49 @@ +package eu.mhsl.craftattack.spawn.core.event; + +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.time.Instant; +import java.util.UUID; + +/** + * Fired after a login attempt was stored in login_attempts. Called on the database thread: + * handlers must return quickly and must never wait for results of the shared Database. + */ +public class LoginAttemptRecordedEvent extends Event { + private static final HandlerList HANDLERS = new HandlerList(); + + @Override + public @NotNull HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } + + public record RecordedAttempt( + long id, + Instant at, + UUID uuid, + String name, + String ip, + @Nullable String network, + String stage, + String result + ) { + } + + private final RecordedAttempt attempt; + + public LoginAttemptRecordedEvent(RecordedAttempt attempt) { + super(true); + this.attempt = attempt; + } + + public RecordedAttempt getAttempt() { + return this.attempt; + } +} diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/network/Networks.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/network/Networks.java new file mode 100644 index 0000000..af47cb3 --- /dev/null +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/network/Networks.java @@ -0,0 +1,72 @@ +package eu.mhsl.craftattack.spawn.core.util.network; + +import org.jetbrains.annotations.Nullable; + +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.StringJoiner; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Groups IP addresses by internet connection: an IPv4 address stands for itself, + * an IPv6 address for its /64 prefix (one prefix per household line). + */ +public final class Networks { + private static final Pattern IPV4 = Pattern.compile("^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$"); + private static final Pattern IPV6 = Pattern.compile("^[0-9a-fA-F:.]*:[0-9a-fA-F:.]*$"); + private static final String IPV6_SUFFIX = "::/64"; + + private Networks() { + } + + /** + * @return the connection key ("93.204.10.20" or "2003:e1:1f00:ab00::/64"), or null if the text is no IP address + */ + public static @Nullable String networkOf(@Nullable String ip) { + if(ip == null) return null; + String text = ip.trim(); + + Matcher ipv4 = IPV4.matcher(text); + if(ipv4.matches()) return ipv4Of(ipv4); + if(!IPV6.matcher(text).matches()) return null; + + InetAddress address; + try { + // the text contains a colon, so it is parsed as IPv6 literal and never resolved via DNS + address = InetAddress.getByName(text); + } catch(UnknownHostException e) { + return null; + } + if(address instanceof Inet4Address) return address.getHostAddress(); + byte[] bytes = address.getAddress(); + return String.format("%x:%x:%x:%x%s", group(bytes, 0), group(bytes, 2), group(bytes, 4), group(bytes, 6), IPV6_SUFFIX); + } + + /** + * Shortened form for display: "93.204.x.x" or "2003:e1:x:x::/64". + */ + public static String mask(String network) { + if(network.endsWith(IPV6_SUFFIX)) { + String[] groups = network.substring(0, network.length() - IPV6_SUFFIX.length()).split(":"); + return groups[0] + ":" + groups[1] + ":x:x" + IPV6_SUFFIX; + } + String[] octets = network.split("\\."); + return octets.length == 4 ? octets[0] + "." + octets[1] + ".x.x" : network; + } + + private static @Nullable String ipv4Of(Matcher matcher) { + StringJoiner joiner = new StringJoiner("."); + for(int i = 1; i <= 4; i++) { + int octet = Integer.parseInt(matcher.group(i)); + if(octet > 255) return null; + joiner.add(Integer.toString(octet)); + } + return joiner.toString(); + } + + private static int group(byte[] bytes, int offset) { + return ((bytes[offset] & 0xff) << 8) | (bytes[offset + 1] & 0xff); + } +}