mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-06 11:24:07 +08:00
feat: 强化登录限流、全局会话吊销
This commit is contained in:
@@ -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` 时必须配置 |
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
## 遇到的问题
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 吊销测试通过')
|
||||
@@ -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')
|
||||
@@ -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 锁定测试通过')
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user