feat: 强化登录限流、全局会话吊销

This commit is contained in:
chaoszhu
2026-08-29 15:32:42 +08:00
parent 309f7c57e3
commit 95354ff0ae
16 changed files with 549 additions and 107 deletions
+3 -1
View File
@@ -76,7 +76,8 @@ Native端复用现有EasyNode后端,在移动设备上提供服务器管理、
- 请牢记账号密码,出于安全原因,不提供一键重置密码的脚本 - 请牢记账号密码,出于安全原因,不提供一键重置密码的脚本
- 访问: - 访问:
- https安全访问:https://ip:8083 【注意:默认启用https自签证书加密访问,首次打开需在浏览器中手动跳过 https 证书错误提示:浏览器页面中点 高级 --> 继续前往】 - 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部署 ### docker-compose部署
@@ -106,6 +107,7 @@ docker compose up -d
| `DEBUG` | 启动日志 | `true` | `false`:关闭,`true`:开启 | | `DEBUG` | 启动日志 | `true` | `false`:关闭,`true`:开启 |
| `RDP_PORT` | RDP服务端口 | - | 无特殊需求保持默认即可 | | `RDP_PORT` | RDP服务端口 | - | 无特殊需求保持默认即可 |
| `ENABLE_HTTPS` | 是否启用HTTPS | `1` | `0`:关闭<br/>`1`:自签证书(适合内网)<br/>`2`:合法证书(适合外网)<br/>外网建议使用 nginx/caddy 进行 HTTPS 转发 | | `ENABLE_HTTPS` | 是否启用HTTPS | `1` | `0`:关闭<br/>`1`:自签证书(适合内网)<br/>`2`:合法证书(适合外网)<br/>外网建议使用 nginx/caddy 进行 HTTPS 转发 |
| `COOKIE_SECURE` | Session Cookie 是否仅通过 HTTPS 发送 | `false` | `true`:启用 Secure 标志,仅可通过 HTTPS 登录;仓库提供的 docker-compose 默认为 `true` |
| `HTTPS_PORT` | HTTPS端口 | `8092` | 默认启用,请使用 https 访问web端 | | `HTTPS_PORT` | HTTPS端口 | `8092` | 默认启用,请使用 https 访问web端 |
| `SSL_CERT_PATH` | HTTPS证书文件路径 | - | 当 `ENABLE_HTTPS=2` 时必须配置 | | `SSL_CERT_PATH` | HTTPS证书文件路径 | - | 当 `ENABLE_HTTPS=2` 时必须配置 |
| `SSL_KEY_PATH` | HTTPS私钥文件路径 | - | 当 `ENABLE_HTTPS=2` 时必须配置 | | `SSL_KEY_PATH` | HTTPS私钥文件路径 | - | 当 `ENABLE_HTTPS=2` 时必须配置 |
+1
View File
@@ -17,6 +17,7 @@ services:
- HTTP_PORT=8082 - HTTP_PORT=8082
- RDP_PORT=8083 - RDP_PORT=8083
- ENABLE_HTTPS=1 # 默认自签证书 - ENABLE_HTTPS=1 # 默认自签证书
- COOKIE_SECURE=true # Session Cookie仅通过HTTPS发送
- HTTPS_PORT=8092 # https 端口 8092 - HTTPS_PORT=8092 # https 端口 8092
- SSL_CERT_PATH= - SSL_CERT_PATH=
- SSL_KEY_PATH= - SSL_KEY_PATH=
+2
View File
@@ -9,6 +9,8 @@ RDP_PORT=8083
# 是否启用HTTPS (0:关闭 1:自签证书(适合内网) 2:传入证书路径) # 是否启用HTTPS (0:关闭 1:自签证书(适合内网) 2:传入证书路径)
ENABLE_HTTPS=0 ENABLE_HTTPS=0
# Session Cookie是否仅通过HTTPS发送
COOKIE_SECURE=false
# HTTPS端口 # HTTPS端口
HTTPS_PORT=8092 HTTPS_PORT=8092
# HTTPS证书文件路径 (当 ENABLE_HTTPS=2 时必须配置) # HTTPS证书文件路径 (当 ENABLE_HTTPS=2 时必须配置)
+3 -2
View File
@@ -15,8 +15,9 @@
快速开始: 快速开始:
```env ```env
# .env 文件 # .env 文件
ENABLE_HTTPS=1 # 0:关闭 1:自签证书 2:传入证书路径 ENABLE_HTTPS=1 # 0:关闭 1:自签证书 2:传入证书路径
HTTPS_PORT=8092 COOKIE_SECURE=false # true: Session Cookie 仅通过 HTTPS 发送
HTTPS_PORT=8092
``` ```
## 遇到的问题 ## 遇到的问题
+2
View File
@@ -6,6 +6,7 @@ const config = {
enableHttps: process.env.ENABLE_HTTPS ? parseInt(process.env.ENABLE_HTTPS) : 1, // 0:关闭 1:自签证书 2:传入证书路径 enableHttps: process.env.ENABLE_HTTPS ? parseInt(process.env.ENABLE_HTTPS) : 1, // 0:关闭 1:自签证书 2:传入证书路径
sslCertPath: process.env.SSL_CERT_PATH, sslCertPath: process.env.SSL_CERT_PATH,
sslKeyPath: process.env.SSL_KEY_PATH, sslKeyPath: process.env.SSL_KEY_PATH,
cookieSecure: process.env.COOKIE_SECURE === 'true',
uploadDir: path.join(process.cwd(),'app/db'), uploadDir: path.join(process.cwd(),'app/db'),
staticDir: path.join(process.cwd(),'app/static'), staticDir: path.join(process.cwd(),'app/static'),
sftpCacheDir: path.join(process.cwd(),'app/socket/sftp-cache'), sftpCacheDir: path.join(process.cwd(),'app/socket/sftp-cache'),
@@ -41,6 +42,7 @@ export const {
enableHttps, enableHttps,
sslCertPath, sslCertPath,
sslKeyPath, sslKeyPath,
cookieSecure,
uploadDir, uploadDir,
staticDir, staticDir,
sftpCacheDir, sftpCacheDir,
+165 -82
View File
@@ -1,17 +1,20 @@
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import axios from 'axios' import axios from 'axios'
import speakeasy from 'speakeasy' import speakeasy from 'speakeasy'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import { UAParser as uap } from 'ua-parser-js' import { UAParser as uap } from 'ua-parser-js'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import PackageJsonModule from '../../package.json' with { type: 'json' } import PackageJsonModule from '../../package.json' with { type: 'json' }
const version = PackageJsonModule.version const version = PackageJsonModule.version
import getLicenseInfo from '../utils/get-plus.js' import getLicenseInfo from '../utils/get-plus.js'
import { sendNoticeAsync } from '../utils/notify.js' import { sendNoticeAsync } from '../utils/notify.js'
import { RSADecryptAsync, AESEncryptAsync, SHA1Encrypt, SHA256Encrypt } from '../utils/encrypt.js' import { InvalidCiphertextError, RSADecryptAsync, AESEncryptAsync, SHA1Encrypt, SHA256Encrypt } from '../utils/encrypt.js'
import { getNetIPInfo, requestWithFailover, timingSafeEqual } from '../utils/tools.js' import { getClientIP, getNetIPInfo, randomStr, requestWithFailover, timingSafeEqual } from '../utils/tools.js'
import { KeyDB, PlusDB, SessionDB } from '../utils/db-class.js' import { KeyDB, PlusDB, SessionDB } from '../utils/db-class.js'
import { RuntimeState } from '../utils/runtime-state.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 keyDB = new KeyDB().getInstance()
const sessionDB = new SessionDB().getInstance() const sessionDB = new SessionDB().getInstance()
@@ -31,95 +34,161 @@ const getpublicKey = async ({ res }) => {
res.success({ data }) res.success({ data })
} }
const parseLoginAgentInfo = (userAgent = '') => { const parseLoginAgentInfo = (userAgent = '') => {
const nativeMatch = userAgent.match(/^EasyNode-(Android|iOS|macOS|Windows|Linux|Native)\/(\S+)\s*(?:\(([^)]*)\))?/) const nativeMatch = userAgent.match(/^EasyNode-(Android|iOS|macOS|Windows|Linux|Native)\/(\S+)\s*(?:\(([^)]*)\))?/)
if (nativeMatch) { if (nativeMatch) {
const [, clientName, appVersion, parenContent = ''] = nativeMatch const [, clientName, appVersion, parenContent = ''] = nativeMatch
const parts = parenContent.split(';').map(s => s.trim()).filter(Boolean) const parts = parenContent.split(';').map(s => s.trim()).filter(Boolean)
return { return {
browser: { name: `EasyNode ${ clientName }`, version: appVersion || '' }, browser: { name: `EasyNode ${ clientName }`, version: appVersion || '' },
os: { name: clientName, version: parts.join('; ') || '' } os: { name: clientName, version: parts.join('; ') || '' }
} }
} }
return uap(userAgent) return uap(userAgent)
} }
let timer = null const respondLoginLocked = (ctx, lockStatus) => {
const allowErrCount = 5 // 允许错误的次数 const { res } = ctx
const forbidTimer = 60 * 5 // 禁止登录时间 const retryAfterSeconds = lockStatus.retryAfterSeconds
let loginErrCount = 0 // 每一轮的登录错误次数 ctx.set('Retry-After', String(retryAfterSeconds))
let loginErrTotal = 0 // 总的登录错误次数 return res.fail({
let loginCountDown = forbidTimer status: 429,
let forbidLogin = false 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 login = async (ctx) => {
const { res, request } = ctx const { res, request } = ctx
let { body: { loginName, ciphertext, jwtExpires, mfa2Token }, ip: clientIp, header } = request const clientIp = getClientIP(ctx.socket?.remoteAddress, ctx.get('x-forwarded-for')) || 'unknown'
if (!loginName || !ciphertext || !jwtExpires || !header) return res.fail({ msg: '请求非法!' }) const lockStatus = loginAttemptLimiter.getStatus(clientIp)
const jwtExpiresDuration = ALLOWED_JWT_EXPIRES[jwtExpires] if (lockStatus.locked) return respondLoginLocked(ctx, lockStatus)
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
// forbidTimer秒后解禁 const body = request.body
setTimeout(() => { if (!body || typeof body !== 'object' || Array.isArray(body)) {
forbidLogin = false return failLoginAttempt(ctx, clientIp, '请求非法!')
}, loginCountDown * 1000)
// 计算登录倒计时
timer = setInterval(() => {
if (loginCountDown <= 0) {
clearInterval(timer)
timer = null
loginCountDown = forbidTimer
return
}
loginCountDown--
}, 1000)
} }
// 登录流程 const { loginName, ciphertext, jwtExpires, mfa2Token } = body
try { const { header } = request
let loginPwd = await RSADecryptAsync(ciphertext) if (
let { user, pwd, enableMFA2, secret } = await keyDB.findOneAsync({}) typeof loginName !== 'string' || !loginName ||
if (enableMFA2) { typeof ciphertext !== 'string' || !ciphertext ||
const isValid = speakeasy.totp.verify({ secret, encoding: 'base32', token: String(mfa2Token), window: 1 }) typeof jwtExpires !== 'string' || !jwtExpires
console.log('MFA2 verfify:', isValid) ) {
if (!isValid) return res.fail({ msg: '验证失败' }) 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加密验证 // 统一使用SHA1加密验证
loginPwd = SHA1Encrypt(loginPwd) loginPwd = SHA1Encrypt(loginPwd)
if (!timingSafeEqual(loginName, user) || !timingSafeEqual(loginPwd, pwd)) return res.fail({ msg: `用户名或密码错误 ${ loginErrTotal }/${ allowErrCount }` }) const loginNameMatches = timingSafeEqual(loginName, user)
if (loginName !== user || loginPwd !== pwd) return res.fail({ msg: `用户名或密码错误 ${ loginErrTotal }/${ allowErrCount }` }) 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, { ctx.cookies.set('session', session, {
httpOnly: true, httpOnly: true,
expires: new Date(jwtExpireAt), expires: new Date(jwtExpireAt),
sameSite: 'strict' sameSite: 'strict',
secure: cookieSecure
}) })
return res.success({ data: { token, deviceId }, msg: '登录成功' }) return res.success({ data: { token, deviceId }, msg: '登录成功' })
} catch (error) { } catch (error) {
console.log('登录失败:', error.message) logger.error('登录失败:', error)
res.fail({ msg: '登录失败, 请查看服务端日志' }) return res.fail({ status: 500, msg: '登录失败, 请查看服务端日志' })
} }
} }
const beforeLoginHandler = async (clientIp, jwtExpires, jwtExpireAt, agentInfo) => { const beforeLoginHandler = async (clientIp, jwtExpires, jwtExpireAt, agentInfo, authSnapshot) => {
loginErrCount = loginErrTotal = 0 // 登录成功, 清空错误次数
const session = uuidv4() const session = uuidv4()
const deviceId = uuidv4() const deviceId = uuidv4()
let { jwtToken, _id: userId } = await keyDB.findOneAsync({}) const { jwtToken, userId } = authSnapshot
if (!jwtToken || !userId) throw new Error('加密串获取失败,请重启服务!')
let token = jwt.sign({ create: Date.now(), userId, session }, `${ jwtToken }-${ userId }`, { expiresIn: jwtExpires }) let token = jwt.sign({ create: Date.now(), userId, session }, `${ jwtToken }-${ userId }`, { expiresIn: jwtExpires })
const tokenHash = SHA256Encrypt(token) const tokenHash = SHA256Encrypt(token)
token = await AESEncryptAsync(token) // 对称加密token后再传输给前端 token = await AESEncryptAsync(token) // 对称加密token后再传输给前端
@@ -135,7 +204,8 @@ const beforeLoginHandler = async (clientIp, jwtExpires, jwtExpireAt, agentInfo)
return { token, session, deviceId } 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 { body: { oldLoginName, oldPwd, newLoginName, newPwd } } = request
let rsaOldPwd = await RSADecryptAsync(oldPwd) let rsaOldPwd = await RSADecryptAsync(oldPwd)
oldPwd = SHA1Encrypt(rsaOldPwd) oldPwd = SHA1Encrypt(rsaOldPwd)
@@ -146,9 +216,22 @@ const updatePwd = async ({ res, request }) => {
newPwd = SHA1Encrypt(await RSADecryptAsync(newPwd)) newPwd = SHA1Encrypt(await RSADecryptAsync(newPwd))
keyObj.user = newLoginName keyObj.user = newLoginName
keyObj.pwd = newPwd keyObj.pwd = newPwd
keyObj.jwtToken = randomStr(32)
await keyDB.updateAsync({ _id: keyObj._id }, { $set: keyObj }) 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 }`) sendNoticeAsync('updatePwd', '用户密码修改提醒', `原用户名:${ user }\n更新用户名: ${ newLoginName }`)
res.success({ data: true, msg: 'success' }) res.success({ data: true, msg: '修改成功,请重新登录' })
} }
const getEasynodeVersion = async ({ res }) => { const getEasynodeVersion = async ({ res }) => {
@@ -321,7 +404,7 @@ const releasePlusDevice = async ({ res, request }) => {
} }
} }
export { export {
login, login,
getpublicKey, getpublicKey,
updatePwd, updatePwd,
+2
View File
@@ -17,6 +17,7 @@ import { throwError, isAllowedIp, getClientIP } from './utils/tools.js'
import { SessionDB } from './utils/db-class.js' import { SessionDB } from './utils/db-class.js'
import { parseCookies } from './utils/verify-auth.js' import { parseCookies } from './utils/verify-auth.js'
import { generateSelfSignedCert } from './utils/ssl-cert.js' import { generateSelfSignedCert } from './utils/ssl-cert.js'
import { registerRdpSocket } from './utils/auth-session.js'
import createRdpProxyMiddleware from './middlewares/rdp-proxy.js' import createRdpProxyMiddleware from './middlewares/rdp-proxy.js'
const sessionDB = new SessionDB().getInstance() const sessionDB = new SessionDB().getInstance()
@@ -117,6 +118,7 @@ const createServer = () => {
// 验证通过,转发请求到 guacamole-lite // 验证通过,转发请求到 guacamole-lite
// guacamole-lite 会验证 URL 中的加密 token // guacamole-lite 会验证 URL 中的加密 token
console.log('RDP 代理转发请求初步验证成功,开始转发...') console.log('RDP 代理转发请求初步验证成功,开始转发...')
registerRdpSocket(socket)
rdpProxy.upgrade(request, socket, head) rdpProxy.upgrade(request, socket, head)
} catch (error) { } catch (error) {
logger.error('RDP 代理异常:', error.message) logger.error('RDP 代理异常:', error.message)
+33
View File
@@ -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
}
+24 -13
View File
@@ -2,18 +2,28 @@ import CryptoJS from 'crypto-js'
import rawCrypto from 'node:crypto' import rawCrypto from 'node:crypto'
import NodeRSA from 'node-rsa' import NodeRSA from 'node-rsa'
import { KeyDB } from './db-class.js' import { KeyDB } from './db-class.js'
const keyDB = new KeyDB().getInstance() const keyDB = new KeyDB().getInstance()
// rsa非对称 私钥解密 class InvalidCiphertextError extends Error {
const RSADecryptAsync = async (ciphertext) => { constructor() {
if (!ciphertext) return Promise.reject(new Error('ciphertext is empty')) super('invalid ciphertext')
let { privateKey } = await keyDB.findOneAsync({}) this.name = 'InvalidCiphertextError'
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') // rsa非对称 私钥解密
return plaintext 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) // aes对称 加密(default commonKey)
const AESEncryptAsync = async (text, key) => { const AESEncryptAsync = async (text, key) => {
@@ -42,7 +52,8 @@ const SHA256Encrypt = (clearText) => {
} }
export { export {
RSADecryptAsync, InvalidCiphertextError,
RSADecryptAsync,
AESEncryptAsync, AESEncryptAsync,
AESDecryptAsync, AESDecryptAsync,
SHA1Encrypt, SHA1Encrypt,
+128
View File
@@ -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
}
+2
View File
@@ -1,5 +1,6 @@
import { Server } from 'socket.io' import { Server } from 'socket.io'
import { verifyWsAuthSync } from './verify-auth.js' import { verifyWsAuthSync } from './verify-auth.js'
import { registerSocketServer } from './auth-session.js'
const createSecureWs = (httpServer, path, otherConfig = {}) => { const createSecureWs = (httpServer, path, otherConfig = {}) => {
const serverIo = new Server(httpServer, { const serverIo = new Server(httpServer, {
@@ -12,6 +13,7 @@ const createSecureWs = (httpServer, path, otherConfig = {}) => {
}) })
// 鉴权 // 鉴权
serverIo.use(verifyWsAuthSync) serverIo.use(verifyWsAuthSync)
registerSocketServer(serverIo)
return serverIo return serverIo
} }
+2 -2
View File
@@ -10,8 +10,8 @@
"start": "node ./index.js", "start": "node ./index.js",
"lint": "eslint . --ext .js,.vue", "lint": "eslint . --ext .js,.vue",
"lint:fix": "eslint . --ext .js,.jsx,.cjs,.mjs --fix", "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": "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-sftp-cache-path.js && node test/test-rsync-command.js && node test/test-ssl-cert-persistence.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:api": "node test/test-rest-api-auth.js",
"test:ws": "node test/test-ws-comprehensive.js", "test:ws": "node test/test-ws-comprehensive.js",
"test:mobile": "node test/test-mobile-crypto.js && node test/test-mobile-ssh-payload.js", "test:mobile": "node test/test-mobile-crypto.js && node test/test-mobile-ssh-payload.js",
+67
View File
@@ -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 吊销测试通过')
+26
View File
@@ -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')
+85
View File
@@ -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 锁定测试通过')
+4 -7
View File
@@ -114,7 +114,7 @@ const handleUpdate = () => {
formRef.value.validate() formRef.value.validate()
.then(async () => { .then(async () => {
$messageBox.confirm( $messageBox.confirm(
'修改用户名后会清除所有登录态,需重新登录', '修改用户名或密码后会清除所有登录态,需重新登录',
'修改提示', '修改提示',
{ {
confirmButtonText: '确定', confirmButtonText: '确定',
@@ -135,11 +135,8 @@ const handleUpdate = () => {
formData.newLoginName = '' formData.newLoginName = ''
formData.newPwd = '' formData.newPwd = ''
formRef.value.resetFields() formRef.value.resetFields()
if (oldLoginName !== newLoginName) { await $store.removeLoginInfo()
$message({ type: 'success', center: true, message: '用户名修改成功, 请重新登录' }) await $router.push('/login')
$store.removeLoginInfo()
$router.push('/login')
}
}) })
}) })
} }
@@ -233,4 +230,4 @@ onMounted(() => {
.disable_btn { .disable_btn {
margin: 0 15px; margin: 0 15px;
} }
</style> </style>