modified: consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentConfig.java

modified:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentPlugin.java
	modified:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/commands/ConsentCommand.java
	modified:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/database/ConsentDatabase.java
	modified:   consent-plugin/src/main/resources/config.yml
This commit is contained in:
simon
2026-04-20 11:50:36 +02:00
parent c2b645ed58
commit 408933106b
5 changed files with 203 additions and 12 deletions

View File

@@ -143,12 +143,29 @@ public class ConsentConfig {
public String getMsgStatusPending() { return cfg.getString("messages.status-pending", "<yellow>You have NOT yet accepted the Privacy Policy (v{version}).</yellow>"); } public String getMsgStatusPending() { return cfg.getString("messages.status-pending", "<yellow>You have NOT yet accepted the Privacy Policy (v{version}).</yellow>"); }
public String getMsgNoPermission() { return cfg.getString("messages.no-permission", "<red>You don't have permission to use this command.</red>"); } public String getMsgNoPermission() { return cfg.getString("messages.no-permission", "<red>You don't have permission to use this command.</red>"); }
public String getMsgAdminNotifyConsent() { return cfg.getString("messages.admin-notify-consent", "<dark_gray>[Consent] {player} accepted the Privacy Policy (v{version}).</dark_gray>"); } public String getMsgAdminNotifyConsent() { return cfg.getString("messages.admin-notify-consent", "<dark_gray>[Consent] {player} accepted the Privacy Policy (v{version}).</dark_gray>"); }
public String getMsgWithdrawn() { return cfg.getString("messages.withdrawn", "<yellow>Your consent has been withdrawn. You will be disconnected.</yellow>"); }
public String getMsgWithdrawKick() { return cfg.getString("messages.withdraw-kick-message", "Consent withdrawn.\nYou may reconnect at any time and accept the Privacy Policy.\nVisit: {policy_url}"); }
/** Whether a message should be sent to online admins when a player accepts. */ /** Whether a message should be sent to online admins when a player accepts. */
public boolean isNotifyAdminsOnConsent() { public boolean isNotifyAdminsOnConsent() {
return cfg.getBoolean("admin.notify-admins-on-consent", false); return cfg.getBoolean("admin.notify-admins-on-consent", false);
} }
/**
* How many days to keep accepted consent records (0 = keep forever).
* GDPR Art. 5(1)(e) storage limitation.
*/
public int getConsentRetentionDays() {
return cfg.getInt("consent.retention-days-consent", 1095); // 3 years default
}
/**
* How many days to keep decline records (0 = keep forever).
*/
public int getDeclineRetentionDays() {
return cfg.getInt("consent.retention-days-declines", 1095);
}
/** /**
* Returns the help text as a list of MiniMessage lines. * Returns the help text as a list of MiniMessage lines.
* Falls back to a built-in list if not configured. * Falls back to a built-in list if not configured.

View File

@@ -83,6 +83,18 @@ public class ConsentPlugin extends JavaPlugin {
getLogger().info("[MCConsent] Started. Mode=" + consentConfig.getEnforcementMode() getLogger().info("[MCConsent] Started. Mode=" + consentConfig.getEnforcementMode()
+ " PolicyVersion=" + consentConfig.getPolicyVersion()); + " PolicyVersion=" + consentConfig.getPolicyVersion());
// GDPR Art. 5(1)(e) automatic retention purge, runs once every 24 h
int consentDays = consentConfig.getConsentRetentionDays();
int declineDays = consentConfig.getDeclineRetentionDays();
if (consentDays > 0 || declineDays > 0) {
long dayTicks = 20L * 60 * 60 * 24;
getServer().getScheduler().runTaskTimerAsynchronously(this,
() -> consentDatabase.purgeOldConsents(consentDays, declineDays),
dayTicks, dayTicks);
getLogger().info("[MCConsent] GDPR retention purge scheduled (consent=" + consentDays
+ "d, declines=" + declineDays + "d).");
}
} }
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────

View File

@@ -18,6 +18,7 @@ import java.util.*;
* <ul> * <ul>
* <li>{@code /consent accept} record consent for the current policy version</li> * <li>{@code /consent accept} record consent for the current policy version</li>
* <li>{@code /consent decline} record decline and kick the player</li> * <li>{@code /consent decline} record decline and kick the player</li>
* <li>{@code /consent withdraw} withdraw previously given consent (Art. 7(3) GDPR)</li>
* <li>{@code /consent status} show current consent status</li> * <li>{@code /consent status} show current consent status</li>
* <li>{@code /consent help} show help text</li> * <li>{@code /consent help} show help text</li>
* </ul> * </ul>
@@ -54,10 +55,11 @@ public class ConsentCommand implements CommandExecutor, TabCompleter {
} }
switch (args[0].toLowerCase(Locale.ROOT)) { switch (args[0].toLowerCase(Locale.ROOT)) {
case "accept" -> handleAccept(sender, cfg); case "accept" -> handleAccept(sender, cfg);
case "decline" -> handleDecline(sender, cfg); case "decline" -> handleDecline(sender, cfg);
case "status" -> handleStatus(sender, cfg); case "withdraw" -> handleWithdraw(sender, cfg);
case "help" -> sendHelp(sender, cfg); case "status" -> handleStatus(sender, cfg);
case "help" -> sendHelp(sender, cfg);
case "admin" -> { case "admin" -> {
if (!sender.hasPermission("mclogger.consent.admin")) { if (!sender.hasPermission("mclogger.consent.admin")) {
sender.sendMessage(MM.deserialize(cfg.getMsgNoPermission())); sender.sendMessage(MM.deserialize(cfg.getMsgNoPermission()));
@@ -116,6 +118,31 @@ public class ConsentCommand implements CommandExecutor, TabCompleter {
plugin.networkKick(player, MM.deserialize(msg)); plugin.networkKick(player, MM.deserialize(msg));
} }
private void handleWithdraw(CommandSender sender, ConsentConfig cfg) {
if (!(sender instanceof Player player)) {
sender.sendMessage(MM.deserialize("<red>This command can only be used in-game."));
return;
}
String uuid = player.getUniqueId().toString();
String version = cfg.getPolicyVersion();
// Revoke all versions so the player must re-accept next time
plugin.getConsentDatabase().revokeConsent(uuid, null);
plugin.getPendingConsent().remove(player.getUniqueId());
// Record the withdrawal in the decline table as an audit trail
plugin.getConsentDatabase().recordDecline(uuid, player.getName(), version + "_WITHDRAWN",
player.getAddress() != null ? player.getAddress().getAddress().getHostAddress() : "unknown");
String withdrawMsg = MessageUtil.replace(cfg.getMsgWithdrawn(), player, cfg);
player.sendMessage(MM.deserialize(withdrawMsg));
// Kick using the configured kick message
String kickMsg = MessageUtil.replace(cfg.getMsgWithdrawKick(), player, cfg);
plugin.networkKick(player, MM.deserialize(kickMsg));
}
private void handleStatus(CommandSender sender, ConsentConfig cfg) { private void handleStatus(CommandSender sender, ConsentConfig cfg) {
if (!(sender instanceof Player player)) { if (!(sender instanceof Player player)) {
sender.sendMessage(MM.deserialize("<red>This command can only be used in-game.")); sender.sendMessage(MM.deserialize("<red>This command can only be used in-game."));
@@ -225,7 +252,7 @@ public class ConsentCommand implements CommandExecutor, TabCompleter {
@Override @Override
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) { public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (args.length == 1) { if (args.length == 1) {
List<String> base = new ArrayList<>(List.of("accept", "decline", "status", "help")); List<String> base = new ArrayList<>(List.of("accept", "decline", "withdraw", "status", "help"));
if (sender.hasPermission("mclogger.consent.admin")) base.add("admin"); if (sender.hasPermission("mclogger.consent.admin")) base.add("admin");
return filter(base, args[0]); return filter(base, args[0]);
} }

View File

@@ -1,5 +1,9 @@
package de.simolzimol.mclogger.consent.database; package de.simolzimol.mclogger.consent.database;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.sql.Connection; import java.sql.Connection;
import java.sql.PreparedStatement; import java.sql.PreparedStatement;
import java.sql.ResultSet; import java.sql.ResultSet;
@@ -92,12 +96,15 @@ public class ConsentDatabase {
try (Connection conn = dataSource.getConnection(); try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement()) { Statement stmt = conn.createStatement()) {
// Accepted consents - primary record of who agreed to what version // Accepted consents primary record of who agreed to what version.
// ip_address is pseudonymized (last octet/80 bits zeroed) at insert time.
// policy_hash is the SHA-256 of the policy_version string for accountability.
stmt.execute( stmt.execute(
"CREATE TABLE IF NOT EXISTS player_consent (" + "CREATE TABLE IF NOT EXISTS player_consent (" +
" uuid VARCHAR(36) NOT NULL," + " uuid VARCHAR(36) NOT NULL," +
" username VARCHAR(16) NOT NULL," + " username VARCHAR(16) NOT NULL," +
" policy_version VARCHAR(64) NOT NULL," + " policy_version VARCHAR(64) NOT NULL," +
" policy_hash VARCHAR(64) NULL," +
" consented_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," + " consented_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
" ip_address VARCHAR(45) NULL," + " ip_address VARCHAR(45) NULL," +
" PRIMARY KEY (uuid, policy_version)," + " PRIMARY KEY (uuid, policy_version)," +
@@ -105,7 +112,12 @@ public class ConsentDatabase {
" INDEX idx_consent_version (policy_version)" + " INDEX idx_consent_version (policy_version)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
// Decline audit trail - never deleted (important for GDPR accountability) // Migrate existing tables: add policy_hash if not present
stmt.execute(
"ALTER TABLE player_consent " +
"ADD COLUMN IF NOT EXISTS policy_hash VARCHAR(64) NULL");
// Decline audit trail
stmt.execute( stmt.execute(
"CREATE TABLE IF NOT EXISTS player_consent_declines (" + "CREATE TABLE IF NOT EXISTS player_consent_declines (" +
" id BIGINT AUTO_INCREMENT PRIMARY KEY," + " id BIGINT AUTO_INCREMENT PRIMARY KEY," +
@@ -148,17 +160,23 @@ public class ConsentDatabase {
/** /**
* Records that the player has accepted the current policy version. * Records that the player has accepted the current policy version.
* Uses {@code INSERT IGNORE} so duplicate calls are safe. * Uses {@code INSERT IGNORE} so duplicate calls are safe.
*
* <p>The IP address is pseudonymized before storage (Art. 5(1)(c) GDPR):
* last octet zeroed for IPv4, last 80 bits zeroed for IPv6.
* A SHA-256 hash of the policy version string is stored for accountability
* (Art. 5(2) GDPR).
*/ */
public void recordConsent(String uuid, String username, String policyVersion, String ip) { public void recordConsent(String uuid, String username, String policyVersion, String ip) {
final String sql = final String sql =
"INSERT IGNORE INTO player_consent (uuid, username, policy_version, ip_address)" + "INSERT IGNORE INTO player_consent (uuid, username, policy_version, policy_hash, ip_address)" +
" VALUES (?, ?, ?, ?)"; " VALUES (?, ?, ?, ?, ?)";
try (Connection conn = dataSource.getConnection(); try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) { PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, uuid); ps.setString(1, uuid);
ps.setString(2, username); ps.setString(2, username);
ps.setString(3, policyVersion); ps.setString(3, policyVersion);
ps.setString(4, ip); ps.setString(4, computePolicyHash(policyVersion));
ps.setString(5, pseudonymizeIp(ip));
ps.executeUpdate(); ps.executeUpdate();
} catch (SQLException e) { } catch (SQLException e) {
plugin.getLogger().log(Level.WARNING, "[MCConsent] recordConsent() failed", e); plugin.getLogger().log(Level.WARNING, "[MCConsent] recordConsent() failed", e);
@@ -167,7 +185,7 @@ public class ConsentDatabase {
/** /**
* Records that the player declined the current policy version. * Records that the player declined the current policy version.
* Declines are appended to an audit table and are never deleted. * Declines are appended to an audit table; their IP is pseudonymized.
*/ */
public void recordDecline(String uuid, String username, String policyVersion, String ip) { public void recordDecline(String uuid, String username, String policyVersion, String ip) {
final String sql = final String sql =
@@ -178,7 +196,7 @@ public class ConsentDatabase {
ps.setString(1, uuid); ps.setString(1, uuid);
ps.setString(2, username); ps.setString(2, username);
ps.setString(3, policyVersion); ps.setString(3, policyVersion);
ps.setString(4, ip); ps.setString(4, pseudonymizeIp(ip));
ps.executeUpdate(); ps.executeUpdate();
} catch (SQLException e) { } catch (SQLException e) {
plugin.getLogger().log(Level.WARNING, "[MCConsent] recordDecline() failed", e); plugin.getLogger().log(Level.WARNING, "[MCConsent] recordDecline() failed", e);
@@ -243,6 +261,100 @@ public class ConsentDatabase {
* @param uuid Player UUID string * @param uuid Player UUID string
* @param version Policy version string * @param version Policy version string
*/ */
// ─────────────────────────────────────────────────────────
// GDPR Art. 5(1)(e) Storage limitation
// ─────────────────────────────────────────────────────────
/**
* Deletes consent and decline records older than the configured retention
* periods. Pass 0 to skip deletion for that table.
*
* <p>Intended to be called from an async scheduled task once per day.
*/
public void purgeOldConsents(int consentRetentionDays, int declineRetentionDays) {
if (consentRetentionDays > 0) {
final String sql =
"DELETE FROM player_consent " +
"WHERE consented_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setInt(1, consentRetentionDays);
int rows = ps.executeUpdate();
if (rows > 0) {
plugin.getLogger().info("[MCConsent] GDPR retention: removed " + rows
+ " consent record(s) older than " + consentRetentionDays + " days.");
}
} catch (SQLException e) {
plugin.getLogger().log(Level.WARNING, "[MCConsent] purgeOldConsents() consent failed", e);
}
}
if (declineRetentionDays > 0) {
final String sql =
"DELETE FROM player_consent_declines " +
"WHERE declined_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setInt(1, declineRetentionDays);
int rows = ps.executeUpdate();
if (rows > 0) {
plugin.getLogger().info("[MCConsent] GDPR retention: removed " + rows
+ " decline record(s) older than " + declineRetentionDays + " days.");
}
} catch (SQLException e) {
plugin.getLogger().log(Level.WARNING, "[MCConsent] purgeOldConsents() declines failed", e);
}
}
}
// ─────────────────────────────────────────────────────────
// Privacy helpers
// ─────────────────────────────────────────────────────────
/**
* Pseudonymizes an IP address for storage (Art. 5(1)(c) GDPR data minimisation).
* <ul>
* <li>IPv4: last octet set to 0 (e.g. {@code 192.168.1.100} → {@code 192.168.1.0})</li>
* <li>IPv6: last 80 bits set to 0 (keeps the first 48 bits / ISP prefix)</li>
* </ul>
* Returns {@code "unknown"} if the address cannot be parsed.
*/
static String pseudonymizeIp(String ip) {
if (ip == null || ip.isBlank() || ip.equals("unknown") || ip.equals("admin-forced")) {
return ip;
}
try {
InetAddress addr = InetAddress.getByName(ip);
byte[] bytes = addr.getAddress();
if (bytes.length == 4) {
// IPv4 zero last octet
bytes[3] = 0;
} else if (bytes.length == 16) {
// IPv6 zero last 10 bytes (80 bits)
for (int i = 6; i < 16; i++) bytes[i] = 0;
}
return InetAddress.getByAddress(bytes).getHostAddress();
} catch (Exception e) {
return "unknown";
}
}
/**
* Computes a SHA-256 hex digest of the policy version string.
* Stored alongside each consent record so the exact version accepted can
* be verified later (Art. 5(2) GDPR accountability).
*/
static String computePolicyHash(String policyVersion) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(policyVersion.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(digest.length * 2);
for (byte b : digest) sb.append(String.format("%02x", b));
return sb.toString();
} catch (NoSuchAlgorithmException e) {
return "unavailable";
}
}
public void logToMCLogger(String eventType, String playerName, String uuid, String version) { public void logToMCLogger(String eventType, String playerName, String uuid, String version) {
if (!plugin.getConsentConfig().isLogToMCLoggerDb()) return; if (!plugin.getConsentConfig().isLogToMCLoggerDb()) return;

View File

@@ -89,7 +89,14 @@ consent:
# Players with this permission bypass all consent checks. # Players with this permission bypass all consent checks.
# Default: consent.bypass (assigned to ops by default) # Default: consent.bypass (assigned to ops by default)
bypass-permission: "consent.bypass" bypass-permission: "consent.bypass"
# ── GDPR Aufbewahrungsfristen (Art. 5(1)(e)) ─────────────────
# Number of days to keep accepted consent records.
# After this period data is automatically deleted by the plugin.
# Set to 0 to keep records indefinitely (not recommended for GDPR).
retention-days-consent: 1095 # 3 years
# Number of days to keep decline records.
retention-days-declines: 1095 # 3 years
# ── Admin settings ─────────────────────────────────────────── # ── Admin settings ───────────────────────────────────────────
admin: admin:
# Log consent accept/decline events to the server_events table in # Log consent accept/decline events to the server_events table in
@@ -125,9 +132,13 @@ messages:
# ── Join prompt ───────────────────────────────────────────── # ── Join prompt ─────────────────────────────────────────────
# Sent to a player when they join and consent is required. # Sent to a player when they join and consent is required.
# This text must inform the player what data is being collected
# and for what purpose (Art. 13 GDPR).
join-prompt: | join-prompt: |
<yellow><bold>Privacy Policy Consent Required</bold></yellow> <yellow><bold>Privacy Policy Consent Required</bold></yellow>
<gray>Before you can play you must read and accept our Privacy Policy.</gray> <gray>Before you can play you must read and accept our Privacy Policy.</gray>
<dark_gray>We collect: chat messages, commands, login/logout times, IP address
(pseudonymized), and player position for server moderation purposes.</dark_gray>
<aqua><click:open_url:'{url}'><hover:show_text:'<gray>Click to open in browser</gray>'>📄 {url}</hover></click></aqua> <aqua><click:open_url:'{url}'><hover:show_text:'<gray>Click to open in browser</gray>'>📄 {url}</hover></click></aqua>
<green>➜ Type <bold>/{command} accept</bold> to agree and start playing.</green> <green>➜ Type <bold>/{command} accept</bold> to agree and start playing.</green>
<red>➜ Type <bold>/{command} decline</bold> to disconnect.</red> <red>➜ Type <bold>/{command} decline</bold> to disconnect.</red>
@@ -197,3 +208,15 @@ messages:
<yellow>⚠ Reminder: Please accept our Privacy Policy.</yellow> <yellow>⚠ Reminder: Please accept our Privacy Policy.</yellow>
<aqua><click:open_url:'{url}'>{url}</click></aqua> <aqua><click:open_url:'{url}'>{url}</click></aqua>
<green>Type /<bold>{command} accept</bold> to accept.</green> <green>Type /<bold>{command} accept</bold> to accept.</green>
# ── Consent withdrawn (Art. 7(3) GDPR) ───────────────────────
# Shown in chat just before the player is kicked after /consent withdraw.
withdrawn: "<yellow>Your consent has been withdrawn. You will be disconnected. You may rejoin and re-accept the Privacy Policy at any time.</yellow>"
# Disconnect screen message shown after /consent withdraw.
withdraw-kick-message: |
Consent Withdrawn
You have successfully withdrawn your consent.
Your data will be handled according to the Privacy Policy.
You may reconnect at any time and accept the Privacy Policy.
Visit: {url}