Skip to content

Commit 23ff914

Browse files
fix(auth+whitelist): 加固认证体系并修复幽灵踢人问题
认证安全: - JWT 签名密钥独立化: 首次启动生成 256 位 SecureRandom,与 admin password 解耦 - 签名算法改 HMAC-SHA256, 验证用 MessageDigest.isEqual 防 timing attack - 移除 X-Admin-Password 头与 isAdminEndpoint 分支, 统一 JWT/API Token - API Token 比较改 constant-time - JwtUtil 全部 getter 增加 has() 与 null 防御 - 随机生成器全面 Random -> SecureRandom 幽灵踢人修复: - loadCache 同时按 UUID 和 name:lowercase 双 key 写入, 覆盖 UUID 待补充条目 - isPlayerWhitelistedOffline UUID miss 后接查 name 缓存 - DB 查询命中后回填双 key 缓存, 避免重复抢异步线程池 - PreLogin 超时 5s -> 15s, 加排队耗时与慢查询警告 - 严格模式踢人前用 plugin.getLogger().warning() 写 Bukkit 主 console, 绕开 SLF4J 桥接黑洞
1 parent f1532f6 commit 23ff914

8 files changed

Lines changed: 240 additions & 122 deletions

File tree

src/main/java/com/xaoxiao/convenientaccess/ConvenientAccessPlugin.java

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ public void onEnable() {
8787
whitelistSystem.getDatabaseManager(),
8888
whitelistSystem.getRegistrationTokenManager(),
8989
configManager.getAdminPassword(),
90+
configManager.getJwtSecret(),
9091
loginAttemptService
9192
);
9293
// 确保超级管理员账户存在
@@ -235,11 +236,11 @@ public void reload() {
235236
}
236237

237238
/**
238-
* 初始化认证配置(自动生成密码和Token
239+
* 初始化认证配置(自动生成密码、Token 和 JWT 签名密钥
239240
*/
240241
private void initializeAuthConfig() {
241242
boolean configChanged = false;
242-
243+
243244
// 检查并生成管理员密码
244245
String adminPassword = configManager.getAdminPassword();
245246
if (adminPassword == null || adminPassword.trim().isEmpty()) {
@@ -252,7 +253,7 @@ private void initializeAuthConfig() {
252253
} else {
253254
logger.info("使用配置文件中的管理员密码");
254255
}
255-
256+
256257
// 检查并生成API Token
257258
String apiToken = configManager.getApiToken();
258259
if (apiToken == null || apiToken.trim().isEmpty()) {
@@ -265,42 +266,63 @@ private void initializeAuthConfig() {
265266
} else {
266267
logger.info("使用配置文件中的API令牌");
267268
}
268-
269+
270+
// 检查并生成 JWT 签名密钥(与 admin password 完全独立,防止密码泄漏导致 token 伪造)
271+
String jwtSecret = configManager.getJwtSecret();
272+
if (jwtSecret == null || jwtSecret.trim().isEmpty()) {
273+
String newSecret = generateJwtSecret();
274+
configManager.setJwtSecret(newSecret);
275+
configChanged = true;
276+
logger.info("已自动生成 JWT 签名密钥 (256 位)");
277+
logger.warn("JWT 密钥已写入 config.yml,请妥善保管。修改此值会让所有已签发的 token 失效");
278+
} else {
279+
logger.info("使用配置文件中的 JWT 签名密钥");
280+
}
281+
269282
if (configChanged) {
270283
logger.info("认证配置已更新并保存到配置文件");
271284
}
272285
}
273-
286+
274287
/**
275-
* 生成随机密码
288+
* 生成随机密码 (使用密码学安全的 SecureRandom)
276289
*/
277290
private String generateRandomPassword(int length) {
278291
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
279292
StringBuilder sb = new StringBuilder();
280-
java.util.Random random = new java.util.Random();
281-
293+
java.security.SecureRandom random = new java.security.SecureRandom();
294+
282295
for (int i = 0; i < length; i++) {
283296
sb.append(chars.charAt(random.nextInt(chars.length())));
284297
}
285-
298+
286299
return sb.toString();
287300
}
288-
301+
289302
/**
290-
* 生成API Token(sk-开头的64位token)
303+
* 生成API Token(sk-开头的64位token, 使用 SecureRandom
291304
*/
292305
private String generateApiToken() {
293306
String prefix = configManager.getTokenPrefix();
294307
String tokenChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
295308
StringBuilder sb = new StringBuilder(prefix);
296-
java.util.Random random = new java.util.Random();
297-
309+
java.security.SecureRandom random = new java.security.SecureRandom();
310+
298311
// 生成64位token(包含前缀)
299312
int tokenLength = 64 - prefix.length();
300313
for (int i = 0; i < tokenLength; i++) {
301314
sb.append(tokenChars.charAt(random.nextInt(tokenChars.length())));
302315
}
303-
316+
304317
return sb.toString();
305318
}
319+
320+
/**
321+
* 生成 JWT 签名密钥:32 字节 (256 位) 密码学随机数, base64 编码
322+
*/
323+
private String generateJwtSecret() {
324+
byte[] bytes = new byte[32];
325+
new java.security.SecureRandom().nextBytes(bytes);
326+
return java.util.Base64.getEncoder().encodeToString(bytes);
327+
}
306328
}

src/main/java/com/xaoxiao/convenientaccess/api/ApiRouter.java

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -78,50 +78,37 @@ private boolean isAuthenticated(HttpServletRequest request, String path) {
7878
}
7979
}
8080

