12 Commits
v1.0.4 ... main

Author SHA1 Message Date
1de6f2186f 精简配置 2026-02-03 21:13:40 +08:00
a198fc9d81 更新配置模板 2026-02-03 21:01:53 +08:00
24caa18883 更新说明书 2026-01-24 20:54:01 +08:00
41c2771ed8 测试脚本是否运行 2026-01-24 20:20:27 +08:00
871d92daa4 修正最新tag检查方式。 2026-01-24 20:10:51 +08:00
fa9a0ea04e 改进服务器端Tag源码更新脚本。 2026-01-24 20:05:27 +08:00
26a64b44e9 添加对签名证书修改后的证书识别能力。 2026-01-24 19:49:51 +08:00
df745e6f83 APK校验接口调试完成 2026-01-24 11:16:13 +08:00
687c5a62d2 固定APK调试文件测试成功 2026-01-23 21:05:12 +08:00
0b44bab651 添加APK文件工具类 2026-01-23 13:16:11 +08:00
db92f7c0ad 更新配置文件示例 2026-01-23 01:48:18 +08:00
5248ed41bd 应用签名联网验证模块完成。 2026-01-22 20:41:22 +08:00
6 changed files with 715 additions and 133 deletions

View File

@@ -20,6 +20,10 @@ java -cp "runtime:libs/*" Main
应用程序通过 ./config/config.ini 文件配置应用运行参数,日志级别与日志路径。
应用程序配置文件兜底路径为 /sdcard/WinBoLLStudio/AuthCenterConsoleApp/config/config.ini 。
Docker环境配置简要
需要挂载config.ini配置文件的apks_folder_path的路径。
[APP]
apks_folder_path=/sdcard/WinBoLLStudio/APKs
公网访问接口
https://console.winboll.cc/authcenter/ping

View File

