refactor: 将默认密码相关逻辑重构为后端处理 (#1247)

* refactor: 将默认密码相关逻辑重构为后端处理

* refactor: 日志路由进行脱敏,生成随机密码使用node:crypto.randomBytes

* feat: 更新密码功能增强,添加新密码强度验证和旧密码检查

* feat: 给文件管理添加WebUI配置文件的脱敏处理和验证逻辑

* refactor: 优化网络显示卡片按钮样式和行为,调整按钮属性以提升用户体验

* feat: 增强路径处理逻辑,添加安全验证以防止路径遍历攻击

* feat: 增强文件路径处理逻辑,添加安全验证以防止路径遍历攻击,并优化查询参数提取

* feat: CodeQL不认可 受不了
This commit is contained in:
时瑾 2025-09-11 13:13:00 +08:00 committed by GitHub
parent 5e032fcc6a
commit df2dabfe76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 650 additions and 299 deletions

View File

@ -9,4 +9,9 @@
"css.customData": [
".vscode/tailwindcss.json"
],
}
"editor.formatOnPaste": false,
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "never"
},
}

View File

@ -62,24 +62,39 @@ const NetworkDisplayCard = <T extends keyof NetworkType>({
<ButtonGroup
fullWidth
isDisabled={editing}
radius="full"
radius="sm"
size="sm"
variant="shadow"
variant="flat"
>
<Button color="warning" startContent={<FiEdit3 />} onPress={onEdit}>
<Button
color="warning"
startContent={<FiEdit3 size={16} />}
onPress={onEdit}
>
</Button>
<Button
color={debug ? 'success' : 'default'}
startContent={<CgDebug />}
color={debug ? 'secondary' : 'success'}
variant="flat"
startContent={
<CgDebug
style={{
width: '16px',
height: '16px',
minWidth: '16px',
minHeight: '16px'
}}
/>
}
onPress={handleEnableDebug}
>
{debug ? '关闭调试' : '开启调试'}
</Button>
<Button
color="primary"
startContent={<MdDeleteForever />}
className="bg-danger/20 text-danger hover:bg-danger/30 transition-colors"
variant="flat"
startContent={<MdDeleteForever size={16} />}
onPress={handleDelete}
>

View File

@ -33,21 +33,6 @@ export default class WebUIManager {
return data.data
}
public static async changePasswordFromDefault(newToken: string) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/auth/update_token',
{ newToken, fromDefault: true }
)
return data.data
}
public static async checkUsingDefaultToken() {
const { data } = await serverRequest.get<ServerResponse<boolean>>(
'/auth/check_using_default_token'
)
return data.data
}
public static async proxy<T>(url = '') {
const data = await serverRequest.get<ServerResponse<string>>(
'/base/proxy?url=' + encodeURIComponent(url)

View File

@ -1,6 +1,5 @@
import { Input } from '@heroui/input'
import { useLocalStorage } from '@uidotdev/usehooks'
import { useEffect, useState } from 'react'
import { Controller, useForm } from 'react-hook-form'
import toast from 'react-hot-toast'
import { useNavigate } from 'react-router-dom'
@ -12,14 +11,12 @@ import SaveButtons from '@/components/button/save_buttons'
import WebUIManager from '@/controllers/webui_manager'
const ChangePasswordCard = () => {
const [isDefaultToken, setIsDefaultToken] = useState<boolean>(false)
const [isLoadingCheck, setIsLoadingCheck] = useState<boolean>(true)
const {
control,
handleSubmit: handleWebuiSubmit,
formState: { isSubmitting },
reset
formState: { isSubmitting, errors },
reset,
watch
} = useForm<{
oldToken: string
newToken: string
@ -33,31 +30,13 @@ const ChangePasswordCard = () => {
const navigate = useNavigate()
const [_, setToken] = useLocalStorage(key.token, '')
// 检查是否使用默认密码
useEffect(() => {
const checkDefaultToken = async () => {
try {
const isDefault = await WebUIManager.checkUsingDefaultToken()
setIsDefaultToken(isDefault)
} catch (error) {
console.error('检查默认密码状态失败:', error)
} finally {
setIsLoadingCheck(false)
}
}
checkDefaultToken()
}, [])
// 监听旧密码的值
const oldTokenValue = watch('oldToken')
const onSubmit = handleWebuiSubmit(async (data) => {
try {
if (isDefaultToken) {
// 从默认密码更新
await WebUIManager.changePasswordFromDefault(data.newToken)
} else {
// 正常密码更新
await WebUIManager.changePassword(data.oldToken, data.newToken)
}
// 使用正常密码更新流程
await WebUIManager.changePassword(data.oldToken, data.newToken)
toast.success('修改成功')
setToken('')
@ -69,53 +48,74 @@ const ChangePasswordCard = () => {
}
})
if (isLoadingCheck) {
return (
<>
<title> - NapCat WebUI</title>
<div className="flex justify-center items-center h-32">
<div className="text-center">...</div>
</div>
</>
)
}
return (
<>
<title> - NapCat WebUI</title>
{isDefaultToken && (
<div className="mb-4 p-3 bg-warning-50 border border-warning-200 rounded-lg">
<p className="text-warning-700 text-sm">
使
</p>
</div>
)}
{!isDefaultToken && (
<Controller
control={control}
name="oldToken"
render={({ field }) => (
<Input
{...field}
label="旧密码"
placeholder="请输入旧密码"
type="password"
/>
)}
/>
)}
<Controller
control={control}
name="oldToken"
rules={{
required: '旧密码不能为空',
validate: (value) => {
if (!value || value.trim().length === 0) {
return '旧密码不能为空'
}
return true
}
}}
render={({ field }) => (
<Input
{...field}
label="旧密码"
placeholder="请输入旧密码"
type="password"
isRequired
isInvalid={!!errors.oldToken}
errorMessage={errors.oldToken?.message}
/>
)}
/>
<Controller
control={control}
name="newToken"
rules={{
required: '新密码不能为空',
minLength: {
value: 6,
message: '新密码至少需要6个字符'
},
validate: (value) => {
if (!value || value.trim().length === 0) {
return '新密码不能为空'
}
if (value.trim().length !== value.length) {
return '新密码不能包含前后空格'
}
if (value === oldTokenValue) {
return '新密码不能与旧密码相同'
}
// 检查是否包含字母
if (!/[a-zA-Z]/.test(value)) {
return '新密码必须包含字母'
}
// 检查是否包含数字
if (!/[0-9]/.test(value)) {
return '新密码必须包含数字'
}
return true
}
}}
render={({ field }) => (
<Input
{...field}
label={isDefaultToken ? "设置新密码" : "新密码"}
placeholder={isDefaultToken ? "请设置一个安全的新密码" : "请输入新密码"}
label="新密码"
placeholder="至少6位包含字母和数字"
type="password"
isRequired
isInvalid={!!errors.newToken}
errorMessage={errors.newToken?.message}
/>
)}
/>

View File

@ -1,52 +1,15 @@
import { Spinner } from '@heroui/spinner'
import { AnimatePresence, motion } from 'motion/react'
import { Suspense, useEffect } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { Suspense } from 'react'
import { Outlet, useLocation } from 'react-router-dom'
import useAuth from '@/hooks/auth'
import useDialog from '@/hooks/use-dialog'
import WebUIManager from '@/controllers/webui_manager'
import DefaultLayout from '@/layouts/default'
const CheckDefaultPassword = () => {
const { isAuth } = useAuth()
const dialog = useDialog()
const navigate = useNavigate()
const checkDefaultPassword = async () => {
const data = await WebUIManager.checkUsingDefaultToken()
if (data) {
dialog.confirm({
title: '修改默认密码',
content: '检测到当前密码为默认密码,为了您的安全,必须立即修改密码。',
confirmText: '前往修改',
onConfirm: () => {
navigate('/config?tab=token')
},
onCancel: () => {
navigate('/config?tab=token')
},
onClose() {
navigate('/config?tab=token')
},
})
}
}
useEffect(() => {
if (isAuth) {
checkDefaultPassword()
}
}, [isAuth])
return null
}
export default function IndexPage() {
const location = useLocation()
return (
<DefaultLayout>
<CheckDefaultPassword />
<Suspense
fallback={
<div className="flex justify-center px-10">

View File

@ -92,42 +92,65 @@ export default function WebLoginPage() {
</CardHeader>
<CardBody className="flex gap-5 py-5 px-5 md:px-10">
<Input
isClearable
type="password"
classNames={{
label: 'text-black/50 dark:text-white/90',
input: [
'bg-transparent',
'text-black/90 dark:text-white/90',
'placeholder:text-default-700/50 dark:placeholder:text-white/60'
],
innerWrapper: 'bg-transparent',
inputWrapper: [
'shadow-xl',
'bg-default-100/70',
'dark:bg-default/60',
'backdrop-blur-xl',
'backdrop-saturate-200',
'hover:bg-default-0/70',
'dark:hover:bg-default/70',
'group-data-[focus=true]:bg-default-100/50',
'dark:group-data-[focus=true]:bg-default/60',
'!cursor-text'
]
<form
onSubmit={(e) => {
e.preventDefault()
onSubmit()
}}
isDisabled={isLoading}
label="Token"
placeholder="请输入token"
radius="lg"
size="lg"
startContent={
<IoKeyOutline className="text-black/50 mb-0.5 dark:text-white/90 text-slate-400 pointer-events-none flex-shrink-0" />
}
value={tokenValue}
onChange={(e) => setTokenValue(e.target.value)}
onClear={() => setTokenValue('')}
/>
>
{/* 隐藏的用户名字段,帮助浏览器识别登录表单 */}
<input
type="text"
name="username"
value="napcat-webui"
autoComplete="username"
className="absolute -left-[9999px] opacity-0 pointer-events-none"
readOnly
tabIndex={-1}
aria-label="Username"
/>
<Input
isClearable
type="password"
name="password"
autoComplete="current-password"
classNames={{
label: 'text-black/50 dark:text-white/90',
input: [
'bg-transparent',
'text-black/90 dark:text-white/90',
'placeholder:text-default-700/50 dark:placeholder:text-white/60'
],
innerWrapper: 'bg-transparent',
inputWrapper: [
'shadow-xl',
'bg-default-100/70',
'dark:bg-default/60',
'backdrop-blur-xl',
'backdrop-saturate-200',
'hover:bg-default-0/70',
'dark:hover:bg-default/70',
'group-data-[focus=true]:bg-default-100/50',
'dark:group-data-[focus=true]:bg-default/60',
'!cursor-text'
]
}}
isDisabled={isLoading}
label="Token"
placeholder="请输入token"
radius="lg"
size="lg"
startContent={
<IoKeyOutline className="text-black/50 mb-0.5 dark:text-white/90 text-slate-400 pointer-events-none flex-shrink-0" />
}
value={tokenValue}
onChange={(e) => setTokenValue(e.target.value)}
onClear={() => setTokenValue('')}
/>
</form>
<div className="text-center text-small text-default-600 dark:text-default-400 px-2">
💡 NapCat
</div>
<Button
className="mx-10 mt-10 text-lg py-7"
color="primary"

View File

@ -17,6 +17,7 @@ import {
NTMsgAtType,
} from '@/core';
import { OB11ConfigLoader } from '@/onebot/config';
import { pendingTokenToSend } from '@/webui/index';
import {
OB11HttpClientAdapter,
OB11WebSocketClientAdapter,
@ -64,8 +65,8 @@ export class NapCatOneBot11Adapter {
networkManager: OB11NetworkManager;
actions: ActionMap;
private readonly bootTime = Date.now() / 1000;
recallEventCache = new Map<string, any>();
constructor(core: NapCatCore, context: InstanceContext, pathWrapper: NapCatPathWrapper) {
recallEventCache = new Map<string, NodeJS.Timeout>();
constructor (core: NapCatCore, context: InstanceContext, pathWrapper: NapCatPathWrapper) {
this.core = core;
this.context = context;
this.configLoader = new OB11ConfigLoader(core, pathWrapper.configPath, OneBotConfigSchema);
@ -79,7 +80,7 @@ export class NapCatOneBot11Adapter {
this.actions = createActionMap(this, core);
this.networkManager = new OB11NetworkManager();
}
async creatOneBotLog(ob11Config: OneBotConfig) {
async creatOneBotLog (ob11Config: OneBotConfig) {
let log = '[network] 配置加载\n';
for (const key of ob11Config.network.httpServers) {
log += `HTTP服务: ${key.host}:${key.port}, : ${key.enable ? '已启动' : '未启动'}\n`;
@ -98,15 +99,44 @@ export class NapCatOneBot11Adapter {
}
return log;
}
async InitOneBot() {
async InitOneBot () {
const selfInfo = this.core.selfInfo;
const ob11Config = this.configLoader.configData;
this.core.apis.UserApi.getUserDetailInfo(selfInfo.uid, false)
.then((user) => {
.then(async (user) => {
selfInfo.nick = user.nick;
this.context.logger.setLogSelfInfo(selfInfo);
WebUiDataRuntime.getQQLoginCallback()(true);
// 检查是否有待发送的token
if (pendingTokenToSend) {
this.context.logger.log('[NapCat] [OneBot] 🔐 检测到待发送的WebUI Token开始发送');
try {
await this.core.apis.MsgApi.sendMsg(
{ chatType: ChatType.KCHATTYPEC2C, peerUid: selfInfo.uid, guildId: '' },
[{
elementType: ElementType.TEXT,
elementId: '',
textElement: {
content:
'[NapCat] 温馨提示:\n'+
'WebUI密码为默认密码已进行强制修改\n'+
'新密码: ' +pendingTokenToSend,
atType: NTMsgAtType.ATTYPEUNKNOWN,
atUid: '',
atTinyId: '',
atNtUid: '',
}
}],
5000
);
this.context.logger.log('[NapCat] [OneBot] ✅ WebUI Token 消息发送成功');
} catch (error) {
this.context.logger.logError('[NapCat] [OneBot] ❌ WebUI Token 消息发送失败:', error);
}
}
WebUiDataRuntime.getQQLoginCallback()(true);
})
.catch(e => this.context.logger.logError(e));
@ -120,7 +150,7 @@ export class NapCatOneBot11Adapter {
// new OB11PluginAdapter('myPlugin', this.core, this,this.actions)
// );
if (existsSync(this.context.pathWrapper.pluginPath)) {
this.context.logger.log(`[Plugins] 插件目录存在,开始加载插件`);
this.context.logger.log('[Plugins] 插件目录存在,开始加载插件');
this.networkManager.registerAdapter(
new OB11PluginMangerAdapter('plugin_manager', this.core, this, this.actions)
);
@ -181,25 +211,6 @@ export class NapCatOneBot11Adapter {
WebUiDataRuntime.setQQVersion(this.core.context.basicInfoWrapper.getFullQQVersion());
WebUiDataRuntime.setQQLoginInfo(selfInfo);
WebUiDataRuntime.setQQLoginStatus(true);
let sendWebUiToken = async (token: string) => {
await this.core.apis.MsgApi.sendMsg(
{ chatType: ChatType.KCHATTYPEC2C, peerUid: selfInfo.uid, guildId: '' },
[{
elementType: ElementType.TEXT,
elementId: '',
textElement: {
content: 'Update WebUi Token: ' + token,
atType: NTMsgAtType.ATTYPEUNKNOWN,
atUid: '',
atTinyId: '',
atNtUid: '',
}
}],
5000
)
};
WebUiDataRuntime.setWebUiTokenChangeCallback(sendWebUiToken);
WebUiDataRuntime.setOnOB11ConfigChanged(async (newConfig) => {
const prev = this.configLoader.configData;
this.configLoader.save(newConfig);
@ -209,7 +220,7 @@ export class NapCatOneBot11Adapter {
}
private async reloadNetwork(prev: OneBotConfig, now: OneBotConfig): Promise<void> {
private async reloadNetwork (prev: OneBotConfig, now: OneBotConfig): Promise<void> {
const prevLog = await this.creatOneBotLog(prev);
const newLog = await this.creatOneBotLog(now);
this.context.logger.log(`[Notice] [OneBot11] 配置变更前:\n${prevLog}`);
@ -222,7 +233,7 @@ export class NapCatOneBot11Adapter {
await this.handleConfigChange(prev.network.websocketClients, now.network.websocketClients, OB11WebSocketClientAdapter);
}
private async handleConfigChange<CT extends NetworkAdapterConfig>(
private async handleConfigChange<CT extends NetworkAdapterConfig> (
prevConfig: NetworkAdapterConfig[],
nowConfig: NetworkAdapterConfig[],
adapterClass: new (
@ -254,7 +265,7 @@ export class NapCatOneBot11Adapter {
}
}
private initMsgListener() {
private initMsgListener () {
const msgListener = new NodeIKernelMsgListener();
msgListener.onRecvSysMsg = (msg) => {
this.apis.MsgApi.parseSysMessage(msg)
@ -368,7 +379,7 @@ export class NapCatOneBot11Adapter {
this.context.session.getMsgService().addKernelMsgListener(proxiedListenerOf(msgListener, this.context.logger));
}
private initBuddyListener() {
private initBuddyListener () {
const buddyListener = new NodeIKernelBuddyListener();
buddyListener.onBuddyReqChange = async (reqs) => {
@ -399,7 +410,7 @@ export class NapCatOneBot11Adapter {
.addKernelBuddyListener(proxiedListenerOf(buddyListener, this.context.logger));
}
private initGroupListener() {
private initGroupListener () {
const groupListener = new NodeIKernelGroupListener();
groupListener.onGroupNotifiesUpdated = async (_, notifies) => {
@ -492,7 +503,7 @@ export class NapCatOneBot11Adapter {
.addKernelGroupListener(proxiedListenerOf(groupListener, this.context.logger));
}
private async emitMsg(message: RawMessage) {
private async emitMsg (message: RawMessage) {
const network = await this.networkManager.getAllConfig();
this.context.logger.logDebug('收到新消息 RawMessage', message);
await Promise.allSettled([
@ -501,7 +512,7 @@ export class NapCatOneBot11Adapter {
]);
}
private async handleMsg(message: RawMessage, network: Array<NetworkAdapterConfig>) {
private async handleMsg (message: RawMessage, network: Array<NetworkAdapterConfig>) {
// 过滤无效消息
if (message.msgType === NTMsgType.KMSGTYPENULL) {
return;
@ -522,17 +533,17 @@ export class NapCatOneBot11Adapter {
}
}
private isSelfMessage(ob11Msg: {
stringMsg: OB11Message;
arrayMsg: OB11Message;
private isSelfMessage (ob11Msg: {
stringMsg: OB11Message
arrayMsg: OB11Message
}): boolean {
return ob11Msg.stringMsg.user_id.toString() == this.core.selfInfo.uin ||
ob11Msg.arrayMsg.user_id.toString() == this.core.selfInfo.uin;
}
private createMsgMap(network: Array<NetworkAdapterConfig>, ob11Msg: {
stringMsg: OB11Message;
arrayMsg: OB11Message;
private createMsgMap (network: Array<NetworkAdapterConfig>, ob11Msg: {
stringMsg: OB11Message
arrayMsg: OB11Message
}, isSelfMsg: boolean, message: RawMessage): Map<string, OB11Message> {
const msgMap: Map<string, OB11Message> = new Map();
network.filter(e => e.enable).forEach(e => {
@ -550,7 +561,7 @@ export class NapCatOneBot11Adapter {
return msgMap;
}
private handleDebugNetwork(network: Array<NetworkAdapterConfig>, msgMap: Map<string, OB11Message>, message: RawMessage) {
private handleDebugNetwork (network: Array<NetworkAdapterConfig>, msgMap: Map<string, OB11Message>, message: RawMessage) {
const debugNetwork = network.filter(e => e.enable && e.debug);
if (debugNetwork.length > 0) {
debugNetwork.forEach(adapter => {
@ -564,7 +575,7 @@ export class NapCatOneBot11Adapter {
}
}
private handleNotReportSelfNetwork(network: Array<NetworkAdapterConfig>, msgMap: Map<string, OB11Message>, isSelfMsg: boolean) {
private handleNotReportSelfNetwork (network: Array<NetworkAdapterConfig>, msgMap: Map<string, OB11Message>, isSelfMsg: boolean) {
if (isSelfMsg) {
const notReportSelfNetwork = network.filter(e => e.enable && (('reportSelfMessage' in e && !e.reportSelfMessage) || !('reportSelfMessage' in e)));
notReportSelfNetwork.forEach(adapter => {
@ -573,7 +584,7 @@ export class NapCatOneBot11Adapter {
}
}
private async handleGroupEvent(message: RawMessage) {
private async handleGroupEvent (message: RawMessage) {
try {
// 群名片修改事件解析 任何都该判断
if (message.senderUin && message.senderUin !== '0') {
@ -606,7 +617,7 @@ export class NapCatOneBot11Adapter {
}
}
private async handlePrivateMsgEvent(message: RawMessage) {
private async handlePrivateMsgEvent (message: RawMessage) {
try {
if (message.msgType === NTMsgType.KMSGTYPEGRAYTIPS) {
// 灰条为单元素消息
@ -624,7 +635,7 @@ export class NapCatOneBot11Adapter {
}
}
private async emitRecallMsg(message: RawMessage, element: MessageElement) {
private async emitRecallMsg (message: RawMessage, element: MessageElement) {
const peer: Peer = { chatType: message.chatType, peerUid: message.peerUid, guildId: '' };
const oriMessageId = MessageUnique.getShortIdByMsgId(message.msgId) ?? MessageUnique.createUniqueMsgId(peer, message.msgId);
if (message.chatType == ChatType.KCHATTYPEC2C) {
@ -635,7 +646,7 @@ export class NapCatOneBot11Adapter {
return;
}
private async emitFriendRecallMsg(message: RawMessage, oriMessageId: number, element: MessageElement) {
private async emitFriendRecallMsg (message: RawMessage, oriMessageId: number, element: MessageElement) {
const operatorUid = element.grayTipElement?.revokeElement.operatorUid;
if (!operatorUid) return undefined;
return new OB11FriendRecallNoticeEvent(
@ -645,7 +656,7 @@ export class NapCatOneBot11Adapter {
);
}
private async emitGroupRecallMsg(message: RawMessage, oriMessageId: number, element: MessageElement) {
private async emitGroupRecallMsg (message: RawMessage, oriMessageId: number, element: MessageElement) {
const operatorUid = element.grayTipElement?.revokeElement.operatorUid;
if (!operatorUid) return undefined;
const operatorId = await this.core.apis.UserApi.getUinByUidV2(operatorUid);

View File

@ -4,6 +4,7 @@
import express from 'express';
import { createServer } from 'http';
import { randomUUID, randomBytes } from 'node:crypto'
import { createServer as createHttpsServer } from 'https';
import { LogWrapper } from '@/common/log';
import { NapCatPathWrapper } from '@/common/path';
@ -30,17 +31,42 @@ const MAX_PORT_TRY = 100;
import * as net from 'node:net';
import { WebUiDataRuntime } from './src/helper/Data';
import { existsSync, readFileSync } from 'node:fs';
export let webUiRuntimePort = 6099;
export async function InitPort(parsedConfig: WebUiConfigType): Promise<[string, number, string]> {
// 全局变量存储需要在QQ登录成功后发送的新token
export let pendingTokenToSend: string | null = null;
/**
* WebUI启动时的初始token
* - 使token进行鉴权
* - napcat重启后生效
* -
*/
let initialWebUiToken: string = '';
export function setInitialWebUiToken(token: string) {
initialWebUiToken = token;
}
export function getInitialWebUiToken(): string {
return initialWebUiToken;
}
export function setPendingTokenToSend(token: string | null) {
pendingTokenToSend = token;
}
export async function InitPort(parsedConfig: WebUiConfigType): Promise<[string, number,string]> {
try {
await tryUseHost(parsedConfig.host);
const port = await tryUsePort(parsedConfig.port, parsedConfig.host);
return [parsedConfig.host, port, parsedConfig.token];
} catch (error) {
console.log('host或port不可用', error);
return ['', 0, ''];
return ['', 0, randomUUID()];
}
}
async function checkCertificates(logger: LogWrapper): Promise<{ key: string, cert: string } | null> {
try {
const certPath = join(webUiPathWrapper.configPath, 'cert.pem');
@ -61,7 +87,27 @@ async function checkCertificates(logger: LogWrapper): Promise<{ key: string, cer
export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapper) {
webUiPathWrapper = pathWrapper;
WebUiConfig = new WebUiConfigWrapper();
const config = await WebUiConfig.GetWebUIConfig();
let config = await WebUiConfig.GetWebUIConfig();
// 检查并更新默认密码 - 最高优先级
if (config.defaultToken || config.token === 'napcat' || !config.token) {
const randomToken = randomBytes(6).toString('hex');
await WebUiConfig.UpdateWebUIConfig({ token: randomToken, defaultToken: false });
logger.log(`[NapCat] [WebUi] 🔐 检测到默认密码,已自动更新为安全密码`);
// 存储token到全局变量等待QQ登录成功后发送
setPendingTokenToSend(randomToken);
logger.log(`[NapCat] [WebUi] 📤 新密码将在QQ登录成功后发送给用户`);
// 重新获取更新后的配置
config = await WebUiConfig.GetWebUIConfig();
} else {
logger.log(`[NapCat] [WebUi] ✅ 当前使用安全密码`);
}
// 存储启动时的初始token用于鉴权
setInitialWebUiToken(config.token);
logger.log(`[NapCat] [WebUi] 🔑 已缓存启动时的token用于鉴权运行时手动修改配置文件密码将不会生效`);
// 检查是否禁用WebUI
if (config.disableWebUI) {
@ -90,19 +136,6 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp
}
}
});
WebUiDataRuntime.setQQLoginCallback(async (_status: boolean) => {
try {
if ((await WebUiConfig.GetWebUIConfig()).defaultToken) {
let randomToken = Math.random().toString(36).slice(-8);
await WebUiConfig.UpdateWebUIConfig({ token: randomToken });
console.log(`[NapCat] [WebUi] Update WebUi Token: ${randomToken}`);
await WebUiDataRuntime.getWebUiTokenChangeCallback()(randomToken);
}
} catch (error) {
console.log(`[NapCat] [WebUi] Update WebUi Token failed.` + error);
}
});
// ------------注册中间件------------
// 使用express的json中间件
app.use(express.json());
@ -182,8 +215,8 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp
// ------------启动服务------------
server.listen(port, host, async () => {
// 启动后打印出相关地址
let searchParams = { token: token };
logger.log(`[NapCat] [WebUi] 🔑 token=${token}`);
logger.log(
`[NapCat] [WebUi] WebUi User Panel Url: ${createUrl('127.0.0.1', port.toString(), '/webui', searchParams)}`
);

View File

@ -1,21 +1,12 @@
import { RequestHandler } from 'express';
import { WebUiConfig } from '@/webui';
import { WebUiConfig, getInitialWebUiToken, setInitialWebUiToken } from '@/webui';
import { AuthHelper } from '@webapi/helper/SignToken';
import { WebUiDataRuntime } from '@webapi/helper/Data';
import { sendSuccess, sendError } from '@webapi/utils/response';
import { isEmpty } from '@webapi/utils/check';
// 检查是否使用默认Token
export const CheckDefaultTokenHandler: RequestHandler = async (_, res) => {
const webuiToken = await WebUiConfig.GetWebUIConfig();
if (webuiToken.defaultToken) {
return sendSuccess(res, true);
}
return sendSuccess(res, false);
};
// 登录
export const LoginHandler: RequestHandler = async (req, res) => {
// 获取WebUI配置
@ -33,8 +24,13 @@ export const LoginHandler: RequestHandler = async (req, res) => {
if (!WebUiDataRuntime.checkLoginRate(clientIP, WebUiConfigData.loginRate)) {
return sendError(res, 'login rate limit');
}
//验证config.token hash是否等于token hash
if (!AuthHelper.comparePasswordHash(WebUiConfigData.token, hash)) {
// 使用启动时缓存的token进行验证而不是动态读取配置文件
const initialToken = getInitialWebUiToken();
if (!initialToken) {
return sendError(res, 'Server token not initialized');
}
//验证初始token hash是否等于提交的token hash
if (!AuthHelper.comparePasswordHash(initialToken, hash)) {
return sendError(res, 'token is invalid');
}
@ -63,8 +59,6 @@ export const LogoutHandler: RequestHandler = async (req, res) => {
// 检查登录状态
export const checkHandler: RequestHandler = async (req, res) => {
// 获取WebUI配置
const WebUiConfigData = await WebUiConfig.GetWebUIConfig();
// 获取请求头中的Authorization
const authorization = req.headers.authorization;
// 检查凭证
@ -79,8 +73,13 @@ export const checkHandler: RequestHandler = async (req, res) => {
return sendError(res, 'Token has been revoked');
}
// 使用启动时缓存的token进行验证
const initialToken = getInitialWebUiToken();
if (!initialToken) {
return sendError(res, 'Server token not initialized');
}
// 验证凭证是否在一小时内有效
const valid = AuthHelper.validateCredentialWithinOneHour(WebUiConfigData.token, Credential);
const valid = AuthHelper.validateCredentialWithinOneHour(initialToken, Credential);
// 返回成功信息
if (valid) return sendSuccess(res, null);
// 返回错误信息
@ -93,16 +92,36 @@ export const checkHandler: RequestHandler = async (req, res) => {
// 修改密码token
export const UpdateTokenHandler: RequestHandler = async (req, res) => {
const { oldToken, newToken, fromDefault } = req.body;
const { oldToken, newToken } = req.body;
const authorization = req.headers.authorization;
if (isEmpty(newToken)) {
return sendError(res, 'newToken is empty');
}
// 如果不是从默认密码更新,则需要验证旧密码
if (!fromDefault && isEmpty(oldToken)) {
return sendError(res, 'oldToken is required when not updating from default password');
// 强制要求旧密码
if (isEmpty(oldToken)) {
return sendError(res, 'oldToken is required');
}
// 检查新旧密码是否相同
if (oldToken === newToken) {
return sendError(res, '新密码不能与旧密码相同');
}
// 检查新密码强度
if (newToken.length < 6) {
return sendError(res, '新密码至少需要6个字符');
}
// 检查是否包含字母
if (!/[a-zA-Z]/.test(newToken)) {
return sendError(res, '新密码必须包含字母');
}
// 检查是否包含数字
if (!/[0-9]/.test(newToken)) {
return sendError(res, '新密码必须包含数字');
}
try {
@ -113,17 +132,18 @@ export const UpdateTokenHandler: RequestHandler = async (req, res) => {
AuthHelper.revokeCredential(Credential);
}
if (fromDefault) {
// 从默认密码更新,直接设置新密码
const currentConfig = await WebUiConfig.GetWebUIConfig();
if (!currentConfig.defaultToken) {
return sendError(res, 'Current password is not default password');
}
await WebUiConfig.UpdateWebUIConfig({ token: newToken, defaultToken: false });
} else {
// 正常的密码更新流程
await WebUiConfig.UpdateToken(oldToken, newToken);
// 使用启动时缓存的token进行验证
const initialToken = getInitialWebUiToken();
if (!initialToken) {
return sendError(res, 'Server token not initialized');
}
if (initialToken !== oldToken) {
return sendError(res, '旧 token 不匹配');
}
// 直接更新配置文件中的token不需要通过WebUiConfig.UpdateToken方法
await WebUiConfig.UpdateWebUIConfig({ token: newToken, defaultToken: false });
// 更新内存中的缓存token使新密码立即生效
setInitialWebUiToken(newToken);
return sendSuccess(res, 'Token updated successfully');
} catch (e: any) {

View File

@ -9,10 +9,18 @@ import { PassThrough } from 'stream';
import multer from 'multer';
import webUIFontUploader from '../uploader/webui_font';
import diskUploader from '../uploader/disk';
import { WebUiConfig } from '@/webui';
import { WebUiConfig, getInitialWebUiToken, webUiPathWrapper } from '@/webui';
const isWindows = os.platform() === 'win32';
// 安全地从查询参数中提取字符串值,防止类型混淆
const getQueryStringParam = (param: any): string => {
if (Array.isArray(param)) {
return String(param[0] || '');
}
return String(param || '');
};
// 获取系统根目录列表Windows返回盘符列表其他系统返回['/']
const getRootDirs = async (): Promise<string[]> => {
if (!isWindows) return ['/'];
@ -32,14 +40,68 @@ const getRootDirs = async (): Promise<string[]> => {
return drives.length > 0 ? drives : ['C:'];
};
// 规范化路径
// 规范化路径并进行安全验证
const normalizePath = (inputPath: string): string => {
if (!inputPath) return isWindows ? 'C:\\' : '/';
// 如果是Windows且输入为纯盘符可能带或不带斜杠统一返回 "X:\"
if (isWindows && /^[A-Z]:[\\/]*$/i.test(inputPath)) {
return inputPath.slice(0, 2) + '\\';
if (!inputPath) {
// 对于空路径Windows返回用户主目录其他系统返回根目录
return isWindows ? process.env['USERPROFILE'] || 'C:\\' : '/';
}
return path.normalize(inputPath);
// 对输入路径进行清理,移除潜在的危险字符
const cleanedPath = inputPath.replace(/[\x00-\x1f\x7f]/g, ''); // 移除控制字符
// 如果是Windows且输入为纯盘符可能带或不带斜杠统一返回 "X:\"
if (isWindows && /^[A-Z]:[\\/]*$/i.test(cleanedPath)) {
return cleanedPath.slice(0, 2) + '\\';
}
// 安全验证:检查是否包含危险的路径遍历模式(在规范化之前)
if (containsPathTraversal(cleanedPath)) {
throw new Error('Invalid path: path traversal detected');
}
// 进行路径规范化
const normalized = path.resolve(cleanedPath);
// 再次检查规范化后的路径,确保没有绕过安全检查
if (containsPathTraversal(normalized)) {
throw new Error('Invalid path: path traversal detected after normalization');
}
// 额外安全检查:确保规范化后的路径不包含连续的路径分隔符
const finalPath = normalized.replace(/[\\\/]+/g, path.sep);
return finalPath;
};
// 检查路径是否包含路径遍历攻击模式
const containsPathTraversal = (inputPath: string): boolean => {
// 对输入进行URL解码防止编码绕过
let decodedPath = inputPath;
try {
decodedPath = decodeURIComponent(inputPath);
} catch {
// 如果解码失败,使用原始路径
}
// 将路径统一为正斜杠格式进行检查
const normalizedForCheck = decodedPath.replace(/\\/g, '/');
// 检查危险模式 - 更全面的路径遍历检测
const dangerousPatterns = [
/\.\.\//, // ../ 模式
/\/\.\./, // /.. 模式
/^\.\./, // 以.. 开头
/\.\.$/, // 以.. 结尾
/\.\.\\/, // ..\ 模式Windows
/\\\.\./, // \.. 模式Windows
/%2e%2e/i, // URL编码的..
/%252e%252e/i, // 双重URL编码的..
/\.\.\x00/, // null字节攻击
/\0/, // null字节
];
return dangerousPatterns.some(pattern => pattern.test(normalizedForCheck));
};
interface FileInfo {
@ -52,6 +114,35 @@ interface FileInfo {
// 添加系统文件黑名单
const SYSTEM_FILES = new Set(['pagefile.sys', 'swapfile.sys', 'hiberfil.sys', 'System Volume Information']);
// 检查是否为WebUI配置文件
const isWebUIConfigFile = (filePath: string): boolean => {
// 先用字符串快速筛选
if (!filePath.includes('webui.json')) {
return false;
}
// 进入更严格的路径判断 - 统一路径分隔符为 /
const webUIConfigPath = path.resolve(webUiPathWrapper.configPath, 'webui.json').replace(/\\/g, '/');
const targetPath = path.resolve(filePath).replace(/\\/g, '/');
// 统一分隔符后进行路径比较
return targetPath === webUIConfigPath;
};
// WebUI配置文件脱敏处理
const sanitizeWebUIConfig = (content: string): string => {
try {
const config = JSON.parse(content);
if (config.token) {
config.token = '******';
}
return JSON.stringify(config, null, 4);
} catch {
// 如果解析失败,返回原内容
return content;
}
};
// 检查同类型的文件或目录是否存在
const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): Promise<boolean> => {
try {
@ -70,8 +161,15 @@ export const ListFilesHandler: RequestHandler = async (req, res) => {
return sendError(res, '默认密码禁止使用');
}
try {
const requestPath = (req.query['path'] as string) || (isWindows ? 'C:\\' : '/');
const normalizedPath = normalizePath(requestPath);
const requestPath = getQueryStringParam(req.query['path']) || (isWindows ? process.env['USERPROFILE'] || 'C:\\' : '/');
let normalizedPath: string;
try {
normalizedPath = normalizePath(requestPath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
const onlyDirectory = req.query['onlyDirectory'] === 'true';
// 如果是根路径且在Windows系统上返回盘符列表
@ -139,7 +237,18 @@ export const ListFilesHandler: RequestHandler = async (req, res) => {
export const CreateDirHandler: RequestHandler = async (req, res) => {
try {
const { path: dirPath } = req.body;
const normalizedPath = normalizePath(dirPath);
let normalizedPath: string;
try {
normalizedPath = normalizePath(dirPath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(normalizedPath)) {
return sendError(res, '路径必须是绝对路径');
}
// 检查是否已存在同类型(目录)
if (await checkSameTypeExists(normalizedPath, true)) {
@ -157,7 +266,19 @@ export const CreateDirHandler: RequestHandler = async (req, res) => {
export const DeleteHandler: RequestHandler = async (req, res) => {
try {
const { path: targetPath } = req.body;
const normalizedPath = normalizePath(targetPath);
let normalizedPath: string;
try {
normalizedPath = normalizePath(targetPath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(normalizedPath)) {
return sendError(res, '路径必须是绝对路径');
}
const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) {
await fsProm.rm(normalizedPath, { recursive: true });
@ -175,7 +296,18 @@ export const BatchDeleteHandler: RequestHandler = async (req, res) => {
try {
const { paths } = req.body;
for (const targetPath of paths) {
const normalizedPath = normalizePath(targetPath);
let normalizedPath: string;
try {
normalizedPath = normalizePath(targetPath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(normalizedPath)) {
return sendError(res, '路径必须是绝对路径');
}
const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) {
await fsProm.rm(normalizedPath, { recursive: true });
@ -192,8 +324,25 @@ export const BatchDeleteHandler: RequestHandler = async (req, res) => {
// 读取文件内容
export const ReadFileHandler: RequestHandler = async (req, res) => {
try {
const filePath = normalizePath(req.query['path'] as string);
const content = await fsProm.readFile(filePath, 'utf-8');
let filePath: string;
try {
filePath = normalizePath(getQueryStringParam(req.query['path']));
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(filePath)) {
return sendError(res, '路径必须是绝对路径');
}
let content = await fsProm.readFile(filePath, 'utf-8');
// 如果是WebUI配置文件对token进行脱敏处理
if (isWebUIConfigFile(filePath)) {
content = sanitizeWebUIConfig(content);
}
return sendSuccess(res, content);
} catch (error) {
return sendError(res, '读取文件失败');
@ -204,8 +353,40 @@ export const ReadFileHandler: RequestHandler = async (req, res) => {
export const WriteFileHandler: RequestHandler = async (req, res) => {
try {
const { path: filePath, content } = req.body;
const normalizedPath = normalizePath(filePath);
await fsProm.writeFile(normalizedPath, content, 'utf-8');
// 安全的路径规范化,如果检测到路径遍历攻击会抛出异常
let normalizedPath: string;
try {
normalizedPath = normalizePath(filePath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(normalizedPath)) {
return sendError(res, '路径必须是绝对路径');
}
let finalContent = content;
// 检查是否为WebUI配置文件
if (isWebUIConfigFile(normalizedPath)) {
try {
// 解析要写入的配置
const configToWrite = JSON.parse(content);
// 获取内存中的token覆盖前端传来的token
const memoryToken = getInitialWebUiToken();
if (memoryToken) {
configToWrite.token = memoryToken;
finalContent = JSON.stringify(configToWrite, null, 4);
}
} catch (e) {
// 如果解析失败 说明不符合json格式 不允许写入
return sendError(res, '写入的WebUI配置文件内容格式错误必须是合法的JSON');
}
}
await fsProm.writeFile(normalizedPath, finalContent, 'utf-8');
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '写入文件失败');
@ -216,7 +397,18 @@ export const WriteFileHandler: RequestHandler = async (req, res) => {
export const CreateFileHandler: RequestHandler = async (req, res) => {
try {
const { path: filePath } = req.body;
const normalizedPath = normalizePath(filePath);
let normalizedPath: string;
try {
normalizedPath = normalizePath(filePath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(normalizedPath)) {
return sendError(res, '路径必须是绝对路径');
}
// 检查是否已存在同类型(文件)
if (await checkSameTypeExists(normalizedPath, false)) {
@ -234,8 +426,21 @@ export const CreateFileHandler: RequestHandler = async (req, res) => {
export const RenameHandler: RequestHandler = async (req, res) => {
try {
const { oldPath, newPath } = req.body;
const normalizedOldPath = normalizePath(oldPath);
const normalizedNewPath = normalizePath(newPath);
let normalizedOldPath: string;
let normalizedNewPath: string;
try {
normalizedOldPath = normalizePath(oldPath);
normalizedNewPath = normalizePath(newPath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(normalizedOldPath) || !path.isAbsolute(normalizedNewPath)) {
return sendError(res, '路径必须是绝对路径');
}
await fsProm.rename(normalizedOldPath, normalizedNewPath);
return sendSuccess(res, true);
} catch (error) {
@ -247,8 +452,21 @@ export const RenameHandler: RequestHandler = async (req, res) => {
export const MoveHandler: RequestHandler = async (req, res) => {
try {
const { sourcePath, targetPath } = req.body;
const normalizedSourcePath = normalizePath(sourcePath);
const normalizedTargetPath = normalizePath(targetPath);
let normalizedSourcePath: string;
let normalizedTargetPath: string;
try {
normalizedSourcePath = normalizePath(sourcePath);
normalizedTargetPath = normalizePath(targetPath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(normalizedSourcePath) || !path.isAbsolute(normalizedTargetPath)) {
return sendError(res, '路径必须是绝对路径');
}
await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
return sendSuccess(res, true);
} catch (error) {
@ -261,8 +479,20 @@ export const BatchMoveHandler: RequestHandler = async (req, res) => {
try {
const { items } = req.body;
for (const { sourcePath, targetPath } of items) {
const normalizedSourcePath = normalizePath(sourcePath);
const normalizedTargetPath = normalizePath(targetPath);
let normalizedSourcePath: string;
let normalizedTargetPath: string;
try {
normalizedSourcePath = normalizePath(sourcePath);
normalizedTargetPath = normalizePath(targetPath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(normalizedSourcePath) || !path.isAbsolute(normalizedTargetPath)) {
return sendError(res, '路径必须是绝对路径');
}
await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
}
return sendSuccess(res, true);
@ -274,10 +504,21 @@ export const BatchMoveHandler: RequestHandler = async (req, res) => {
// 新增:文件下载处理方法(注意流式传输,不将整个文件读入内存)
export const DownloadHandler: RequestHandler = async (req, res) => {
try {
const filePath = normalizePath(req.query['path'] as string);
let filePath: string;
try {
filePath = normalizePath(getQueryStringParam(req.query['path']));
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
if (!filePath) {
return sendError(res, '参数错误');
}
// 额外安全检查:确保路径是绝对路径
if (!path.isAbsolute(filePath)) {
return sendError(res, '路径必须是绝对路径');
}
const stat = await fsProm.stat(filePath);
@ -316,12 +557,25 @@ export const BatchDownloadHandler: RequestHandler = async (req, res) => {
const zipStream = new compressing.zip.Stream();
// 修改:根据文件类型设置 relativePath
for (const filePath of paths) {
const normalizedPath = normalizePath(filePath);
let normalizedPath: string;
try {
normalizedPath = normalizePath(filePath);
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
// 额外安全检查:确保规范化后的路径是绝对路径
if (!path.isAbsolute(normalizedPath)) {
return sendError(res, '路径必须是绝对路径');
}
const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) {
zipStream.addEntry(normalizedPath, { relativePath: '' });
} else {
zipStream.addEntry(normalizedPath, { relativePath: path.basename(normalizedPath) });
// 确保相对路径只使用文件名,防止路径遍历
const safeName = path.basename(normalizedPath);
zipStream.addEntry(normalizedPath, { relativePath: safeName });
}
}
zipStream.pipe(res);

View File

@ -5,6 +5,12 @@ import { terminalManager } from '../terminal/terminal_manager';
import { WebUiConfig } from '@/webui';
// 判断是否是 macos
const isMacOS = process.platform === 'darwin';
// 日志脱敏函数
const sanitizeLog = (log: string): string => {
// 脱敏 token 参数,将 token=xxx 替换为 token=***
return log.replace(/token=[\w\d]+/gi, 'token=***');
};
// 日志记录
export const LogHandler: RequestHandler = async (req, res) => {
const filename = req.query['id'];
@ -16,7 +22,8 @@ export const LogHandler: RequestHandler = async (req, res) => {
return sendError(res, 'ID不合法');
}
const logContent = await WebUiConfig.GetLogContent(filename);
return sendSuccess(res, logContent);
const sanitizedLogContent = sanitizeLog(logContent);
return sendSuccess(res, sanitizedLogContent);
};
// 日志列表
@ -31,7 +38,8 @@ export const LogRealTimeHandler: RequestHandler = async (req, res) => {
res.setHeader('Connection', 'keep-alive');
const listener = (log: string) => {
try {
res.write(`data: ${log}\n\n`);
const sanitizedLog = sanitizeLog(log);
res.write(`data: ${sanitizedLog}\n\n`);
} catch (error) {
console.error('向客户端写入日志数据时出错:', error);
}

View File

@ -1,4 +1,4 @@
import { webUiPathWrapper } from '@/webui';
import { webUiPathWrapper, getInitialWebUiToken } from '@/webui';
import { Type, Static } from '@sinclair/typebox';
import Ajv from 'ajv';
import fs, { constants } from 'node:fs/promises';
@ -63,6 +63,46 @@ export class WebUiConfigWrapper {
}
async GetWebUIConfig(): Promise<WebUiConfigType> {
if (this.WebUiConfigData) {
return this.WebUiConfigData
}
try {
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
await this.ensureConfigFileExists(configPath);
const parsedConfig = await this.readAndValidateConfig(configPath);
// 使用内存中缓存的token进行覆盖确保强兼容性
this.WebUiConfigData = {
...parsedConfig,
// 首次读取内存中是没有token的需要进行一层兜底
token: getInitialWebUiToken() || parsedConfig.token
};
return this.WebUiConfigData;
} catch (e) {
console.log('读取配置文件失败', e);
const defaultConfig = this.validateAndApplyDefaults({});
return {
...defaultConfig,
token: defaultConfig.token
};
}
}
async UpdateWebUIConfig(newConfig: Partial<WebUiConfigType>): Promise<void> {
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
// 使用原始配置进行合并避免内存token覆盖影响配置更新
const currentConfig = await this.GetRawWebUIConfig();
const mergedConfig = deepMerge({ ...currentConfig }, newConfig);
const updatedConfig = this.validateAndApplyDefaults(mergedConfig);
await this.writeConfig(configPath, updatedConfig);
this.WebUiConfigData = updatedConfig;
}
/**
* token覆盖
*
*/
async GetRawWebUIConfig(): Promise<WebUiConfigType> {
if (this.WebUiConfigData) {
return this.WebUiConfigData;
}
@ -78,18 +118,12 @@ export class WebUiConfigWrapper {
}
}
async UpdateWebUIConfig(newConfig: Partial<WebUiConfigType>): Promise<void> {
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
const currentConfig = await this.GetWebUIConfig();
const mergedConfig = deepMerge({ ...currentConfig }, newConfig);
const updatedConfig = this.validateAndApplyDefaults(mergedConfig);
await this.writeConfig(configPath, updatedConfig);
this.WebUiConfigData = updatedConfig;
}
async UpdateToken(oldToken: string, newToken: string): Promise<void> {
const currentConfig = await this.GetWebUIConfig();
if (currentConfig.token !== oldToken) {
// 使用内存中缓存的token进行验证确保强兼容性
const cachedToken = getInitialWebUiToken();
const tokenToCheck = cachedToken || (await this.GetWebUIConfig()).token;
if (tokenToCheck !== oldToken) {
throw new Error('旧 token 不匹配');
}
await this.UpdateWebUIConfig({ token: newToken, defaultToken: false });

View File

@ -1,6 +1,6 @@
import { NextFunction, Request, Response } from 'express';
import { WebUiConfig } from '@/webui';
import { getInitialWebUiToken } from '@/webui';
import { AuthHelper } from '@webapi/helper/SignToken';
import { sendError } from '@webapi/utils/response';
@ -30,10 +30,13 @@ export async function auth(req: Request, res: Response, next: NextFunction) {
} catch (e) {
return sendError(res, 'Unauthorized');
}
// 获取配置
const config = await WebUiConfig.GetWebUIConfig();
// 使用启动时缓存的token进行验证而不是动态读取配置文件 因为有可能运行时手动修改了密码
const initialToken = getInitialWebUiToken();
if (!initialToken) {
return sendError(res, 'Server token not initialized');
}
// 验证凭证在1小时内有效
const credentialJson = AuthHelper.validateCredentialWithinOneHour(config.token, Credential);
const credentialJson = AuthHelper.validateCredentialWithinOneHour(initialToken, Credential);
if (credentialJson) {
// 通过验证
return next();

View File

@ -1,7 +1,6 @@
import { Router } from 'express';
import {
CheckDefaultTokenHandler,
checkHandler,
LoginHandler,
LogoutHandler,
@ -17,7 +16,5 @@ router.post('/check', checkHandler);
router.post('/logout', LogoutHandler);
// router:更新token
router.post('/update_token', UpdateTokenHandler);
// router:检查默认token
router.get('/check_using_default_token', CheckDefaultTokenHandler);
export { router as AuthRouter };