v2.5 安全加固 + 权限体系 + 封面网络化

- 双密码(管理员/用户) + requireAdmin 角色隔离
- 登录失败按IP锁定 + 搜索缓存限流
- 封面远程URL优先+本地回退
- sessions.role / songs.cover_remote 运行时迁移
This commit is contained in:
Xiao-no-love committed 2026-09-25 22:25:25 +08:00
1 parent 16df0a7d08
commit f07fbbc21f
13 files changed
+236 -40

No files matched your search

+42
View File
@@ -415,3 +415,45 @@ python test_enrich.py # 给 5 首补封面(已跑过,5/5 成功)
- `login.html` 独立暗色主题(自己的 `:root`)。
- `body` 字体栈、`--log-bg` 终端风格:保留。
---
## 18. v2.5 安全加固 + 权限体系 + 封面网络化(已完成 2026-09-25)
> 老板提出 6 点问题,本轮解决其中 5 点(延迟调节因复杂度暂缓)。
### 18.1 双密码 + 角色权限(核心)
- **config.js**:`ADMIN_PASSWORD`(兼容旧 `PANEL_PASSWORD`)/ `USER_PASSWORD`;新增 `LOGIN_MAX_FAILS`/`LOGIN_LOCK_MS`/`SEARCH_CACHE_TTL_MS`/`SEARCH_RATE_MAX`/`SEARCH_RATE_WINDOW_MS`。
- **db.js**:`sessions` 表加 `role` 列;`songs` 表加 `cover_remote` 列。均带**运行时迁移**(PRAGMA table_info 检测后 ALTER)。
- **auth.js**:重写。`verifyLogin` 返回 `"admin"|"user"|null`;`createSession(role)`;新增 `requireAdmin` 中间件、`getRole/isAdmin`;**按 IP 登录失败锁定**(默认 5 次锁 10 分钟,内存 Map)。
- **routes/auth.js**:登录返回 `{ok, role}`;锁定返回 429;`/me` 返回 role。
- **权限矩阵**:用户 = 管理员 − 上传 − 设置页。`/api/music/import` 加 `requireAdmin`;`/api/system/*` 整块 `requireAdmin`。其余接口(资源/流/播放列表/搜索/下载)两者一致。
- **前端**:`init()` 先查 `/api/auth/me` 拿 role → `applyRoleUI()` 隐藏设置页 tab + 本地导入卡片;`loadSettings()` 非管理员直接 return。
### 18.2 搜索缓存 + 限流(防服务器 IP 被封)
- **实测确认**:网易云搜索 API **无 CORS 头**(返回 text/plain),**无法下放到浏览器**,必须后端代理。
- **对策**:`routes/music.js` 的 `/search` 加 ①内存缓存(同关键词 TTL 5 分钟内直接返回,上限 200 条 LRU);②按 IP 限流(默认 60 秒 20 次,超限 429)。命中缓存不消耗限流额度。
### 18.3 封面网络化(远程优先 + 本地回退)
- **实测确认**:网易云图片 CDN 返回 `Access-Control-Allow-Origin: *` 且不校验 Referer,浏览器可直接加载。
- **改动**:`enrichSong` 下载封面时**同时**存远程 URL(`cover_remote`)和本地(`cover`);API 返回 `albumImage`(远程优先)+ `coverLocal`(本地兜底)。
- **前端**:`coverSrcAttr(item)` 生成 `<img src=远程 data-local=本地 onerror=回退>`;mini player / 收听页封面同样 onerror 回退。
### 18.4 刷新优化
- init 时统一获取角色;SSE 事件保持增量刷新(`loadPlaylist`+`refreshLive` 节流)。
### 18.5 验证
- 回归:**api 16/16、e2e 17/17、write 5/5** 全绿。
- 新增功能手测(curl):
- admin 登录 → `/api/system/cache` 200、`/logs` 200;user 登录 → 同接口 **403**,`/api/music/import` **403**,`/api/resources` 200、`/api/streams` 200。
- `/api/auth/me` 正确返回 role。
- 搜索同关键词第二次返回 `cached:true`。
### 18.6 启动方式
- `start.cmd` 更新为:`set "ADMIN_PASSWORD=test123"` + `set "USER_PASSWORD=user123"` + `node index.js`。
- 托管启动:`cmd /c "set ADMIN_PASSWORD=test123&& set USER_PASSWORD=user123&& node index.js"`(注意用 `&&` 且不加尾空格,否则密码带空格)。
### 18.7 未做
- **播放延迟调节**(HLS_TIME 运行时热更新):老板明确暂缓。
- 用户密码/管理员密码的 UI 设置界面(现走环境变量)。
- 会话表已有 role 列,但登录失败锁定的 failMap 是内存态,重启清零(可接受)。
+22 -2
View File
@@ -35,6 +35,15 @@ function formatBytes(b) {
if (b >= 1024) return (b / 1024).toFixed(1) + " KB";
return b + " B";
}
// 封面:远程 URL 优先,失败自动回退本地
function coverSrcAttr(item) {
const remote = item.albumImage || "";
const local = item.coverLocal || "";
if (remote && local && remote !== local) {
return `src="${esc(remote)}" data-local="${esc(local)}" onerror="this.onerror=null;this.src=this.dataset.local;"`;
}
return `src="${esc(remote || local)}"`;
}
// ==================== API(统一带 cookie,401 跳登录) ====================
async function api(url, options = {}) {
@@ -49,6 +58,14 @@ async function api(url, options = {}) {
if (!res.ok) throw new Error((data && data.error) || text || `HTTP ${res.status}`);
return data;
}
// 角色 UI:非管理员隐藏 设置页 + 本地导入
function applyRoleUI(role) {
const isAdmin = role === "admin";
const tab = document.querySelector('.nav-tab[data-page="pageSettings"]');
if (tab) tab.style.display = isAdmin ? "" : "none";
const imp = document.getElementById("importCard");
if (imp) imp.style.display = isAdmin ? "" : "none";
}
function post(url, body) {
return api(url, {
method: "POST",
@@ -238,7 +255,7 @@ function reconcileList(container, items, keyOf, create, update) {
// ==================== Resources ====================
function renderResItem(item) {
const cover = item.albumImage
? `<img class="res-cover" src="${esc(item.albumImage)}" alt="" loading="lazy">`
? `<img class="res-cover" ${coverSrcAttr(item)} alt="" loading="lazy">`
: `<div class="res-cover-ph"><i class="bi bi-music-note"></i></div>`;
const bg = item.albumImage
? `<div class="res-bg" style="background-image:url('${esc(item.albumImage)}')"></div>`
@@ -456,7 +473,7 @@ const mpExpand = document.getElementById("mpExpand");
function showMiniPlayer(info) {
mpName.textContent = info.name || "未知";
mpArtist.textContent = info.artist || "--";
if (info.albumImage) { mpCover.src = info.albumImage; mpCover.style.display = ""; }
if (info.albumImage) { mpCover.onerror = info.coverLocal ? () => { mpCover.onerror = null; mpCover.src = info.coverLocal; } : null; mpCover.src = info.albumImage; mpCover.style.display = ""; }
else mpCover.style.display = "none";
miniPlayer.classList.add("show");
document.body.classList.add("mini-player-visible");
@@ -755,6 +772,7 @@ async function loadLiveCover() {
const r = await api(`/api/streams/${currentStreamId}/playlist`);
const cur = (r.list || []).find((x) => x.isCurrent) || (r.list || [])[0];
if (cur && cur.albumImage) {
liveCover.onerror = cur.coverLocal ? () => { liveCover.onerror = null; liveCover.src = cur.coverLocal; } : null;
liveCover.src = cur.albumImage;
liveCover.style.display = "";
liveCoverPh.style.display = "none";
@@ -824,6 +842,7 @@ livePlayBtn.addEventListener("click", toggleLive);
function fmtMB(b) { return (b / 1048576).toFixed(1) + " MB"; }
async function loadSettings() {
if (state.role !== "admin") return;
// 缓存
try {
const c = await api("/api/system/cache");
@@ -882,6 +901,7 @@ document.getElementById("logTail").addEventListener("change", loadLogs);
// ==================== Init ====================
(async function init() {
try { const me = await api("/api/auth/me"); state.role = me.role || null; applyRoleUI(state.role); } catch (_) {}
await loadStreams();
loadLiveCover();
await loadResources();
+77 -20
View File
@@ -6,15 +6,43 @@ const store = require("./store");
const COOKIE_NAME = "mr_session";
// 密码来源:环境变量 PANEL_PASSWORD;未设置则首次启动随机生成并打印
let panelPassword = config.PANEL_PASSWORD;
let generated = false;
if (!panelPassword) {
panelPassword = crypto.randomBytes(6).toString("hex");
generated = true;
/* ==================== 密码来源 ==================== */
// 管理员密码:ADMIN_PASSWORD(兼容旧 PANEL_PASSWORD);未设置则首次随机生成并打印
// 用户密码:USER_PASSWORD;未设置则「无用户密码」——只有管理员能登录
let adminPassword = config.ADMIN_PASSWORD;
let userPassword = config.USER_PASSWORD;
let adminGenerated = false;
if (!adminPassword) {
adminPassword = crypto.randomBytes(6).toString("hex");
adminGenerated = true;
}
/* ==================== 登录失败锁定(按 IP) ==================== */
const failMap = new Map(); // ip -> { count, lockedUntil }
function isLocked(ip) {
const rec = failMap.get(ip);
if (!rec) return 0;
if (rec.lockedUntil && rec.lockedUntil > Date.now()) {
return Math.ceil((rec.lockedUntil - Date.now()) / 1000);
}
return 0;
}
function recordFail(ip) {
const rec = failMap.get(ip) || { count: 0, lockedUntil: 0 };
rec.count += 1;
if (rec.count >= config.LOGIN_MAX_FAILS) {
rec.lockedUntil = Date.now() + config.LOGIN_LOCK_MS;
rec.count = 0;
logger.warn(`[AUTH] IP ${ip} 登录失败次数过多,锁定 ${config.LOGIN_LOCK_MS / 60000} 分钟`);
}
failMap.set(ip, rec);
}
function clearFail(ip) {
failMap.delete(ip);
}
/* ==================== Cookie ==================== */
function parseCookies(header) {
const out = {};
if (!header) return out;
@@ -25,22 +53,29 @@ function parseCookies(header) {
return out;
}
function createSession() {
/* ==================== 会话 ==================== */
function createSession(role) {
const token = crypto.randomBytes(24).toString("hex");
const expiresAt = Date.now() + config.SESSION_TTL_MS;
store.createSession(token, expiresAt);
return { token, expiresAt };
store.createSession(token, role, expiresAt);
return { token, expiresAt, role };
}
function timingEqual(a, b) {
const ba = Buffer.from(String(a));
const bb = Buffer.from(String(b));
if (ba.length !== bb.length) return false;
return crypto.timingSafeEqual(ba, bb);
}
// 返回角色 "admin" | "user" | null
function verifyLogin(password) {
// 定长比较,防时序攻击
const a = Buffer.from(String(password));
const b = Buffer.from(panelPassword);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
if (!password) return null;
if (timingEqual(password, adminPassword)) return "admin";
if (userPassword && timingEqual(password, userPassword)) return "user";
return null;
}
// 从请求里取有效会话
function getValidSession(req) {
const cookies = parseCookies(req.headers.cookie);
const token = cookies[COOKIE_NAME];
@@ -51,6 +86,13 @@ function getValidSession(req) {
function isAuthed(req) {
return !!getValidSession(req);
}
function getRole(req) {
const s = getValidSession(req);
return s ? s.role || "user" : null;
}
function isAdmin(req) {
return getRole(req) === "admin";
}
function setSessionCookie(res, token, expiresAt) {
const maxAge = Math.floor((expiresAt - Date.now()) / 1000);
@@ -63,7 +105,7 @@ function clearSessionCookie(res) {
res.setHeader("Set-Cookie", `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
}
// 中间件:保护 /api(登录相关除外)与面板
/* ==================== 中间件 ==================== */
function requireAuth(req, res, next) {
if (isAuthed(req)) return next();
const isApi = (req.originalUrl || req.url || "").startsWith("/api/");
@@ -71,6 +113,13 @@ function requireAuth(req, res, next) {
return res.redirect("/login");
}
// 需要管理员角色
function requireAdmin(req, res, next) {
if (!isAuthed(req)) return res.status(401).json({ ok: false, error: "未登录" });
if (!isAdmin(req)) return res.status(403).json({ ok: false, error: "需要管理员权限" });
next();
}
function logout(req) {
const cookies = parseCookies(req.headers.cookie);
const token = cookies[COOKIE_NAME];
@@ -78,14 +127,16 @@ function logout(req) {
}
function initPasswordLog() {
if (generated) {
if (adminGenerated) {
logger.warn("========================================");
logger.warn(`[AUTH] 未设置 PANEL_PASSWORD,已生成随机登录密码: ${panelPassword}`);
logger.warn("[AUTH] 请用此密码登录面板,或在环境变量中设置 PANEL_PASSWORD 固定密码");
logger.warn(`[AUTH] 未设置 ADMIN_PASSWORD,已生成随机管理员密码: ${adminPassword}`);
logger.warn("[AUTH] 请用此密码登录面板,或在环境变量中设置 ADMIN_PASSWORD 固定密码");
logger.warn("========================================");
} else {
logger.info("[AUTH] 已启用面板鉴权(PANEL_PASSWORD 已设置)");
logger.info("[AUTH] 已启用管理员鉴权(ADMIN_PASSWORD 已设置)");
}
if (userPassword) logger.info("[AUTH] 已启用用户密码(USER_PASSWORD 已设置)");
else logger.info("[AUTH] 未设置 USER_PASSWORD,当前仅管理员可登录");
}
module.exports = {
@@ -94,9 +145,15 @@ module.exports = {
verifyLogin,
getValidSession,
isAuthed,
getRole,
isAdmin,
setSessionCookie,
clearSessionCookie,
requireAuth,
requireAdmin,
logout,
initPasswordLog,
isLocked,
recordFail,
clearFail,
};
+7 -1
View File
@@ -40,8 +40,14 @@ const config = {
LOG_LEVEL: envStr("LOG_LEVEL", "info"),
LOG_FILE: path.join(ROOT, "server.log"),
PANEL_PASSWORD: envStr("PANEL_PASSWORD", ""),
ADMIN_PASSWORD: envStr("ADMIN_PASSWORD", envStr("PANEL_PASSWORD", "")),
USER_PASSWORD: envStr("USER_PASSWORD", ""),
SESSION_TTL_MS: envInt("SESSION_TTL_HOURS", 72) * 3600 * 1000,
LOGIN_MAX_FAILS: envInt("LOGIN_MAX_FAILS", 5),
LOGIN_LOCK_MS: envInt("LOGIN_LOCK_MIN", 10) * 60 * 1000,
SEARCH_CACHE_TTL_MS: envInt("SEARCH_CACHE_TTL_SEC", 300) * 1000,
SEARCH_RATE_MAX: envInt("SEARCH_RATE_MAX", 20),
SEARCH_RATE_WINDOW_MS: envInt("SEARCH_RATE_WINDOW_SEC", 60) * 1000,
};
config.LIVE_TICK_INTERVAL = config.HLS_TIME * 1000;
+16
View File
@@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS songs (
name TEXT NOT NULL DEFAULT '',
artist TEXT NOT NULL DEFAULT '',
cover TEXT NOT NULL DEFAULT '',
cover_remote TEXT NOT NULL DEFAULT '',
duration REAL,
file_size INTEGER NOT NULL DEFAULT 0,
heat INTEGER NOT NULL DEFAULT 0,
@@ -47,6 +48,7 @@ CREATE TABLE IF NOT EXISTS settings (
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
role TEXT NOT NULL DEFAULT 'user',
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
@@ -61,6 +63,20 @@ function init() {
db.pragma("foreign_keys = ON");
db.pragma("busy_timeout = 5000");
db.exec(SCHEMA);
// 迁移:旧库 sessions 表补 role 列
const sessCols = db.prepare("PRAGMA table_info(sessions)").all();
if (!sessCols.some((c) => c.name === "role")) {
db.exec("ALTER TABLE sessions ADD COLUMN role TEXT NOT NULL DEFAULT 'user'");
logger.info("[DB] 迁移 sessions 表:新增 role 列");
}
// 迁移:songs 表补 cover_remote 列(远程封面 URL,优先展示,省带宽)
const songCols = db.prepare("PRAGMA table_info(songs)").all();
if (!songCols.some((c) => c.name === "cover_remote")) {
db.exec("ALTER TABLE songs ADD COLUMN cover_remote TEXT NOT NULL DEFAULT ''");
logger.info("[DB] 迁移 songs 表:新增 cover_remote 列");
}
logger.info(`[DB] 已打开 ${config.DB_FILE} (WAL)`);
ensureDefaultStream();
+2
View File
@@ -184,6 +184,8 @@ async function enrichSong(file, opts = {}) {
if (hit) {
if (needArtist && hit.artist) fields.artist = hit.artist;
if (needCover && hit.albumImage) {
// 远程 URL 优先(省带宽),本地下载作兜底
fields.cover_remote = hit.albumImage;
const cover = await downloadCover(hit.albumImage, fileHash(file));
if (cover) fields.cover = cover;
}
+18 -5
View File
@@ -6,15 +6,27 @@ const logger = require("../logger");
const router = express.Router();
router.post("/login", (req, res) => {
const ip = req.ip || req.connection?.remoteAddress || "unknown";
const locked = auth.isLocked(ip);
if (locked > 0) {
return res.status(429).json({ ok: false, error: `登录失败次数过多,请 ${locked} 秒后再试` });
}
const { password } = req.body || {};
if (!password) return res.status(400).json({ ok: false, error: "请输入密码" });
if (!auth.verifyLogin(password)) {
logger.warn(`[AUTH] 登录失败 from ${req.ip}`);
const role = auth.verifyLogin(password);
if (!role) {
auth.recordFail(ip);
logger.warn(`[AUTH] 登录失败 from ${ip}`);
return res.status(401).json({ ok: false, error: "密码错误" });
}
const { token, expiresAt } = auth.createSession();
auth.clearFail(ip);
const { token, expiresAt } = auth.createSession(role);
auth.setSessionCookie(res, token, expiresAt);
res.json({ ok: true });
logger.info(`[AUTH] 登录成功 (${role}) from ${ip}`);
res.json({ ok: true, role });
});
router.post("/logout", (req, res) => {
@@ -24,7 +36,8 @@ router.post("/logout", (req, res) => {
});
router.get("/me", (req, res) => {
res.json({ ok: true, authed: auth.isAuthed(req) });
const role = auth.getRole(req);
res.json({ ok: true, authed: !!role, role: role || null });
});
module.exports = router;
+37 -2
View File
@@ -13,13 +13,48 @@ const { sanitizeFileName, isValidFileName, isAudioFile, fileHash, getDirSize } =
const router = express.Router();
/* ==================== 搜索缓存 + 限流 ==================== */
const searchCache = new Map(); // keyword -> { at, data }
const searchRate = new Map(); // ip -> { count, resetAt }
function searchRateLimited(ip) {
const now = Date.now();
const rec = searchRate.get(ip);
if (!rec || rec.resetAt < now) {
searchRate.set(ip, { count: 1, resetAt: now + config.SEARCH_RATE_WINDOW_MS });
return false;
}
rec.count += 1;
return rec.count > config.SEARCH_RATE_MAX;
}
/* ==================== 网易云搜索/详情 ==================== */
router.get("/search", async (req, res) => {
const { keyword } = req.query;
if (!keyword || !String(keyword).trim())
return res.status(400).json({ ok: false, error: "缺少搜索关键词" });
const kw = String(keyword).trim();
// 命中缓存直接返回(不消耗限流额度)
const cached = searchCache.get(kw);
if (cached && Date.now() - cached.at < config.SEARCH_CACHE_TTL_MS) {
return res.json({ ok: true, ...cached.data, cached: true });
}
// 未命中 → 限流检查
const ip = req.ip || req.connection?.remoteAddress || "unknown";
if (searchRateLimited(ip)) {
return res.status(429).json({ ok: false, error: "搜索过于频繁,请稍后再试" });
}
try {
const r = await meta.searchNetEase(String(keyword).trim());
const r = await meta.searchNetEase(kw);
searchCache.set(kw, { at: Date.now(), data: r });
// 缓存上限,防内存膨胀
if (searchCache.size > 200) {
const oldest = [...searchCache.entries()].sort((a, b) => a[1].at - b[1].at)[0];
if (oldest) searchCache.delete(oldest[0]);
}
res.json({ ok: true, ...r });
} catch (e) {
res.status(400).json({ ok: false, error: e.message });
@@ -106,7 +141,7 @@ const upload = multer({
},
});
router.post("/import", upload.single("audio"), async (req, res) => {
router.post("/import", require("../auth").requireAdmin, upload.single("audio"), async (req, res) => {
if (!req.file) return res.status(400).json({ ok: false, error: "未收到文件" });
const tmpPath = req.file.path;
try {
+2 -1
View File
@@ -18,7 +18,8 @@ router.get("/", (req, res) => {
file: s.file,
title: s.name || s.file,
artist: s.artist || "",
albumImage: s.cover || "",
albumImage: s.cover_remote || s.cover || "",
coverLocal: s.cover || "",
duration: s.duration || null,
fileSize: s.file_size || 0,
heat: s.heat || 0,
+2 -1
View File
@@ -71,7 +71,8 @@ function snapshot(streamId) {
file: r.file,
title: r.name || r.file,
artist: r.artist || "",
albumImage: r.cover || "",
albumImage: r.cover_remote || r.cover || "",
coverLocal: r.cover || "",
duration: r.duration || null,
heat: r.heat || 0,
cacheStatus: live.getStatus(r.file),
+1 -1
View File
@@ -123,7 +123,7 @@ function createApp() {
app.use("/api/music", auth.requireAuth, musicRoutes);
app.use("/api/resources", auth.requireAuth, resourceRoutes);
app.use("/api/streams", auth.requireAuth, streamRoutes);
app.use("/api/system", auth.requireAuth, systemRoutes);
app.use("/api/system", auth.requireAdmin, systemRoutes);
// 其余 /api 需要登录
app.use("/api", auth.requireAuth);
+8 -6
View File
@@ -18,11 +18,12 @@ function upsertSong(s) {
const ex = getSongByFile(s.file);
if (ex) {
d.prepare(
`UPDATE songs SET name=?, artist=?, cover=?, duration=?, file_size=? WHERE file=?`
`UPDATE songs SET name=?, artist=?, cover=?, cover_remote=?, duration=?, file_size=? WHERE file=?`
).run(
s.name != null ? s.name : ex.name,
s.artist != null ? s.artist : ex.artist,
s.cover != null ? s.cover : ex.cover,
s.cover_remote != null ? s.cover_remote : (ex.cover_remote || ""),
s.duration != null ? s.duration : ex.duration,
s.file_size != null ? s.file_size : ex.file_size,
s.file
@@ -30,13 +31,14 @@ function upsertSong(s) {
return getSongByFile(s.file);
}
d.prepare(
`INSERT INTO songs (file, name, artist, cover, duration, file_size, heat, cache_status, created_at)
VALUES (?, ?, ?, ?, ?, ?, 0, 'pending', ?)`
`INSERT INTO songs (file, name, artist, cover, cover_remote, duration, file_size, heat, cache_status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 'pending', ?)`
).run(
s.file,
s.name || "",
s.artist || "",
s.cover || "",
s.cover_remote || "",
s.duration != null ? s.duration : null,
s.file_size || 0,
Date.now()
@@ -135,10 +137,10 @@ function clearPlaylist(streamId) {
}
/* ==================== sessions ==================== */
function createSession(token, expiresAt) {
function createSession(token, role, expiresAt) {
getDb()
.prepare("INSERT INTO sessions (token, created_at, expires_at) VALUES (?, ?, ?)")
.run(token, Date.now(), expiresAt);
.prepare("INSERT INTO sessions (token, role, created_at, expires_at) VALUES (?, ?, ?, ?)")
.run(token, role || "user", Date.now(), expiresAt);
}
function getSession(token) {
const s = getDb().prepare("SELECT * FROM sessions WHERE token=?").get(token);
+2 -1
View File
@@ -1,3 +1,4 @@
@echo off
set "PANEL_PASSWORD=test123"
set "ADMIN_PASSWORD=test123"
set "USER_PASSWORD=user123"
node index.js