mirror of
https://github.com/qaiu/netdisk-fast-download.git
synced 2026-02-24 06:05:23 +00:00
feat: harden donated-account failure token and document key usage
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -56,6 +56,7 @@ test-filelist.java
|
|||||||
*.temp
|
*.temp
|
||||||
*.log
|
*.log
|
||||||
*.bak
|
*.bak
|
||||||
|
**/secret.yml
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -179,6 +179,18 @@ GET /parser?url={分享链接}&pwd={密码}&auth={加密后的认证参数}
|
|||||||
|
|
||||||
> 💡 提示:Web 界面已内置认证配置功能,可自动处理加密过程,无需手动构造参数。
|
> 💡 提示:Web 界面已内置认证配置功能,可自动处理加密过程,无需手动构造参数。
|
||||||
> [可以使用在线认证参数加密](https://qaiu.top/nfd-auth.html)
|
> [可以使用在线认证参数加密](https://qaiu.top/nfd-auth.html)
|
||||||
|
|
||||||
|
#### 密钥作用说明
|
||||||
|
|
||||||
|
- `server.authEncryptKey`
|
||||||
|
- 作用:用于 `auth` 参数的 AES 加解密
|
||||||
|
- 要求:16位(AES-128)
|
||||||
|
|
||||||
|
- `server.donatedAccountFailureTokenSignKey`
|
||||||
|
- 作用:用于“捐赠账号失败计数 token”的 HMAC 签名/验签
|
||||||
|
- 目的:防止客户端伪造失败计数请求
|
||||||
|
- 建议:使用高强度随机字符串,且不要与 `authEncryptKey` 相同
|
||||||
|
|
||||||
### 特殊说明
|
### 特殊说明
|
||||||
|
|
||||||
- 移动云云空间的 `分享key` 取分享链接中的 `data` 参数值
|
- 移动云云空间的 `分享key` 取分享链接中的 `data` 参数值
|
||||||
|
|||||||
@@ -36,6 +36,22 @@ URL解码 → Base64解码 → AES解密 → JSON对象
|
|||||||
- **密钥长度**: 16位(128位)
|
- **密钥长度**: 16位(128位)
|
||||||
- **默认密钥**: `nfd_auth_key2026`(可在 `app-dev.yml` 中通过 `server.authEncryptKey` 配置)
|
- **默认密钥**: `nfd_auth_key2026`(可在 `app-dev.yml` 中通过 `server.authEncryptKey` 配置)
|
||||||
|
|
||||||
|
### 密钥作用说明(重要)
|
||||||
|
|
||||||
|
当前系统中涉及两类不同用途的密钥:
|
||||||
|
|
||||||
|
1. `server.authEncryptKey`
|
||||||
|
- 用途:加解密 `auth` 参数(前端/调用方传入的认证信息)
|
||||||
|
- 影响范围:`/parser`、`/json/parser`、`/v2/linkInfo` 等接口中的 `auth` 参数
|
||||||
|
- 注意:这是 **AES 对称加密密钥**,要求 16 位
|
||||||
|
|
||||||
|
2. `server.donatedAccountFailureTokenSignKey`
|
||||||
|
- 用途:签名和验签“捐赠账号失败计数 token”(用于防伪造、失败计数)
|
||||||
|
- 影响范围:捐赠账号失败计数与自动失效逻辑
|
||||||
|
- 注意:这是 **HMAC 签名密钥**,与 `authEncryptKey` 已解耦,建议使用高强度随机字符串
|
||||||
|
|
||||||
|
> 建议:生产环境务必同时自定义这两个密钥,且不要设置为相同值。
|
||||||
|
|
||||||
## JSON 模型定义
|
## JSON 模型定义
|
||||||
|
|
||||||
### AuthParam 对象
|
### AuthParam 对象
|
||||||
@@ -301,14 +317,25 @@ if (auths != null) {
|
|||||||
|
|
||||||
## 配置说明
|
## 配置说明
|
||||||
|
|
||||||
在 `app-dev.yml` 中配置加密密钥:
|
在 `app-dev.yml` 中配置密钥:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
server:
|
server:
|
||||||
# auth参数加密密钥(16位AES密钥)
|
# auth参数加密密钥(16位AES密钥)
|
||||||
authEncryptKey: 'your_custom_key16'
|
authEncryptKey: 'your_custom_key16'
|
||||||
|
|
||||||
|
# 捐赠账号失败计数token签名密钥(HMAC)
|
||||||
|
# 建议使用较长随机字符串,并与 authEncryptKey 不同
|
||||||
|
donatedAccountFailureTokenSignKey: 'your_random_hmac_sign_key'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 密钥管理建议
|
||||||
|
|
||||||
|
- 不要在公开仓库提交生产密钥
|
||||||
|
- 建议通过环境变量或私有配置注入
|
||||||
|
- 调整 `authEncryptKey` 会影响 `auth` 参数兼容性
|
||||||
|
- 调整 `donatedAccountFailureTokenSignKey` 会使已签发的失败计数 token 失效(短期可接受)
|
||||||
|
|
||||||
## 更新日志
|
## 更新日志
|
||||||
|
|
||||||
- **2026-02-05**: 初始版本,支持 accesstoken、cookie、password、custom 认证类型
|
- **2026-02-05**: 初始版本,支持 accesstoken、cookie、password、custom 认证类型
|
||||||
|
|||||||
@@ -369,16 +369,34 @@
|
|||||||
</el-alert>
|
</el-alert>
|
||||||
|
|
||||||
<!-- 已捐赠账号数量统计 -->
|
<!-- 已捐赠账号数量统计 -->
|
||||||
<div v-if="donateAccountCounts.total > 0" style="margin-bottom: 16px;">
|
<div v-if="donateAccountCounts.active.total + donateAccountCounts.inactive.total > 0" style="margin-bottom: 16px;">
|
||||||
<el-divider content-position="left">当前账号池(共 {{ donateAccountCounts.total }} 个)</el-divider>
|
<el-divider content-position="left">
|
||||||
<el-tag
|
当前账号池(活跃 {{ donateAccountCounts.active.total }} / 失效 {{ donateAccountCounts.inactive.total }})
|
||||||
v-for="(count, panType) in donateAccountCounts"
|
</el-divider>
|
||||||
:key="panType"
|
|
||||||
v-show="panType !== 'total'"
|
<div style="margin-bottom: 8px;">
|
||||||
type="success"
|
<el-tag type="success" style="margin-right: 8px;">活跃账号</el-tag>
|
||||||
style="margin-right: 6px; margin-bottom: 4px;">
|
<el-tag
|
||||||
{{ getPanDisplayName(panType) }}: {{ count }} 个
|
v-for="(count, panType) in donateAccountCounts.active"
|
||||||
</el-tag>
|
:key="`active-${panType}`"
|
||||||
|
v-show="panType !== 'total'"
|
||||||
|
type="success"
|
||||||
|
style="margin-right: 6px; margin-bottom: 4px;">
|
||||||
|
{{ getPanDisplayName(panType) }}: {{ count }} 个
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<el-tag type="danger" style="margin-right: 8px;">失效账号</el-tag>
|
||||||
|
<el-tag
|
||||||
|
v-for="(count, panType) in donateAccountCounts.inactive"
|
||||||
|
:key="`inactive-${panType}`"
|
||||||
|
v-show="panType !== 'total'"
|
||||||
|
type="danger"
|
||||||
|
style="margin-right: 6px; margin-bottom: 4px;">
|
||||||
|
{{ getPanDisplayName(panType) }}: {{ count }} 个
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else style="margin-bottom: 16px; text-align: center; color: #999;">
|
<div v-else style="margin-bottom: 16px; text-align: center; color: #999;">
|
||||||
暂无捐赠账号,成为第一个捐赠者吧!
|
暂无捐赠账号,成为第一个捐赠者吧!
|
||||||
@@ -530,8 +548,11 @@ export default {
|
|||||||
token: '',
|
token: '',
|
||||||
remark: ''
|
remark: ''
|
||||||
},
|
},
|
||||||
// 捐赠账号数量统计 { panType: count, total: N }
|
// 捐赠账号数量统计
|
||||||
donateAccountCounts: { total: 0 }
|
donateAccountCounts: {
|
||||||
|
active: { total: 0 },
|
||||||
|
inactive: { total: 0 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -804,6 +825,7 @@ export default {
|
|||||||
if (config.ext3) authObj.ext3 = config.ext3
|
if (config.ext3) authObj.ext3 = config.ext3
|
||||||
if (config.ext4) authObj.ext4 = config.ext4
|
if (config.ext4) authObj.ext4 = config.ext4
|
||||||
if (config.ext5) authObj.ext5 = config.ext5
|
if (config.ext5) authObj.ext5 = config.ext5
|
||||||
|
if (config.donatedAccountToken) authObj.donatedAccountToken = config.donatedAccountToken
|
||||||
|
|
||||||
// AES 加密 + Base64 + URL 编码
|
// AES 加密 + Base64 + URL 编码
|
||||||
try {
|
try {
|
||||||
@@ -1310,21 +1332,39 @@ export default {
|
|||||||
async loadDonateAccountCounts() {
|
async loadDonateAccountCounts() {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${this.baseAPI}/v2/donateAccountCounts`)
|
const response = await axios.get(`${this.baseAPI}/v2/donateAccountCounts`)
|
||||||
// 解包可能的 JsonResult 嵌套: { code, data: { code, data: { QK: 3, total: 4 } } }
|
// 解包可能的 JsonResult 嵌套
|
||||||
let data = response.data
|
let data = response.data
|
||||||
while (data && data.data !== undefined && data.code !== undefined) {
|
while (data && data.data !== undefined && data.code !== undefined) {
|
||||||
data = data.data
|
data = data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data && typeof data === 'object') {
|
if (data && typeof data === 'object') {
|
||||||
// 确保有 total 字段
|
// 兼容新结构: { active: {...}, inactive: {...} }
|
||||||
if (data.total === undefined) {
|
if (data.active && data.inactive) {
|
||||||
let total = 0
|
if (data.active.total === undefined) {
|
||||||
for (const [key, val] of Object.entries(data)) {
|
data.active.total = Object.entries(data.active)
|
||||||
if (typeof val === 'number') total += val
|
.filter(([k, v]) => k !== 'total' && typeof v === 'number')
|
||||||
|
.reduce((s, [, v]) => s + v, 0)
|
||||||
|
}
|
||||||
|
if (data.inactive.total === undefined) {
|
||||||
|
data.inactive.total = Object.entries(data.inactive)
|
||||||
|
.filter(([k, v]) => k !== 'total' && typeof v === 'number')
|
||||||
|
.reduce((s, [, v]) => s + v, 0)
|
||||||
|
}
|
||||||
|
this.donateAccountCounts = data
|
||||||
|
} else {
|
||||||
|
// 兼容旧结构: { QK: 3, total: 4 }
|
||||||
|
const active = { ...data }
|
||||||
|
if (active.total === undefined) {
|
||||||
|
active.total = Object.entries(active)
|
||||||
|
.filter(([k, v]) => k !== 'total' && typeof v === 'number')
|
||||||
|
.reduce((s, [, v]) => s + v, 0)
|
||||||
|
}
|
||||||
|
this.donateAccountCounts = {
|
||||||
|
active,
|
||||||
|
inactive: { total: 0 }
|
||||||
}
|
}
|
||||||
data.total = total
|
|
||||||
}
|
}
|
||||||
this.donateAccountCounts = data
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载捐赠账号统计失败:', e)
|
console.error('加载捐赠账号统计失败:', e)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import cn.qaiu.lz.common.util.URLParamUtil;
|
|||||||
import cn.qaiu.lz.web.model.AuthParam;
|
import cn.qaiu.lz.web.model.AuthParam;
|
||||||
import cn.qaiu.lz.web.model.CacheLinkInfo;
|
import cn.qaiu.lz.web.model.CacheLinkInfo;
|
||||||
import cn.qaiu.lz.web.service.CacheService;
|
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.RouteHandler;
|
||||||
import cn.qaiu.vx.core.annotaions.RouteMapping;
|
import cn.qaiu.vx.core.annotaions.RouteMapping;
|
||||||
import cn.qaiu.vx.core.enums.RouteMethod;
|
import cn.qaiu.vx.core.enums.RouteMethod;
|
||||||
@@ -29,6 +30,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
public class ServerApi {
|
public class ServerApi {
|
||||||
|
|
||||||
private final CacheService cacheService = AsyncServiceUtil.getAsyncServiceInstance(CacheService.class);
|
private final CacheService cacheService = AsyncServiceUtil.getAsyncServiceInstance(CacheService.class);
|
||||||
|
private final DbService dbService = AsyncServiceUtil.getAsyncServiceInstance(DbService.class);
|
||||||
|
|
||||||
@RouteMapping(value = "/parser", method = RouteMethod.GET, order = 1)
|
@RouteMapping(value = "/parser", method = RouteMethod.GET, order = 1)
|
||||||
public Future<Void> parse(HttpServerResponse response, HttpServerRequest request, RoutingContext rcx, String pwd, String auth) {
|
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())
|
response.putHeader("nfd-cache-hit", res.getCacheHit().toString())
|
||||||
.putHeader("nfd-cache-expires", res.getExpires()),
|
.putHeader("nfd-cache-expires", res.getExpires()),
|
||||||
res.getDirectLink(), promise))
|
res.getDirectLink(), promise))
|
||||||
.onFailure(t -> promise.fail(t.fillInStackTrace()));
|
.onFailure(t -> {
|
||||||
|
recordDonatedAccountFailureIfNeeded(otherParam, t);
|
||||||
|
promise.fail(t.fillInStackTrace());
|
||||||
|
});
|
||||||
return promise.future();
|
return promise.future();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +56,8 @@ public class ServerApi {
|
|||||||
public Future<CacheLinkInfo> parseJson(HttpServerRequest request, String pwd, String auth) {
|
public Future<CacheLinkInfo> parseJson(HttpServerRequest request, String pwd, String auth) {
|
||||||
String url = URLParamUtil.parserParams(request);
|
String url = URLParamUtil.parserParams(request);
|
||||||
JsonObject otherParam = buildOtherParam(request, auth);
|
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)
|
@RouteMapping(value = "/json/:type/:key", method = RouteMethod.GET)
|
||||||
@@ -106,10 +112,48 @@ public class ServerApi {
|
|||||||
otherParam.put("authInfo3", authParam.getExt3());
|
otherParam.put("authInfo3", authParam.getExt3());
|
||||||
otherParam.put("authInfo4", authParam.getExt4());
|
otherParam.put("authInfo4", authParam.getExt4());
|
||||||
otherParam.put("authInfo5", authParam.getExt5());
|
otherParam.put("authInfo5", authParam.getExt5());
|
||||||
|
if (authParam.getDonatedAccountToken() != null && !authParam.getDonatedAccountToken().isBlank()) {
|
||||||
|
otherParam.put("donatedAccountToken", authParam.getDonatedAccountToken());
|
||||||
|
}
|
||||||
log.debug("已解码认证参数: authType={}", authParam.getAuthType());
|
log.debug("已解码认证参数: authType={}", authParam.getAuthType());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return otherParam;
|
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("认证");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ public class AuthParam implements ToJson {
|
|||||||
*/
|
*/
|
||||||
private String ext5;
|
private String ext5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 捐赠账号失败计数令牌(服务端签发,不可伪造)
|
||||||
|
*/
|
||||||
|
private String donatedAccountToken;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从 JsonObject 构造
|
* 从 JsonObject 构造
|
||||||
*/
|
*/
|
||||||
@@ -111,6 +116,7 @@ public class AuthParam implements ToJson {
|
|||||||
this.ext3 = json.getString("ext3");
|
this.ext3 = json.getString("ext3");
|
||||||
this.ext4 = json.getString("ext4");
|
this.ext4 = json.getString("ext4");
|
||||||
this.ext5 = json.getString("ext5");
|
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 (ext3 != null) json.put("ext3", ext3);
|
||||||
if (ext4 != null) json.put("ext4", ext4);
|
if (ext4 != null) json.put("ext4", ext4);
|
||||||
if (ext5 != null) json.put("ext5", ext5);
|
if (ext5 != null) json.put("ext5", ext5);
|
||||||
|
if (donatedAccountToken != null) json.put("donatedAccountToken", donatedAccountToken);
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ public class DonatedAccount {
|
|||||||
@Constraint(notNull = true, defaultValue = "true")
|
@Constraint(notNull = true, defaultValue = "true")
|
||||||
private Boolean enabled = 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")
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||||
private Date createTime = new Date();
|
private Date createTime = new Date();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,4 +67,13 @@ public interface DbService extends BaseAsyncService {
|
|||||||
*/
|
*/
|
||||||
Future<JsonObject> getRandomDonatedAccount(String panType);
|
Future<JsonObject> getRandomDonatedAccount(String panType);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 签发捐赠账号失败计数令牌(服务端临时令牌)
|
||||||
|
*/
|
||||||
|
Future<String> issueDonatedAccountFailureToken(Long accountId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用服务端失败计数令牌记录捐赠账号解析失败
|
||||||
|
*/
|
||||||
|
Future<Void> recordDonatedAccountFailureByToken(String failureToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ package cn.qaiu.lz.web.service.impl;
|
|||||||
|
|
||||||
import cn.qaiu.db.pool.JDBCPoolInit;
|
import cn.qaiu.db.pool.JDBCPoolInit;
|
||||||
import cn.qaiu.lz.common.model.UserInfo;
|
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.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.annotaions.Service;
|
||||||
import cn.qaiu.vx.core.model.JsonResult;
|
import cn.qaiu.vx.core.model.JsonResult;
|
||||||
import io.vertx.core.Future;
|
import io.vertx.core.Future;
|
||||||
@@ -14,8 +15,13 @@ import io.vertx.sqlclient.Row;
|
|||||||
import io.vertx.sqlclient.Tuple;
|
import io.vertx.sqlclient.Tuple;
|
||||||
import io.vertx.sqlclient.templates.SqlTemplate;
|
import io.vertx.sqlclient.templates.SqlTemplate;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -28,6 +34,11 @@ import java.util.List;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
public class DbServiceImpl implements DbService {
|
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
|
@Override
|
||||||
public Future<JsonObject> sayOk(String data) {
|
public Future<JsonObject> sayOk(String data) {
|
||||||
log.info("say ok1 -> wait...");
|
log.info("say ok1 -> wait...");
|
||||||
@@ -271,88 +282,231 @@ public class DbServiceImpl implements DbService {
|
|||||||
@Override
|
@Override
|
||||||
public Future<JsonObject> saveDonatedAccount(JsonObject account) {
|
public Future<JsonObject> saveDonatedAccount(JsonObject account) {
|
||||||
JDBCPool client = JDBCPoolInit.instance().getPool();
|
JDBCPool client = JDBCPoolInit.instance().getPool();
|
||||||
Promise<JsonObject> promise = Promise.promise();
|
|
||||||
|
|
||||||
String sql = """
|
Future<String> encryptedUsername = CryptoUtil.encrypt(account.getString("username"));
|
||||||
INSERT INTO donated_account
|
Future<String> encryptedPassword = CryptoUtil.encrypt(account.getString("password"));
|
||||||
(pan_type, auth_type, username, password, token, remark, ip, enabled, create_time)
|
Future<String> encryptedToken = CryptoUtil.encrypt(account.getString("token"));
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, true, NOW())
|
|
||||||
""";
|
|
||||||
|
|
||||||
client.preparedQuery(sql)
|
return ensureFailCountColumn(client).compose(v ->
|
||||||
.execute(Tuple.of(
|
Future.all(encryptedUsername, encryptedPassword, encryptedToken).compose(compositeFuture -> {
|
||||||
account.getString("panType"),
|
String sql = """
|
||||||
account.getString("authType"),
|
INSERT INTO donated_account
|
||||||
account.getString("username"),
|
(pan_type, auth_type, username, password, token, remark, ip, enabled, fail_count, create_time)
|
||||||
account.getString("password"),
|
VALUES (?, ?, ?, ?, ?, ?, ?, true, 0, NOW())
|
||||||
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 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
|
@Override
|
||||||
public Future<JsonObject> getDonatedAccountCounts() {
|
public Future<JsonObject> getDonatedAccountCounts() {
|
||||||
JDBCPool client = JDBCPoolInit.instance().getPool();
|
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) {
|
for (Row row : rows) {
|
||||||
String panType = row.getString("pan_type");
|
String panType = row.getString("pan_type");
|
||||||
Integer count = row.getInteger("count");
|
boolean enabled = row.getBoolean("enabled");
|
||||||
counts.put(panType, count);
|
int count = row.getInteger("count");
|
||||||
total += count;
|
|
||||||
}
|
|
||||||
counts.put("total", total);
|
|
||||||
promise.complete(JsonResult.data(counts).toJsonObject());
|
|
||||||
}).onFailure(e -> {
|
|
||||||
log.error("getDonatedAccountCounts failed", e);
|
|
||||||
promise.fail(e);
|
|
||||||
});
|
|
||||||
|
|
||||||
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
|
@Override
|
||||||
public Future<JsonObject> getRandomDonatedAccount(String panType) {
|
public Future<JsonObject> getRandomDonatedAccount(String panType) {
|
||||||
JDBCPool client = JDBCPoolInit.instance().getPool();
|
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";
|
String sql = "SELECT * FROM donated_account WHERE pan_type = ? AND enabled = true ORDER BY RAND() LIMIT 1";
|
||||||
|
|
||||||
client.preparedQuery(sql)
|
return client.preparedQuery(sql)
|
||||||
.execute(Tuple.of(panType))
|
.execute(Tuple.of(panType))
|
||||||
.onSuccess(rows -> {
|
.compose(rows -> {
|
||||||
if (rows.size() > 0) {
|
if (rows.size() > 0) {
|
||||||
Row row = rows.iterator().next();
|
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
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();
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package cn.qaiu.lz.web.util;
|
||||||
|
|
||||||
|
public class CryptoException extends RuntimeException {
|
||||||
|
public CryptoException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
100
web-service/src/main/java/cn/qaiu/lz/web/util/CryptoUtil.java
Normal file
100
web-service/src/main/java/cn/qaiu/lz/web/util/CryptoUtil.java
Normal 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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user