Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
182545de59 | ||
|
|
c4a5fc9a61 | ||
|
|
caaf09fce0 |
@@ -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异常: "
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user