From abd29a834fc43eb1328acfd6e9e971975daab6d2 Mon Sep 17 00:00:00 2001 From: bear Date: Sat, 4 Apr 2020 19:42:05 +0800 Subject: [PATCH] Support MFA-TOTP Auth (halo #745) (#124) * login page add authcode-form * halo mfa auth test * profile page --- src/api/admin.js | 16 ++- src/api/user.js | 32 ++++++ src/store/modules/user.js | 5 +- src/views/user/Login.vue | 105 +++++++++++++++++-- src/views/user/Profile.vue | 202 ++++++++++++++++++++++++++++++++++++- 5 files changed, 349 insertions(+), 11 deletions(-) diff --git a/src/api/admin.js b/src/api/admin.js index 186993b4..6f5d7066 100644 --- a/src/api/admin.js +++ b/src/api/admin.js @@ -33,9 +33,9 @@ adminApi.install = data => { }) } -adminApi.login = (username, password) => { +adminApi.loginPreCheck = (username, password) => { return service({ - url: `${baseUrl}/login`, + url: `${baseUrl}/login/precheck`, data: { username: username, password: password @@ -44,6 +44,18 @@ adminApi.login = (username, password) => { }) } +adminApi.login = (username, password, authcode) => { + return service({ + url: `${baseUrl}/login`, + data: { + username: username, + password: password, + authcode: authcode + }, + method: 'post' + }) +} + adminApi.logout = () => { return service({ url: `${baseUrl}/logout`, diff --git a/src/api/user.js b/src/api/user.js index eb689fc4..b0bcb7b6 100644 --- a/src/api/user.js +++ b/src/api/user.js @@ -30,4 +30,36 @@ userApi.updatePassword = (oldPassword, newPassword) => { }) } +userApi.mfaGenerate = (mfaType) => { + return service({ + url: `${baseUrl}/mfa/generate`, + method: 'put', + data: { + mfaType: mfaType + } + }) +} + +userApi.mfaUpdate = (mfaType, mfaKey, authcode) => { + return service({ + url: `${baseUrl}/mfa/update`, + method: 'put', + data: { + mfaType: mfaType, + mfaKey: mfaKey, + authcode: authcode + } + }) +} + +userApi.mfaCheck = (authcode) => { + return service({ + url: `${baseUrl}/mfa/check`, + method: 'put', + data: { + authcode: authcode + } + }) +} + export default userApi diff --git a/src/store/modules/user.js b/src/store/modules/user.js index 2c8b984d..e5b3371d 100644 --- a/src/store/modules/user.js +++ b/src/store/modules/user.js @@ -45,11 +45,12 @@ const user = { commit }, { username, - password + password, + authcode }) { return new Promise((resolve, reject) => { adminApi - .login(username, password) + .login(username, password, authcode) .then(response => { const token = response.data.data Vue.$log.debug('Got token', token) diff --git a/src/views/user/Login.vue b/src/views/user/Login.vue index ebba2b58..dad3734a 100644 --- a/src/views/user/Login.vue +++ b/src/views/user/Login.vue @@ -1,15 +1,18 @@ @@ -138,7 +186,12 @@ export default { return { username: null, password: null, + authcode: null, + needAuthCode: false, + formVisible: 'login-form', // login-form api-form authcode-form + loginVisible: true, apiModifyVisible: false, + authcodeVisible: false, defaultApiBefore: window.location.protocol + '//', apiUrl: window.location.host, resetPasswordButton: false, @@ -157,6 +210,13 @@ export default { } }) }, + watch: { + formVisible(value) { + this.loginVisible = (value === 'authcode-form') + this.apiModifyVisible = (value === 'api-form') + this.authcodeVisible = (value === 'authcode-form') + } + }, methods: { ...mapActions(['login', 'loadUser', 'loadOptions']), ...mapMutations({ @@ -170,6 +230,28 @@ export default { } }) }, + handleLoginPreCheck() { + if (!this.username) { + this.$message.warn('用户名不能为空!') + return + } + + if (!this.password) { + this.$message.warn('密码不能为空!') + return + } + + adminApi.loginPreCheck(this.username, this.password).then(response => { + if (response.data.data && response.data.data.needMFACode) { + this.formVisible = 'authcode-form' + this.authcode = null + this.needAuthCode = true + } else { + this.needAuthCode = false + this.handleLogin() + } + }) + }, handleLogin() { if (!this.username) { this.$message.warn('用户名不能为空!') @@ -180,8 +262,14 @@ export default { this.$message.warn('密码不能为空!') return } + + if (this.needAuthCode && !this.authcode) { + this.$message.warn('两步验证码不能为空!') + return + } + this.landing = true - this.login({ username: this.username, password: this.password }) + this.login({ username: this.username, password: this.password, authcode: this.authcode }) .then(response => { // Go to dashboard this.loginSuccess() @@ -204,18 +292,23 @@ export default { }, handleApiModifyOk() { this.setApiUrl(this.apiUrl) - this.apiModifyVisible = false + this.formVisible = 'login-form' }, handleApiUrlRestore() { this.restoreApiUrl() this.apiUrl = this.defaultApiUrl }, toggleShowApiForm() { + this.formVisible = this.apiModifyVisible ? 'login-form' : 'api-form' this.apiModifyVisible = !this.apiModifyVisible if (this.apiModifyVisible) { this.apiUrl = this.defaultApiUrl } }, + toggleShowLoginForm() { + this.formVisible = 'login-form' + this.password = null + }, toggleHidden() { this.resetPasswordButton = !this.resetPasswordButton } diff --git a/src/views/user/Profile.vue b/src/views/user/Profile.vue index cc6de564..9c3f216a 100644 --- a/src/views/user/Profile.vue +++ b/src/views/user/Profile.vue @@ -133,6 +133,67 @@ + + + 两步验证 + + + + + + + + Authy 功能丰富 专为两步验证码 + + + IOS/Android/Windows/Mac/Linux + + + + + Chrome扩展 + + + + + GoogleAuthenticator 简单易用 但不支持密钥导出备份 + + + IOS + + + + + Android + + + + + MicrosoftAuthenticator 使用微软全家桶的推荐 + + + IOS/Android + + + + + 1Password 强大安全的密码管理付费应用 + + + IOS/Android/Windows/Mac/Linux/ChromeOS + + + + + + @@ -146,6 +207,70 @@ title="选择头像" isChooseAvatar /> + + + + + + + + + + +
+

+ 1.请扫描二维码或导入 key + +

+

+ MFAKey:
{{ mfaParam.mfaKey }} +

+

+ 2.验证两步验证码 + + + + + +

+

* 推荐使用 Authy | 微软验证器

+
+
@@ -171,6 +296,21 @@ export default { newPassword: null, confirmPassword: null }, + mfaParam: { + mfaKey: null, + mfaType: 'NONE', + mfaUsed: false, + authcode: null, + qrImage: null, + modal: { + title: '确认开启两步验证?', + visible: false + }, + switch: { + loading: false, + checked: false + } + }, attachment: {} } }, @@ -178,11 +318,27 @@ export default { passwordUpdateButtonDisabled() { return !(this.passwordParam.oldPassword && this.passwordParam.newPassword) }, - ...mapGetters(['options']) + ...mapGetters(['options']), + mfaType() { + return this.mfaParam.mfaType + }, + mfaUsed() { + return this.mfaParam.mfaUsed + } }, created() { this.getStatistics() }, + watch: { + mfaType(value) { + if (value) { + this.mfaParam.mfaUsed = (value !== 'NONE') + } + }, + mfaUsed(value) { + this.mfaParam.switch.checked = value + } + }, methods: { ...mapMutations({ setUser: 'SET_USER' }), getStatistics() { @@ -190,6 +346,7 @@ export default { this.user = response.data.data.user this.statistics = response.data.data this.statisticsLoading = false + this.mfaParam.mfaType = this.user.mfaType && this.user.mfaType }) }, handleUpdatePassword() { @@ -240,6 +397,49 @@ export default { handleSelectGravatar() { this.user.avatar = '//cn.gravatar.com/avatar/' + new MD5().update(this.user.email).digest('hex') + '&d=mm' this.attachmentDrawerVisible = false + }, + handleMFASwitch(useMFAuth) { + // loding + this.mfaParam.switch.loading = true + if (!useMFAuth && this.mfaUsed) { + // true -> false + // show cloes MFA modal + this.mfaParam.modal.title = '确认关闭两步验证?' + this.mfaParam.modal.visible = true + } else { + // false -> true + // show open MFA modal + this.mfaParam.modal.title = '确认开启两步验证?' + // generate MFAKey and Qr Image + userApi.mfaGenerate('TFA_TOTP').then(response => { + this.mfaParam.mfaKey = response.data.data.mfaKey + this.mfaParam.qrImage = response.data.data.qrImage + this.mfaParam.modal.visible = true + }) + } + }, + handleSetMFAuth() { + var mfaType = this.mfaUsed ? 'NONE' : 'TFA_TOTP' + if (mfaType === 'NONE') { + if (!this.mfaParam.authcode) { + this.$message.warn('两步验证码不能为空') + return + } + } + userApi.mfaUpdate(mfaType, this.mfaParam.mfaKey, this.mfaParam.authcode).then(response => { + this.handleCloseMFAuthModal() + this.mfaParam.mfaType = response.data.data.mfaType + this.$message.success(this.mfaUsed ? '两步验证已关闭!' : '两步验证已开启,下次登陆生效!') + }) + }, + handleCloseMFAuthModal() { + this.mfaParam.modal.visible = false + this.mfaParam.switch.loading = false + this.mfaParam.switch.checked = this.mfaUsed + // clean + this.mfaParam.authcode = null + this.mfaParam.qrImage = null + this.mfaParam.mfaKey = null } } }