add auth check

This commit is contained in:
2026-08-03 11:16:49 +08:00
parent 6330b99860
commit 92780cedef
18 changed files with 381 additions and 242 deletions
+175 -42
View File
@@ -1,12 +1,13 @@
const fs = require("node:fs");
const path = require("node:path");
const { randomUUID, constants, createHmac, timingSafeEqual, verify } = require("node:crypto");
const { randomUUID, constants, verify } = require("node:crypto");
const express = require("express");
const multer = require("multer");
const BASE_FOLDER = "ReinLoop_GUI";
const VOLUME_REQUEST_TTL_MS = Number(process.env.VOLUME_REQUEST_TTL_MS || 300000);
const DOWNLOAD_TOKEN_TTL_MS = Number(process.env.DOWNLOAD_TOKEN_TTL_MS || 300000);
const DOWNLOAD_URL_TTL_MS = Number(process.env.DOWNLOAD_URL_TTL_MS || process.env.DOWNLOAD_TOKEN_TTL_MS || 300000);
const DEVICE_TOKEN_TTL_MS = Number(process.env.DEVICE_TOKEN_TTL_MS || 15 * 60 * 1000);
const IDENTIFICATION_RETENTION_MS = Number(process.env.IDENTIFICATION_RETENTION_MS || 30 * 24 * 60 * 60 * 1000);
const DEVICE_HEARTBEAT_TTL_MS = 30_000;
const CONFIG_SCHEMAS = {
@@ -161,6 +162,8 @@ function createApp({
const app = express();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 100 * 1024 * 1024 } });
const pendingUploads = new Map();
const pendingDownloads = new Map();
const pendingDeviceTokens = new Map();
app.disable("x-powered-by");
app.use(express.json({ limit: "2mb" }));
@@ -200,23 +203,68 @@ function createApp({
};
}
function signDownload(fileID, expiresAtMs) {
return createHmac("sha256", adminToken).update(`${fileID}\n${expiresAtMs}`).digest("hex");
function requestIp(req) {
return String(req.ip || req.socket?.remoteAddress || "").trim();
}
function downloadUrl(req, fileID) {
const baseUrl = `${publicBaseUrl(req)}/files/${encodeURIComponent(fileID)}`;
if (fileID.startsWith("model://")) return baseUrl;
const expires = Date.now() + DOWNLOAD_TOKEN_TTL_MS;
return `${baseUrl}?expires=${expires}&token=${signDownload(fileID, expires)}`;
function issueDownloadUrl(req, fileID) {
const ticket = randomUUID().replace(/-/g, "");
pendingDownloads.set(ticket, {
fileID,
requestIp: requestIp(req),
expiresAtMs: Date.now() + DOWNLOAD_URL_TTL_MS
});
return `${publicBaseUrl(req)}/downloads/${ticket}`;
}
function hasValidDownloadToken(req, fileID) {
const expires = Number(req.query.expires);
const token = String(req.query.token || "");
if (!Number.isSafeInteger(expires) || expires < Date.now() || !/^[0-9a-f]{64}$/.test(token)) return false;
const expected = signDownload(fileID, expires);
return timingSafeEqual(Buffer.from(token, "hex"), Buffer.from(expected, "hex"));
function consumeDownloadTicket(req, ticket) {
const request = pendingDownloads.get(ticket);
if (!request) return { ok: false, errMsg: "下载链接无效或已失效" };
pendingDownloads.delete(ticket);
if (request.expiresAtMs < Date.now()) return { ok: false, errMsg: "下载链接已过期" };
if (!request.requestIp || request.requestIp !== requestIp(req)) {
return { ok: false, errMsg: "下载请求来源IP不匹配" };
}
return { ok: true, fileID: request.fileID };
}
function verifyLicenseRecord(record, deviceId) {
if (!record) return { success: false, valid: false, status: "not_found" };
if (deviceId && deviceId !== record.deviceId) {
return { success: true, valid: false, status: "device_mismatch", licenseId: record.licenseId };
}
if (new Date(record.expiryAt || parseLicenseTimestamp(record.expiry, "expiry")) <= new Date()) {
return { success: true, valid: false, status: "expired", licenseId: record.licenseId };
}
return {
success: true,
valid: record.status === "active",
status: record.status,
licenseId: record.licenseId
};
}
function issueDeviceToken({ deviceId, licenseId }) {
const token = randomUUID().replace(/-/g, "");
const expiresAtMs = Date.now() + DEVICE_TOKEN_TTL_MS;
pendingDeviceTokens.set(token, { deviceId, licenseId, expiresAtMs });
return { token, expiresAtMs };
}
function consumeDeviceContext(event) {
const deviceToken = String(event.deviceToken || "").trim();
if (!deviceToken) {
return { ok: false, errCode: "DEVICE_TOKEN_REQUIRED", errMsg: "缺少 deviceToken" };
}
const session = pendingDeviceTokens.get(deviceToken);
if (!session) {
return { ok: false, errCode: "DEVICE_TOKEN_INVALID", errMsg: "deviceToken 无效" };
}
if (session.expiresAtMs <= Date.now()) {
pendingDeviceTokens.delete(deviceToken);
return { ok: false, errCode: "DEVICE_TOKEN_EXPIRED", errMsg: "deviceToken 已过期" };
}
return { ok: true, ...session };
}
//config test-----------------------------------
@@ -540,17 +588,8 @@ function createApp({
case "validateLicense": {
const database = await store.read();
const record = database.licenses.find((item) => item.licenseId === event.licenseId);
if (!record) return { success: true, valid: false, status: "not_found" };
if (event.deviceId && normalizeDeviceId(event.deviceId) !== record.deviceId) {
return { success: true, valid: false, status: "device_mismatch", licenseId: record.licenseId };
}
if (new Date(record.expiryAt || parseLicenseTimestamp(record.expiry, "expiry")) <= new Date()) {
return { success: true, valid: false, status: "expired", licenseId: record.licenseId };
}
return {
success: true, valid: record.status === "active",
status: record.status, licenseId: record.licenseId
};
const deviceId = event.deviceId ? normalizeDeviceId(event.deviceId) : null;
return verifyLicenseRecord(record, deviceId);
}
case "uploadDataFile":
if (normalizeRelativePath(event.folder, "data_record").endsWith("/model_config")) {
@@ -584,13 +623,11 @@ function createApp({
const database = await store.read();
const record = database.fileRecords.find((item) => item.fileID === event.fileID);
if (!record || !store.resolveStoredFile(record.fileID)) return { success: false, errMsg: "文件不存在" };
if (!record.fileID.startsWith("model://")) {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
}
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
return {
success: true,
url: downloadUrl(req, record.fileID),
url: issueDownloadUrl(req, record.fileID),
fileName: record.fileName,
originalFileName: record.originalFileName || record.fileName
};
@@ -651,7 +688,7 @@ function createApp({
if (!record) return { success: true, found: false, deviceId };
return {
success: true, found: true, deviceId, fileID: record.fileID,
fileName: record.fileName, url: downloadUrl(req, record.fileID),
fileName: record.fileName, url: issueDownloadUrl(req, record.fileID),
updatedAtMs: config.updatedAtMs, requestId: config.requestId
};
}
@@ -667,7 +704,7 @@ function createApp({
const record = database.fileRecords.find((item) => item.folder === `${deviceId}/identification_config` && item.fileName === "identification_config.csv");
if (!record) return { success: false, errMsg: "服务器尚未配置辨识参数" };
logConfigRead({ configType: "identification", deviceId, record });
return { success: true, fileName: record.fileName, fileID: record.fileID, cloudPath: record.cloudPath, url: downloadUrl(req, record.fileID) };
return { success: true, fileName: record.fileName, fileID: record.fileID, cloudPath: record.cloudPath, url: issueDownloadUrl(req, record.fileID) };
}
case "getPendingPanelFile": {
const authError = requireAdmin(event);
@@ -689,7 +726,7 @@ function createApp({
fileName: message.fileName,
mediaType: message.mediaType,
uploadTime: message.uploadTime,
url: downloadUrl(req, message.fileID)
url: issueDownloadUrl(req, message.fileID)
};
}
case "getPendingPanelNotification": {
@@ -812,7 +849,7 @@ function createApp({
fileName: record.fileName,
uploadTime: record.uploadTime,
size: record.size,
url: downloadUrl(req, record.fileID)
url: issueDownloadUrl(req, record.fileID)
};
}
case "deleteControlFile": {
@@ -844,7 +881,7 @@ function createApp({
fileID: fileRecord.fileID,
fileName: fileRecord.fileName,
mediaType: historyRecord.mediaType,
url: downloadUrl(req, fileRecord.fileID)
url: issueDownloadUrl(req, fileRecord.fileID)
};
}
case "deleteIdentificationFile": {
@@ -969,7 +1006,7 @@ function createApp({
const fileRecord = database.fileRecords.find((item) => item.fileID === record.configFileID);
if (!fileRecord) return { success: false, errMsg: "容积配置文件记录不存在" };
logConfigRead({ configType: "volume", deviceId, requestId: record.requestId, record: fileRecord });
return { success: true, ready: true, expired: false, requestId: record.requestId, fileName: record.configFileName, fileID: fileRecord.fileID, cloudPath: fileRecord.cloudPath, uploadedAtMs: record.uploadedAtMs, url: downloadUrl(req, record.configFileID) };
return { success: true, ready: true, expired: false, requestId: record.requestId, fileName: record.configFileName, fileID: fileRecord.fileID, cloudPath: fileRecord.cloudPath, uploadedAtMs: record.uploadedAtMs, url: issueDownloadUrl(req, record.configFileID) };
}
case "ackVolumeConfigRequest": {
const deviceId = normalizeDeviceId(event.deviceId);
@@ -989,6 +1026,88 @@ function createApp({
}
}
async function dispatchDevice(event, req) {
if (event.type === "deviceAuth") {
const licenseId = String(event.licenseId || "").trim();
if (!licenseId) return { success: false, errCode: "LICENSE_ID_REQUIRED", errMsg: "缺少 licenseId" };
const deviceId = normalizeDeviceId(event.deviceId);
const database = await store.read();
const record = database.licenses.find((item) => item.licenseId === licenseId);
const validation = verifyLicenseRecord(record, deviceId);
if (!validation.valid) {
const errCodeMap = {
not_found: "LICENSE_NOT_FOUND",
device_mismatch: "DEVICE_ID_MISMATCH",
expired: "LICENSE_EXPIRED",
revoked: "LICENSE_REVOKED"
};
return {
success: false,
errCode: errCodeMap[validation.status] || "LICENSE_INVALID",
errMsg: `许可证不可用: ${validation.status}`
};
}
const issued = issueDeviceToken({ deviceId, licenseId });
return {
success: true,
deviceId,
licenseId,
deviceToken: issued.token,
expiresAtMs: issued.expiresAtMs
};
}
const context = consumeDeviceContext(event);
if (!context.ok) return { success: false, errCode: context.errCode, errMsg: context.errMsg };
const deviceId = context.deviceId;
switch (event.type) {
case "deviceHeartbeat":
case "registerIdentificationResult":
case "getIdentificationFeedback":
case "ackIdentificationFeedback":
case "createVolumeConfigRequest":
case "getPendingVolumeConfigRequest":
case "submitVolumeConfigFile":
case "getVolumeConfigRequest":
case "ackVolumeConfigRequest":
case "getIdentificationConfig":
return dispatch({ ...event, deviceId }, req);
case "uploadDataFile": {
const folder = normalizeRelativePath(event.folder, "data_record");
const devicePrefix = `${deviceId}/`;
if (!folder.startsWith(devicePrefix)) {
return { success: false, errMsg: "设备接口仅允许访问当前 deviceId 目录" };
}
if (folder.endsWith("/model_config")) {
return { success: false, errMsg: "设备接口不允许上传模型" };
}
return dispatch({ ...event, folder }, req);
}
case "listModels": {
const folder = `${deviceId}/model_config`;
return dispatch({ ...event, folder }, req);
}
case "downloadModel": {
if (!event.fileID) return { success: false, errMsg: "缺少 fileID" };
const database = await store.read();
const record = database.fileRecords.find((item) => item.fileID === event.fileID);
if (!record || !store.resolveStoredFile(record.fileID)) return { success: false, errMsg: "文件不存在" };
if (record.folder !== `${deviceId}/model_config`) {
return { success: false, errMsg: "设备接口无权下载该模型" };
}
return {
success: true,
url: issueDownloadUrl(req, record.fileID),
fileName: record.fileName,
originalFileName: record.originalFileName || record.fileName
};
}
default:
return { success: false, errMsg: "无效的 type 字段" };
}
}
app.get("/health", (req, res) => res.json({ success: true, service: "reinloop-server" }));
app.post("/upload/:token", upload.single("file"), async (req, res, next) => {
@@ -1085,12 +1204,13 @@ function createApp({
}
});
app.get("/files/:fileID", async (req, res, next) => {
app.get("/downloads/:ticket", async (req, res, next) => {
try {
const fileID = decodeURIComponent(req.params.fileID);
if (!fileID.startsWith("model://") && !hasValidDownloadToken(req, fileID)) {
return res.status(403).json({ success: false, errMsg: "下载凭证无效或已过期" });
}
const ticket = String(req.params.ticket || "").trim();
if (!ticket) return res.status(403).json({ success: false, errMsg: "下载链接无效或已失效" });
const consumed = consumeDownloadTicket(req, ticket);
if (!consumed.ok) return res.status(403).json({ success: false, errMsg: consumed.errMsg });
const fileID = consumed.fileID;
const database = await store.read();
const record = database.fileRecords.find((item) => item.fileID === fileID);
const filePath = record && store.resolveStoredFile(fileID);
@@ -1103,6 +1223,10 @@ function createApp({
}
});
app.get("/files/:fileID", (req, res) => {
res.status(403).json({ success: false, errMsg: "旧下载链接已停用,请先通过 API 获取临时下载链接" });
});
const apiHandler = async (req, res) => {
try {
res.json(await dispatch(normalizeEventCompat(req.body || {}), req));
@@ -1112,6 +1236,15 @@ function createApp({
};
app.post("/", apiHandler);
const deviceApiHandler = async (req, res) => {
try {
res.json(await dispatchDevice(normalizeEventCompat(req.body || {}), req));
} catch (error) {
res.status(400).json({ success: false, errMsg: error.message });
}
};
app.post("/device", deviceApiHandler);
app.use((error, req, res, next) => {
console.error(error);
res.status(500).json({ success: false, errMsg: "服务器内部错误" });