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 时必须配置)
+1
View File
@@ -16,6 +16,7 @@
```env ```env
# .env 文件 # .env 文件
ENABLE_HTTPS=1 # 0:关闭 1:自签证书 2:传入证书路径 ENABLE_HTTPS=1 # 0:关闭 1:自签证书 2:传入证书路径
COOKIE_SECURE=false # true: Session Cookie 仅通过 HTTPS 发送
HTTPS_PORT=8092 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,
+140 -57
View File
@@ -8,10 +8,13 @@ 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()
@@ -44,82 +47,148 @@ const parseLoginAgentInfo = (userAgent = '') => {
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)
if (lockStatus.locked) return respondLoginLocked(ctx, lockStatus)
const body = request.body
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return failLoginAttempt(ctx, clientIp, '请求非法!')
}
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] const jwtExpiresDuration = ALLOWED_JWT_EXPIRES[jwtExpires]
if (typeof jwtExpiresDuration !== 'number') return res.fail({ msg: '请求非法!' }) if (typeof jwtExpiresDuration !== 'number') return failLoginAttempt(ctx, clientIp, '请求非法!')
const jwtExpireAt = Date.now() + jwtExpiresDuration 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秒后解禁 let loginPwd
setTimeout(() => {
forbidLogin = false
}, loginCountDown * 1000)
// 计算登录倒计时
timer = setInterval(() => {
if (loginCountDown <= 0) {
clearInterval(timer)
timer = null
loginCountDown = forbidTimer
return
}
loginCountDown--
}, 1000)
}
// 登录流程
try { try {
let loginPwd = await RSADecryptAsync(ciphertext) loginPwd = await RSADecryptAsync(ciphertext)
let { user, pwd, enableMFA2, secret } = await keyDB.findOneAsync({}) } catch (error) {
if (enableMFA2) { if (error instanceof InvalidCiphertextError) return failLoginAttempt(ctx, clientIp, '请求非法!')
const isValid = speakeasy.totp.verify({ secret, encoding: 'base32', token: String(mfa2Token), window: 1 }) logger.error('登录密码解密失败:', error)
console.log('MFA2 verfify:', isValid) return res.fail({ status: 500, msg: '登录失败, 请查看服务端日志' })
if (!isValid) return res.fail({ 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 }) => {
+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
}
+14 -3
View File
@@ -4,15 +4,25 @@ 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()
class InvalidCiphertextError extends Error {
constructor() {
super('invalid ciphertext')
this.name = 'InvalidCiphertextError'
}
}
// rsa非对称 私钥解密 // rsa非对称 私钥解密
const RSADecryptAsync = async (ciphertext) => { const RSADecryptAsync = async (ciphertext) => {
if (!ciphertext) return Promise.reject(new Error('ciphertext is empty')) if (typeof ciphertext !== 'string' || !ciphertext) throw new InvalidCiphertextError()
let { privateKey } = await keyDB.findOneAsync({}) let { privateKey } = await keyDB.findOneAsync({})
privateKey = await AESDecryptAsync(privateKey) // 先解密私钥 privateKey = await AESDecryptAsync(privateKey) // 先解密私钥
const rsakey = new NodeRSA(privateKey) const rsakey = new NodeRSA(privateKey)
rsakey.setOptions({ encryptionScheme: 'pkcs1', environment: 'browser' }) // Must Set It When Frontend Use jsencrypt rsakey.setOptions({ encryptionScheme: 'pkcs1', environment: 'browser' }) // Must Set It When Frontend Use jsencrypt
const plaintext = rsakey.decrypt(ciphertext, 'utf8') try {
return plaintext return rsakey.decrypt(ciphertext, 'utf8')
} catch {
throw new InvalidCiphertextError()
}
} }
// aes对称 加密(default commonKey) // aes对称 加密(default commonKey)
@@ -42,6 +52,7 @@ const SHA256Encrypt = (clearText) => {
} }
export { export {
InvalidCiphertextError,
RSADecryptAsync, RSADecryptAsync,
AESEncryptAsync, AESEncryptAsync,
AESDecryptAsync, AESDecryptAsync,
+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 锁定测试通过')
+3 -6
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')
}
}) })
}) })
} }