81-
// 检查API Token (用于非管理员的API访问)
81+
// 检查API Token (用于非管理员的API访问,constant-time 比较防 timing attack)
8282
String apiKey = request.getHeader("X-API-Key");
8383
if (apiKey != null) {
8484
String validToken = configManager.getApiToken();
85-
if (validToken != null && validToken.equals(apiKey)) {
85+
if (validToken != null && !validToken.isEmpty()
86+
&& java.security.MessageDigest.isEqual(
87+
validToken.getBytes(java.nio.charset.StandardCharsets.UTF_8),
88+
apiKey.getBytes(java.nio.charset.StandardCharsets.UTF_8))) {
8689
return true;
8790
}
8891
}
89-
90-
// 对于管理员端点,检查管理员密码 (用于生成注册token等操作)
91-
if (isAdminEndpoint(path)) {
92-
String adminPassword = request.getHeader("X-Admin-Password");
93-
if (adminPassword != null) {
94-
String validPassword = configManager.getAdminPassword();
95-
return validPassword != null && validPassword.equals(adminPassword);
96-
}
97-
}
98-
92+
9993
return false;
10094
}
101-
95+
10296
/**
10397
* 判断是否为公开端点(不需要认证)
10498
*/
10599
private boolean isPublicEndpoint(String path) {
106-
return path.equals("/api/v1/admin/login") ||
100+
return path.equals("/api/v1/admin/login") ||
107101
path.equals("/api/v1/admin/register");
108102
}
109-
110-
/**
111-
* 判断是否为管理员端点
112-
*/
113-
private boolean isAdminEndpoint(String path) {
114-
return path.startsWith("/api/v1/admin/");
115-
}
116-
103+
117104
/**
118105
* 发送认证失败响应
119106
*/
120107
private void sendAuthFailedResponse(HttpServletResponse response) throws IOException {
121108
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
122109
response.setContentType("application/json");
123110
response.setCharacterEncoding("UTF-8");
124-
response.getWriter().write("{\"success\":false,\"error\":\"Unauthorized: Invalid API key or admin password\"}");
111+
response.getWriter().write("{\"success\":false,\"error\":\"Unauthorized: Invalid API key or token\"}");
125112
response.getWriter().flush();
126113
}
127114

@@ -302,7 +289,7 @@ protected void doOptions(HttpServletRequest request, HttpServletResponse respons
302289
// 设置CORS头
303290
response.setHeader("Access-Control-Allow-Origin", "*");
304291
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
305-
response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, X-Admin-Password");
292+
response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key");
306293
response.setHeader("Access-Control-Max-Age", "3600");
307294
response.setStatus(HttpServletResponse.SC_OK);
308295
}

