mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-07 12:48:28 +08:00
423 lines
16 KiB
JavaScript
423 lines
16 KiB
JavaScript
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()
|
|
const plusDB = new PlusDB().getInstance()
|
|
const runtimeState = new RuntimeState().getInstance()
|
|
|
|
// 仅允许三种登录有效期:3天 / 7天 / 30天
|
|
const ALLOWED_JWT_EXPIRES = {
|
|
'3d': 3 * 24 * 60 * 60 * 1000,
|
|
'7d': 7 * 24 * 60 * 60 * 1000,
|
|
'30d': 30 * 24 * 60 * 60 * 1000
|
|
}
|
|
|
|
const getpublicKey = async ({ res }) => {
|
|
let { publicKey: data } = await keyDB.findOneAsync({})
|
|
if (!data) return res.fail({ msg: 'publicKey not found, Try to restart the server', status: 500 })
|
|
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('; ') || '' }
|
|
}
|
|
}
|
|
return uap(userAgent)
|
|
}
|
|
|
|
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
|
|
const clientIp = getClientIP(ctx.socket?.remoteAddress, ctx.get('x-forwarded-for')) || 'unknown'
|
|
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]
|
|
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)
|
|
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
|
|
}
|
|
|
|
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',
|
|
secure: cookieSecure
|
|
})
|
|
return res.success({ data: { token, deviceId }, msg: '登录成功' })
|
|
} catch (error) {
|
|
logger.error('登录失败:', error)
|
|
return res.fail({ status: 500, msg: '登录失败, 请查看服务端日志' })
|
|
}
|
|
}
|
|
|
|
const beforeLoginHandler = async (clientIp, jwtExpires, jwtExpireAt, agentInfo, authSnapshot) => {
|
|
const session = uuidv4()
|
|
const deviceId = uuidv4()
|
|
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后再传输给前端
|
|
|
|
const clientIPInfo = await getNetIPInfo(clientIp)
|
|
const { ip, country, city } = clientIPInfo || {}
|
|
logger.info('登录成功:', { ip, country, city, agentInfo })
|
|
|
|
// 登录通知
|
|
sendNoticeAsync('login', '登录提醒', `地点:${ country + city }\nIP: ${ ip }\n设备信息: ${ agentInfo?.browser?.name } ${ agentInfo?.os?.name }`)
|
|
|
|
await sessionDB.insertAsync({ session, tokenHash, userId, deviceId, revoked: false, ip, country, city, agentInfo, create: Date.now(), expireAt: jwtExpireAt })
|
|
return { token, session, deviceId }
|
|
}
|
|
|
|
const updatePwd = async (ctx) => {
|
|
const { res, request } = ctx
|
|
let { body: { oldLoginName, oldPwd, newLoginName, newPwd } } = request
|
|
let rsaOldPwd = await RSADecryptAsync(oldPwd)
|
|
oldPwd = SHA1Encrypt(rsaOldPwd)
|
|
let keyObj = await keyDB.findOneAsync({})
|
|
let { user, pwd } = keyObj
|
|
if (oldLoginName !== user || oldPwd !== pwd) return res.fail({ data: false, msg: '原用户名或密码校验失败' })
|
|
// 旧密钥校验通过,加密保存新密码
|
|
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: '修改成功,请重新登录' })
|
|
}
|
|
|
|
const getEasynodeVersion = async ({ res }) => {
|
|
try {
|
|
// const { data } = await axios.get('https://api.github.com/repos/chaos-zhu/easynode/releases/latest')
|
|
const { data } = await axios.get('https://get-easynode-latest-version.chaoszhu.workers.dev/version')
|
|
res.success({ data, msg: 'success' })
|
|
} catch (error) {
|
|
logger.error('Failed to fetch Easynode latest version:', error)
|
|
res.fail({ msg: 'Failed to fetch Easynode latest version' })
|
|
}
|
|
}
|
|
|
|
let tempSecret = null
|
|
const getMFA2Status = async ({ res }) => {
|
|
const { enableMFA2 = false } = await keyDB.findOneAsync({})
|
|
res.success({ data: enableMFA2, msg: 'success' })
|
|
}
|
|
const getMFA2Code = async ({ res }) => {
|
|
const { user } = await keyDB.findOneAsync({})
|
|
let { otpauth_url, base32 } = speakeasy.generateSecret({ name: `EasyNode-${ user }`, length: 20 })
|
|
tempSecret = base32
|
|
const qrImage = await QRCode.toDataURL(otpauth_url)
|
|
const data = { qrImage, secret: tempSecret }
|
|
res.success({ data, msg: 'success' })
|
|
}
|
|
|
|
const enableMFA2 = async ({ res, request }) => {
|
|
const { body: { token } } = request
|
|
if (!token) return res.fail({ data: false, msg: '参数错误' })
|
|
try {
|
|
// const isValid = authenticator.verify({ token, secret: tempSecret })
|
|
const isValid = speakeasy.totp.verify({ secret: tempSecret, encoding: 'base32', token, window: 1 })
|
|
if (!isValid) return res.fail({ msg: '验证失败' })
|
|
const keyConfig = await keyDB.findOneAsync({})
|
|
keyConfig.enableMFA2 = true
|
|
keyConfig.secret = tempSecret
|
|
tempSecret = null
|
|
await keyDB.updateAsync({ _id: keyConfig._id }, { $set: keyConfig })
|
|
res.success({ msg: '验证成功' })
|
|
} catch (error) {
|
|
logger.error('MFA2验证失败:', error.message)
|
|
res.fail({ msg: `验证失败: ${ error.message }` })
|
|
}
|
|
}
|
|
|
|
const disableMFA2 = async ({ res, request }) => {
|
|
const { body: { token } } = request
|
|
if (!token) return res.fail({ data: false, msg: '请输入MFA2验证码' })
|
|
|
|
try {
|
|
const keyConfig = await keyDB.findOneAsync({})
|
|
const { secret } = keyConfig
|
|
|
|
// 验证MFA2 token
|
|
const isValid = speakeasy.totp.verify({ secret, encoding: 'base32', token: String(token), window: 1 })
|
|
if (!isValid) return res.fail({ msg: '验证码错误' })
|
|
|
|
// 验证通过,禁用MFA2
|
|
keyConfig.enableMFA2 = false
|
|
keyConfig.secret = null
|
|
await keyDB.updateAsync({ _id: keyConfig._id }, { $set: keyConfig })
|
|
res.success({ msg: '禁用成功' })
|
|
} catch (error) {
|
|
logger.error('禁用MFA2失败:', error.message)
|
|
res.fail({ msg: `禁用失败: ${ error.message }` })
|
|
}
|
|
}
|
|
|
|
const getPlusInfo = async ({ res }) => {
|
|
const dbData = (await plusDB.findOneAsync({})) || {}
|
|
delete dbData._id
|
|
delete dbData.decryptKey
|
|
|
|
// 运行时真实激活状态:内存里 decryptKey 在 + 未被踢 = 真正可用
|
|
const kicked = runtimeState.getPlusKicked()
|
|
const active = Boolean(runtimeState.getDecryptKey()) && !kicked
|
|
const data = {
|
|
key: dbData.key || '',
|
|
instanceId: dbData.instanceId || '',
|
|
active, // 前端/移动端用这个判断「Plus 正常激活使用中」
|
|
status: active ? 'active'
|
|
: kicked ? 'kicked'
|
|
: dbData.key ? 'inactive' : 'unset',
|
|
needRestart: kicked,
|
|
tokenExpireAt: runtimeState.getTokenExpireAt() || 0,
|
|
error: kicked
|
|
? '授权已在其它实例被占用,重启服务后重试'
|
|
: ''
|
|
}
|
|
res.success({ data, msg: 'success' })
|
|
}
|
|
|
|
const getPlusDiscount = async ({ res } = {}) => {
|
|
// if (process.env.EXEC_ENV === 'local') return res.success({ discount: false })
|
|
|
|
try {
|
|
const response = await requestWithFailover(`/api/announcement/public?version=${ version }`)
|
|
|
|
if (response.ok) {
|
|
const data = await response.json()
|
|
return res.success({ data, msg: 'success' })
|
|
}
|
|
|
|
// 如果是403或其他错误状态码
|
|
logger.error('获取折扣信息失败,状态码:', response.status)
|
|
return res.success({ discount: false })
|
|
|
|
} catch (error) {
|
|
logger.error('获取折扣信息失败:', error.message)
|
|
return res.success({ discount: false })
|
|
}
|
|
}
|
|
|
|
const getPlusConf = async ({ res }) => {
|
|
const { key } = await plusDB.findOneAsync({}) || {}
|
|
res.success({ data: key || '', msg: 'success' })
|
|
}
|
|
|
|
const updatePlusKey = async ({ res, request }) => {
|
|
const { body: { key } } = request
|
|
if (runtimeState.getPlusKicked()) {
|
|
runtimeState.clearSessionId()
|
|
return res.fail({ data: { needRestart: true }, msg: '检测到授权已在其它实例被占用,请重启面板服务后重试' })
|
|
}
|
|
const { success, msg, needRestart } = await getLicenseInfo(key)
|
|
if (!success) return res.fail({ data: { needRestart }, msg })
|
|
res.success({ msg: 'success' })
|
|
}
|
|
|
|
const getPlusDevices = async ({ res }) => {
|
|
if (runtimeState.getPlusKicked()) return res.fail({ data: { needRestart: true }, msg: '授权已在其它实例被占用,请重启面板服务后重试' })
|
|
const { key, instanceId } = (await plusDB.findOneAsync({})) || {}
|
|
const sessionId = runtimeState.getSessionId()
|
|
if (!key || !instanceId || !sessionId) return res.fail({ msg: 'Plus未激活' })
|
|
try {
|
|
const response = await requestWithFailover('/api/plus/devices', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ key, instanceId, sessionId, version })
|
|
})
|
|
const body = await response.json().catch(() => ({}))
|
|
if (!response.ok || !body?.success) return res.fail({ msg: body?.message || '获取设备列表失败' })
|
|
res.success({ data: body.data })
|
|
} catch (error) {
|
|
logger.error(`获取Plus设备列表失败: ${ error?.message || error }`)
|
|
res.fail({ msg: error?.message || '获取设备列表失败' })
|
|
}
|
|
}
|
|
|
|
const releasePlusDevice = async ({ res, request }) => {
|
|
if (runtimeState.getPlusKicked()) return res.fail({ data: { needRestart: true }, msg: '授权已在其它实例被占用,请重启面板服务后重试' })
|
|
const { targetInstanceId } = request.body
|
|
const { key, instanceId } = (await plusDB.findOneAsync({})) || {}
|
|
const sessionId = runtimeState.getSessionId()
|
|
if (!key || !instanceId || !sessionId) return res.fail({ msg: 'Plus未激活' })
|
|
if (!targetInstanceId || targetInstanceId === instanceId) return res.fail({ msg: '不能释放本机' })
|
|
try {
|
|
const response = await requestWithFailover('/api/plus/release', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ key, instanceId, sessionId, version, targetInstanceId })
|
|
})
|
|
const body = await response.json().catch(() => ({}))
|
|
if (!response.ok || !body?.success) return res.fail({ msg: body?.message || '释放失败' })
|
|
res.success({ msg: '释放成功' })
|
|
} catch (error) {
|
|
logger.error(`释放Plus设备失败: ${ error?.message || error }`)
|
|
res.fail({ msg: error?.message || '释放失败' })
|
|
}
|
|
}
|
|
|
|
export {
|
|
login,
|
|
getpublicKey,
|
|
updatePwd,
|
|
getEasynodeVersion,
|
|
getMFA2Status,
|
|
getMFA2Code,
|
|
enableMFA2,
|
|
disableMFA2,
|
|
getPlusInfo,
|
|
getPlusDiscount,
|
|
getPlusConf,
|
|
updatePlusKey,
|
|
getPlusDevices,
|
|
releasePlusDevice
|
|
}
|