mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-10 05:10:58 +00:00
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.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
</script>
|
||||
@@ -165,14 +197,31 @@ const alertVisible = ref(true);
|
||||
<div v-if="!fetched" class="loading-spacer" />
|
||||
|
||||
<template v-else>
|
||||
<a-alert v-if="confAlerts.length > 0 && alertVisible" type="error" show-icon closable class="conf-alert"
|
||||
<a-alert
|
||||
v-if="mustChangeCredentials"
|
||||
type="error"
|
||||
show-icon
|
||||
banner
|
||||
message="Change your default admin credentials to unlock the panel"
|
||||
description="All other panel sections are blocked until you set a unique username and password in the Authentication tab."
|
||||
class="conf-alert"
|
||||
/>
|
||||
|
||||
<a-alert v-if="hasSecurityGaps && alertVisible" type="error" show-icon closable class="conf-alert"
|
||||
@close="alertVisible = false">
|
||||
<template #message>Security warnings</template>
|
||||
<template #message>Security posture checklist</template>
|
||||
<template #description>
|
||||
<b>Your panel may be exposed:</b>
|
||||
<ul>
|
||||
<li v-for="(msg, i) in confAlerts" :key="i">{{ msg }}</li>
|
||||
</ul>
|
||||
<a-list size="small" :data-source="securityChecklist">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item class="checklist-item">
|
||||
<a-space :size="8" wrap>
|
||||
<a-tag :color="item.ok ? 'green' : 'red'">{{ item.ok ? 'OK' : 'Action' }}</a-tag>
|
||||
<strong>{{ item.label }}</strong>
|
||||
<span>{{ item.ok ? 'Configured' : item.action }}</span>
|
||||
</a-space>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
@@ -199,7 +248,7 @@ const alertVisible = ref(true);
|
||||
</a-col>
|
||||
|
||||
<a-col :span="24">
|
||||
<a-tabs default-active-key="1">
|
||||
<a-tabs v-model:activeKey="activeTab">
|
||||
<a-tab-pane key="1" class="tab-pane">
|
||||
<template #tab>
|
||||
<SettingOutlined />
|
||||
@@ -286,6 +335,11 @@ const alertVisible = ref(true);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.checklist-item {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user