mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-06 14:23:19 +08:00
feat: 强化登录限流、全局会话吊销
This commit is contained in:
@@ -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` 时必须配置 |
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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 时必须配置)
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 { 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
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
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')
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user