210 lines
8.1 KiB
JavaScript
210 lines
8.1 KiB
JavaScript
const jwt = require('jsonwebtoken')
|
||
const axios = require('axios')
|
||
const speakeasy = require('speakeasy')
|
||
const QRCode = require('qrcode')
|
||
const version = require('../../package.json').version
|
||
const { plusServer1, plusServer2 } = require('../utils/plus-server')
|
||
const { sendNoticeAsync } = require('../utils/notify')
|
||
const { RSADecryptAsync, AESEncryptAsync, SHA1Encrypt } = require('../utils/encrypt')
|
||
const { getNetIPInfo } = require('../utils/tools')
|
||
const { KeyDB, LogDB, PlusDB } = require('../utils/db-class')
|
||
const keyDB = new KeyDB().getInstance()
|
||
const logDB = new LogDB().getInstance()
|
||
const plusDB = new PlusDB().getInstance()
|
||
|
||
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 })
|
||
}
|
||
|
||
let timer = null
|
||
const allowErrCount = 5 // 允许错误的次数
|
||
const forbidTimer = 60 * 5 // 禁止登录时间
|
||
let loginErrCount = 0 // 每一轮的登录错误次数
|
||
let loginErrTotal = 0 // 总的登录错误次数
|
||
let loginCountDown = forbidTimer
|
||
let forbidLogin = false
|
||
|
||
const login = async ({ res, request }) => {
|
||
let { body: { loginName, ciphertext, jwtExpires, mfa2Token }, ip: clientIp } = request
|
||
if (!loginName && !ciphertext) return res.fail({ msg: '请求非法!' })
|
||
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秒后解禁
|
||
setTimeout(() => {
|
||
forbidLogin = false
|
||
}, loginCountDown * 1000)
|
||
|
||
// 计算登录倒计时
|
||
timer = setInterval(() => {
|
||
if (loginCountDown <= 0) {
|
||
clearInterval(timer)
|
||
timer = null
|
||
loginCountDown = forbidTimer
|
||
return
|
||
}
|
||
loginCountDown--
|
||
}, 1000)
|
||
}
|
||
|
||
// 登录流程
|
||
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: mfa2Token, window: 1 })
|
||
console.log('MFA2 verfify:', isValid)
|
||
if (!isValid) return res.fail({ msg: '验证失败' })
|
||
}
|
||
if (loginName === user && loginPwd === 'admin' && pwd === 'admin') {
|
||
const token = await beforeLoginHandler(clientIp, jwtExpires)
|
||
return res.success({ data: { token, jwtExpires }, msg: '登录成功,请及时修改默认用户名和密码' })
|
||
}
|
||
loginPwd = SHA1Encrypt(loginPwd)
|
||
if (loginName !== user || loginPwd !== pwd) return res.fail({ msg: `用户名或密码错误 ${ loginErrTotal }/${ allowErrCount }` })
|
||
const token = await beforeLoginHandler(clientIp, jwtExpires)
|
||
return res.success({ data: { token, jwtExpires }, msg: '登录成功' })
|
||
} catch (error) {
|
||
console.log('登录失败:', error.message)
|
||
res.fail({ msg: '登录失败, 请查看服务端日志' })
|
||
}
|
||
}
|
||
|
||
const beforeLoginHandler = async (clientIp, jwtExpires) => {
|
||
loginErrCount = loginErrTotal = 0 // 登录成功, 清空错误次数
|
||
|
||
// consola.success('登录成功, 准备生成token', new Date())
|
||
// 生产token
|
||
let { commonKey } = await keyDB.findOneAsync({})
|
||
let token = jwt.sign({ date: Date.now() }, commonKey, { expiresIn: jwtExpires }) // 生成token
|
||
token = await AESEncryptAsync(token) // 对称加密token后再传输给前端
|
||
|
||
// 记录客户端登录IP(用于判断是否异地且只保留最近10<31><30>)
|
||
const clientIPInfo = await getNetIPInfo(clientIp)
|
||
const { ip, country, city } = clientIPInfo || {}
|
||
consola.info('登录成功:', new Date(), { ip, country, city })
|
||
|
||
// 登录通知
|
||
sendNoticeAsync('login', '登录提醒', `地点:${ country + city }\nIP: ${ ip }`)
|
||
|
||
await logDB.insertAsync({ ip, country, city, date: Date.now(), type: 'login' })
|
||
return token
|
||
}
|
||
|
||
const updatePwd = async ({ res, request }) => {
|
||
let { body: { oldLoginName, oldPwd, newLoginName, newPwd } } = request
|
||
let rsaOldPwd = await RSADecryptAsync(oldPwd)
|
||
oldPwd = rsaOldPwd === 'admin' ? 'admin' : SHA1Encrypt(rsaOldPwd)
|
||
let keyObj = await keyDB.findOneAsync({})
|
||
let { user, pwd } = keyObj
|
||
if (oldLoginName !== user || oldPwd !== pwd) return res.fail({ data: false, msg: '原用户名或密码校验失败' })
|
||
// 旧密钥校验通过,加密保存新密码
|
||
newPwd = await RSADecryptAsync(newPwd) === 'admin' ? 'admin' : SHA1Encrypt(await RSADecryptAsync(newPwd))
|
||
keyObj.user = newLoginName
|
||
keyObj.pwd = newPwd
|
||
await keyDB.updateAsync({}, keyObj)
|
||
sendNoticeAsync('updatePwd', '用户密码修改提醒', `原用户名:${ user }\n更新用户名: ${ newLoginName }`)
|
||
res.success({ data: true, msg: 'success' })
|
||
}
|
||
|
||
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) {
|
||
consola.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({}, keyConfig)
|
||
res.success({ msg: '验证成功' })
|
||
} catch (error) {
|
||
res.fail({ msg: `验证失败: ${ error.message }` })
|
||
}
|
||
}
|
||
|
||
const disableMFA2 = async ({ res }) => {
|
||
const keyConfig = await keyDB.findOneAsync({})
|
||
keyConfig.enableMFA2 = false
|
||
keyConfig.secret = null
|
||
await keyDB.updateAsync({}, keyConfig)
|
||
res.success({ msg: 'success' })
|
||
}
|
||
|
||
const getPlusInfo = async ({ res }) => {
|
||
let data = await plusDB.findOneAsync({})
|
||
delete data?._id
|
||
delete data?.decryptKey
|
||
res.success({ data, msg: 'success' })
|
||
}
|
||
|
||
const getPlusDiscount = async ({ res } = {}) => {
|
||
const servers = [plusServer1, plusServer2]
|
||
for (const server of servers) {
|
||
try {
|
||
const url = `${ server }/api/announcement/public?version=${ version }`
|
||
const response = await fetch(url)
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${ response.status }`)
|
||
}
|
||
const data = await response.json()
|
||
return res.success({ data, msg: 'success' })
|
||
} catch (error) {
|
||
if (server === servers[servers.length - 1]) {
|
||
consola.error('All servers failed:', error.message)
|
||
return res.success({ discount: false })
|
||
}
|
||
continue
|
||
}
|
||
}
|
||
}
|
||
|
||
module.exports = {
|
||
login,
|
||
getpublicKey,
|
||
updatePwd,
|
||
getEasynodeVersion,
|
||
getMFA2Status,
|
||
getMFA2Code,
|
||
enableMFA2,
|
||
disableMFA2,
|
||
getPlusInfo,
|
||
getPlusDiscount
|
||
}
|