diff options
| author | Shishkevich D. <135337715+shishkevichd@users.noreply.github.com> | 2025-05-08 17:20:58 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-05-08 17:20:58 +0300 |
| commit | fe3b1c9b52f584b0f045907585b206344fed55db (patch) | |
| tree | aeef0fa82a0355ee899ec3aecee23045a327a6dc /web/html | |
| parent | d39ccf4b8f77f99d4468580085e9d89e8b5f0b1c (diff) | |
chore: implement 2fa auth (#2968)
* chore: implement 2fa auth
from #2786
* chore: format code
* chore: replace two factor token input with qr-code
* chore: requesting confirmation of setting/removing two-factor authentication
otpauth library was taken from cdnjs
* chore: revert changes in `ClipboardManager`
don't need it.
* chore: removing twoFactor prop in settings page
* chore: remove `twoFactorQr` object in `mounted` function
Diffstat (limited to 'web/html')
| -rw-r--r-- | web/html/login.html | 20 | ||||
| -rw-r--r-- | web/html/modals/two_factor_modal.html | 118 | ||||
| -rw-r--r-- | web/html/settings.html | 64 | ||||
| -rw-r--r-- | web/html/settings/panel/security.html | 24 |
4 files changed, 163 insertions, 63 deletions
diff --git a/web/html/login.html b/web/html/login.html index 9f292d84..515ea2b3 100644 --- a/web/html/login.html +++ b/web/html/login.html @@ -512,11 +512,11 @@ <a-icon slot="prefix" type="lock" :style="{ fontSize: '1rem' }"></a-icon> </a-input-password> </a-form-item> - <a-form-item v-if="secretEnable"> - <a-input-password autocomplete="secret" name="secret" v-model.trim="user.loginSecret" - placeholder='{{ i18n "secretToken" }}' @keydown.enter.native="login"> + <a-form-item v-if="twoFactorEnable"> + <a-input autocomplete="totp" name="twoFactorCode" v-model.trim="user.twoFactorCode" + placeholder='{{ i18n "twoFactorCode" }}' @keydown.enter.native="login"> <a-icon slot="prefix" type="key" :style="{ fontSize: '1rem' }"></a-icon> - </a-input-password> + </a-input> </a-form-item> <a-form-item> <a-row justify="center" class="centered"> @@ -549,14 +549,14 @@ user: { username: "", password: "", - loginSecret: "" + twoFactorCode: "" }, - secretEnable: false, + twoFactorEnable: false, lang: "" }, async mounted() { this.lang = LanguageManager.getLanguage(); - this.secretEnable = await this.getSecretStatus(); + this.twoFactorEnable = await this.getTwoFactorEnable(); }, methods: { async login() { @@ -567,12 +567,12 @@ location.href = basePath + 'panel/'; } }, - async getSecretStatus() { + async getTwoFactorEnable() { this.loading = true; - const msg = await HttpUtil.post('/getSecretStatus'); + const msg = await HttpUtil.post('/getTwoFactorEnable'); this.loading = false; if (msg.success) { - this.secretEnable = msg.obj; + this.twoFactorEnable = msg.obj; return msg.obj; } }, diff --git a/web/html/modals/two_factor_modal.html b/web/html/modals/two_factor_modal.html new file mode 100644 index 00000000..d2f8c442 --- /dev/null +++ b/web/html/modals/two_factor_modal.html @@ -0,0 +1,118 @@ +{{define "modals/twoFactorModal"}} +<a-modal id="two-factor-modal" v-model="twoFactorModal.visible" :title="twoFactorModal.title" :closable="true" + :class="themeSwitcher.currentTheme"> + <template v-if="twoFactorModal.type === 'set'"> + <p>{{ i18n "pages.settings.security.twoFactorModalSteps" }}</p> + <a-divider></a-divider> + <p>{{ i18n "pages.settings.security.twoFactorModalFirstStep" }}</p> + <div :style="{ display: 'flex', alignItems: 'center', flexDirection: 'column', gap: '12px' }"> + <div + :style="{ border: '1px solid', borderRadius: '1rem', borderColor: themeSwitcher.isDarkTheme ? 'var(--dark-color-surface-300)' : '#d9d9d9', padding: 0 }"> + <img :src="twoFactorModal.qrImage" + :style="{ filter: themeSwitcher.isDarkTheme ? 'invert(1)' : 'none'}" + :alt="twoFactorModal.token"> + </div> + <span :style="{ fontSize: '12px', fontFamily: 'monospace' }">[[ twoFactorModal.token ]]</span> + </div> + <a-divider></a-divider> + <p>{{ i18n "pages.settings.security.twoFactorModalSecondStep" }}</p> + <a-input v-model.trim="twoFactorModal.enteredCode" :style="{ width: '100%' }"></a-input> + </template> + <template v-if="twoFactorModal.type === 'remove'"> + <p>{{ i18n "pages.settings.security.twoFactorModalRemoveStep" }}</p> + <a-input v-model.trim="twoFactorModal.enteredCode" :style="{ width: '100%' }"></a-input> + </template> + <template slot="footer"> + <a-button @click="twoFactorModal.cancel"> + <span>{{ i18n "cancel" }}</span> + </a-button> + <a-button type="primary" :disabled="twoFactorModal.enteredCode.length < 6" @click="twoFactorModal.ok"> + <span>{{ i18n "confirm" }}</span> + </a-button> + </template> +</a-modal> + +<script> + const twoFactorModal = { + title: '', + fileName: '', + token: '', + enteredCode: '', + visible: false, + type: 'set', + confirm: null, + totpObject: null, + qrImage: "", + ok() { + if (twoFactorModal.totpObject.generate() === twoFactorModal.enteredCode) { + ObjectUtil.execute(twoFactorModal.confirm, true) + + twoFactorModal.close() + + switch (twoFactorModal.type) { + case 'set': + Vue.prototype.$message['success']('{{ i18n "pages.settings.security.twoFactorModalSetSuccess" }}') + break; + case 'remove': + Vue.prototype.$message['success']('{{ i18n "pages.settings.security.twoFactorModalDeleteSuccess" }}') + break; + default: + break; + } + } else { + Vue.prototype.$message['error']('{{ i18n "pages.settings.security.twoFactorModalError" }}') + } + }, + cancel() { + ObjectUtil.execute(twoFactorModal.confirm, false) + + twoFactorModal.close() + }, + show: function ({ + title = '', + token = '', + type = 'set', + confirm = (success) => { } + }) { + this.title = title; + this.token = token; + this.visible = true; + this.confirm = confirm; + this.type = type; + + this.totpObject = new OTPAuth.TOTP({ + issuer: "3x-ui", + label: "Administrator", + algorithm: "SHA1", + digits: 6, + period: 30, + secret: twoFactorModal.token, + }); + + if (type === 'set') { + this.qrImage = new QRious({ + size: 150, + value: twoFactorModal.totpObject.toString(), + background: 'white', + backgroundAlpha: 0, + foreground: 'black', + padding: 12, + level: 'L' + }).toDataURL() + } + }, + close: function () { + twoFactorModal.enteredCode = ""; + twoFactorModal.visible = false; + }, + }; + + const twoFactorModalApp = new Vue({ + delimiters: ['[[', ']]'], + el: '#two-factor-modal', + data: { + twoFactorModal: twoFactorModal, + }, + }); +</script> +{{end}}
\ No newline at end of file diff --git a/web/html/settings.html b/web/html/settings.html index 82e636d9..dec40de7 100644 --- a/web/html/settings.html +++ b/web/html/settings.html @@ -122,10 +122,13 @@ </a-layout> </a-layout> {{template "js" .}} +<script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script> +<script src="{{ .base_path }}assets/otpauth/otpauth.umd.min.js?{{ .cur_ver }}"></script> <script src="{{ .base_path }}assets/js/model/setting.js?{{ .cur_ver }}"></script> {{template "component/aSidebar" .}} {{template "component/aThemeSwitch" .}} {{template "component/aSettingListItem" .}} +{{template "modals/twoFactorModal"}} <script> const app = new Vue({ delimiters: ['[[', ']]'], @@ -133,7 +136,6 @@ data: { themeSwitcher, spinning: false, - changeSecret: false, oldAllSetting: new AllSetting(), allSetting: new AllSetting(), saveBtnDisable: true, @@ -258,7 +260,6 @@ app.changeRemarkSample(); this.saveBtnDisable = true; } - await this.fetchUserSecret(); }, async updateAllSetting() { this.loading(true); @@ -302,38 +303,34 @@ window.location.replace(url); } }, - async fetchUserSecret() { - this.loading(true); - const userMessage = await HttpUtil.post("/panel/setting/getUserSecret", this.user); - if (userMessage.success) { - this.user = userMessage.obj; - } - this.loading(false); - }, - async updateSecret() { - this.loading(true); - const msg = await HttpUtil.post("/panel/setting/updateUserSecret", this.user); - if (msg && msg.obj) { - this.user = msg.obj; - } - this.loading(false); - await this.updateAllSetting(); - }, - async getNewSecret() { - if (!this.changeSecret) { - this.changeSecret = true; - this.user.loginSecret = ''; - const newSecret = RandomUtil.randomSeq(64); - await PromiseUtil.sleep(1000); - this.user.loginSecret = newSecret; - this.changeSecret = false; - } - }, - async toggleToken(value) { - if (value) { - await this.getNewSecret(); + toggleTwoFactor(newValue) { + if (newValue) { + const newTwoFactorToken = RandomUtil.randomBase32String() + + twoFactorModal.show({ + title: '{{ i18n "pages.settings.security.twoFactorModalSetTitle" }}', + token: newTwoFactorToken, + type: 'set', + confirm: (success) => { + if (success) { + this.allSetting.twoFactorToken = newTwoFactorToken + } + + this.allSetting.twoFactorEnable = success + } + }) } else { - this.user.loginSecret = ""; + twoFactorModal.show({ + title: '{{ i18n "pages.settings.security.twoFactorModalDeleteTitle" }}', + token: this.allSetting.twoFactorToken, + type: 'remove', + confirm: (success) => { + if (success) { + this.allSetting.twoFactorEnable = false + this.allSetting.twoFactorToken = "" + } + } + }) } }, addNoise() { @@ -526,6 +523,7 @@ }, async mounted() { await this.getAllSetting(); + while (true) { await PromiseUtil.sleep(1000); this.saveBtnDisable = this.oldAllSetting.equals(this.allSetting); diff --git a/web/html/settings/panel/security.html b/web/html/settings/panel/security.html index 48e7796f..2a570faa 100644 --- a/web/html/settings/panel/security.html +++ b/web/html/settings/panel/security.html @@ -31,30 +31,14 @@ </a-space> </a-list-item> </a-collapse-panel> - <a-collapse-panel key="2" header='{{ i18n "pages.settings.security.secret"}}'> + <a-collapse-panel key="2" header='{{ i18n "pages.settings.security.twoFactor" }}'> <a-setting-list-item paddings="small"> - <template #title>{{ i18n "pages.settings.security.loginSecurity" }}</template> - <template #description>{{ i18n "pages.settings.security.loginSecurityDesc" }}</template> + <template #title>{{ i18n "pages.settings.security.twoFactorEnable" }}</template> + <template #description>{{ i18n "pages.settings.security.twoFactorEnableDesc" }}</template> <template #control> - <a-switch @change="toggleToken(allSetting.secretEnable)" v-model="allSetting.secretEnable"></a-switch> - <a-icon :style="{ marginLeft: '1rem' }" v-if="allSetting.secretEnable" :spin="this.changeSecret" type="sync" - @click="getNewSecret"></a-icon> + <a-switch @click="toggleTwoFactor" :checked="allSetting.twoFactorEnable"></a-switch> </template> </a-setting-list-item> - <a-setting-list-item paddings="small"> - <template #title>{{ i18n "pages.settings.security.secretToken" }}</template> - <template #description>{{ i18n "pages.settings.security.secretTokenDesc" }}</template> - <template #control> - <a-textarea type="text" :disabled="!allSetting.secretEnable" v-model="user.loginSecret"></a-textarea> - </template> - </a-setting-list-item> - <a-list-item> - <a-space direction="horizontal" :style="{ padding: '0 20px' }"> - <a-button type="primary" :loading="this.changeSecret" @click="updateSecret"> - <span>{{ i18n "confirm"}}</span> - </a-button> - </a-space> - </a-list-item> </a-collapse-panel> </a-collapse> {{end}}
\ No newline at end of file |
