feat: harden donated-account failure token and document key usage

This commit is contained in:
rensumo
2026-02-22 12:24:47 +08:00
parent 07c650a474
commit 81ffbbd6b1
11 changed files with 489 additions and 85 deletions

View File

@@ -5,6 +5,7 @@ import cn.qaiu.lz.common.util.URLParamUtil;
import cn.qaiu.lz.web.model.AuthParam;
import cn.qaiu.lz.web.model.CacheLinkInfo;
import cn.qaiu.lz.web.service.CacheService;
import cn.qaiu.lz.web.service.DbService;
import cn.qaiu.vx.core.annotaions.RouteHandler;
import cn.qaiu.vx.core.annotaions.RouteMapping;
import cn.qaiu.vx.core.enums.RouteMethod;
@@ -29,6 +30,7 @@ import lombok.extern.slf4j.Slf4j;
public class ServerApi {
private final CacheService cacheService = AsyncServiceUtil.getAsyncServiceInstance(CacheService.class);
private final DbService dbService = AsyncServiceUtil.getAsyncServiceInstance(DbService.class);
@RouteMapping(value = "/parser", method = RouteMethod.GET, order = 1)
public Future<Void> parse(HttpServerResponse response, HttpServerRequest request, RoutingContext rcx, String pwd, String auth) {
@@ -43,7 +45,10 @@ public class ServerApi {
response.putHeader("nfd-cache-hit", res.getCacheHit().toString())
.putHeader("nfd-cache-expires", res.getExpires()),
res.getDirectLink(), promise))
.onFailure(t -> promise.fail(t.fillInStackTrace()));
.onFailure(t -> {
recordDonatedAccountFailureIfNeeded(otherParam, t);
promise.fail(t.fillInStackTrace());
});
return promise.future();
}
@@ -51,7 +56,8 @@ public class ServerApi {
public Future<CacheLinkInfo> parseJson(HttpServerRequest request, String pwd, String auth) {
String url = URLParamUtil.parserParams(request);
JsonObject otherParam = buildOtherParam(request, auth);
return cacheService.getCachedByShareUrlAndPwd(url, pwd, otherParam);
return cacheService.getCachedByShareUrlAndPwd(url, pwd, otherParam)
.onFailure(t -> recordDonatedAccountFailureIfNeeded(otherParam, t));
}
@RouteMapping(value = "/json/:type/:key", method = RouteMethod.GET)
@@ -106,10 +112,48 @@ public class ServerApi {
otherParam.put("authInfo3", authParam.getExt3());
otherParam.put("authInfo4", authParam.getExt4());
otherParam.put("authInfo5", authParam.getExt5());
if (authParam.getDonatedAccountToken() != null && !authParam.getDonatedAccountToken().isBlank()) {
otherParam.put("donatedAccountToken", authParam.getDonatedAccountToken());
}
log.debug("已解码认证参数: authType={}", authParam.getAuthType());
}
}
return otherParam;
}
private void recordDonatedAccountFailureIfNeeded(JsonObject otherParam, Throwable cause) {
if (!isLikelyAuthFailure(cause)) {
return;
}
String donatedAccountToken = otherParam.getString("donatedAccountToken");
if (donatedAccountToken == null || donatedAccountToken.isBlank()) {
return;
}
dbService.recordDonatedAccountFailureByToken(donatedAccountToken)
.onFailure(e -> log.warn("记录捐赠账号失败次数失败", e));
}
private boolean isLikelyAuthFailure(Throwable cause) {
if (cause == null) {
return false;
}
String msg = cause.getMessage();
if (msg == null || msg.isBlank()) {
return false;
}
String lower = msg.toLowerCase();
return lower.contains("auth")
|| lower.contains("token")
|| lower.contains("cookie")
|| lower.contains("password")
|| lower.contains("credential")
|| lower.contains("401")
|| lower.contains("403")
|| lower.contains("unauthorized")
|| lower.contains("forbidden")
|| lower.contains("expired")
|| lower.contains("登录")
|| lower.contains("认证");
}
}

View File

