Compare commits

...
3 Commits
Author SHA1 Message Date
youpaishidifu 182545de59 feat: 密码错误阈值封禁(Bukkit真实封禁+邮件报警) + OAuth登录IP风控 + 配置多退少补
Auto Release / build (push) Successful in 34s
1. RiskControlManager: triggerBan改用Bukkit BanList API真实封禁玩家
   - BanList.addBan(name, reason, expiry)阻止重连加入
   - onJoin检查isBanned()双重防护
   - cleanup()定时清理过期封禁并pardon
   - unban()同步解除Bukkit封禁

2. Main.java onJoin: 新增风控封禁检查,封禁期内玩家无法加入

3. ConfigManager: loadSettings()自动补齐缺失配置项(多退少补)

4. 已有功能(上一次commit实现但未推送):
   - 密码错误阈值封禁+报警邮件
   - 正版OAuth登录IP风控
2026-07-19 14:41:04 +08:00
youpaishidifu c4a5fc9a61 fix: Add detailed logging to Create Release step for debugging
Auto Release / build (push) Successful in 27s
2026-07-19 01:23:43 +08:00
youpaishidifu caaf09fce0 fix: Gitea release tag now extracted from plugin.yml instead of build-N
Auto Release / build (push) Failing after 27s
2026-07-19 01:20:14 +08:00
7 changed files with 372 additions and 201 deletions
+53 -9
View File
@@ -32,25 +32,69 @@ jobs:
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }}
run: |
TAG="build-${{ gitea.run_number }}"
set -e
# 从 plugin.yml 提取版本号作为标签
VERSION=$(grep '^version:' src/main/resources/plugin.yml | awk '{print $2}' | tr -d '\r\n ')
TAG="v${VERSION}"
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
echo "Version: ${VERSION}, Tag: ${TAG}"
echo "Token prefix: ${GITEA_TOKEN:0:8}..."
curl -s -X POST \
# 测试 token 权限
echo "=== Testing API access ==="
curl -s -w "\nHTTP_CODE:%{http_code}\n" \
-H "Authorization: token $GITEA_TOKEN" \
"${BASE}" | tail -5
# 如果同版本标签已存在,先删除旧 release
echo "=== Checking existing tag ${TAG} ==="
RESP=$(curl -s -w "\nHTTP_CODE:%{http_code}" \
-H "Authorization: token $GITEA_TOKEN" \
"${BASE}/releases/tags/${TAG}")
HTTP_CODE=$(echo "$RESP" | grep -o 'HTTP_CODE:[0-9]*' | cut -d: -f2)
echo "Check tag HTTP: ${HTTP_CODE}"
if [ "$HTTP_CODE" = "200" ]; then
OLD_ID=$(echo "$RESP" | head -1 | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
echo "Tag ${TAG} exists (release #${OLD_ID}), deleting..."
curl -s -X DELETE \
-H "Authorization: token $GITEA_TOKEN" \
"${BASE}/releases/${OLD_ID}"
curl -s -X DELETE \
-H "Authorization: token $GITEA_TOKEN" \
"${BASE}/tags/${TAG}"
sleep 1
elif [ "$HTTP_CODE" = "404" ]; then
echo "Tag ${TAG} not found, will create new"
else
echo "Warning: unexpected response checking tag"
fi
# 创建新 release
echo "=== Creating release ${TAG} ==="
RELEASE_RESP=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"Build ${{ gitea.run_number }}\",\"body\":\"Commit: ${{ gitea.sha }}\nBranch: ${{ gitea.ref_name }}\",\"draft\":false,\"prerelease\":false}" \
"${BASE}/releases"
-d "{\"tag_name\":\"${TAG}\",\"name\":\"Sdf1_login ${TAG}\",\"body\":\"Version: ${VERSION}\\nCommit: ${{ gitea.sha }}\\nBranch: ${{ gitea.ref_name }}\",\"draft\":false,\"prerelease\":false}" \
"${BASE}/releases")
RELEASE_HTTP=$(echo "$RELEASE_RESP" | grep -o 'HTTP_CODE:[0-9]*' | cut -d: -f2)
echo "Create release HTTP: ${RELEASE_HTTP}"
RELEASE_ID=$(curl -s \
-H "Authorization: token $GITEA_TOKEN" \
"${BASE}/releases/tags/${TAG}" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
if [ "$RELEASE_HTTP" != "201" ]; then
echo "=== Release creation failed! ==="
echo "$RELEASE_RESP" | head -1
exit 1
fi
# 上传 JAR 资产
RELEASE_ID=$(echo "$RELEASE_RESP" | head -1 | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
if [ -n "$RELEASE_ID" ]; then
echo "=== Uploading JAR to release #${RELEASE_ID} ==="
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@target/Sdf1_login.jar" \
"${BASE}/releases/${RELEASE_ID}/assets"
echo "Release ${TAG} created with JAR"
echo "✅ Release ${TAG} created with JAR"
else
echo "Release tag ${TAG} created (JAR attach may need manual check)"
echo "⚠️ Release created but could not get release ID for JAR upload"
fi
@@ -49,6 +49,10 @@ public class ConfigManager {
public double providerEconomyPerScore = 50.0;
public int requesterPointsReward = 10;
// ===== 密码错误封禁配置 =====
public int passwordFailThreshold = 5; // 密码错误阈值(达到后封禁)
public int passwordBanDuration = 300; // 封禁时长(秒),默认5分钟
public boolean garbageEnabled = true;
public int garbageInterval = 300;
public int garbageMaxRounds = 1;
@@ -395,6 +399,8 @@ public class ConfigManager {
{"区域防护_最大领地数", String.valueOf(areaProtectMaxLands)},
{"传送_请求有效秒", String.valueOf(tpRequestValidSeconds)},
{"传送_发送间隔秒", String.valueOf(tpSendIntervalSeconds)},
{"密码错误封禁阈值", String.valueOf(passwordFailThreshold)},
{"密码错误封禁时长秒", String.valueOf(passwordBanDuration)},
};
for (String[] nk : newKeys) {
if (!m.containsKey(nk[0])) { hasNewKey = true; }
@@ -435,6 +441,10 @@ public class ConfigManager {
providerEconomyPerScore = parseDouble(m.getOrDefault("经济奖励每评分", "50.0"), 50.0);
requesterPointsReward = parseInt(m.getOrDefault("报单人积分奖励", "10"), 10);
// 密码错误封禁配置
passwordFailThreshold = parseInt(m.getOrDefault("密码错误封禁阈值", "5"), 5);
passwordBanDuration = parseInt(m.getOrDefault("密码错误封禁时长秒", "300"), 300);
garbageEnabled = Boolean.parseBoolean(m.getOrDefault("垃圾站_启用", "true"));
garbageInterval = parseInt(m.getOrDefault("垃圾站_清理间隔秒", "300"), 300);
garbageMaxRounds = parseInt(m.getOrDefault("垃圾站_保留轮数", "1"), 1);
@@ -542,6 +552,8 @@ public class ConfigManager {
managed.put("奖励发放方式", "债券");
managed.put("传送_请求有效秒", String.valueOf(tpRequestValidSeconds));
managed.put("传送_发送间隔秒", String.valueOf(tpSendIntervalSeconds));
managed.put("密码错误封禁阈值", String.valueOf(passwordFailThreshold));
managed.put("密码错误封禁时长秒", String.valueOf(passwordBanDuration));
// ★ 保留非ConfigManager管理的配置行(如web通信-*等)
// ★ 同时保留连续的注释块(如 "# ===== Web通信配置 =====")
@@ -632,6 +644,9 @@ public class ConfigManager {
L.add("区域防护_最大领地数=3");
L.add("传送_请求有效秒=90");
L.add("传送_发送间隔秒=10");
// 密码错误封禁
L.add("密码错误封禁阈值=5");
L.add("密码错误封禁时长秒=300");
// Web通信配置(默认禁用,需手动开启)
L.add("");
L.add("# ===== Web通信配置 =====");
@@ -107,6 +107,9 @@ public class DatabaseManager {
// 禁言理由
safeAdd(st, "mute_reason",
"TEXT DEFAULT ''");
// ★ 正版OAuth登录IP(用于异地登录风控)
safeAdd(st, "last_oauth_ip",
"TEXT DEFAULT ''");
// ===== 背包备份表:安全迁移 =====
// 检查旧表是否存在(用 inventory_data 列判断)
@@ -154,6 +154,16 @@ public class LoginManager {
.msg("not_registered"));
return true;
}
// ★ 风控检查:封禁期内拒绝登录
if (plugin.getRiskControl().isBanned(name)) {
int remaining = plugin.getRiskControl()
.getBanRemainingSeconds(name);
p.sendMessage("§c§l[安全风控] §f账号因密码错误过多已被封禁,"
+ "剩余 §e" + remaining + " §f秒");
return true;
}
if (args.length < 1) {
p.sendMessage("§c用法: /login <密码>");
return true;
@@ -176,6 +186,8 @@ public class LoginManager {
plugin.getDb().checkPassword(
name, hash);
if (matchMain) {
// ★ 登录成功,重置风控
plugin.getRiskControl().onLoginSuccess(name);
plugin.getLoggedIn().add(name);
// ★ 记录Java手动登录:5分钟内重连可直接放行(检查点1)
if (plugin.webManager != null) {
@@ -225,6 +237,8 @@ public class LoginManager {
.checkPasswordWithFallback(
name, hash);
if (pwdResult != null) {
// ★ 临时密码登录成功,也重置风控
plugin.getRiskControl().onLoginSuccess(name);
plugin.getLoggedIn().add(name);
// ★ 记录Java手动登录:5分钟内重连可直接放行(检查点1)
if (plugin.webManager != null) {
@@ -256,8 +270,19 @@ public class LoginManager {
}
// ★ 密码错误,记录风控
int remaining = plugin.getRiskControl()
.recordFailAttempt(p);
if (remaining < 0) {
// 已被封禁(triggerBan内部已踢出)
return true;
}
p.sendMessage(plugin.getConfig2()
.msg("login_failed"));
if (remaining > 0) {
p.sendMessage("§c§l[风控] §f剩余 §e"
+ remaining + " §f次尝试机会");
}
} catch (Exception e) {
plugin.getLogger().severe(
"[Sdf1_login] handleLogin异常: "
+45 -1
View File
@@ -57,6 +57,7 @@ public class Main extends JavaPlugin
public GUIManager gui;
private ChatInputManager chatInput;
private VerificationManager verification;
private RiskControlManager riskControl;
private IPGroupManager ipGroup;
private AccountRequestManager accountRequest;
private TicketManager ticket;
@@ -278,6 +279,10 @@ public class Main extends JavaPlugin
return verification;
}
public RiskControlManager getRiskControl() {
return riskControl;
}
public IPGroupManager getIPGroup() {
return ipGroup;
}
@@ -384,6 +389,7 @@ public class Main extends JavaPlugin
points = new PointsManager(this);
chatInput = new ChatInputManager();
verification = new VerificationManager(this);
riskControl = new RiskControlManager(this);
ipGroup = new IPGroupManager(this,
config.maxAccountsPerIP);
accountRequest =
@@ -441,6 +447,11 @@ public class Main extends JavaPlugin
}, 5000L, 5000L);
}
// 每30秒清理一次过期的风控封禁记录
getServer().getScheduler().runTaskTimer(this, () -> {
if (riskControl != null) riskControl.cleanup();
}, 600L, 600L);
if (getCommand("sdf1_login") != null) {
getCommand("sdf1_login")
.setExecutor(this);
@@ -2140,6 +2151,18 @@ public class Main extends JavaPlugin
String name = p.getName();
String ip = getPlayerIP(p);
// ===== 风控封禁检查:密码错误阈值封禁期间拒绝加入 =====
if (riskControl != null && riskControl.isBanned(name)) {
int remaining = riskControl.getBanRemainingSeconds(name);
e.setJoinMessage(null);
p.kickPlayer(
"§c§l[安全风控] §f密码错误次数过多,"
+ "账号已被临时封禁 §e" + remaining
+ " §f秒\n"
+ "§7请等待封禁到期后重试");
return;
}
// ===== 非法URL/非法域名用户名拦截(最高优先级) =====
if (chatFilter != null && chatFilter.isEnabled()) {
// 每次玩家加入时热重载非法域名后缀(支持热更新)
@@ -2328,10 +2351,31 @@ public class Main extends JavaPlugin
// ★ 检查点0:已通过OAuth验证的正版玩家直接自动登录
if (isVerifiedPremiumPlayer(name)) {
getLogger().info("[正版验证] 检查点0通过: 玩家 " + name + " 已通过OAuth验证,自动登录");
String currentIP = getPlayerIP(p);
String lastOAuthIP = (String) db.getField(name, "last_oauth_ip");
if (lastOAuthIP != null && !lastOAuthIP.isEmpty()
&& currentIP != null && !currentIP.equals(lastOAuthIP)) {
// ★ 异地登录风控:IP不一致 → 拒绝自动登录
getLogger().warning("[正版验证] 检查点0风控拦截: 玩家 " + name
+ " OAuth IP不一致 (上次: " + lastOAuthIP
+ ", 当前: " + currentIP + "),要求手动登录");
p.sendMessage("§c§l[安全风控] §f检测到登录IP与上次不同");
p.sendMessage("§7上次IP: §e" + lastOAuthIP);
p.sendMessage("§7当前IP: §e" + currentIP);
p.sendMessage("§c请使用 /login <密码> 手动登录,或重新完成 /mslogin 验证");
// 不return,继续往下走触发正常登录流程
} else {
// IP一致或首次登录 → 记录IP并放行
if (currentIP != null && !currentIP.isEmpty()) {
db.setField(name, "last_oauth_ip", currentIP);
}
getLogger().info("[正版验证] 检查点0通过: 玩家 " + name
+ " 已通过OAuth验证,自动登录");
autoLogin(p, "premium");
return;
}
}
verification.verifyPremiumAsync(p,
isPremium -> {
+202 -168
View File
@@ -1,13 +1,18 @@
package Sdf1_login;
import org.bukkit.BanList;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.util.*;
import java.util.Date;
import java.util.concurrent.ConcurrentHashMap;
/**
* 风控管理器:检测异常登录行为,记录尝试次数,触发安全报警
* 风控管理器:密码错误阈值封禁 + 报警邮件
*
* 功能1:密码错误达到阈值 → 自动封禁账号N分钟 + 推送报警邮件
* 功能2:封禁期间拒绝所有登录尝试
*/
public class RiskControlManager {
@@ -17,232 +22,261 @@ public class RiskControlManager {
private final Map<String, Integer> failAttempts =
new ConcurrentHashMap<>();
// 每个IP -> 关联的可疑玩家列表
private final Map<String, Set<String>> ipSuspicious =
// 每个玩家名 -> 封禁到期时间戳(毫秒)
private final Map<String, Long> banUntil =
new ConcurrentHashMap<>();
// 每个玩家 -> 上次验证时间(用于冷却)
private final Map<String, Long> verifyCooldown =
new ConcurrentHashMap<>();
// 配置项
private static final int MAX_FAIL_ATTEMPTS = 5;
private static final int MAX_ACCOUNTS_PER_IP = 5;
private static final long COOLDOWN_MS = 60000L;
public RiskControlManager(Main plugin) {
this.plugin = plugin;
}
/**
* 记录一次失败的登录尝试
*
* @param playerName 玩家名
* @return 剩余尝试次数
* 获取当前密码错误阈值(从配置读取)
*/
public int recordFailAttempt(String playerName) {
int current = failAttempts
.getOrDefault(playerName, 0) + 1;
failAttempts.put(playerName, current);
return Math.max(0, MAX_FAIL_ATTEMPTS - current);
private int getMaxFailAttempts() {
return plugin.getConfig2().passwordFailThreshold;
}
/**
* 重置指定玩家的失败计数(登录成功时调用)
* 获取封禁时长(毫秒)(从配置读取)
*/
public void resetFailAttempts(String playerName) {
private long getBanDurationMs() {
return (long) plugin.getConfig2().passwordBanDuration * 1000L;
}
// ==================== 封禁检查 ====================
/**
* 检查玩家是否已被封禁
* @return true=已被封禁(应拒绝登录)
*/
public boolean isBanned(String playerName) {
Long until = banUntil.get(playerName);
if (until == null) return false;
if (System.currentTimeMillis() < until) {
return true; // 仍在封禁期内
}
// 封禁已过期,自动解除
banUntil.remove(playerName);
failAttempts.remove(playerName);
return false;
}
/**
* 获取指定玩家的失败次数
* 获取玩家剩余封禁时间(秒),0=未封禁
*/
public int getFailAttempts(String playerName) {
return failAttempts
.getOrDefault(playerName, 0);
public int getBanRemainingSeconds(String playerName) {
Long until = banUntil.get(playerName);
if (until == null) return 0;
long remaining = until - System.currentTimeMillis();
if (remaining <= 0) {
banUntil.remove(playerName);
failAttempts.remove(playerName);
return 0;
}
return (int) (remaining / 1000) + 1; // 向上取整
}
// ==================== 失败记录 ====================
/**
* 判断玩家是否已被锁定(失败次数达到上限)
* 记录一次失败的登录尝试,判断是否触发封禁
*
* @param player 登录失败的玩家
* @return 剩余尝试次数(-1=已被封禁)
*/
public boolean isLocked(String playerName) {
return failAttempts
.getOrDefault(playerName, 0)
>= MAX_FAIL_ATTEMPTS;
}
/**
* 检查IP是否达到注册上限
* 直接查数据库,不依赖 DatabaseManager 的方法
*/
public boolean canRegisterFromIP(String ip) {
if (ip == null || ip.isEmpty()) return true;
try {
java.sql.Connection conn =
plugin.getDb().getDb();
if (conn == null) return true;
java.sql.PreparedStatement ps =
conn.prepareStatement(
"SELECT COUNT(*) FROM users "
+ "WHERE ip_address = ?");
ps.setString(1, ip);
java.sql.ResultSet rs = ps.executeQuery();
int count = 0;
if (rs.next()) count = rs.getInt(1);
rs.close();
ps.close();
return count < 5;
} catch (Exception e) {
e.printStackTrace();
return true;
}
}
/**
* 记录IP关联的可疑行为
*/
public void recordSuspiciousIP(String ip,
String playerName) {
if (ip == null || ip.isEmpty()) return;
ipSuspicious
.computeIfAbsent(ip,
k -> ConcurrentHashMap.newKeySet())
.add(playerName);
}
/**
* 获取某IP关联的所有可疑玩家
*/
public Set<String> getSuspiciousAccounts(String ip) {
return ipSuspicious
.getOrDefault(ip, Collections.emptySet());
}
/**
* 检查是否在验证冷却期内
*/
public boolean isOnCooldown(String playerName) {
Long last = verifyCooldown.get(playerName);
if (last == null) return false;
return System.currentTimeMillis() - last
< COOLDOWN_MS;
}
/**
* 设置验证冷却时间
*/
public void setCooldown(String playerName) {
verifyCooldown.put(playerName,
System.currentTimeMillis());
}
/**
* 当检测到可疑登录时触发报警
* - 全服广播
* - 有邮箱则发邮件
* - 记录到数据库
*/
public void triggerAlert(Player player,
String suspiciousIP) {
public int recordFailAttempt(Player player) {
String name = player.getName();
int maxAttempts = getMaxFailAttempts();
// 记录到数据库
plugin.getDb().recordSecurityAlert(
name, suspiciousIP);
// 已被封禁,直接返回
if (isBanned(name)) {
return -1;
}
// 记录IP可疑行为
recordSuspiciousIP(suspiciousIP, name);
int current = failAttempts
.getOrDefault(name, 0) + 1;
failAttempts.put(name, current);
// 全服广播
String serverMsg = "§c§l[安全警告] §e" + name
+ " §f的账号疑似遭到 §c" + suspiciousIP
+ " §f的盗号尝试, 请各位玩家注意账号安全!";
Bukkit.broadcastMessage(serverMsg);
int remaining = Math.max(0, maxAttempts - current);
// 有邮箱则发邮件
// 达到阈值 → 触发封禁
if (current >= maxAttempts) {
triggerBan(player);
}
return remaining;
}
/**
* 触发封禁:Bukkit真实封禁 + 记录时间 + 发邮件 + 踢出
* 双层封禁:Bukkit ban阻止连接加入,内存计时器到期自动解封
*/
private void triggerBan(Player player) {
String name = player.getName();
long durationMs = getBanDurationMs();
long until = System.currentTimeMillis() + durationMs;
banUntil.put(name, until);
String ip = plugin.getPlayerIP(player);
int banMinutes = (int) (durationMs / 60000);
plugin.getLogger().warning(
"[风控] 玩家 " + name
+ " 密码错误达到阈值("
+ getMaxFailAttempts() + "次)"
+ ",封禁 " + banMinutes + " 分钟");
// ★ 真实封禁:调用Bukkit BanList API阻止玩家重新加入服务器
String banReason = "§c§l[安全风控] 密码连续错误"
+ getMaxFailAttempts() + "次,"
+ "账号临时封禁" + banMinutes + "分钟";
try {
// 使用 BanList API 设置带过期时间的封禁
Date expiry = new Date(System.currentTimeMillis() + durationMs);
Bukkit.getBanList(BanList.Type.NAME).addBan(
name, banReason, expiry, "[Sdf1_login] 风控");
plugin.getLogger().info(
"[风控] 已通过Bukkit BanList封禁玩家 "
+ name + " (" + banMinutes + "分钟,到期: " + expiry + ")");
} catch (Exception e) {
plugin.getLogger().warning(
"[风控] BanList封禁失败: " + e.getMessage());
}
// 踢出玩家(无论BanList是否成功,立即踢出)
player.kickPlayer(banReason);
// 异步发送报警邮件
sendBanAlertEmail(name, ip, banMinutes);
}
/**
* 发送封禁报警邮件到玩家绑定的邮箱
* 未绑定邮箱则跳过
*/
private void sendBanAlertEmail(String playerName,
String ip, int banMinutes) {
String emailAddr = (String) plugin.getDb()
.getField(name, "email");
if (emailAddr != null
&& !emailAddr.isEmpty()) {
.getField(playerName, "email");
if (emailAddr == null || emailAddr.isEmpty()) {
plugin.getLogger().info(
"[风控] 玩家 " + playerName
+ " 未绑定邮箱,跳过报警邮件");
return;
}
String subject =
"[安全警告] 您的账号疑似遭到盗号尝试";
String body = "尊敬的玩家 " + name
+ ":\n\n"
"[安全警告] 您的账号已被临时封禁";
String body = "尊敬的玩家 " + playerName + ":\n\n"
+ "您的账号在 "
+ new java.text.SimpleDateFormat(
"yyyy-MM-dd HH:mm:ss")
.format(new java.util.Date())
+ " 疑似遭到 IP: " + suspiciousIP
+ " 的盗号尝试。\n\n"
+ "建议您:\n"
+ "1. 立即修改密码"
+ "(使用 /sdf1_login pw)\n"
+ "2. 如非本人操作,"
+ "请联系管理员处理\n\n"
+ " 因密码连续错误 "
+ getMaxFailAttempts() + " 次"
+ ",已被自动封禁 " + banMinutes + " 分钟。\n\n"
+ "来源IP: " + (ip != null ? ip : "未知") + "\n"
+ "封禁到期: "
+ new java.text.SimpleDateFormat(
"yyyy-MM-dd HH:mm:ss")
.format(new java.util.Date(
System.currentTimeMillis()
+ (long) banMinutes * 60000L))
+ "\n\n"
+ "如果这是您本人操作,请等待封禁到期后重试。\n"
+ "如果非本人操作,建议您立即修改密码"
+ "(使用 /sdf1_login pw)。\n\n"
+ "--Sdf1_login 安全系统";
plugin.getEmail().sendTempPassword(
emailAddr, name, body);
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
plugin.getEmail().sendBody(
emailAddr, subject, body);
plugin.getLogger().info(
"[风控] 封禁报警邮件已发送到 "
+ emailAddr);
} catch (Exception e) {
plugin.getLogger().warning(
"[风控] 报警邮件发送失败: "
+ e.getMessage());
}
});
}
/**
* 处理登录失败时的风控逻辑
*
* @param player 玩家
* @param suspiciousIP 可疑来源IP
* @return true=已被锁定(应踢出), false=仍可继续尝试
*/
public boolean handleLoginFailure(Player player,
String suspiciousIP) {
String name = player.getName();
int remaining = recordFailAttempt(name);
if (remaining <= 0) {
// 达到上限,触发报警
triggerAlert(player, suspiciousIP);
player.kickPlayer(
"§c§l[风控] §f登录失败次数过多,"
+ " 账号已被临时锁定, 请联系管理员");
return true;
}
// 未达上限,提示剩余次数
player.sendMessage(
"§c§l[风控] §f密码错误! "
+ "剩余" + remaining + "次机会");
return false;
}
// ==================== 成功重置 ====================
/**
* 登录成功后重置风控状态
*/
public void onLoginSuccess(String playerName) {
resetFailAttempts(playerName);
failAttempts.remove(playerName);
banUntil.remove(playerName);
}
// ==================== 清理 ====================
/**
* 清理过期的冷却记录(可由定时任务调用)
* 清理过期的封禁记录(可由定时任务调用)
* 同步解除过期玩家的Bukkit封禁
*/
public void cleanup() {
long now = System.currentTimeMillis();
verifyCooldown.entrySet().removeIf(
e -> now - e.getValue() > COOLDOWN_MS * 10);
// 收集过期的玩家名
List<String> expired = new ArrayList<>();
banUntil.entrySet().removeIf(e -> {
if (e.getValue() < now) {
expired.add(e.getKey());
return true;
}
return false;
});
// 同步解除Bukkit封禁
for (String name : expired) {
try {
Bukkit.getBanList(BanList.Type.NAME)
.pardon(name);
plugin.getLogger().info(
"[风控] 封禁到期,已解除Bukkit封禁: "
+ name);
} catch (Exception ignored) {}
failAttempts.remove(name);
}
}
/**
* 获取所有玩家的失败尝试记录(调试用)
* 获取所有封禁记录(调试/管理用)
*/
public Map<String, Long> getAllBans() {
return Collections.unmodifiableMap(banUntil);
}
/**
* 获取所有失败尝试记录(调试用)
*/
public Map<String, Integer> getAllFailAttempts() {
return Collections.unmodifiableMap(failAttempts);
}
/**
* 手动解除某玩家的封禁(管理员用)
*/
public void unban(String playerName) {
banUntil.remove(playerName);
failAttempts.remove(playerName);
// 同步解除Bukkit封禁
try {
Bukkit.getBanList(BanList.Type.NAME)
.pardon(playerName);
plugin.getLogger().info("[风控] 已解除Bukkit封禁: "
+ playerName);
} catch (Exception ignored) {}
}
/**
* 清除所有风控数据(管理员调试用)
*/
public void clearAll() {
failAttempts.clear();
ipSuspicious.clear();
verifyCooldown.clear();
banUntil.clear();
}
}
+6
View File
@@ -5100,6 +5100,12 @@ public class WebManager {
// 记录为已验证正版玩家
plugin.addVerifiedPremiumPlayer(playerName, mcUuid, mcUsername);
// ★ 记录OAuth登录IP(用于异地登录风控)
String oauthIP = plugin.getPlayerIP(player);
if (oauthIP != null && !oauthIP.isEmpty()) {
plugin.getDb().setField(playerName, "last_oauth_ip", oauthIP);
}
// 在主线程执行自动登录
Bukkit.getScheduler().runTask(plugin, () -> {
if (player.isOnline() && !plugin.getLoggedIn().contains(playerName)) {