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 证书错误提示:浏览器页面中点 高级 --> 继续前往】
- 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`:关闭<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端 |
| `SSL_CERT_PATH` | HTTPS证书文件路径 | - | 当 `ENABLE_HTTPS=2` 时必须配置 |
| `SSL_KEY_PATH` | HTTPS私钥文件路径 | - | 当 `ENABLE_HTTPS=2` 时必须配置 |
+1
View File
@@ -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=
+2
View File
@@ -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 时必须配置)
+3 -2
View File
@@ -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
```
## 遇到的问题
+2
View File
@@ -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,
+165 -82
View File
@@ -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,
+2
View File
@@ -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)
+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 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,
+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 { 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
}
+2 -2
View File
@@ -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",
+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()
.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;
}
</style>
</style>