@@ -93,6 +93,11 @@ public class AuthParam implements ToJson {
*/
private String ext5;
/**
* 捐赠账号失败计数令牌(服务端签发,不可伪造)
*/
private String donatedAccountToken;
/**
* 从 JsonObject 构造
*/
@@ -111,6 +116,7 @@ public class AuthParam implements ToJson {
this.ext3 = json.getString("ext3");
this.ext4 = json.getString("ext4");
this.ext5 = json.getString("ext5");
this.donatedAccountToken = json.getString("donatedAccountToken");
}
/**
@@ -129,6 +135,7 @@ public class AuthParam implements ToJson {
if (ext3 != null) json.put("ext3", ext3);
if (ext4 != null) json.put("ext4", ext4);
if (ext5 != null) json.put("ext5", ext5);
if (donatedAccountToken != null) json.put("donatedAccountToken", donatedAccountToken);
return json;
}

View File

@@ -47,6 +47,9 @@ public class DonatedAccount {
@Constraint(notNull = true, defaultValue = "true")
private Boolean enabled = true; // 是否启用
@Constraint(notNull = true, defaultValue = "0")
private Integer failCount = 0; // 失败次数,达到阈值自动禁用
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime = new Date();
}

View File

@@ -67,4 +67,13 @@ public interface DbService extends BaseAsyncService {
*/
Future<JsonObject> getRandomDonatedAccount(String panType);
/**
* 签发捐赠账号失败计数令牌(服务端临时令牌)
*/
Future<String> issueDonatedAccountFailureToken(Long accountId);
/**
* 使用服务端失败计数令牌记录捐赠账号解析失败
*/
Future<Void> recordDonatedAccountFailureByToken(String failureToken);
}

View File