src/main/java/com/xaoxiao/convenientaccess/auth/AdminAuthService.java

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,19 @@ public class AdminAuthService {
1919
private final RegistrationTokenManager tokenManager;
2020
private final LoginAttemptService loginAttemptService;
2121
private final String systemAdminPassword;
22-
23-
public AdminAuthService(DatabaseManager dbManager, RegistrationTokenManager tokenManager,
24-
String systemAdminPassword, LoginAttemptService loginAttemptService) {
22+
23+
public AdminAuthService(DatabaseManager dbManager, RegistrationTokenManager tokenManager,
24+
String systemAdminPassword, String jwtSecret,
25+
LoginAttemptService loginAttemptService) {
2526
this.adminUserDao = new AdminUserDao(dbManager);
2627
this.authLogDao = new AuthLogDao(dbManager);
2728
this.tokenManager = tokenManager;
2829
this.loginAttemptService = loginAttemptService;
2930
this.systemAdminPassword = systemAdminPassword;
30-
31-
// 初始化JWT密钥
32-
JwtUtil.initialize(systemAdminPassword);
33-
31+
32+
// 初始化 JWT 密钥(与 admin password 完全独立,避免密码泄漏导致 token 可伪造)
33+
JwtUtil.initialize(jwtSecret);
34+
3435
// 确保超级管理员账号存在
3536
ensureSuperAdminExists();
3637
}

src/main/java/com/xaoxiao/convenientaccess/auth/JwtUtil.java

Lines changed: 69 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,32 @@
88
import java.util.Base64;
99
import java.util.UUID;
1010

11+
import javax.crypto.Mac;
12+
import javax.crypto.spec.SecretKeySpec;
13+
1114
import com.google.gson.JsonObject;
1215
import com.google.gson.JsonParser;
1316

1417
/**
15-
* JWT工具类 - 简化版JWT实现
18+
* JWT工具类 - 使用 HMAC-SHA256 签名 (HS256)
1619
* 格式: header.payload.signature
1720
*/
1821
public class JwtUtil {
19-
private static final String SECRET_KEY_PREFIX = "ConvenientAccess-";
20-
private static String secretKey;
21-
22+
private static final String HMAC_ALGORITHM = "HmacSHA256";
23+
private static volatile byte[] secretKey;
24+
2225
/**
2326
* 初始化JWT密钥
27+
* 必须在使用任何签名/验签接口之前调用。
28+
* Why: 旧实现把签名密钥从 admin password 简单拼接派生 (SECRET_KEY_PREFIX + password),
29+
* admin password 一旦泄漏(默认 12 位字符 + 配置文件明文),JWT 签名即可被伪造。
30+
* 现在改为接收一个独立的、首次启动随机生成的高熵 secret。
2431
*/
25-
public static void initialize(String adminPassword) {
26-
secretKey = SECRET_KEY_PREFIX + adminPassword;
32+
public static void initialize(String jwtSecret) {
33+
if (jwtSecret == null || jwtSecret.isEmpty()) {
34+
throw new IllegalArgumentException("JWT secret 不能为空");
35+
}
36+
secretKey = jwtSecret.getBytes(StandardCharsets.UTF_8);
2737
}
2838

2939
/**
@@ -78,77 +88,109 @@ public static JsonObject verifyAndDecode(String token) {
7888
String encodedPayload = parts[1];
7989
String signature = parts[2];
8090

81-
// 验证签名
91+
// 验证签名 (constant-time 比较,防 timing attack)
8292
String data = encodedHeader + "." + encodedPayload;
8393
String expectedSignature = createSignature(data);
84-
if (!signature.equals(expectedSignature)) {
94+
byte[] signatureBytes = signature.getBytes(StandardCharsets.UTF_8);
95+
byte[] expectedBytes = expectedSignature.getBytes(StandardCharsets.UTF_8);
96+
if (!MessageDigest.isEqual(signatureBytes, expectedBytes)) {
8597
return null;
8698
}
8799

88100
// 解析payload
89101
String payloadJson = base64UrlDecode(encodedPayload);
90102
JsonObject payload = JsonParser.parseString(payloadJson).getAsJsonObject();
91-
103+
104+
// 必备字段校验(防止伪造或损坏的 token 通过签名验证后引发 NPE)
105+
if (!payload.has("exp") || !payload.has("sub") || !payload.has("adminId")) {
106+
return null;
107+
}
108+
92109
// 检查过期时间
93110
long exp = payload.get("exp").getAsLong();
94111
if (Instant.now().getEpochSecond() > exp) {
95112
return null; // Token已过期
96113
}
97-
114+
98115
return payload;
99116
} catch (Exception e) {
100117
return null;
101118
}
102119
}
103-
120+
104121
/**
105122
* 从token中提取管理员ID
106123
*/
107124
public static Long getAdminId(String token) {
108125
JsonObject payload = verifyAndDecode(token);
109-
return payload != null ? payload.get("adminId").getAsLong() : null;
126+
if (payload == null || !payload.has("adminId")) {
127+
return null;
128+
}
129+
try {
130+
return payload.get("adminId").getAsLong();
131+
} catch (Exception e) {
132+
return null;
133+
}
110134
}
111-
135+
112136
/**
113137
* 从token中提取用户名
114138
*/
115139
public static String getUsername(String token) {
116140
JsonObject payload = verifyAndDecode(token);
117-
return payload != null ? payload.get("sub").getAsString() : null;
141+
if (payload == null || !payload.has("sub")) {
142+
return null;
143+
}
144+
try {
145+
return payload.get("sub").getAsString();
146+
} catch (Exception e) {
147+
return null;
148+
}
118149
}
119-
150+
120151
/**
121152
* 检查token是否过期
122153
*/
123154
public static boolean isTokenExpired(String token) {
124155
JsonObject payload = verifyAndDecode(token);
125-
if (payload == null) {
156+
if (payload == null || !payload.has("exp")) {
157+
return true;
158+
}
159+
try {
160+
long exp = payload.get("exp").getAsLong();
161+
return Instant.now().getEpochSecond() > exp;
162+
} catch (Exception e) {
126163
return true;
127164
}
128-
long exp = payload.get("exp").getAsLong();
129-
return Instant.now().getEpochSecond() > exp;
130165
}
131-
166+
132167
/**
133168
* 获取token过期时间
134169
*/
135170
public static LocalDateTime getExpirationTime(String token) {
136171
JsonObject payload = verifyAndDecode(token);
137-
if (payload == null) {
172+
if (payload == null || !payload.has("exp")) {
173+
return null;
174+
}
175+
try {
176+
long exp = payload.get("exp").getAsLong();
177+
return LocalDateTime.ofInstant(Instant.ofEpochSecond(exp), ZoneId.systemDefault());
178+
} catch (Exception e) {
138179
return null;
139180
}
140-
long exp = payload.get("exp").getAsLong();
141-
return LocalDateTime.ofInstant(Instant.ofEpochSecond(exp), ZoneId.systemDefault());
142181
}
143182

144183
/**
145-
* 创建签名
184+
* 创建签名 (HMAC-SHA256)
146185
*/
147186
private static String createSignature(String data) throws Exception {
148-
String signData = data + secretKey;
149-
MessageDigest digest = MessageDigest.getInstance("SHA-256");
150-
byte[] hash = digest.digest(signData.getBytes(StandardCharsets.UTF_8));
151-
return base64UrlEncode(Base64.getEncoder().encodeToString(hash));
187+
if (secretKey == null) {
188+
throw new IllegalStateException("JwtUtil 未初始化, 请先调用 JwtUtil.initialize()");
189+
}
190+
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
191+
mac.init(new SecretKeySpec(secretKey, HMAC_ALGORITHM));
192+
byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
193+
return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
152194
}
153195

154196
/**

src/main/java/com/xaoxiao/convenientaccess/config/ConfigManager.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,21 @@ public void setApiToken(String token) {
128128
public String getTokenPrefix() {
129129
return config.getString("api.auth.token-prefix", "sk-");
130130
}
131+
132+
/**
133+
* 获取JWT签名密钥(base64 编码的 256 位随机串)
134+
*/
135+
public String getJwtSecret() {
136+
return config.getString("api.auth.jwt-secret", "");
137+
}
138+
139+
/**
140+
* 设置JWT签名密钥到配置文件
141+
*/
142+
public void setJwtSecret(String secret) {
143+
config.set("api.auth.jwt-secret", secret);
144+
plugin.saveConfig();
145+
}
131146

132147
/**
133148
* 获取管理员密码

0 commit comments

Comments
 (0)