From 95354ff0ae2d9bb1dcb7c1b41b748ff4ac2acb65 Mon Sep 17 00:00:00 2001 From: chaoszhu Date: Sat, 29 Aug 2026 15:32:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BC=BA=E5=8C=96=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E9=99=90=E6=B5=81=E3=80=81=E5=85=A8=E5=B1=80=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=90=8A=E9=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- docker-compose.yml | 1 + server/.env.template | 2 + server/README.md | 5 +- server/app/config/index.js | 2 + server/app/controller/user.js | 247 +++++++++++++++------- server/app/server.js | 2 + server/app/utils/auth-session.js | 33 +++ server/app/utils/encrypt.js | 37 ++-- server/app/utils/login-attempt-limiter.js | 128 +++++++++++ server/app/utils/ws-tool.js | 2 + server/package.json | 4 +- server/test/test-auth-session.js | 67 ++++++ server/test/test-cookie-config.js | 26 +++ server/test/test-login-attempt-limiter.js | 85 ++++++++ web/src/views/setting/components/user.vue | 11 +- 16 files changed, 549 insertions(+), 107 deletions(-) create mode 100644 server/app/utils/auth-session.js create mode 100644 server/app/utils/login-attempt-limiter.js create mode 100644 server/test/test-auth-session.js create mode 100644 server/test/test-cookie-config.js create mode 100644 server/test/test-login-attempt-limiter.js diff --git a/README.md b/README.md index 0a54102..1aadfb7 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,8 @@ Native端复用现有EasyNode后端,在移动设备上提供服务器管理、 - 请牢记账号密码,出于安全原因,不提供一键重置密码的脚本 - 访问: - https安全访问:https://ip:8083 【注意:默认启用https自签证书加密访问,首次打开需在浏览器中手动跳过 https 证书错误提示:浏览器页面中点 高级 --> 继续前往】 - - http 内网访问:http://ip:8082 【仓库提供的docker-compose默认仅开放 12.0.0.1 内网访问,切记开放公网访问**请勿使用http**】 + - http 内网访问:http://ip:8082 【切记:开放公网访问**请勿使用http**,仓库提供的 docker-compose.yml 文件默认仅127.0.0.1内网访问】 +ps:仓库提供的 docker-compose.yml 文件默认启用变量`COOKIE_SECURE=true`, 此时http协议无法正常使用,如需非加密访问,请注释`COOKIE_SECURE=true` ### docker-compose部署 @@ -106,6 +107,7 @@ docker compose up -d | `DEBUG` | 启动日志 | `true` | `false`:关闭,`true`:开启 | | `RDP_PORT` | RDP服务端口 | - | 无特殊需求保持默认即可 | | `ENABLE_HTTPS` | 是否启用HTTPS | `1` | `0`:关闭
`1`:自签证书(适合内网)
`2`:合法证书(适合外网)
外网建议使用 nginx/caddy 进行 HTTPS 转发 | +| `COOKIE_SECURE` | Session Cookie 是否仅通过 HTTPS 发送 | `false` | `true`:启用 Secure 标志,仅可通过 HTTPS 登录;仓库提供的 docker-compose 默认为 `true` | | `HTTPS_PORT` | HTTPS端口 | `8092` | 默认启用,请使用 https 访问web端 | | `SSL_CERT_PATH` | HTTPS证书文件路径 | - | 当 `ENABLE_HTTPS=2` 时必须配置 | | `SSL_KEY_PATH` | HTTPS私钥文件路径 | - | 当 `ENABLE_HTTPS=2` 时必须配置 | diff --git a/docker-compose.yml b/docker-compose.yml index 31f54c0..5bb1b99 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,7 @@ services: - HTTP_PORT=8082 - RDP_PORT=8083 - ENABLE_HTTPS=1 # 默认自签证书 + - COOKIE_SECURE=true # Session Cookie仅通过HTTPS发送 - HTTPS_PORT=8092 # https 端口 8092 - SSL_CERT_PATH= - SSL_KEY_PATH= diff --git a/server/.env.template b/server/.env.template index 2346e58..116ab3b 100644 --- a/server/.env.template +++ b/server/.env.template @@ -9,6 +9,8 @@ RDP_PORT=8083 # 是否启用HTTPS (0:关闭 1:自签证书(适合内网) 2:传入证书路径) ENABLE_HTTPS=0 +# Session Cookie是否仅通过HTTPS发送 +COOKIE_SECURE=false # HTTPS端口 HTTPS_PORT=8092 # HTTPS证书文件路径 (当 ENABLE_HTTPS=2 时必须配置) diff --git a/server/README.md b/server/README.md index c55f26e..bf1ee31 100644 --- a/server/README.md +++ b/server/README.md @@ -15,8 +15,9 @@ 快速开始: ```env # .env 文件 -ENABLE_HTTPS=1 # 0:关闭 1:自签证书 2:传入证书路径 -HTTPS_PORT=8092 +ENABLE_HTTPS=1 # 0:关闭 1:自签证书 2:传入证书路径 +COOKIE_SECURE=false # true: Session Cookie 仅通过 HTTPS 发送 +HTTPS_PORT=8092 ``` ## 遇到的问题 diff --git a/server/app/config/index.js b/server/app/config/index.js index 693e2ff..cb5d662 100644 --- a/server/app/config/index.js +++ b/server/app/config/index.js @@ -6,6 +6,7 @@ const config = { enableHttps: process.env.ENABLE_HTTPS ? parseInt(process.env.ENABLE_HTTPS) : 1, // 0:关闭 1:自签证书 2:传入证书路径 sslCertPath: process.env.SSL_CERT_PATH, sslKeyPath: process.env.SSL_KEY_PATH, + cookieSecure: process.env.COOKIE_SECURE === 'true', uploadDir: path.join(process.cwd(),'app/db'), staticDir: path.join(process.cwd(),'app/static'), sftpCacheDir: path.join(process.cwd(),'app/socket/sftp-cache'), @@ -41,6 +42,7 @@ export const { enableHttps, sslCertPath, sslKeyPath, + cookieSecure, uploadDir, staticDir, sftpCacheDir, diff --git a/server/app/controller/user.js b/server/app/controller/user.js index 2c44cbe..00e63ff 100644 --- a/server/app/controller/user.js +++ b/server/app/controller/user.js @@ -1,17 +1,20 @@ -import jwt from 'jsonwebtoken' -import axios from 'axios' -import speakeasy from 'speakeasy' -import QRCode from 'qrcode' -import { UAParser as uap } from 'ua-parser-js' -import { v4 as uuidv4 } from 'uuid' -import PackageJsonModule from '../../package.json' with { type: 'json' } -const version = PackageJsonModule.version -import getLicenseInfo from '../utils/get-plus.js' -import { sendNoticeAsync } from '../utils/notify.js' -import { RSADecryptAsync, AESEncryptAsync, SHA1Encrypt, SHA256Encrypt } from '../utils/encrypt.js' -import { getNetIPInfo, requestWithFailover, timingSafeEqual } from '../utils/tools.js' -import { KeyDB, PlusDB, SessionDB } from '../utils/db-class.js' -import { RuntimeState } from '../utils/runtime-state.js' +import jwt from 'jsonwebtoken' +import axios from 'axios' +import speakeasy from 'speakeasy' +import QRCode from 'qrcode' +import { UAParser as uap } from 'ua-parser-js' +import { v4 as uuidv4 } from 'uuid' +import PackageJsonModule from '../../package.json' with { type: 'json' } +const version = PackageJsonModule.version +import getLicenseInfo from '../utils/get-plus.js' +import { sendNoticeAsync } from '../utils/notify.js' +import { InvalidCiphertextError, RSADecryptAsync, AESEncryptAsync, SHA1Encrypt, SHA256Encrypt } from '../utils/encrypt.js' +import { getClientIP, getNetIPInfo, randomStr, requestWithFailover, timingSafeEqual } from '../utils/tools.js' +import { KeyDB, PlusDB, SessionDB } from '../utils/db-class.js' +import { RuntimeState } from '../utils/runtime-state.js' +import { DEFAULT_LOCK_DURATION_MS, DEFAULT_MAX_ATTEMPTS, loginAttemptLimiter } from '../utils/login-attempt-limiter.js' +import { cookieSecure } from '../config/index.js' +import { disconnectAllSessionConnections, revokeAllSessions } from '../utils/auth-session.js' const keyDB = new KeyDB().getInstance() const sessionDB = new SessionDB().getInstance() @@ -31,95 +34,161 @@ const getpublicKey = async ({ res }) => { res.success({ data }) } -const parseLoginAgentInfo = (userAgent = '') => { - const nativeMatch = userAgent.match(/^EasyNode-(Android|iOS|macOS|Windows|Linux|Native)\/(\S+)\s*(?:\(([^)]*)\))?/) - if (nativeMatch) { - const [, clientName, appVersion, parenContent = ''] = nativeMatch - const parts = parenContent.split(';').map(s => s.trim()).filter(Boolean) - return { - browser: { name: `EasyNode ${ clientName }`, version: appVersion || '' }, - os: { name: clientName, version: parts.join('; ') || '' } - } - } +const parseLoginAgentInfo = (userAgent = '') => { + const nativeMatch = userAgent.match(/^EasyNode-(Android|iOS|macOS|Windows|Linux|Native)\/(\S+)\s*(?:\(([^)]*)\))?/) + if (nativeMatch) { + const [, clientName, appVersion, parenContent = ''] = nativeMatch + const parts = parenContent.split(';').map(s => s.trim()).filter(Boolean) + return { + browser: { name: `EasyNode ${ clientName }`, version: appVersion || '' }, + os: { name: clientName, version: parts.join('; ') || '' } + } + } return uap(userAgent) } -let timer = null -const allowErrCount = 5 // 允许错误的次数 -const forbidTimer = 60 * 5 // 禁止登录时间 -let loginErrCount = 0 // 每一轮的登录错误次数 -let loginErrTotal = 0 // 总的登录错误次数 -let loginCountDown = forbidTimer -let forbidLogin = false +const respondLoginLocked = (ctx, lockStatus) => { + const { res } = ctx + const retryAfterSeconds = lockStatus.retryAfterSeconds + ctx.set('Retry-After', String(retryAfterSeconds)) + return res.fail({ + status: 429, + data: { retryAfterSeconds }, + msg: `登录失败次数过多,请在 ${ retryAfterSeconds } 秒后重试` + }) +} + +const notifyLoginLocked = async (clientIp) => { + const { country = '未知', city = '未知' } = await getNetIPInfo(clientIp) + await sendNoticeAsync( + 'err_login', + '登录错误提醒', + `错误登录次数: ${ DEFAULT_MAX_ATTEMPTS }\n地点:${ country }${ city }\nIP: ${ clientIp }\n锁定时间: ${ DEFAULT_LOCK_DURATION_MS / 60_000 }分钟` + ) +} + +const failLoginAttempt = (ctx, clientIp, msg) => { + const lockStatus = loginAttemptLimiter.recordFailure(clientIp) + if (lockStatus.locked) { + if (lockStatus.justLocked) { + notifyLoginLocked(clientIp).catch(error => logger.error('发送登录锁定通知失败:', error.message)) + } + return respondLoginLocked(ctx, lockStatus) + } + return ctx.res.fail({ + status: 400, + msg: `${ msg } ${ lockStatus.failedAttempts }/${ DEFAULT_MAX_ATTEMPTS }` + }) +} const login = async (ctx) => { const { res, request } = ctx - let { body: { loginName, ciphertext, jwtExpires, mfa2Token }, ip: clientIp, header } = request - if (!loginName || !ciphertext || !jwtExpires || !header) return res.fail({ msg: '请求非法!' }) - const jwtExpiresDuration = ALLOWED_JWT_EXPIRES[jwtExpires] - if (typeof jwtExpiresDuration !== 'number') return res.fail({ msg: '请求非法!' }) - const jwtExpireAt = Date.now() + jwtExpiresDuration - if (forbidLogin) return res.fail({ msg: `禁止登录! 倒计时[${ loginCountDown }s]后尝试登录或重启面板服务` }) - loginErrCount++ - loginErrTotal++ - if (loginErrCount >= allowErrCount) { - const { ip, country, city } = await getNetIPInfo(clientIp) - // 异步发送通知&禁止登录 - sendNoticeAsync('err_login', '登录错误提醒', `错误登录次数: ${ loginErrTotal }\n地点:${ country + city }\nIP: ${ ip }`) - forbidLogin = true - loginErrCount = 0 + const clientIp = getClientIP(ctx.socket?.remoteAddress, ctx.get('x-forwarded-for')) || 'unknown' + const lockStatus = loginAttemptLimiter.getStatus(clientIp) + if (lockStatus.locked) return respondLoginLocked(ctx, lockStatus) - // forbidTimer秒后解禁 - setTimeout(() => { - forbidLogin = false - }, loginCountDown * 1000) - - // 计算登录倒计时 - timer = setInterval(() => { - if (loginCountDown <= 0) { - clearInterval(timer) - timer = null - loginCountDown = forbidTimer - return - } - loginCountDown-- - }, 1000) + const body = request.body + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return failLoginAttempt(ctx, clientIp, '请求非法!') } - // 登录流程 - try { - let loginPwd = await RSADecryptAsync(ciphertext) - let { user, pwd, enableMFA2, secret } = await keyDB.findOneAsync({}) - if (enableMFA2) { - const isValid = speakeasy.totp.verify({ secret, encoding: 'base32', token: String(mfa2Token), window: 1 }) - console.log('MFA2 verfify:', isValid) - if (!isValid) return res.fail({ msg: '验证失败' }) - } + const { loginName, ciphertext, jwtExpires, mfa2Token } = body + const { header } = request + if ( + typeof loginName !== 'string' || !loginName || + typeof ciphertext !== 'string' || !ciphertext || + typeof jwtExpires !== 'string' || !jwtExpires + ) { + return failLoginAttempt(ctx, clientIp, '请求非法!') + } + const jwtExpiresDuration = ALLOWED_JWT_EXPIRES[jwtExpires] + if (typeof jwtExpiresDuration !== 'number') return failLoginAttempt(ctx, clientIp, '请求非法!') + const jwtExpireAt = Date.now() + jwtExpiresDuration + + let loginPwd + try { + loginPwd = await RSADecryptAsync(ciphertext) + } catch (error) { + if (error instanceof InvalidCiphertextError) return failLoginAttempt(ctx, clientIp, '请求非法!') + logger.error('登录密码解密失败:', error) + return res.fail({ status: 500, msg: '登录失败, 请查看服务端日志' }) + } + + let keyRecord + try { + keyRecord = await keyDB.findOneAsync({}) + } catch (error) { + logger.error('读取登录配置失败:', error) + return res.fail({ status: 500, msg: '登录失败, 请查看服务端日志' }) + } + + const { user, pwd, enableMFA2, secret, jwtToken, _id: userId } = keyRecord || {} + if ( + typeof user !== 'string' || typeof pwd !== 'string' || + typeof jwtToken !== 'string' || !jwtToken || !userId + ) { + logger.error('登录配置缺少用户名、密码或签名密钥') + return res.fail({ status: 500, msg: '登录失败, 请查看服务端日志' }) + } + + if (enableMFA2) { + if (typeof secret !== 'string' || !secret) { + logger.error('MFA2 已启用但缺少密钥') + return res.fail({ status: 500, msg: '登录失败, 请查看服务端日志' }) + } + let isValid + try { + isValid = speakeasy.totp.verify({ secret, encoding: 'base32', token: String(mfa2Token), window: 1 }) + } catch (error) { + logger.error('MFA2 验证配置异常:', error) + return res.fail({ status: 500, msg: '登录失败, 请查看服务端日志' }) + } + if (!isValid) return failLoginAttempt(ctx, clientIp, 'MFA2验证失败') + } + + try { // 统一使用SHA1加密验证 loginPwd = SHA1Encrypt(loginPwd) - if (!timingSafeEqual(loginName, user) || !timingSafeEqual(loginPwd, pwd)) return res.fail({ msg: `用户名或密码错误 ${ loginErrTotal }/${ allowErrCount }` }) - if (loginName !== user || loginPwd !== pwd) return res.fail({ msg: `用户名或密码错误 ${ loginErrTotal }/${ allowErrCount }` }) + const loginNameMatches = timingSafeEqual(loginName, user) + const passwordMatches = timingSafeEqual(loginPwd, pwd) + if (!loginNameMatches || !passwordMatches) { + return failLoginAttempt(ctx, clientIp, '用户名或密码错误') + } + } catch { + return failLoginAttempt(ctx, clientIp, '请求非法!') + } + if (loginName !== user || loginPwd !== pwd) { + failLoginAttempt(ctx, clientIp, '用户名或密码错误') + return + } - const { token, session, deviceId } = await beforeLoginHandler(clientIp, jwtExpires, jwtExpireAt, parseLoginAgentInfo(header?.['user-agent'] || '')) + try { + const { token, session, deviceId } = await beforeLoginHandler( + clientIp, + jwtExpires, + jwtExpireAt, + parseLoginAgentInfo(header?.['user-agent'] || ''), + { jwtToken, userId } + ) + loginAttemptLimiter.reset(clientIp) ctx.cookies.set('session', session, { httpOnly: true, expires: new Date(jwtExpireAt), - sameSite: 'strict' + sameSite: 'strict', + secure: cookieSecure }) return res.success({ data: { token, deviceId }, msg: '登录成功' }) } catch (error) { - console.log('登录失败:', error.message) - res.fail({ msg: '登录失败, 请查看服务端日志' }) + logger.error('登录失败:', error) + return res.fail({ status: 500, msg: '登录失败, 请查看服务端日志' }) } } -const beforeLoginHandler = async (clientIp, jwtExpires, jwtExpireAt, agentInfo) => { - loginErrCount = loginErrTotal = 0 // 登录成功, 清空错误次数 +const beforeLoginHandler = async (clientIp, jwtExpires, jwtExpireAt, agentInfo, authSnapshot) => { const session = uuidv4() const deviceId = uuidv4() - let { jwtToken, _id: userId } = await keyDB.findOneAsync({}) - if (!jwtToken || !userId) throw new Error('加密串获取失败,请重启服务!') + const { jwtToken, userId } = authSnapshot let token = jwt.sign({ create: Date.now(), userId, session }, `${ jwtToken }-${ userId }`, { expiresIn: jwtExpires }) const tokenHash = SHA256Encrypt(token) token = await AESEncryptAsync(token) // 对称加密token后再传输给前端 @@ -135,7 +204,8 @@ const beforeLoginHandler = async (clientIp, jwtExpires, jwtExpireAt, agentInfo) return { token, session, deviceId } } -const updatePwd = async ({ res, request }) => { +const updatePwd = async (ctx) => { + const { res, request } = ctx let { body: { oldLoginName, oldPwd, newLoginName, newPwd } } = request let rsaOldPwd = await RSADecryptAsync(oldPwd) oldPwd = SHA1Encrypt(rsaOldPwd) @@ -146,9 +216,22 @@ const updatePwd = async ({ res, request }) => { newPwd = SHA1Encrypt(await RSADecryptAsync(newPwd)) keyObj.user = newLoginName keyObj.pwd = newPwd + keyObj.jwtToken = randomStr(32) await keyDB.updateAsync({ _id: keyObj._id }, { $set: keyObj }) + try { + await revokeAllSessions(sessionDB) + } finally { + // 已建立的长连接不会再次经过鉴权,必须主动断开。 + disconnectAllSessionConnections() + } + ctx.cookies.set('session', '', { + httpOnly: true, + expires: new Date(0), + sameSite: 'strict', + secure: cookieSecure + }) sendNoticeAsync('updatePwd', '用户密码修改提醒', `原用户名:${ user }\n更新用户名: ${ newLoginName }`) - res.success({ data: true, msg: 'success' }) + res.success({ data: true, msg: '修改成功,请重新登录' }) } const getEasynodeVersion = async ({ res }) => { @@ -321,7 +404,7 @@ const releasePlusDevice = async ({ res, request }) => { } } -export { +export { login, getpublicKey, updatePwd, diff --git a/server/app/server.js b/server/app/server.js index 299a040..03344e5 100644 --- a/server/app/server.js +++ b/server/app/server.js @@ -17,6 +17,7 @@ import { throwError, isAllowedIp, getClientIP } from './utils/tools.js' import { SessionDB } from './utils/db-class.js' import { parseCookies } from './utils/verify-auth.js' import { generateSelfSignedCert } from './utils/ssl-cert.js' +import { registerRdpSocket } from './utils/auth-session.js' import createRdpProxyMiddleware from './middlewares/rdp-proxy.js' const sessionDB = new SessionDB().getInstance() @@ -117,6 +118,7 @@ const createServer = () => { // 验证通过,转发请求到 guacamole-lite // guacamole-lite 会验证 URL 中的加密 token console.log('RDP 代理转发请求初步验证成功,开始转发...') + registerRdpSocket(socket) rdpProxy.upgrade(request, socket, head) } catch (error) { logger.error('RDP 代理异常:', error.message) diff --git a/server/app/utils/auth-session.js b/server/app/utils/auth-session.js new file mode 100644 index 0000000..88ee111 --- /dev/null +++ b/server/app/utils/auth-session.js @@ -0,0 +1,33 @@ +const socketServers = new Set() +const rdpSockets = new Set() + +const registerSocketServer = (serverIo) => { + socketServers.add(serverIo) + return serverIo +} + +const registerRdpSocket = (socket) => { + rdpSockets.add(socket) + socket.once('close', () => rdpSockets.delete(socket)) +} + +const revokeAllSessions = async (sessionStore) => { + return sessionStore.updateAsync( + {}, + { $set: { revoked: true } }, + { multi: true } + ) +} + +const disconnectAllSessionConnections = () => { + for (const serverIo of socketServers) serverIo.disconnectSockets(true) + for (const socket of rdpSockets) socket.destroy() + rdpSockets.clear() +} + +export { + disconnectAllSessionConnections, + registerRdpSocket, + registerSocketServer, + revokeAllSessions +} diff --git a/server/app/utils/encrypt.js b/server/app/utils/encrypt.js index e96dbcc..cfde32e 100644 --- a/server/app/utils/encrypt.js +++ b/server/app/utils/encrypt.js @@ -2,18 +2,28 @@ import CryptoJS from 'crypto-js' import rawCrypto from 'node:crypto' import NodeRSA from 'node-rsa' import { KeyDB } from './db-class.js' -const keyDB = new KeyDB().getInstance() - -// rsa非对称 私钥解密 -const RSADecryptAsync = async (ciphertext) => { - if (!ciphertext) return Promise.reject(new Error('ciphertext is empty')) - let { privateKey } = await keyDB.findOneAsync({}) - privateKey = await AESDecryptAsync(privateKey) // 先解密私钥 - const rsakey = new NodeRSA(privateKey) - rsakey.setOptions({ encryptionScheme: 'pkcs1', environment: 'browser' }) // Must Set It When Frontend Use jsencrypt - const plaintext = rsakey.decrypt(ciphertext, 'utf8') - return plaintext -} +const keyDB = new KeyDB().getInstance() + +class InvalidCiphertextError extends Error { + constructor() { + super('invalid ciphertext') + this.name = 'InvalidCiphertextError' + } +} + +// rsa非对称 私钥解密 +const RSADecryptAsync = async (ciphertext) => { + if (typeof ciphertext !== 'string' || !ciphertext) throw new InvalidCiphertextError() + let { privateKey } = await keyDB.findOneAsync({}) + privateKey = await AESDecryptAsync(privateKey) // 先解密私钥 + const rsakey = new NodeRSA(privateKey) + rsakey.setOptions({ encryptionScheme: 'pkcs1', environment: 'browser' }) // Must Set It When Frontend Use jsencrypt + try { + return rsakey.decrypt(ciphertext, 'utf8') + } catch { + throw new InvalidCiphertextError() + } +} // aes对称 加密(default commonKey) const AESEncryptAsync = async (text, key) => { @@ -42,7 +52,8 @@ const SHA256Encrypt = (clearText) => { } export { - RSADecryptAsync, + InvalidCiphertextError, + RSADecryptAsync, AESEncryptAsync, AESDecryptAsync, SHA1Encrypt, diff --git a/server/app/utils/login-attempt-limiter.js b/server/app/utils/login-attempt-limiter.js new file mode 100644 index 0000000..c42b047 --- /dev/null +++ b/server/app/utils/login-attempt-limiter.js @@ -0,0 +1,128 @@ +const DEFAULT_MAX_ATTEMPTS = 3 // 最大重试次数 +const DEFAULT_LOCK_DURATION_MS = 10 * 60 * 1000 // 封锁时间 +const DEFAULT_MAX_ENTRIES = 100 // 最大记录 IP 数 + +class LoginAttemptLimiter { + constructor({ + maxAttempts = DEFAULT_MAX_ATTEMPTS, + lockDurationMs = DEFAULT_LOCK_DURATION_MS, + maxEntries = DEFAULT_MAX_ENTRIES, + now = () => Date.now() + } = {}) { + this.maxAttempts = maxAttempts + this.lockDurationMs = lockDurationMs + this.maxEntries = maxEntries + this.now = now + this.records = new Map() + } + + getStatus(ip) { + const now = this.now() + const record = this.records.get(ip) + if (!record) return this.#emptyStatus() + + if (record.lockedUntil > 0 && record.lockedUntil <= now) { + this.records.delete(ip) + return this.#emptyStatus() + } + + if (record.lockedUntil > now) { + record.lastActivityAt = now + return this.#status(record) + } + + return this.#status(record) + } + + recordFailure(ip) { + const now = this.now() + let record = this.records.get(ip) + + if (record?.lockedUntil > 0 && record.lockedUntil <= now) { + this.records.delete(ip) + record = null + } + + if (record?.lockedUntil > now) { + record.lastActivityAt = now + return this.#status(record) + } + + if (!record) { + this.#makeRoom(now) + record = { failedAttempts: 0, lockedUntil: 0, lastActivityAt: now } + this.records.set(ip, record) + } + + record.failedAttempts += 1 + record.lastActivityAt = now + let justLocked = false + if (record.failedAttempts >= this.maxAttempts) { + record.failedAttempts = this.maxAttempts + record.lockedUntil = now + this.lockDurationMs + justLocked = true + } + + return { ...this.#status(record), justLocked } + } + + reset(ip) { + this.records.delete(ip) + } + + clear() { + this.records.clear() + } + + get size() { + return this.records.size + } + + #makeRoom(now) { + for (const [ip, record] of this.records) { + if (record.lockedUntil > 0 && record.lockedUntil <= now) this.records.delete(ip) + } + + while (this.records.size >= this.maxEntries) { + let oldestIp = null + let oldestActivityAt = Infinity + for (const [ip, record] of this.records) { + if (record.lastActivityAt < oldestActivityAt) { + oldestIp = ip + oldestActivityAt = record.lastActivityAt + } + } + if (oldestIp === null) break + this.records.delete(oldestIp) + } + } + + #status(record) { + const remainingMs = Math.max(0, record.lockedUntil - this.now()) + return { + locked: remainingMs > 0, + failedAttempts: record.failedAttempts, + retryAfterSeconds: remainingMs > 0 ? Math.ceil(remainingMs / 1000) : 0, + justLocked: false + } + } + + #emptyStatus() { + return { + locked: false, + failedAttempts: 0, + retryAfterSeconds: 0, + justLocked: false + } + } +} + +const loginAttemptLimiter = new LoginAttemptLimiter() + +export { + DEFAULT_MAX_ATTEMPTS, + DEFAULT_LOCK_DURATION_MS, + DEFAULT_MAX_ENTRIES, + LoginAttemptLimiter, + loginAttemptLimiter +} diff --git a/server/app/utils/ws-tool.js b/server/app/utils/ws-tool.js index 6f430a4..4b2c06f 100644 --- a/server/app/utils/ws-tool.js +++ b/server/app/utils/ws-tool.js @@ -1,5 +1,6 @@ import { Server } from 'socket.io' import { verifyWsAuthSync } from './verify-auth.js' +import { registerSocketServer } from './auth-session.js' const createSecureWs = (httpServer, path, otherConfig = {}) => { const serverIo = new Server(httpServer, { @@ -12,6 +13,7 @@ const createSecureWs = (httpServer, path, otherConfig = {}) => { }) // 鉴权 serverIo.use(verifyWsAuthSync) + registerSocketServer(serverIo) return serverIo } diff --git a/server/package.json b/server/package.json index 49e500b..78ed133 100644 --- a/server/package.json +++ b/server/package.json @@ -10,8 +10,8 @@ "start": "node ./index.js", "lint": "eslint . --ext .js,.vue", "lint:fix": "eslint . --ext .js,.jsx,.cjs,.mjs --fix", - "test": "node test/test-sftp-cache-path.js && node test/test-rsync-command.js && node test/test-rest-api-auth.js && node test/test-ws-comprehensive.js", - "test:security": "node test/test-sftp-cache-path.js && node test/test-rsync-command.js && node test/test-ssl-cert-persistence.js", + "test": "node test/test-login-attempt-limiter.js && node test/test-auth-session.js && node test/test-sftp-cache-path.js && node test/test-rsync-command.js && node test/test-cookie-config.js && node test/test-rest-api-auth.js && node test/test-ws-comprehensive.js", + "test:security": "node test/test-login-attempt-limiter.js && node test/test-auth-session.js && node test/test-sftp-cache-path.js && node test/test-rsync-command.js && node test/test-cookie-config.js && node test/test-ssl-cert-persistence.js", "test:api": "node test/test-rest-api-auth.js", "test:ws": "node test/test-ws-comprehensive.js", "test:mobile": "node test/test-mobile-crypto.js && node test/test-mobile-ssh-payload.js", diff --git a/server/test/test-auth-session.js b/server/test/test-auth-session.js new file mode 100644 index 0000000..ca18e97 --- /dev/null +++ b/server/test/test-auth-session.js @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import Datastore from '@seald-io/nedb' +import { + disconnectAllSessionConnections, + registerRdpSocket, + registerSocketServer, + revokeAllSessions +} from '../app/utils/auth-session.js' + +const calls = [] +const sessionStore = { + async updateAsync(query, update, options) { + calls.push({ query, update, options }) + return { numAffected: 3 } + } +} + +const result = await revokeAllSessions(sessionStore) + +assert.deepEqual(calls, [{ + query: {}, + update: { $set: { revoked: true } }, + options: { multi: true } +}]) +assert.deepEqual(result, { numAffected: 3 }) + +const realSessionStore = new Datastore() +await realSessionStore.insertAsync([ + { session: 'session-1', revoked: false }, + { session: 'session-2', revoked: false }, + { session: 'session-3', revoked: true } +]) +await revokeAllSessions(realSessionStore) +const storedSessions = await realSessionStore.findAsync({}) +assert.equal(storedSessions.length, 3) +assert.ok(storedSessions.every(session => session.revoked === true)) + +let disconnectCalls = 0 +let destroyCalls = 0 +const closeListeners = [] +registerSocketServer({ + disconnectSockets(close) { + assert.equal(close, true) + disconnectCalls++ + } +}) +registerRdpSocket({ + once(event, listener) { + assert.equal(event, 'close') + closeListeners.push(listener) + }, + destroy() { + destroyCalls++ + } +}) + +disconnectAllSessionConnections() +assert.equal(disconnectCalls, 1) +assert.equal(destroyCalls, 1) + +// 已清空的 RDP 连接不得被重复销毁,迟到的 close 事件也必须安全。 +closeListeners[0]() +disconnectAllSessionConnections() +assert.equal(disconnectCalls, 2) +assert.equal(destroyCalls, 1) + +console.log('全量 Session 吊销测试通过') diff --git a/server/test/test-cookie-config.js b/server/test/test-cookie-config.js new file mode 100644 index 0000000..7922696 --- /dev/null +++ b/server/test/test-cookie-config.js @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict' + +async function loadCookieSecure(value, cacheKey) { + if (value === undefined) { + delete process.env.COOKIE_SECURE + } else { + process.env.COOKIE_SECURE = value + } + const { cookieSecure } = await import(`../app/config/index.js?${ cacheKey }`) + return cookieSecure +} + +const originalValue = process.env.COOKIE_SECURE + +assert.equal(await loadCookieSecure(undefined, 'unset'), false) +assert.equal(await loadCookieSecure('false', 'false'), false) +assert.equal(await loadCookieSecure('TRUE', 'uppercase'), false) +assert.equal(await loadCookieSecure('true', 'true'), true) + +if (originalValue === undefined) { + delete process.env.COOKIE_SECURE +} else { + process.env.COOKIE_SECURE = originalValue +} + +console.log('Cookie security config tests passed') diff --git a/server/test/test-login-attempt-limiter.js b/server/test/test-login-attempt-limiter.js new file mode 100644 index 0000000..2f93e42 --- /dev/null +++ b/server/test/test-login-attempt-limiter.js @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict' +import { + LoginAttemptLimiter, + DEFAULT_LOCK_DURATION_MS +} from '../app/utils/login-attempt-limiter.js' +import { getClientIP } from '../app/utils/tools.js' + +let now = 1_000 +const createLimiter = (options = {}) => new LoginAttemptLimiter({ + now: () => now, + ...options +}) + +const limiter = createLimiter() +assert.deepEqual(limiter.recordFailure('203.0.113.10'), { + locked: false, + failedAttempts: 1, + retryAfterSeconds: 0, + justLocked: false +}) +assert.equal(limiter.recordFailure('203.0.113.10').failedAttempts, 2) + +const locked = limiter.recordFailure('203.0.113.10') +assert.equal(locked.locked, true) +assert.equal(locked.justLocked, true) +assert.equal(locked.retryAfterSeconds, 600) +assert.equal(limiter.getStatus('203.0.113.10').locked, true) +assert.equal(limiter.getStatus('198.51.100.20').locked, false) +assert.equal(limiter.recordFailure('203.0.113.10').justLocked, false) +assert.equal(limiter.getStatus('203.0.113.10').retryAfterSeconds, 600) + +now += DEFAULT_LOCK_DURATION_MS - 1 +assert.equal(limiter.getStatus('203.0.113.10').retryAfterSeconds, 1) +now += 1 +assert.deepEqual(limiter.getStatus('203.0.113.10'), { + locked: false, + failedAttempts: 0, + retryAfterSeconds: 0, + justLocked: false +}) + +limiter.recordFailure('10.0.0.2') +limiter.recordFailure('10.0.0.2') +limiter.reset('10.0.0.2') +assert.equal(limiter.recordFailure('10.0.0.2').failedAttempts, 1) + +const boundedLimiter = createLimiter({ maxEntries: 3 }) +boundedLimiter.recordFailure('192.168.1.1') +now += 1 +boundedLimiter.recordFailure('192.168.1.2') +now += 1 +boundedLimiter.recordFailure('192.168.1.3') +now += 1 +boundedLimiter.recordFailure('192.168.1.4') +assert.equal(boundedLimiter.size, 3) +assert.equal(boundedLimiter.getStatus('192.168.1.1').failedAttempts, 0) +assert.equal(boundedLimiter.getStatus('192.168.1.4').failedAttempts, 1) + +const cleanupLimiter = createLimiter({ maxAttempts: 1, lockDurationMs: 10, maxEntries: 2 }) +cleanupLimiter.recordFailure('172.16.0.1') +now += 10 +cleanupLimiter.recordFailure('172.16.0.2') +assert.equal(cleanupLimiter.size, 1) + +const defaultCapacityLimiter = createLimiter() +for (let i = 1; i <= 101; i++) { + now += 1 + defaultCapacityLimiter.recordFailure(`10.0.0.${ i }`) +} +assert.equal(defaultCapacityLimiter.size, 100) +assert.equal(defaultCapacityLimiter.getStatus('10.0.0.1').failedAttempts, 0) + +assert.equal(getClientIP('::ffff:192.168.1.10'), '192.168.1.10') +assert.equal(getClientIP('10.1.2.3'), '10.1.2.3') +assert.equal(getClientIP('172.16.2.3'), '172.16.2.3') +assert.equal(getClientIP('172.31.2.3'), '172.31.2.3') +assert.equal(getClientIP('192.168.2.3'), '192.168.2.3') +assert.equal(getClientIP('::1'), '::1') +assert.equal(getClientIP('fd00::10'), 'fd00::10') +assert.equal(getClientIP('192.168.1.10', '10.0.0.8'), '10.0.0.8') +assert.equal(getClientIP('fd00::10', 'fd00::20'), 'fd00::20') +assert.equal(getClientIP('203.0.113.10', '198.51.100.20'), '203.0.113.10') +assert.equal(getClientIP('::1', '2001:db8::10'), '2001:db8::10') + +console.log('登录 IP 锁定测试通过') diff --git a/web/src/views/setting/components/user.vue b/web/src/views/setting/components/user.vue index c2495b4..b1c92aa 100644 --- a/web/src/views/setting/components/user.vue +++ b/web/src/views/setting/components/user.vue @@ -114,7 +114,7 @@ const handleUpdate = () => { formRef.value.validate() .then(async () => { $messageBox.confirm( - '修改用户名后会清除所有登录态,需重新登录', + '修改用户名或密码后会清除所有登录态,需重新登录', '修改提示', { confirmButtonText: '确定', @@ -135,11 +135,8 @@ const handleUpdate = () => { formData.newLoginName = '' formData.newPwd = '' formRef.value.resetFields() - if (oldLoginName !== newLoginName) { - $message({ type: 'success', center: true, message: '用户名修改成功, 请重新登录' }) - $store.removeLoginInfo() - $router.push('/login') - } + await $store.removeLoginInfo() + await $router.push('/login') }) }) } @@ -233,4 +230,4 @@ onMounted(() => { .disable_btn { margin: 0 15px; } - \ No newline at end of file +