@@ -2,8 +2,9 @@ package cn.qaiu.lz.web.service.impl;
import cn.qaiu.db.pool.JDBCPoolInit;
import cn.qaiu.lz.common.model.UserInfo;
import cn.qaiu.lz.web.service.DbService;
import cn.qaiu.lz.web.model.StatisticsInfo;
import cn.qaiu.lz.web.service.DbService;
import cn.qaiu.lz.web.util.CryptoUtil;
import cn.qaiu.vx.core.annotaions.Service;
import cn.qaiu.vx.core.model.JsonResult;
import io.vertx.core.Future;
@@ -14,8 +15,13 @@ import io.vertx.sqlclient.Row;
import io.vertx.sqlclient.Tuple;
import io.vertx.sqlclient.templates.SqlTemplate;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
@@ -28,6 +34,11 @@ import java.util.List;
@Slf4j
@Service
public class DbServiceImpl implements DbService {
private static final int DONATED_ACCOUNT_DISABLE_THRESHOLD = 3;
private static final long FAILURE_TOKEN_TTL_MILLIS = 10 * 60 * 1000L;
private static final String HMAC_ALGORITHM = "HmacSHA256";
private static final String DONATED_ACCOUNT_TOKEN_SIGN_KEY_CONFIG = "donatedAccountFailureTokenSignKey";
private static final String DONATED_ACCOUNT_TOKEN_SIGN_KEY_FALLBACK = "nfd_donate_fail_token_sign_2026";
@Override
public Future<JsonObject> sayOk(String data) {
log.info("say ok1 -> wait...");
@@ -271,88 +282,231 @@ public class DbServiceImpl implements DbService {
@Override
public Future<JsonObject> saveDonatedAccount(JsonObject account) {
JDBCPool client = JDBCPoolInit.instance().getPool();
Promise<JsonObject> promise = Promise.promise();
String sql = """
INSERT INTO donated_account
(pan_type, auth_type, username, password, token, remark, ip, enabled, create_time)
VALUES (?, ?, ?, ?, ?, ?, ?, true, NOW())
""";
Future<String> encryptedUsername = CryptoUtil.encrypt(account.getString("username"));
Future<String> encryptedPassword = CryptoUtil.encrypt(account.getString("password"));
Future<String> encryptedToken = CryptoUtil.encrypt(account.getString("token"));
client.preparedQuery(sql)
.execute(Tuple.of(
account.getString("panType"),
account.getString("authType"),
account.getString("username"),
account.getString("password"),
account.getString("token"),
account.getString("remark"),
account.getString("ip")
))
.onSuccess(res -> {
promise.complete(JsonResult.success("捐赠成功").toJsonObject());
})
.onFailure(e -> {
log.error("saveDonatedAccount failed", e);
promise.fail(e);
});
return ensureFailCountColumn(client).compose(v ->
Future.all(encryptedUsername, encryptedPassword, encryptedToken).compose(compositeFuture -> {
String sql = """
INSERT INTO donated_account
(pan_type, auth_type, username, password, token, remark, ip, enabled, fail_count, create_time)
VALUES (?, ?, ?, ?, ?, ?, ?, true, 0, NOW())
""";
return promise.future();
return client.preparedQuery(sql)
.execute(Tuple.of(
account.getString("panType"),
account.getString("authType"),
encryptedUsername.result(),
encryptedPassword.result(),
encryptedToken.result(),
account.getString("remark"),
account.getString("ip")
))
.map(res -> JsonResult.success("捐赠成功").toJsonObject())
.onFailure(e -> log.error("saveDonatedAccount failed", e));
}));
}
@Override
public Future<JsonObject> getDonatedAccountCounts() {
JDBCPool client = JDBCPoolInit.instance().getPool();
Promise<JsonObject> promise = Promise.promise();
String sql = "SELECT pan_type, COUNT(*) as count FROM donated_account WHERE enabled = true GROUP BY pan_type";
String sql = "SELECT pan_type, enabled, COUNT(*) as count FROM donated_account GROUP BY pan_type, enabled";
return client.query(sql).execute().map(rows -> {
JsonObject result = new JsonObject();
JsonObject activeCounts = new JsonObject();
JsonObject inactiveCounts = new JsonObject();
int totalActive = 0;
int totalInactive = 0;
client.query(sql).execute().onSuccess(rows -> {
JsonObject counts = new JsonObject();
int total = 0;
for (Row row : rows) {
String panType = row.getString("pan_type");
Integer count = row.getInteger("count");
counts.put(panType, count);
total += count;
}
counts.put("total", total);
promise.complete(JsonResult.data(counts).toJsonObject());
}).onFailure(e -> {
log.error("getDonatedAccountCounts failed", e);
promise.fail(e);
});
boolean enabled = row.getBoolean("enabled");
int count = row.getInteger("count");
return promise.future();
if (enabled) {
activeCounts.put(panType, count);
totalActive += count;
} else {
inactiveCounts.put(panType, count);
totalInactive += count;
}
}
activeCounts.put("total", totalActive);
inactiveCounts.put("total", totalInactive);
result.put("active", activeCounts);
result.put("inactive", inactiveCounts);
return JsonResult.data(result).toJsonObject();
}).onFailure(e -> log.error("getDonatedAccountCounts failed", e));
}
@Override
public Future<JsonObject> getRandomDonatedAccount(String panType) {
JDBCPool client = JDBCPoolInit.instance().getPool();
Promise<JsonObject> promise = Promise.promise();
String sql = "SELECT * FROM donated_account WHERE pan_type = ? AND enabled = true ORDER BY RAND() LIMIT 1";
client.preparedQuery(sql)
.execute(Tuple.of(panType))
.onSuccess(rows -> {
if (rows.size() > 0) {
Row row = rows.iterator().next();
JsonObject account = new JsonObject();
account.put("authType", row.getString("auth_type"));
account.put("username", row.getString("username"));
account.put("password", row.getString("password"));
account.put("token", row.getString("token"));
promise.complete(JsonResult.data(account).toJsonObject());
} else {
promise.complete(JsonResult.data(new JsonObject()).toJsonObject());
}
})
.onFailure(e -> {
log.error("getRandomDonatedAccount failed", e);
promise.fail(e);
});
return client.preparedQuery(sql)
.execute(Tuple.of(panType))
.compose(rows -> {
if (rows.size() > 0) {
Row row = rows.iterator().next();
Future<String> usernameFuture = decryptOrPlain(row.getString("username"));
Future<String> passwordFuture = decryptOrPlain(row.getString("password"));
Future<String> tokenFuture = decryptOrPlain(row.getString("token"));
Future<String> failureTokenFuture = issueDonatedAccountFailureToken(row.getLong("id"));
return Future.all(usernameFuture, passwordFuture, tokenFuture, failureTokenFuture)
.map(compositeFuture -> {
JsonObject account = new JsonObject();
account.put("authType", row.getString("auth_type"));
account.put("username", usernameFuture.result());
account.put("password", passwordFuture.result());
account.put("token", tokenFuture.result());
account.put("donatedAccountToken", failureTokenFuture.result());
return JsonResult.data(account).toJsonObject();
});
} else {
return Future.succeededFuture(JsonResult.data(new JsonObject()).toJsonObject());
}
})
.onFailure(e -> log.error("getRandomDonatedAccount failed", e));
}
@Override
public Future<String> issueDonatedAccountFailureToken(Long accountId) {
if (accountId == null) {
return Future.failedFuture("accountId is null");
}
try {
long issuedAt = System.currentTimeMillis();
String payload = accountId + ":" + issuedAt;
String signature = hmacSha256(payload);
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.getBytes(StandardCharsets.UTF_8))
+ "."
+ Base64.getUrlEncoder().withoutPadding().encodeToString(signature.getBytes(StandardCharsets.UTF_8));
return Future.succeededFuture(token);
} catch (Exception e) {
return Future.failedFuture(e);
}
}
@Override
public Future<Void> recordDonatedAccountFailureByToken(String failureToken) {
JDBCPool client = JDBCPoolInit.instance().getPool();
Long accountId;
try {
accountId = parseAndVerifyFailureToken(failureToken);
} catch (Exception e) {
return Future.failedFuture(e);
}
String updateSql = """
UPDATE donated_account
SET fail_count = fail_count + 1,
enabled = CASE
WHEN fail_count + 1 >= ? THEN false
ELSE enabled
END
WHERE id = ?
""";
return ensureFailCountColumn(client)
.compose(v -> client.preparedQuery(updateSql)
.execute(Tuple.of(DONATED_ACCOUNT_DISABLE_THRESHOLD, accountId))
.mapEmpty())
.onFailure(e -> log.error("recordDonatedAccountFailureByToken failed", e));
}
private Future<Void> ensureFailCountColumn(JDBCPool client) {
Promise<Void> promise = Promise.promise();
String sql = "ALTER TABLE donated_account ADD COLUMN IF NOT EXISTS fail_count INT DEFAULT 0 NOT NULL";
client.query(sql).execute()
.onSuccess(res -> promise.complete())
.onFailure(e -> {
String msg = e.getMessage() == null ? "" : e.getMessage().toLowerCase();
if (!(msg.contains("duplicate") || msg.contains("exists") || msg.contains("already"))) {
log.warn("ensure fail_count column failed, continue without schema migration", e);
}
promise.complete();
});
return promise.future();
}
private Future<String> decryptOrPlain(String value) {
if (value == null) {
return Future.succeededFuture(null);
}
if (!isLikelyEncrypted(value)) {
return Future.succeededFuture(value);
}
return CryptoUtil.decrypt(value).recover(e -> {
log.warn("decrypt donated account field failed, fallback to plaintext", e);
return Future.succeededFuture(value);
});
}
private boolean isLikelyEncrypted(String value) {
try {
byte[] decoded = Base64.getDecoder().decode(value);
return decoded.length > 16;
} catch (Exception e) {
return false;
}
}
private Long parseAndVerifyFailureToken(String token) throws Exception {
if (token == null || token.isBlank() || !token.contains(".")) {
throw new IllegalArgumentException("invalid donated account token");
}
String[] parts = token.split("\\.", 2);
String payload = new String(Base64.getUrlDecoder().decode(parts[0]), StandardCharsets.UTF_8);
String signature = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8);
String expected = hmacSha256(payload);
if (!expected.equals(signature)) {
throw new IllegalArgumentException("donated account token signature invalid");
}
String[] payloadParts = payload.split(":", 2);
if (payloadParts.length != 2) {
throw new IllegalArgumentException("invalid donated account token payload");
}
Long accountId = Long.parseLong(payloadParts[0]);
long issuedAt = Long.parseLong(payloadParts[1]);
if (System.currentTimeMillis() - issuedAt > FAILURE_TOKEN_TTL_MILLIS) {
throw new IllegalArgumentException("donated account token expired");
}
return accountId;
}
private String hmacSha256(String payload) throws Exception {
String secret = getDonatedAccountFailureTokenSignKey();
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM));
byte[] digest = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(digest);
}
private String getDonatedAccountFailureTokenSignKey() {
try {
String configKey = cn.qaiu.vx.core.util.SharedDataUtil
.getJsonStringForServerConfig(DONATED_ACCOUNT_TOKEN_SIGN_KEY_CONFIG);
if (StringUtils.isNotBlank(configKey)) {
return configKey;
}
} catch (Exception e) {
log.debug("读取捐赠账号失败计数签名密钥失败,使用默认值: {}", e.getMessage());
}
return DONATED_ACCOUNT_TOKEN_SIGN_KEY_FALLBACK;
}
}

