security: 改密即吊销全部旧令牌 + 登录防爆破 + COS 异地备份(环境变量开关) + 启动失败重试按钮
This commit is contained in:
+3
-3
File diff suppressed because one or more lines are too long
+2
-2
@@ -1,2 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-C-z1ujqe.js","./index-DT_fURyb.js","./index-2EGKD3r-.css"])))=>i.map(i=>d[i]);
|
||||
import{r as s,_ as i}from"./index-DT_fURyb.js";var t;(function(e){e.Documents="DOCUMENTS",e.Data="DATA",e.Library="LIBRARY",e.Cache="CACHE",e.External="EXTERNAL",e.ExternalStorage="EXTERNAL_STORAGE"})(t||(t={}));var a;(function(e){e.UTF8="utf8",e.ASCII="ascii",e.UTF16="utf16"})(a||(a={}));const E=s("Filesystem",{web:()=>i(()=>import("./web-C-z1ujqe.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-DtE1VpZK.js","./index-D6jOKt8Z.js","./index-2EGKD3r-.css"])))=>i.map(i=>d[i]);
|
||||
import{r as s,_ as i}from"./index-D6jOKt8Z.js";var t;(function(e){e.Documents="DOCUMENTS",e.Data="DATA",e.Library="LIBRARY",e.Cache="CACHE",e.External="EXTERNAL",e.ExternalStorage="EXTERNAL_STORAGE"})(t||(t={}));var a;(function(e){e.UTF8="utf8",e.ASCII="ascii",e.UTF16="utf16"})(a||(a={}));const E=s("Filesystem",{web:()=>i(()=>import("./web-DtE1VpZK.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -8,7 +8,7 @@
|
||||
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
|
||||
<title>实习工作日志</title>
|
||||
<script type="module" crossorigin src="./assets/index-DT_fURyb.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-D6jOKt8Z.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-2EGKD3r-.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -26,7 +26,8 @@ async function main() {
|
||||
check('错误密码登录 401', bad.status === 401, bad.status)
|
||||
const ok = await post('/api/auth/login', { username: 'audit_a', password: 'aapass123' })
|
||||
check('正确密码登录 200', ok.status === 200 && !!ok.body.token, ok.status)
|
||||
const tokA = reg.body.token, tokA2 = ok.body.token
|
||||
let tokA = reg.body.token
|
||||
const tokA2 = ok.body.token
|
||||
const noAuth = await post('/api/sync', { tables: {} })
|
||||
check('无 token 同步被拒 401', noAuth.status === 401, noAuth.status)
|
||||
const regB = await post('/api/auth/register', { username: 'audit_b', password: 'bbpass123' })
|
||||
@@ -85,6 +86,10 @@ async function main() {
|
||||
check('旧密码立即失效 401', oldLogin.status === 401, oldLogin.status)
|
||||
const newLogin = await post('/api/auth/login', { username: 'audit_a', password: 'aapass456' })
|
||||
check('新密码登录 200', newLogin.status === 200, newLogin.status)
|
||||
// 改密后旧令牌立即失效(会话吊销)
|
||||
const oldTokSync = await post('/api/sync', { tables: {} }, tokA)
|
||||
check('改密后旧 token 同步被拒 401', oldTokSync.status === 401, oldTokSync.status)
|
||||
tokA = newLogin.body.token
|
||||
const chgWrong = await post('/api/auth/password', { oldPassword: 'zzz', newPassword: 'whatever1' }, tokA)
|
||||
check('错误旧密码改密被拒 400', chgWrong.status === 400, chgWrong.status)
|
||||
|
||||
@@ -123,6 +128,8 @@ async function main() {
|
||||
check('注册表仅存哈希不存明文密码', !!aRec && !JSON.stringify(aRec).includes('aapass'))
|
||||
await new Promise(r => globalThis.setTimeout(r, 400))
|
||||
check('启动自动备份已生成', fsMod.existsSync('server/data/backups/' + new Date().toISOString().slice(0, 10) + '/registry.json'))
|
||||
const hInfo = await fetch(BASE + '/api/health').then(x => x.json()).catch(() => ({}))
|
||||
check('health 暴露备份状态', hInfo && hInfo.backup === 'local-only', hInfo.backup)
|
||||
|
||||
const d1 = await post('/api/auth/delete', {}, tokA)
|
||||
check('删除账号 200', d1.status === 200, d1.body)
|
||||
|
||||
@@ -15,6 +15,12 @@ export function showFatalError(title: string, detail: string): void {
|
||||
const box = document.createElement('div')
|
||||
box.style.cssText = 'border:1px solid #E5484D;border-radius:8px;padding:12px;margin-bottom:12px;word-break:break-all;white-space:pre-wrap;'
|
||||
box.textContent = title + '\n' + detail
|
||||
// 重试入口:纯在线模式下连不上服务器时(如启动失败),一键重载而不是干等着重启应用
|
||||
const retry = document.createElement('button')
|
||||
retry.textContent = '重新加载'
|
||||
retry.style.cssText = 'margin:-4px 0 12px;padding:6px 16px;border:1px solid #E5484D;border-radius:6px;background:#FFFFFF;color:#E5484D;font-size:13px;cursor:pointer;'
|
||||
retry.addEventListener('click', () => { try { location.reload() } catch { /* 忽略 */ } })
|
||||
el.appendChild(box)
|
||||
el.appendChild(retry)
|
||||
} catch { /* 浮层失败时保持静默 */ }
|
||||
}
|
||||
|
||||
+16
-1
@@ -62,7 +62,22 @@ Caddy 会自动为 daywk.linyux.cn 申请并续期 HTTPS 证书(需 DNS 已生
|
||||
- 数据目录:/opt/daywork/server/data
|
||||
- registry.json 用户注册表(用户名 / 角色 / PBKDF2 哈希)
|
||||
- users/<id>.json 每用户九张表数据 + 墓碑
|
||||
- backups/<日期>/ **每日自动备份**(启动与每次注册表变更也会触发,保留最近 14 天)
|
||||
- backups/<日期>/ **每日自动本地备份**(启动与每次注册表变更也会触发,保留最近 14 天)
|
||||
- **异地备份(推荐开启)**:在 docker-compose.yml 的 internlog-sync 服务加环境变量,备份自动上传腾讯云 COS:
|
||||
|
||||
environment:
|
||||
COS_SECRET_ID: <你的 SecretId>
|
||||
COS_SECRET_KEY: <你的 SecretKey>
|
||||
COS_BUCKET: <桶名-AppId,如 footprint-1435255581>
|
||||
COS_REGION: <地域,如 ap-guangzhou>
|
||||
|
||||
开启后每次本地备份都会同步上传(含 latest/ 覆盖版);上传状态可随时查看:
|
||||
|
||||
curl https://daywk.linyux.cn/api/health
|
||||
|
||||
返回 backup: "ok" / "failed: ..." / "local-only"(未配置)。
|
||||
- 会话安全:修改密码或管理员重置密码后,该用户**所有旧登录令牌立即失效**(各端自动用保存的凭据重新登录,用户无感知)
|
||||
- 登录防爆破:同一 IP 十分钟内密码错误满 10 次将临时拒绝(429)
|
||||
- 更新版本:cd /opt/daywork && git pull && cd server && docker compose up -d --build
|
||||
- 同步协议:POST /api/sync(Bearer token;推送差异并返回权威全量快照)
|
||||
- 实时通知:GET /api/events?token=...(SSE,同账号任意端有变更即广播 change)
|
||||
|
||||
+96
-9
@@ -39,6 +39,69 @@ function writeJson(file, data) {
|
||||
fs.renameSync(tmp, path.join(DATA_DIR, file))
|
||||
}
|
||||
|
||||
// ---------- 异地备份:可选,配置环境变量后每日备份与注册表变更时自动上传 COS ----------
|
||||
// COS_SECRET_ID / COS_SECRET_KEY / COS_BUCKET(形如 name-125xxxxxx) / COS_REGION(形如 ap-guangzhou)
|
||||
const COS_CFG = {
|
||||
secretId: process.env.COS_SECRET_ID || '',
|
||||
secretKey: process.env.COS_SECRET_KEY || '',
|
||||
bucket: process.env.COS_BUCKET || '',
|
||||
region: process.env.COS_REGION || ''
|
||||
}
|
||||
let lastOffsiteBackup = { state: 'disabled', at: '', detail: '' }
|
||||
function cosEncode(s) {
|
||||
return encodeURIComponent(s).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase())
|
||||
}
|
||||
function cosAuth(method, pathname) {
|
||||
const start = Math.floor(Date.now() / 1000) - 60
|
||||
const end = start + 600
|
||||
const keyTime = start + ';' + end
|
||||
const signKey = crypto.createHmac('sha1', COS_CFG.secretKey).update(keyTime).digest('hex')
|
||||
const lowerPath = pathname.toLowerCase()
|
||||
const httpString = method.toLowerCase() + '\n' + cosEncode(lowerPath) + '\n' + '' + '\n' + 'host=' + COS_CFG.bucket + '.cos.' + COS_CFG.region + '.myqcloud.com' + '\n'
|
||||
const stringToSign = crypto.createHash('sha1').update(httpString).digest('hex') + '\n' + signKey + '\n' + keyTime + '\n' + crypto.createHash('sha1').update('host').digest('hex')
|
||||
const signature = crypto.createHmac('sha1', signKey).update(stringToSign).digest('hex')
|
||||
return 'q-sign-algorithm=sha1&q-ak=' + COS_CFG.secretId + '&q-sign-time=' + keyTime + '&q-key-time=' + keyTime + '&q-header-list=host&q-url-param-list=&q-signature=' + signature
|
||||
}
|
||||
async function cosPut(key, filePath) {
|
||||
const body = fs.readFileSync(filePath)
|
||||
const pathname = '/' + key.split('/').map(cosEncode).join('/')
|
||||
const auth = cosAuth('put', pathname)
|
||||
const url = 'https://' + COS_CFG.bucket + '.cos.' + COS_CFG.region + '.myqcloud.com' + pathname
|
||||
const resp = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Host: COS_CFG.bucket + '.cos.' + COS_CFG.region + '.myqcloud.com',
|
||||
Authorization: auth,
|
||||
'Content-Length': String(body.length)
|
||||
},
|
||||
body
|
||||
})
|
||||
if (!resp.ok) throw new Error('COS ' + resp.status + ' ' + (await resp.text()).slice(0, 120))
|
||||
}
|
||||
async function backupOffsite() {
|
||||
if (!COS_CFG.secretId || !COS_CFG.secretKey || !COS_CFG.bucket || !COS_CFG.region) return
|
||||
try {
|
||||
const day = new Date().toISOString().slice(0, 10)
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const files = [['registry.json', 'registry.json'], ['secret.key', 'secret.key']]
|
||||
try {
|
||||
const usersDir = path.join(DATA_DIR, 'users')
|
||||
if (fs.existsSync(usersDir)) {
|
||||
for (const f of fs.readdirSync(usersDir)) if (f.endsWith('.json')) files.push(['users/' + f, 'users/' + f])
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
for (const [rel, src] of files) {
|
||||
const full = path.join(DATA_DIR, src)
|
||||
if (!fs.existsSync(full)) continue
|
||||
await cosPut('daywork-backup/' + day + '/' + stamp + '/' + rel, full)
|
||||
await cosPut('daywork-backup/latest/' + rel, full)
|
||||
}
|
||||
lastOffsiteBackup = { state: 'ok', at: new Date().toISOString(), detail: files.length + ' files' }
|
||||
} catch (e) {
|
||||
lastOffsiteBackup = { state: 'failed', at: new Date().toISOString(), detail: (e && e.message) || String(e) }
|
||||
console.error('offsite backup failed:', e && e.message)
|
||||
}
|
||||
}
|
||||
if (!fs.existsSync(path.join(DATA_DIR, 'secret.key'))) {
|
||||
writeJson('secret.key', { secret: crypto.randomBytes(32).toString('hex') })
|
||||
}
|
||||
@@ -55,7 +118,7 @@ for (const u of registry.users) {
|
||||
if (adminDirty) saveRegistry()
|
||||
function isAdminToken(token) {
|
||||
const auth = verifyToken(token)
|
||||
if (!auth) return null
|
||||
if (!auth || !tokenUsable(auth)) return null
|
||||
const rec = registry.users.find(u => u.id === auth.userId)
|
||||
if (!rec || rec.role !== 'admin') return null
|
||||
return rec
|
||||
@@ -76,7 +139,7 @@ function pbkdf2Hash(password, salt) {
|
||||
return crypto.pbkdf2Sync(password, salt, ITER, 32, 'sha256').toString('hex')
|
||||
}
|
||||
function makeToken(userId) {
|
||||
const payload = base64url(JSON.stringify({ userId, exp: Date.now() + TOKEN_TTL_MS }))
|
||||
const payload = base64url(JSON.stringify({ userId, exp: Date.now() + TOKEN_TTL_MS, iat: Date.now() }))
|
||||
const sig = crypto.createHmac('sha256', SECRET).update(payload).digest('base64url')
|
||||
return payload + '.' + sig
|
||||
}
|
||||
@@ -95,12 +158,26 @@ function verifyToken(token) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
// 会话时效:改密 / 管理员重置密码后,签发时间早于 pwd_changed_at 的令牌一律失效
|
||||
function tokenUsable(payload) {
|
||||
if (!payload || !payload.userId) return false
|
||||
const rec = registry.users.find(u => u.id === payload.userId)
|
||||
if (!rec) return false
|
||||
if (rec.pwd_changed_at) {
|
||||
const since = Date.parse(rec.pwd_changed_at)
|
||||
if (!payload.iat || payload.iat < since) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function base64url(s) {
|
||||
return Buffer.from(s).toString('base64url')
|
||||
}
|
||||
|
||||
// ---------- SSE 长连接:按用户分组,数据变更时实时通知 ----------
|
||||
const sseClients = new Map()
|
||||
// 登录防爆破:IP -> { count, first }(内存态,重启即清)
|
||||
const loginFails = new Map()
|
||||
function broadcast(userId, event) {
|
||||
const set = sseClients.get(userId)
|
||||
if (!set) return
|
||||
@@ -189,12 +266,12 @@ async function handle(req, res) {
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && route === '/api/health') { json(res, 200, { ok: true }); return }
|
||||
if (req.method === 'GET' && route === '/api/health') { json(res, 200, { ok: true, backup: lastOffsiteBackup.state === 'disabled' ? 'local-only' : lastOffsiteBackup }); return }
|
||||
|
||||
// SSE 事件流:token 经查询参数传递(EventSource 无法自定义请求头)
|
||||
if (req.method === 'GET' && route === '/api/events') {
|
||||
const auth = verifyToken(url.searchParams.get('token'))
|
||||
if (!auth) { json(res, 401, { error: '未认证' }); return }
|
||||
if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return }
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
@@ -213,7 +290,7 @@ async function handle(req, res) {
|
||||
// 修改登录密码(验证旧密码后重置盐与哈希)
|
||||
if (req.method === 'POST' && route === '/api/auth/password') {
|
||||
const auth = verifyToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim())
|
||||
if (!auth) { json(res, 401, { error: '未认证' }); return }
|
||||
if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return }
|
||||
const body = await readBody(req)
|
||||
const rec = registry.users.find(u => u.id === auth.userId)
|
||||
if (!rec) { json(res, 404, { error: '用户不存在' }); return }
|
||||
@@ -221,7 +298,7 @@ async function handle(req, res) {
|
||||
if (String(body.newPassword || '').length < 6) { json(res, 400, { error: '新密码至少 6 位' }); return }
|
||||
rec.pwd_salt = crypto.randomBytes(16).toString('hex')
|
||||
rec.pwd_hash = pbkdf2Hash(String(body.newPassword), rec.pwd_salt)
|
||||
rec.pwd_changed_at = new Date().toISOString()
|
||||
rec.pwd_changed_at = new Date().toISOString() // 吊销该用户全部旧令牌
|
||||
saveRegistry()
|
||||
json(res, 200, { ok: true })
|
||||
return
|
||||
@@ -230,7 +307,7 @@ async function handle(req, res) {
|
||||
// 删除云端账号:注册表与全部业务数据一并清除(不可恢复,客户端已做二次确认)
|
||||
if (req.method === 'POST' && route === '/api/auth/delete') {
|
||||
const auth = verifyToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim())
|
||||
if (!auth) { json(res, 401, { error: '未认证' }); return }
|
||||
if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return }
|
||||
const idx = registry.users.findIndex(u => u.id === auth.userId)
|
||||
if (idx >= 0) { registry.users.splice(idx, 1); saveRegistry() }
|
||||
try { fs.rmSync(userFile(auth.userId), { force: true }) } catch { /* 忽略 */ }
|
||||
@@ -256,18 +333,27 @@ async function handle(req, res) {
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && route === '/api/auth/login') {
|
||||
const loginIp = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.socket.remoteAddress || 'unknown'
|
||||
const nowMs = Date.now()
|
||||
let st = loginFails.get(loginIp)
|
||||
if (st && nowMs - st.first < 10 * 60 * 1000 && st.count >= 10) { json(res, 429, { error: '尝试次数过多,请 10 分钟后再试' }); return }
|
||||
const body = await readBody(req)
|
||||
const username = String(body.username || '').trim()
|
||||
const rec = registry.users.find(u => u.username === username)
|
||||
if (!rec) { json(res, 401, { error: '用户不存在' }); return }
|
||||
if (pbkdf2Hash(String(body.password || ''), rec.pwd_salt) !== rec.pwd_hash) { json(res, 401, { error: '密码不正确' }); return }
|
||||
if (pbkdf2Hash(String(body.password || ''), rec.pwd_salt) !== rec.pwd_hash) {
|
||||
const s2 = loginFails.get(loginIp)
|
||||
loginFails.set(loginIp, !s2 || nowMs - s2.first >= 10 * 60 * 1000 ? { count: 1, first: nowMs } : { count: s2.count + 1, first: s2.first })
|
||||
json(res, 401, { error: '密码不正确' }); return
|
||||
}
|
||||
loginFails.delete(loginIp)
|
||||
json(res, 200, { userId: rec.id, username: rec.username, token: makeToken(rec.id) })
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && route === '/api/sync') {
|
||||
const auth = verifyToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim())
|
||||
if (!auth) { json(res, 401, { error: '未认证' }); return }
|
||||
if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return }
|
||||
const body = await readBody(req)
|
||||
const userData = loadUserData(auth.userId)
|
||||
if (!userData.cursor) userData.cursor = 0
|
||||
@@ -444,6 +530,7 @@ function doBackup() {
|
||||
while (days.length > 14) { const old = days.shift(); try { fs.rmSync(path.join(backupsRoot, old), { recursive: true, force: true }) } catch { /* 忽略 */ } }
|
||||
} catch { /* 忽略 */ }
|
||||
} catch (e) { console.error('backup failed:', e && e.message) }
|
||||
void backupOffsite()
|
||||
}
|
||||
doBackup()
|
||||
setInterval(doBackup, 24 * 60 * 60 * 1000)
|
||||
|
||||
Reference in New Issue
Block a user