From 56ce6073ce09f08147f989858e0e88b3a4359546 Mon Sep 17 00:00:00 2001 From: farhadh Date: Mon, 11 May 2026 21:09:48 +0200 Subject: [PATCH] feat(auth): block panel with default admin/admin credentials and guide credential change checkLogin middleware now detects default admin/admin credentials and redirects every panel route to /panel/settings until they are changed. The settings page auto-opens the Authentication tab, shows a non-dismissible error banner, and lists 'Default credentials' first in the security checklist. Login response includes mustChangeCredentials so the login page can redirect directly. Logout is now POST-only. Password must be at least 10 characters and cannot be admin/admin. --- frontend/src/pages/login/LoginPage.vue | 4 +- frontend/src/pages/settings/SecurityTab.vue | 42 ++++++-- frontend/src/pages/settings/SettingsPage.vue | 102 ++++++++++++++----- web/controller/base.go | 35 ++++++- web/controller/dist.go | 9 +- web/controller/index.go | 16 ++- web/controller/setting.go | 47 ++++++++- web/service/user.go | 15 +++ 8 files changed, 228 insertions(+), 42 deletions(-) diff --git a/frontend/src/pages/login/LoginPage.vue b/frontend/src/pages/login/LoginPage.vue index fab7ba9d..bbccbcef 100644 --- a/frontend/src/pages/login/LoginPage.vue +++ b/frontend/src/pages/login/LoginPage.vue @@ -52,7 +52,9 @@ async function login() { submitting.value = true; try { const msg = await HttpUtil.post('/login', user); - if (msg.success) window.location.href = basePath + 'panel/'; + if (msg.success) { + window.location.href = basePath + (msg.obj?.mustChangeCredentials ? 'panel/settings' : 'panel/'); + } } finally { submitting.value = false; } diff --git a/frontend/src/pages/settings/SecurityTab.vue b/frontend/src/pages/settings/SecurityTab.vue index d841c787..3edb1f3b 100644 --- a/frontend/src/pages/settings/SecurityTab.vue +++ b/frontend/src/pages/settings/SecurityTab.vue @@ -21,9 +21,10 @@ const tfa = reactive({ description: '', token: '', type: 'set', - // resolveConfirm is called by the modal's @confirm with the success bool; + // resolveConfirm is called by the modal's @confirm with the success bool + // and, for redacted-token confirm flows, the code entered by the user. // it then routes the value back to whichever flow opened the modal. - resolveConfirm: (_success) => { }, + resolveConfirm: (_success, _code) => { }, }); function openTfa({ title, description = '', token = '', type, onConfirm }) { @@ -35,8 +36,8 @@ function openTfa({ title, description = '', token = '', type, onConfirm }) { tfa.open = true; } -function onTfaConfirm(success) { - tfa.resolveConfirm(success); +function onTfaConfirm(success, code = '') { + tfa.resolveConfirm(success, code); } const user = reactive({ @@ -52,16 +53,23 @@ async function sendUpdateUser() { try { const msg = await HttpUtil.post('/panel/setting/updateUser', user); if (msg?.success) { - // Force re-login at the standard logout path; basePath is handled - // by the Go router so a relative redirect is correct here. - const basePath = window.X_UI_BASE_PATH || ''; - window.location.replace(`${basePath}logout`); + await logoutAndReturn(); } } finally { updating.value = false; } } +async function logoutAndReturn() { + await HttpUtil.post('/logout'); + window.location.replace(window.X_UI_BASE_PATH || '/'); +} + +async function verifyTwoFactor(code) { + const msg = await HttpUtil.post('/panel/setting/verifyTwoFactor', { code }); + return !!(msg?.success && msg.obj === true); +} + function updateUser() { if (props.allSetting.twoFactorEnable) { openTfa({ @@ -69,7 +77,11 @@ function updateUser() { description: t('pages.settings.security.twoFactorModalChangeCredentialsStep'), token: props.allSetting.twoFactorToken, type: 'confirm', - onConfirm: (ok) => { if (ok) sendUpdateUser(); }, + onConfirm: async (ok, code) => { + if (!ok) return; + const verified = props.allSetting.twoFactorToken ? ok : await verifyTwoFactor(code); + if (verified) sendUpdateUser(); + }, }); } else { sendUpdateUser(); @@ -88,7 +100,10 @@ async function loadApiToken() { apiTokenLoading.value = true; try { const msg = await HttpUtil.get('/panel/setting/getApiToken'); - if (msg?.success) apiToken.value = msg.obj || ''; + if (msg?.success) { + apiToken.value = msg.obj || ''; + props.allSetting.hasApiToken = !!apiToken.value; + } } finally { apiTokenLoading.value = false; } @@ -124,6 +139,7 @@ function regenerateApiToken() { const msg = await HttpUtil.post('/panel/setting/regenerateApiToken'); if (msg?.success) { apiToken.value = msg.obj || ''; + props.allSetting.hasApiToken = !!apiToken.value; message.success(t('success')); } } finally { @@ -147,6 +163,7 @@ function toggleTwoFactor() { if (ok) { message.success(t('pages.settings.security.twoFactorModalSetSuccess')); props.allSetting.twoFactorToken = newToken; + props.allSetting.hasTwoFactorToken = true; } props.allSetting.twoFactorEnable = ok; }, @@ -157,11 +174,14 @@ function toggleTwoFactor() { description: t('pages.settings.security.twoFactorModalRemoveStep'), token: props.allSetting.twoFactorToken, type: 'confirm', - onConfirm: (ok) => { + onConfirm: async (ok, code) => { if (!ok) return; + const verified = props.allSetting.twoFactorToken ? ok : await verifyTwoFactor(code); + if (!verified) return; message.success(t('pages.settings.security.twoFactorModalDeleteSuccess')); props.allSetting.twoFactorEnable = false; props.allSetting.twoFactorToken = ''; + props.allSetting.hasTwoFactorToken = false; }, }); } diff --git a/frontend/src/pages/settings/SettingsPage.vue b/frontend/src/pages/settings/SettingsPage.vue index 5f2ddf39..2166a259 100644 --- a/frontend/src/pages/settings/SettingsPage.vue +++ b/frontend/src/pages/settings/SettingsPage.vue @@ -26,6 +26,9 @@ const { t } = useI18n(); const { fetched, spinning, saveDisabled, allSetting, saveAll } = useAllSetting(); const { isMobile } = useMediaQuery(); +const mustChangeCredentials = window.X_UI_MUST_CHANGE_CREDENTIALS === true +const activeTab = ref(mustChangeCredentials ? '2' : '1') + const basePath = window.X_UI_BASE_PATH || ''; const requestUri = window.location.pathname; @@ -117,39 +120,68 @@ function restartPanel() { }); } -// Conf alerts mirror the legacy banner — pure derivation off allSetting. -const confAlerts = computed(() => { - const out = []; - if (window.location.protocol !== 'https:') { - out.push('Panel is served over plain HTTP — set up TLS for production.'); - } - if (allSetting.webPort === 2053) { - out.push('Default port 2053 is well-known — change it to a random port.'); - } +const securityChecklist = computed(() => { const segs = window.location.pathname.split('/').length < 4; - if (segs && allSetting.webBasePath === '/') { - out.push('Default base path "/" is well-known — change it to a random path.'); + const out = [] + if (mustChangeCredentials) { + out.push({ + label: 'Default credentials', + ok: false, + action: 'Change the default admin/admin credentials in Authentication settings.', + }) } + out.push( + { + label: 'TLS', + ok: window.location.protocol === 'https:', + action: 'Set certificate and key paths, then restart.', + }, + { + label: 'Base path', + ok: !(segs && allSetting.webBasePath === '/'), + action: 'Change the panel URL path from "/".', + }, + { + label: 'Panel port', + ok: allSetting.webPort !== 2053, + action: 'Use a non-default listening port.', + }, + { + label: 'Two-factor authentication', + ok: allSetting.twoFactorEnable && allSetting.hasTwoFactorToken, + action: 'Enable 2FA in Security.', + }, + { + label: 'API token', + ok: allSetting.hasApiToken, + action: 'Generate or rotate the API token in Security.', + }, + ) if (allSetting.subEnable) { let subPath = allSetting.subPath; if (allSetting.subURI) { try { subPath = new URL(allSetting.subURI).pathname; } catch (_e) { } } - if (subPath === '/sub/') { - out.push('Default subscription path "/sub/" is well-known — change it.'); - } + out.push({ + label: 'Subscription path', + ok: subPath !== '/sub/', + action: 'Change the default subscription path.', + }); } if (allSetting.subJsonEnable) { let p = allSetting.subJsonPath; if (allSetting.subJsonURI) { try { p = new URL(allSetting.subJsonURI).pathname; } catch (_e) { } } - if (p === '/json/') { - out.push('Default JSON subscription path "/json/" is well-known — change it.'); - } + out.push({ + label: 'JSON subscription path', + ok: p !== '/json/', + action: 'Change the default JSON subscription path.', + }); } return out; }); +const hasSecurityGaps = computed(() => securityChecklist.value.some((item) => !item.ok)); const alertVisible = ref(true); @@ -165,14 +197,31 @@ const alertVisible = ref(true);