View File

@@ -0,0 +1,7 @@
package cn.qaiu.lz.web.util;
public class CryptoException extends RuntimeException {
public CryptoException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,100 @@
package cn.qaiu.lz.web.util;
import cn.qaiu.vx.core.util.ConfigUtil;
import cn.qaiu.vx.core.util.VertxHolder;
import io.vertx.core.Future;
import io.vertx.core.Vertx;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
public class CryptoUtil {
private static final Logger logger = LoggerFactory.getLogger(CryptoUtil.class);
private static final String ALGORITHM = "AES/GCM/NoPadding";
private static final int GCM_IV_LENGTH = 12; // 96 bits
private static final int GCM_TAG_LENGTH = 16; // 128 bits
private static Future<SecretKeySpec> secretKeyFuture;
static {
Vertx vertx = VertxHolder.getVertxInstance();
if (vertx != null) {
secretKeyFuture = ConfigUtil.readYamlConfig("secret", vertx)
.map(config -> {
String key = config.getJsonObject("encrypt").getString("key");
if (key == null || key.length() != 32) {
throw new IllegalArgumentException("Invalid AES key length in secret.yml. Key must be 32 bytes.");
}
return new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "AES");
})
.onFailure(err -> logger.error("Failed to load encryption key from secret.yml", err));
} else {
logger.error("Vertx instance is not available for CryptoUtil initialization.");
secretKeyFuture = Future.failedFuture("Vertx instance not available.");
}
}
public static Future<String> encrypt(String strToEncrypt) {
if (strToEncrypt == null) {
return Future.succeededFuture(null);
}
return secretKeyFuture.compose(secretKey -> {
try {
byte[] iv = new byte[GCM_IV_LENGTH];
SecureRandom random = new SecureRandom();
random.nextBytes(iv);
Cipher cipher = Cipher.getInstance(ALGORITHM);
GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmParameterSpec);
byte[] cipherText = cipher.doFinal(strToEncrypt.getBytes(StandardCharsets.UTF_8));
// Prepend IV to ciphertext
ByteBuffer byteBuffer = ByteBuffer.allocate(iv.length + cipherText.length);
byteBuffer.put(iv);
byteBuffer.put(cipherText);
return Future.succeededFuture(Base64.getEncoder().encodeToString(byteBuffer.array()));
} catch (Exception e) {
return Future.failedFuture(new CryptoException("Encryption failed", e));
}
});
}
public static Future<String> decrypt(String strToDecrypt) {
if (strToDecrypt == null) {
return Future.succeededFuture(null);
}
return secretKeyFuture.compose(secretKey -> {
try {
byte[] decodedBytes = Base64.getDecoder().decode(strToDecrypt);
// Extract IV from the beginning of the decoded bytes
ByteBuffer byteBuffer = ByteBuffer.wrap(decodedBytes);
byte[] iv = new byte[GCM_IV_LENGTH];
byteBuffer.get(iv);
byte[] cipherText = new byte[byteBuffer.remaining()];
byteBuffer.get(cipherText);
Cipher cipher = Cipher.getInstance(ALGORITHM);
GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv);
cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmParameterSpec);
byte[] decryptedText = cipher.doFinal(cipherText);
return Future.succeededFuture(new String(decryptedText, StandardCharsets.UTF_8));
} catch (Exception e) {
return Future.failedFuture(new CryptoException("Decryption failed", e));
}
});
}
}