@@ -20,7 +20,7 @@ if [ $? -ne 0 ]; then
exit 1
fi
# 拉最新代码:已最新/拉取成功都继续,拉取失败才退出(彻底兼容所有场景
# 拉取main分支最新代码+远程所有tag核心同步最新代码和tag
echo "拉取main分支最新代码..."
git pull origin main --rebase >/dev/null 2>&1
if [ $? -ne 0 ]; then
@@ -28,12 +28,14 @@ if [ $? -ne 0 ]; then
exit 1
fi
# 优先取HEAD关联tag无则取仓库最近tag
# 拉取远程所有tag确保本地有最新tag关键补充
echo "同步远程最新tag..."
git fetch origin --tags >/dev/null 2>&1
# 【核心修复】直接获取仓库最新tag按提交时间排序删除HEAD关联tag的优先逻辑
get_latest_tag() {
local head_tag=$(git log -1 --pretty=format:%D 2>/dev/null | grep -o 'tag: [^, ]*' | awk -F': ' '{print $2}')
[ -n "$head_tag" ] && { echo "$head_tag"; return 0; }
local latest_tag=$(git describe --tags --abbrev=0 2>/dev/null || true)
# 按提交时间倒序取最新tag兼容v1.0.8、v1.0.9命名格式
local latest_tag=$(git tag -l | sort -V | tail -n1 2>/dev/null || true)
[ -n "$latest_tag" ] && { echo "$latest_tag"; return 0; }
echo "no_tag" && return 1
@@ -45,7 +47,7 @@ if [ "$latest_tag" = "no_tag" ]; then
echo "错误仓库无任何tag终止执行"
exit 1
fi
echo "获取到目标tag$latest_tag"
echo "获取到最新目标tag$latest_tag"
# 切换tag失败退出
echo "切换至tag $latest_tag..."
@@ -66,5 +68,5 @@ fi
# 确保config目录存在写入tag信息
mkdir -p config >/dev/null 2>&1
echo "$latest_tag" > config/version.flags
echo "✅ 全部操作完成!tag已写入config/version.flags"
echo "✅ 全部操作完成!最新 tag[$latest_tag] 已写入 config/version.flags"

View File

@@ -1,42 +1,68 @@
;AuthCenter 配置文件INI格式适配smtp_auth_code读取校验
; 说明:已匹配日志校验键名,替换为占位符说明,填真实值可直接启动,分号开头为注释
; 说明:已匹配日志校验键名,按注释提示填写后可直接启动,分号开头为注释,等号前后无空格
; 配置规则路径支持绝对路径数值型参数为整型时间单位统一为毫秒timeout/interval类
[GlobalConfig]
log_level=【必填|可选值ALL/FINE/INFO/WARNING/SEVERE】日志输出级别默认填INFO
service_heartbeat_interval=【必填|正整数】服务心跳检测间隔(毫秒)默认填500
; 项目根目录绝对路径需指向AuthCenterConsoleApp根目录建议与部署路径一致
root_path=/sdcard/ZhanGSKen/Sources/AuthCenterConsoleApp
; 日志级别:可选 ALL/FINE/INFO/WARNING/SEVERE开发环境用ALL生产环境用INFO/WARNING
log_level=ALL
; 日志文件存储目录基于root_path的相对路径自动创建
log_path=logs
; RSA密钥对存储目录基于root_path的相对路径自动创建用于加解密
rsakeys_path=rsakeys
; 报告文件存储目录基于root_path的相对路径自动创建如校验报告/运行报告)
reports_path=reports
; 服务心跳检测间隔单位毫秒建议500-2000数值越小检测越频繁
service_heartbeat_interval=500
[VersionConfig]
; 版本号文件存储路径基于root_path的相对路径由构建脚本自动写入tag信息
version_file_path=config/version.flags
; 版本号合法正则表达式匹配v+主版本+次版本+修订版如v1.0.9,请勿随意修改)
version_valid_regex=^v\d+.\d+.\d+$
[APP]
; APK文件根目录绝对路径用于存储待校验的APK安装包自动按项目名/版本名分目录)
apks_folder_path=/sdcard/WinBoLLStudio/APKs
[EmailConfig]
; 邮件SMTP服务器QQ邮箱smtp.qq.com163smtp.163.com需修改)
smtp_host=【必填】SMTP服务器地址QQ邮箱固定smtp.qq.com
; SSL端口465兼容性强无需非SSL填25
smtp_port=【必填|端口号】邮件服务端口推荐填465SSL
; 发送方邮箱(需和smtp_auth_code对应
send_email_account=【必填】发送通知的邮箱账号(如xxx@qq.com
; 核心smtp_auth_codeQQ邮箱填授权码非登录密码其他邮箱填对应授权码/密码
smtp_auth_code=【必填】邮箱SMTP授权码QQ邮箱需去官网开启SMTP后获取
; 发件人昵称(收件方看到的发件人名称)
from_nickname=【可选】发件人显示昵称如WinBoLLStudio
smtp_connect_timeout=【可选|正整数】邮件连接超时时间(毫秒)默认填5000
email_subject_prefix=【可选】邮件标题前缀如WinBoLLStudio
verify_code_email_content=【可选】验证码邮件内容,{code}为验证码占位符,请勿删除
[ServerConfig]
server_base_url=【必填】服务基础地址本地调试填http://localhost:端口号和http_server_port一致
; 邮件SMTP服务器地址QQ邮箱固定为smtp.qq.com163邮箱为smtp.163.com需修改)
smtp_host=smtp.qq.com
; SMTP服务器端口SSL加密端口465兼容性强,无需修改请勿改为25/587
smtp_port=465
; 发送方邮箱(必填需与SMTP授权码匹配如QQ邮箱/163邮箱
send_email_account=[请填写发件人邮箱,如xxx@qq.com]
; SMTP授权码必填非邮箱登录密码需在邮箱后台开启SMTP服务后获取
; 获取方式QQ邮箱-设置-账户-POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务-开启SMTP-生成授权码
smtp_auth_code=[请填写邮箱SMTP授权码非登录密码]
; 发件人昵称(自定义,收件人看到的发件人名称,可随意修改)
from_nickname=WinBoLLStudio
; SMTP服务器连接超时时间单位毫秒建议3000-10000避免网络波动导致连接失败
smtp_connect_timeout=5000
; 邮件主题前缀验证码邮件的主题前缀可自定义如WinBoLL-认证验证码)
email_subject_prefix=WinBoLLStudio
; 验证码邮件内容模板({code}为占位符会自动替换为实际6位验证码请勿删除占位符
verify_code_email_content=您的认证验证码为:{code}有效期5分钟请及时使用请勿泄露给他人
[VerifyCodeConfig]
verify_code_length=【可选|正整数】验证码长度默认填6
verify_code_expire_minutes=【可选|正整数】验证码有效期(分钟)默认填5
expire_code_clean_interval=【可选|正整数】过期验证码清理间隔(分钟)默认填1
; 验证码长度整型建议6位请勿修改为小于4位/大于8位
verify_code_length=6
; 验证码有效期单位分钟建议5-10分钟平衡安全性和易用性
verify_code_expire_minutes=5
; 过期验证码清理间隔单位分钟建议1-5分钟自动清理过期验证码释放内存
expire_code_clean_interval=1
[HttpServiceConfig]
http_server_port=【必填|端口号】HTTP服务监听端口,默认填8080(需和server_base_url端口一致
http_start_timeout=【可选|正整数】HTTP服务启动超时时间(毫秒)默认填3000
; HTTP服务监听端口整型建议8080/8090需保证端口未被占用server_base_url端口一致
http_server_port=8080
; HTTP服务启动超时时间单位毫秒建议3000-5000等待服务完全启动的最大时间
http_start_timeout=3000
; 新增:测试用例专属配置节(可选,集中管理测试相关参数
; 新增:测试用例专属配置节(可选,仅自动化测试时生效,生产环境可忽略
[TestConfig]
; 测试用例目录(基于PROJECT_ROOT_DIR拼接无需绝对路径
test_case_dir=【可选】测试用例存放目录,默认填test/cases
; 测试报告输出目录(测试结果保存路径)
test_report_dir=【可选】测试报告输出目录,默认填test/reports
; 测试数据文件路径(如测试邮箱列表、测试验证码池
test_data_file=【可选】测试数据文件路径,默认填test/data/test_data.json
; 测试用例目录(基于root_path的相对路径存储自动化测试用例脚本
test_case_dir=test/cases
; 测试报告输出目录(基于root_path的相对路径自动化测试完成后生成的报告存储路径)
test_report_dir=test/reports
; 测试数据文件路径(基于root_path的相对路径存储测试用的基础数据如测试邮箱列表、测试参数
test_data_file=test/data/test_data.json

View File

@@ -0,0 +1,330 @@
package cc.winboll.app;
import cc.winboll.LogUtils;
import cc.winboll.util.IniConfigUtils;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Enumeration;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
/**
* APK文件工具类单例- 生产级签名+哈希双校验版修复Too short异常兼容MT重签名
* 1. 稳定解析META-INF下所有RSA原始字节与客户端Signature.toByteArray()1:1对齐解决X509解析异常
* 2. 支持SHA256文件哈希字节级唯一校验签名+哈希双重验证
* 3. 入参包含:项目名/版本名/APK名/客户端签名/客户端哈希,适配生产级版本管理
* 4. APK路径规范apks_root/项目名/debug/tag/APK文件支持调试/正式环境)
* 5. 兼容MT重签名遍历META-INF所有.RSA/.rsa文件适配自定义签名文件命名
* @Author ZhanGSKen<zhangsken@qq.com>
*/
public class APKFileUtils {
// 单例实例
private static volatile APKFileUtils sInstance;
// 配置项
private static final String CONFIG_SECTION = "APP";
private static final String KEY_APKS_FOLDER = "apks_folder_path";
// 算法常量(与客户端严格对齐)
private static final String SIGN_ALGORITHM = "SHA1"; // 签名摘要算法
private static final String HASH_ALGORITHM = "SHA-256"; // 文件哈希算法
// 签名文件目录与后缀兼容MT重签名遍历所有RSA文件
private static final String META_INF_DIR = "META-INF/";
private static final String RSA_SUFFIX_UPPER = ".RSA";
private static final String RSA_SUFFIX_LOWER = ".rsa";
// APK根目录
private String apksRootPath;
private APKFileUtils() {}
/**
* 初始化工具类(需在应用启动时调用)
*/
public static void init() {
if (sInstance == null) {
synchronized (APKFileUtils.class) {
if (sInstance == null) {
sInstance = new APKFileUtils();
sInstance.loadConfig();
}
}
}
}
/**
* 获取单例实例
*/
public static APKFileUtils getInstance() {
if (sInstance == null) {
LogUtils.e("APKFileUtils", "请先调用init()初始化工具类");
throw new IllegalStateException("APKFileUtils未初始化请先调用init()");
}
return sInstance;
}
/**
* 加载配置文件中的APK根目录
*/
private void loadConfig() {
try {
apksRootPath = IniConfigUtils.getConfigValue(CONFIG_SECTION, KEY_APKS_FOLDER, "").trim();
if (apksRootPath.isEmpty()) {
LogUtils.e("APKFileUtils", "配置项apks_folder_path为空初始化失败");
return;
}
File rootDir = new File(apksRootPath);
if (!rootDir.exists() && !rootDir.mkdirs()) {
LogUtils.e("APKFileUtils", "APK根目录创建失败" + apksRootPath);
apksRootPath = "";
return;
}
LogUtils.i("APKFileUtils", "APK根目录加载成功" + apksRootPath);
} catch (Exception e) {
LogUtils.e("APKFileUtils", "加载APK根目录配置失败", e);
apksRootPath = "";
}
}
/**
* 对外暴露核心校验方法:签名 + SHA256文件哈希 双校验
* 入参包含:项目名/版本名/APK文件名/客户端签名Base64/客户端文件哈希
* APK路径规范apksRootPath/项目名/版本名/APK文件
* @param isDebug 调试环境标识
* @param projectName 项目名(非空)
* @param versionName 版本名非空如15.11.11
* @param apkFileName APK文件名非空需以.apk结尾
* @param clientSignBase64 客户端传入的签名Base64非空
* @param clientFileHash 客户端传入的APK文件SHA256哈希小写/大写均可,非空)
* @return 校验通过返回true否则false
*/
public static boolean checkAPK(boolean isDebug, String projectName, String versionName, String apkFileName,
String clientSignBase64, String clientFileHash) {
return getInstance().doCheckAPK(isDebug, projectName, versionName, apkFileName, clientSignBase64, clientFileHash);
}
/**
* 核心校验实现:严格按「哈希先验,签名后验」顺序,哈希不匹配直接返回
*/
private boolean doCheckAPK(boolean isDebug, String projectName, String versionName, String apkFileName,
String clientSignBase64, String clientFileHash) {
// 1. 基础入参非空校验
if (isParamEmpty(projectName) || isParamEmpty(versionName) || isParamEmpty(apkFileName)
|| isParamEmpty(clientSignBase64) || isParamEmpty(clientFileHash)) {
LogUtils.w("APKFileUtils", "基础参数不能为空projectName/versionName/apkFileName/clientSignBase64/clientFileHash");
return false;
}
// 2. APK文件名格式校验
if (!apkFileName.endsWith(".apk")) {
LogUtils.w("APKFileUtils", "APK文件名格式错误需以.apk结尾" + apkFileName);
return false;
}
// 3. APK根目录校验
if (isParamEmpty(apksRootPath)) {
LogUtils.w("APKFileUtils", "APK根目录未配置无法进行校验");
return false;
}
// 4. 拼接标准APK路径根目录/项目名/debug/项目名_版本名.apk调试环境可切换tag
String apkFullPath;
if (isDebug) {
apkFullPath = String.format("%s/%s/debug/%s_%s.apk",
apksRootPath,
projectName,
projectName,
versionName);
} else {
//正式环境路径(注释保留,切换时解开即可)
apkFullPath = String.format("%s/%s/tag/%s_%s.apk",
apksRootPath,
projectName,
projectName,
versionName);
}
LogUtils.d("APKFileUtils", String.format("apkFullPath : %s", apkFullPath));
File apkFile = new File(apkFullPath);
// 5. APK文件存在性校验
if (!apkFile.exists() || !apkFile.isFile()) {
LogUtils.w("APKFileUtils", "APK文件不存在或非文件类型" + apkFullPath);
return false;
}
try {
// ===== 第一步SHA256文件哈希校验字节级唯一优先级最高=====
String serverFileHash = getAPKFileHash(apkFile);
if (isParamEmpty(serverFileHash)) {
LogUtils.w("APKFileUtils", "解析服务端APK文件哈希失败" + apkFileName);
return false;
}
boolean isHashMatch = serverFileHash.equalsIgnoreCase(clientFileHash.trim());
LogUtils.d("APKFileUtils", "【哈希对比】服务端SHA256" + serverFileHash);
LogUtils.d("APKFileUtils", "【哈希对比】客户端SHA256" + clientFileHash.trim());
if (!isHashMatch) {
LogUtils.i("APKFileUtils", "【哈希对比结果】❌ 不匹配(字节级文件不一致)");
return false;
}
LogUtils.i("APKFileUtils", "【哈希对比结果】✅ 匹配(字节级文件完全一致)");
// ===== 第二步签名校验遍历META-INF所有RSA文件与客户端严格对齐=====
String serverSignBase64 = getAPKSign(apkFile);
if (isParamEmpty(serverSignBase64)) {
LogUtils.w("APKFileUtils", "解析服务端APK签名失败" + apkFileName);
return false;
}
boolean isSignMatch = serverSignBase64.equals(clientSignBase64.trim());
LogUtils.d("APKFileUtils", "【签名对比】服务端Base64" + serverSignBase64);
LogUtils.d("APKFileUtils", "【签名对比】客户端Base64" + clientSignBase64.trim());
if (!isSignMatch) {
LogUtils.i("APKFileUtils", "【签名对比结果】❌ 不匹配(签名不一致)");
return false;
}
LogUtils.i("APKFileUtils", "【签名对比结果】✅ 匹配(签名完全一致)");
// 所有校验通过
LogUtils.i("APKFileUtils", "APK双校验全部通过项目名=" + projectName + ",版本名=" + versionName + ",文件名=" + apkFileName);
return true;
} catch (Exception e) {
LogUtils.e("APKFileUtils", "APK双校验异常", e);
return false;
}
}
/**
* 稳定解析APK签名遍历META-INF所有.RSA/.rsa文件读取第一个有效文件原始字节SHA1+Base64与客户端1:1对齐
* 兼容MT重签名自定义签名文件命名解决X509证书解析的Too short异常适配所有APK普通/加固/重签名)
* @param apkFile APK文件
* @return 签名Base64字符串失败返回null
*/
private String getAPKSign(File apkFile) {
JarFile jarFile = null;
InputStream certIs = null;
try {
jarFile = new JarFile(apkFile);
Enumeration<JarEntry> entries = jarFile.entries();
JarEntry targetRsaEntry = null;
// 核心改造遍历META-INF下所有条目找到第一个有效.RSA/.rsa文件兼容MT重签名
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
String entryName = entry.getName();
// 过滤条件META-INF目录下 + 非目录 + 以.RSA/.rsa结尾
if (entryName.startsWith(META_INF_DIR) && !entry.isDirectory()
&& (entryName.endsWith(RSA_SUFFIX_UPPER) || entryName.endsWith(RSA_SUFFIX_LOWER))) {
targetRsaEntry = entry;
LogUtils.d("APKFileUtils", "找到有效签名文件,路径:" + entryName);
break; // 取第一个有效RSA文件即可重签名APK仅会有一个签名文件
}
}
// 未找到任何RSA签名文件
if (targetRsaEntry == null) {
LogUtils.w("APKFileUtils", "APK中META-INF目录下未找到任何.RSA/.rsa签名文件");
return null;
}
// 读取签名文件原始字节流不做证书解析适配PKCS7签名块与客户端保持一致
certIs = jarFile.getInputStream(targetRsaEntry);
byte[] sigRawBytes = readStreamToBytes(certIs);
if (sigRawBytes == null || sigRawBytes.length == 0) {
LogUtils.w("APKFileUtils", "读取签名文件原始字节为空:" + targetRsaEntry.getName());
return null;
}
// 与客户端完全一致的处理流程SHA1摘要 → Base64编码去换行保证格式一致
MessageDigest md = MessageDigest.getInstance(SIGN_ALGORITHM);
byte[] signDigest = md.digest(sigRawBytes);
String signBase64 = Base64.getEncoder().encodeToString(signDigest)
.replaceAll("\\r", "").replaceAll("\\n", "");
LogUtils.d("APKFileUtils", "APK签名解析成功(Base64)" + signBase64);
return signBase64;
} catch (NoSuchAlgorithmException e) {
LogUtils.e("APKFileUtils", "解析签名失败:" + SIGN_ALGORITHM + "算法不存在", e);
return null;
} catch (Exception e) {
LogUtils.e("APKFileUtils", "解析APK签名异常", e);
return null;
} finally {
// 强制关闭流资源,避免内存泄漏
try {
if (certIs != null) certIs.close();
if (jarFile != null) jarFile.close();
} catch (IOException e) {
LogUtils.e("APKFileUtils", "关闭签名文件流失败", e);
}
}
}
/**
* 解析APK文件的SHA256哈希字节级唯一任何字节修改都会改变
* @param apkFile APK文件
* @return 小写64位SHA256哈希字符串失败返回null
*/
private String getAPKFileHash(File apkFile) {
FileInputStream fis = null;
try {
MessageDigest md = MessageDigest.getInstance(HASH_ALGORITHM);
fis = new FileInputStream(apkFile);
byte[] buffer = new byte[8192]; // 8K缓冲区提升大APK读取效率
int len;
while ((len = fis.read(buffer)) != -1) {
md.update(buffer, 0, len);
}
// 哈希字节转小写16进制字符串64位官方标准格式
byte[] hashBytes = md.digest();
StringBuilder sb = new StringBuilder();
for (byte b : hashBytes) {
sb.append(String.format("%02x", b));
}
String fileHash = sb.toString();
LogUtils.d("APKFileUtils", "APK文件SHA256哈希解析成功" + fileHash);
return fileHash;
} catch (NoSuchAlgorithmException e) {
LogUtils.e("APKFileUtils", "获取文件哈希失败:" + HASH_ALGORITHM + "算法不存在", e);
return null;
} catch (Exception e) {
LogUtils.e("APKFileUtils", "解析APK文件哈希异常", e);
return null;
} finally {
if (fis != null) {
try {
fis.close();
} catch (IOException e) {
LogUtils.e("APKFileUtils", "关闭APK文件流失败", e);
}
}
}
}
/**
* 流转字节数组工具方法:稳定读取任意输入流,无截断/空指针问题
*/
private byte[] readStreamToBytes(InputStream is) throws IOException {
if (is == null) {
LogUtils.w("APKFileUtils", "readStreamToBytes: 输入流为null");
return new byte[0];
}
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buffer = new byte[4096];
int len;
while ((len = is.read(buffer)) != -1) {
bos.write(buffer, 0, len);
}
byte[] result = bos.toByteArray();
// 按顺序关闭流
is.close();
bos.close();
return result;
}
/**
* 工具方法判断参数是否为空null/空字符串/全空格)
*/
private boolean isParamEmpty(String param) {
return param == null || param.trim().isEmpty();
}
}

View File

@@ -1,30 +1,45 @@
package cc.winboll.service;
import cc.winboll.LogUtils;
import cc.winboll.util.ServerUtils;
import cc.winboll.util.ConsoleVersionUtils;
import cc.winboll.app.APKFileUtils;
import cc.winboll.util.IniConfigUtils;
import fi.iki.elonen.NanoHTTPD;
import java.io.IOException;
import java.net.SocketException;
import java.util.Map;
/**
* 独立HTTP监听服务类仅负责请求接收与分发业务逻辑完全依赖ServerUtils
* HTTP监听服务类仅负责服务启停、请求接收与分发
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
* @Date 2026-01-15 23:45:00
* @LastEditTime 新增/api/version版本查询接口
* @LastEditTime 2026-01-22 修复GET参数解析+签名接口路由
*/
public class AuthCenterHttpService extends NanoHTTPD {
private static final String TAG = "AuthCenterHttpService";
private boolean isRunning = false;
// 依赖业务处理工具类(通过构造注入,便于测试)
private final AuthHttpHandler mHttpHandler;
// 构造方法:传入业务处理工具类
public AuthCenterHttpService(int port) {
super(port);
LogUtils.d(TAG, "构造方法调用,监听端口:" + port);
this.mHttpHandler = new AuthHttpHandler(); // 默认初始化工具类
LogUtils.d(TAG, "构造方法调用,监听端口:" + port + ",业务处理工具类初始化完成");
}
// 重载构造:支持外部注入工具类(便于单元测试)
public AuthCenterHttpService(int port, AuthHttpHandler httpHandler) {
super(port);
this.mHttpHandler = httpHandler;
LogUtils.d(TAG, "构造方法调用(外部注入工具类),监听端口:" + port);
}
public void start() throws IOException {
LogUtils.d(TAG, "start() 函数调用启动HTTP监听服务");
// ========== 必须按此顺序添加(核心修复)==========
IniConfigUtils.init(); // 1. 先初始化配置工具
LogUtils.init(); // 2. 再初始化日志工具
APKFileUtils.init(); // 3. 最后初始化APK签名校验工具
// ==============================================
super.start(NanoHTTPD.SOCKET_READ_TIMEOUT, false);
isRunning = true;
LogUtils.i(TAG, "HTTP监听服务启动成功端口" + getListeningPort());
@@ -48,111 +63,78 @@ public class AuthCenterHttpService extends NanoHTTPD {
LogUtils.d(TAG, "接收请求method=" + method.name() + "原始uri=" + rawUri + "规范化uri=" + normUri);
try {
// 新增 /api/version 版本查询接口GET
if (Method.GET.equals(method) && "/api/version".equals(normUri)) {
return handleVersionQuery();
} else if (Method.GET.equals(method) && "/".equals(normUri)) {
return handleHelloWorld();
// 请求路由分发(仅做判断,业务逻辑交给工具类
if (Method.GET.equals(method)) {
return handleGetRequest(normUri, session); // 传session
} else if (Method.POST.equals(method)) {
return handlePostRequest(normUri, session);
} else {
LogUtils.d(TAG, "非目标请求返回404");
return newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "404 Not Found");
LogUtils.w(TAG, "不支持的请求方法:" + method.name());
return mHttpHandler.buildErrorResponse(Response.Status.METHOD_NOT_ALLOWED, "不支持的请求方法");
}
} catch (Exception e) {
LogUtils.e(TAG, "请求处理异常", e);
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, "application/json",
"{\"code\":500,\"msg\":\"服务内部异常\",\"data\":null}");
return mHttpHandler.buildErrorResponse(Response.Status.INTERNAL_ERROR, "服务内部异常");
}
}
/**
* 处理根路径默认请求
*/
private Response handleHelloWorld() {
LogUtils.d(TAG, "根路径请求响应成功,返回服务运行标识");
return newFixedLengthResponse(Response.Status.OK, "text/plain", "WinBoLL is Running...");
}
// 修复GET路由传session用NanoHTTPD原生方法解析参数更可靠
private Response handleGetRequest(String normUri, IHTTPSession session) {
// 优先处理favicon.ico消除冗余日志
if ("/favicon.ico".equals(normUri)) {
return NanoHTTPD.newFixedLengthResponse(Response.Status.NO_CONTENT, "text/plain", "");
}
// 新增版本查询接口实现调用ConsoleVersionUtils返回结果
private Response handleVersionQuery() {
String version = ConsoleVersionUtils.getVersion();
LogUtils.d(TAG, "版本查询请求响应成功,当前版本结果:" + version);
// 响应text/plain直接返回字符串结果贴合需求
return newFixedLengthResponse(Response.Status.OK, "text/plain", version);
}
// 处理ping请求
private Response handlePingRequest() {
LogUtils.d(TAG, "ping请求响应成功返回pong");
return newFixedLengthResponse(Response.Status.OK, "application/json",
"{\"code\":200,\"msg\":\"pong\",\"data\":null}");
}
// 发送验证码:修改为调用本地方法,解决递归问题
private Response handleSendVerifyCode(IHTTPSession session) throws IOException, NanoHTTPD.ResponseException{
session.parseBody(null);
// 原生解析GET参数无需自己拆分URI
Map<String, String> params = session.getParms();
String email = params.get("email");
LogUtils.d(TAG, "接收验证码发送请求,邮箱:" + email);
// 核心修改:替换为本地发送方法,返回布尔值
boolean sendResult = ServerUtils.sendVerifyCodeLocal(email);
// 统一返回JSON格式与其他接口保持一致
String responseJson;
if (sendResult) {
responseJson = "{\"code\":200,\"msg\":\"验证码发送成功\",\"data\":null}";
} else {
responseJson = "{\"code\":500,\"msg\":\"验证码发送失败\",\"data\":null}";
switch (normUri) {
case "/":
return mHttpHandler.handleHelloWorld();
case "/api/version":
return mHttpHandler.handleVersionQuery();
case "/ping":
return mHttpHandler.handlePingRequest();
// 签名校验接口:传原生解析的参数
case "/api/app-signatures-check":
return mHttpHandler.handleAppSignatureCheck(params);
default:
LogUtils.d(TAG, "GET请求未匹配" + normUri);
return mHttpHandler.buildErrorResponse(Response.Status.NOT_FOUND, "404 Not Found");
}
return newFixedLengthResponse(Response.Status.OK, "application/json", responseJson);
}
// 校验验证码直接调用ServerUtils
private Response handleVerifyCode(IHTTPSession session) throws IOException, NanoHTTPD.ResponseException {
session.parseBody(null);
Map<String, String> params = session.getParms();
String email = params.get("email");
String code = params.get("code");
LogUtils.d(TAG, "接收验证码校验请求,邮箱:" + email + ",验证码:" + code);
// 移除自己写的getParameters方法原生session.getParms()更稳定避免拆分bug
String result = ServerUtils.verifyCode(email, code);
// 兜底处理null值避免返回空响应
if (result == null) {
result = "{\"code\":500,\"msg\":\"校验失败\",\"data\":null}";
// 处理POST请求路由
private Response handlePostRequest(String normUri, IHTTPSession session) throws IOException, ResponseException {
// 解析请求参数后续要和AuthHttpHandler的parseRequestParams统一
Map<String, String> params = mHttpHandler.parseRequestParams(session);
if (params == null) {
return mHttpHandler.buildErrorResponse(Response.Status.BAD_REQUEST, "参数解析失败");
}
return newFixedLengthResponse(Response.Status.OK, "application/json", result);
}
// 提交公钥直接调用ServerUtils
private Response handleSubmitPublicKey(IHTTPSession session) throws IOException, NanoHTTPD.ResponseException {
session.parseBody(null);
Map<String, String> params = session.getParms();
String email = params.get("email");
String publicKey = params.get("appPublicKey");
LogUtils.d(TAG, "接收公钥提交请求,邮箱:" + email + ",公钥:" + publicKey);
String result = ServerUtils.submitAppPublicKey(email, publicKey);
if (result == null) {
result = "{\"code\":500,\"msg\":\"公钥提交失败\",\"data\":null}";
switch (normUri) {
case "/send-verify-code":
return mHttpHandler.handleSendVerifyCode(params);
case "/verify-code":
return mHttpHandler.handleVerifyCode(params);
case "/submit-public-key":
return mHttpHandler.handleSubmitPublicKey(params);
case "/heartbeat-ping":
return mHttpHandler.handleHeartbeatPing(params);
default:
LogUtils.d(TAG, "POST请求未匹配" + normUri);
return mHttpHandler.buildErrorResponse(Response.Status.NOT_FOUND, "404 Not Found");
}
return newFixedLengthResponse(Response.Status.OK, "application/json", result);
}
// 心跳请求直接调用ServerUtils
private Response handleHeartbeatPing(IHTTPSession session) throws IOException, NanoHTTPD.ResponseException {
session.parseBody(null);
Map<String, String> params = session.getParms();
String encryptData = params.get("encryptData");
LogUtils.d(TAG, "接收心跳请求,加密数据:" + encryptData);
String result = ServerUtils.sendPingRequest(encryptData);
if (result == null) {
result = "{\"code\":500,\"msg\":\"心跳请求失败\",\"data\":null}";
}
return newFixedLengthResponse(Response.Status.OK, "application/json", result);
}
public boolean isRunning() {
return isRunning;
}
// 对外暴露工具类(可选,便于外部扩展)
public AuthHttpHandler getHttpHandler() {
return mHttpHandler;
}
}

View File

@@ -0,0 +1,238 @@
package cc.winboll.service;
import cc.winboll.LogUtils;
import cc.winboll.app.APKFileUtils;
import cc.winboll.util.ServerUtils;
import cc.winboll.util.ConsoleVersionUtils;
import fi.iki.elonen.NanoHTTPD;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Base64;
/**
* HTTP接口业务处理工具类封装所有接口逻辑无HTTP依赖
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
* @Date 2026-01-21 16:30:00
*/
public class AuthHttpHandler {
private static final String TAG = "AuthHttpHandler";
private static final String CONTENT_TYPE_JSON = "application/json";
private static final String CONTENT_TYPE_PLAIN = "text/plain";
/**
* 构建JSON错误响应
*/
public NanoHTTPD.Response buildErrorResponse(NanoHTTPD.Response.Status status, String msg) {
String json = String.format("{\"code\":%d,\"msg\":\"%s\",\"data\":null}", status.getRequestStatus(), msg);
return NanoHTTPD.newFixedLengthResponse(status, CONTENT_TYPE_JSON, json);
}
/**
* 通用参数解析兼容GET/POST替换原parsePostParams
*/
public Map<String, String> parseRequestParams(NanoHTTPD.IHTTPSession session) throws IOException, NanoHTTPD.ResponseException {
Map<String, String> params = new HashMap<>();
// 先取URL参数优先级高
params.putAll(session.getParms());
// 再取POST Body参数重复key覆盖URL参数
session.parseBody(params);
LogUtils.d(TAG, "解析请求参数:" + params);
return params.isEmpty() ? null : params;
}
// ==================== 接口业务逻辑实现 ====================
/**
* 根路径默认响应
*/
public NanoHTTPD.Response handleHelloWorld() {
LogUtils.d(TAG, "处理根路径请求");
return NanoHTTPD.newFixedLengthResponse(NanoHTTPD.Response.Status.OK, CONTENT_TYPE_PLAIN, "WinBoLL is Running...");
}
/**
* 版本查询接口
*/
public NanoHTTPD.Response handleVersionQuery() {
LogUtils.d(TAG, "处理版本查询请求");
String version = ConsoleVersionUtils.getVersion();
LogUtils.d(TAG, "版本查询结果:" + version);
return NanoHTTPD.newFixedLengthResponse(NanoHTTPD.Response.Status.OK, CONTENT_TYPE_PLAIN, version);
}
/**
* Ping请求响应
*/
public NanoHTTPD.Response handlePingRequest() {
LogUtils.d(TAG, "处理ping请求");
return NanoHTTPD.newFixedLengthResponse(NanoHTTPD.Response.Status.OK, CONTENT_TYPE_JSON,
"{\"code\":200,\"msg\":\"pong\",\"data\":null}");
}
/**
* 发送验证码接口
*/
public NanoHTTPD.Response handleSendVerifyCode(Map<String, String> params) {
if (params == null) {
LogUtils.w(TAG, "发送验证码失败:未获取到参数");
return buildErrorResponse(NanoHTTPD.Response.Status.BAD_REQUEST, "未获取到请求参数");
}
String email = params.get("email");
LogUtils.d(TAG, "处理验证码发送请求,邮箱:" + email);
if (email == null || email.isEmpty()) {
LogUtils.w(TAG, "发送验证码失败:邮箱为空");
return buildErrorResponse(NanoHTTPD.Response.Status.BAD_REQUEST, "邮箱不能为空");
}
boolean sendResult = ServerUtils.sendVerifyCodeLocal(email);
String json = sendResult ?
"{\"code\":200,\"msg\":\"验证码发送成功\",\"data\":null}" :
"{\"code\":500,\"msg\":\"验证码发送失败\",\"data\":null}";
return NanoHTTPD.newFixedLengthResponse(NanoHTTPD.Response.Status.OK, CONTENT_TYPE_JSON, json);
}
/**
* 校验验证码接口
*/
public NanoHTTPD.Response handleVerifyCode(Map<String, String> params) {
if (params == null) {
LogUtils.w(TAG, "校验验证码失败:未获取到参数");
return buildErrorResponse(NanoHTTPD.Response.Status.BAD_REQUEST, "未获取到请求参数");
}
String email = params.get("email");
String code = params.get("code");
LogUtils.d(TAG, "处理验证码校验请求,邮箱:" + email + ",验证码:" + code);
if (email == null || code == null || email.isEmpty() || code.isEmpty()) {
LogUtils.w(TAG, "校验验证码失败:参数不全");
return buildErrorResponse(NanoHTTPD.Response.Status.BAD_REQUEST, "邮箱和验证码不能为空");
}
String result = ServerUtils.verifyCode(email, code);
if (result == null) {
result = "{\"code\":500,\"msg\":\"校验失败\",\"data\":null}";
}
return NanoHTTPD.newFixedLengthResponse(NanoHTTPD.Response.Status.OK, CONTENT_TYPE_JSON, result);
}
/**
* 提交公钥接口
*/
public NanoHTTPD.Response handleSubmitPublicKey(Map<String, String> params) {
if (params == null) {
LogUtils.w(TAG, "提交公钥失败:未获取到参数");
return buildErrorResponse(NanoHTTPD.Response.Status.BAD_REQUEST, "未获取到请求参数");
}
String email = params.get("email");
String publicKey = params.get("appPublicKey");
LogUtils.d(TAG, "处理公钥提交请求,邮箱:" + email + ",公钥:" + publicKey);
if (email == null || publicKey == null || email.isEmpty() || publicKey.isEmpty()) {
LogUtils.w(TAG, "提交公钥失败:参数不全");
return buildErrorResponse(NanoHTTPD.Response.Status.BAD_REQUEST, "邮箱和公钥不能为空");
}
String result = ServerUtils.submitAppPublicKey(email, publicKey);
if (result == null) {
result = "{\"code\":500,\"msg\":\"公钥提交失败\",\"data\":null}";
}
return NanoHTTPD.newFixedLengthResponse(NanoHTTPD.Response.Status.OK, CONTENT_TYPE_JSON, result);
}
/**
* 心跳请求接口
*/
public NanoHTTPD.Response handleHeartbeatPing(Map<String, String> params) {
if (params == null) {
LogUtils.w(TAG, "心跳请求失败:未获取到参数");
return buildErrorResponse(NanoHTTPD.Response.Status.BAD_REQUEST, "未获取到请求参数");
}
String encryptData = params.get("encryptData");
LogUtils.d(TAG, "处理心跳请求,加密数据:" + encryptData);
if (encryptData == null || encryptData.isEmpty()) {
LogUtils.w(TAG, "心跳请求失败:加密数据为空");
return buildErrorResponse(NanoHTTPD.Response.Status.BAD_REQUEST, "加密数据不能为空");
}
String result = ServerUtils.sendPingRequest(encryptData);
if (result == null) {
result = "{\"code\":500,\"msg\":\"心跳请求失败\",\"data\":null}";
}
return NanoHTTPD.newFixedLengthResponse(NanoHTTPD.Response.Status.OK, CONTENT_TYPE_JSON, result);
}
/**
* 应用签名+文件哈希双重校验接口(处理/api/app-signatures-check请求
* 适配URIhttp://localhost:8080/api/app-signatures-check?projectName=WinBoLL&versionName=15.11.11&clientSign=xxx&clientHash=xxx
* 核心:官方签名校验 + SHA256文件哈希校验全异常捕获避免500错误
*/
public NanoHTTPD.Response handleAppSignatureCheck(Map<String, String> params) {
LogUtils.d(TAG, "处理应用签名+哈希双重校验请求,参数:" + params);
// 全局异常捕获解决500内部错误
try {
// 前置判空
if (params == null) {
LogUtils.w(TAG, "双重校验失败:未获取到任何参数");
return buildErrorResponse(NanoHTTPD.Response.Status.BAD_REQUEST, "未获取到请求参数");
}
// 1. 解析新核心必选参数projectName/versionName/clientSign/clientHash
String szIsDebug = params.get("isDebug");
String projectName = params.get("projectName");
String versionName = params.get("versionName");
String clientSign = params.get("clientSign");
String clientHash = params.get("clientHash");
// 2. 必选参数非空+格式基础校验
if (szIsDebug == null || szIsDebug.trim().isEmpty()
|| projectName == null || projectName.trim().isEmpty()
|| versionName == null || versionName.trim().isEmpty()
|| clientSign == null || clientSign.trim().isEmpty()
|| clientHash == null || clientHash.trim().isEmpty()) {
LogUtils.w(TAG, "双重校验失败必选参数不全projectName/versionName/clientSign/clientHash不能为空");
return buildErrorResponse(NanoHTTPD.Response.Status.BAD_REQUEST,
"参数错误projectName、versionName、clientSign、clientHash为必填参数");
}
// 校验哈希长度SHA256固定64位16进制过滤明显非法值
if (clientHash.trim().length() != 64) {
LogUtils.w(TAG, "双重校验失败clientHash格式错误需为64位SHA256哈希");
return buildErrorResponse(NanoHTTPD.Response.Status.BAD_REQUEST,
"参数错误clientHash需为64位SHA256十六进制哈希字符串");
}
// 3. 核心双校验调用改造后的APKFileUtils.checkAPK官方签名+文件哈希)
// 注APK文件名拼接规则项目名_版本名.apk与客户端包名/版本名对齐,规范命名)
String apkFileName = String.format("%s_%s.apk", projectName.trim(), versionName.trim());
boolean isValid = APKFileUtils.checkAPK(szIsDebug.trim().equals("true"), projectName.trim(), versionName.trim(),
apkFileName, clientSign.trim(), clientHash.trim());
// 4. 日志输出详情(便于问题排查,含完整校验维度)
LogUtils.d(TAG, String.format("签名+哈希双重校验结果:%s | 项目名:%s | 版本名:%s | APK文件名%s | 客户端签名:%s | 客户端哈希:%s",
isValid ? "✅ 成功" : "❌ 失败",
projectName.trim(), versionName.trim(), apkFileName,
clientSign.trim(), clientHash.trim()));
// 5. 构建响应结果与APP端预期格式一致字段简洁无冗余含核心返参
String msg = isValid ? "应用签名+文件哈希双重校验通过,为合法正版应用" : "应用校验失败:签名或文件哈希不匹配,非正版/篡改应用";
int code = isValid ? 200 : 403;
String responseJson = String.format(
"{\"code\":%d,\"msg\":\"%s\",\"data\":{\"valid\":%b,\"projectName\":\"%s\",\"versionName\":\"%s\",\"apkFileName\":\"%s\"}}",
code, msg, isValid, projectName.trim(), versionName.trim(), apkFileName
);
return NanoHTTPD.newFixedLengthResponse(NanoHTTPD.Response.Status.OK, CONTENT_TYPE_JSON, responseJson);
} catch (IllegalStateException e) {
// 捕获APKFileUtils未初始化异常最常见原因
LogUtils.e(TAG, "双重校验失败APKFileUtils未初始化请在服务启动时调用APKFileUtils.init()", e);
return buildErrorResponse(NanoHTTPD.Response.Status.INTERNAL_ERROR, "服务内部异常APKFileUtils未初始化");
} catch (Exception e) {
// 捕获所有其他异常,打印完整堆栈(定位根因)
LogUtils.e(TAG, "签名+哈希双重校验失败:服务内部异常", e);
return buildErrorResponse(NanoHTTPD.Response.Status.INTERNAL_ERROR, "服务内部异常:" + e.getMessage());
}
}
}