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