Browse Source

Support MFA-TOTP Auth (halo #745) (#124)

* login page add authcode-form

* halo mfa auth test

* profile page
pull/125/head
bear 5 years ago committed by GitHub
parent
commit
abd29a834f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 16
      src/api/admin.js
  2. 32
      src/api/user.js
  3. 5
      src/store/modules/user.js
  4. 105
      src/views/user/Login.vue
  5. 202
      src/views/user/Profile.vue

16
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`,

32
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

5
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)

105
src/views/user/Login.vue

@ -1,15 +1,18 @@
<template>
<div class="container-wrapper">
<div class="halo-logo animated fadeInUp">
<span>Halo<small v-if="apiModifyVisible">API 设置</small></span>
<span>Halo
<small v-if="apiModifyVisible">API 设置</small>
<small v-if="authcodeVisible">两步验证</small>
</span>
</div>
<div
v-show="!apiModifyVisible"
v-show="formVisible == 'login-form'"
class="login-form animated"
>
<a-form
layout="vertical"
@keyup.enter.native="handleLogin"
@keyup.enter.native="handleLoginPreCheck"
>
<a-form-item
class="animated fadeInUp"
@ -50,7 +53,7 @@
:loading="landing"
type="primary"
:block="true"
@click="handleLogin"
@click="handleLoginPreCheck"
>登录</a-button>
</a-form-item>
@ -126,6 +129,51 @@
</a-row>
</a-form>
</div>
<div
v-show="authcodeVisible"
class="authcode-form animated"
>
<a-form layout="vertical" @keyup.enter.native="handleLogin">
<a-form-item
class="animated fadeInUp"
:style="{'animation-delay': '0.1s'}"
>
<a-input
placeholder="两步验证码"
v-model="authcode"
:maxLength="6"
>
<a-icon
slot="prefix"
type="safety-certificate"
style="color: rgba(0,0,0,.25)"
/>
</a-input>
</a-form-item>
<a-form-item
class="animated fadeInUp"
:style="{'animation-delay': '0.3s'}"
>
<a-button
:loading="landing"
type="primary"
:block="true"
@click="handleLogin"
>验证</a-button>
</a-form-item>
<a-row>
<a
@click="toggleShowLoginForm"
class="tip animated fadeInUp"
:style="{'animation-delay': '0.4s'}"
>
<a-icon type="rollback" />
</a>
</a-row>
</a-form>
</div>
</div>
</template>
@ -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
}

202
src/views/user/Profile.vue

@ -133,6 +133,67 @@
</a-form-item>
</a-form>
</a-tab-pane>
<a-tab-pane key="3">
<span slot="tab">
<a-icon type="safety-certificate" />两步验证
</span>
<a-form-item label="两步验证:">
<a-switch
v-model="mfaParam.switch.checked"
:loading="mfaParam.switch.loading"
@change="handleMFASwitch"
/>
</a-form-item>
<a-form-item label="两步验证应用:">
<a-list
:loading="statisticsLoading"
itemLayout="horizontal"
>
<a-list-item>
<b>Authy</b> 功能丰富 专为两步验证码
<a-divider type="vertical" />
<a target="_blank" href="https://authy.com/download/">
IOS/Android/Windows/Mac/Linux
<a-icon type="link" />
</a>
<a-divider type="vertical" />
<a target="_blank" href="https://chrome.google.com/webstore/detail/authy/gaedmjdfmmahhbjefcbgaolhhanlaolb?hl=cn">
Chrome扩展
<a-icon type="link" />
</a>
</a-list-item>
<a-list-item>
<b>GoogleAuthenticator</b> 简单易用 但不支持密钥导出备份
<a-divider type="vertical" />
<a target="_blank" href="https://apps.apple.com/us/app/google-authenticator/id388497605">
IOS
<a-icon type="link" />
</a>
<a-divider type="vertical" />
<a target="_blank" href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=cn">
Android
<a-icon type="link" />
</a>
</a-list-item>
<a-list-item>
<b>MicrosoftAuthenticator</b> 使用微软全家桶的推荐
<a-divider type="vertical" />
<a target="_blank" href="https://www.microsoft.com/zh-cn/account/authenticator">
IOS/Android
<a-icon type="link" />
</a>
</a-list-item>
<a-list-item>
<b>1Password</b> 强大安全的密码管理付费应用
<a-divider type="vertical" />
<a target="_blank" href="https://1password.com/zh-cn/downloads/">
IOS/Android/Windows/Mac/Linux/ChromeOS
<a-icon type="link" />
</a>
</a-list-item>
</a-list>
</a-form-item>
</a-tab-pane>
</a-tabs>
</div>
</a-card>
@ -146,6 +207,70 @@
title="选择头像"
isChooseAvatar
/>
<a-modal
:title="mfaParam.modal.title"
:visible="mfaParam.modal.visible"
@ok="handleSetMFAuth"
:confirmLoading="false"
@cancel="handleCloseMFAuthModal"
:closable="false"
:maskClosable="false"
icon="safety-certificate"
:keyboard="false"
:centered="true"
:destroyOnClose="true"
:width="300"
>
<a-form layout="inline" v-if="mfaUsed">
<a-form-item
extra="* 需要验证两步验证码"
>
<a-input
placeholder="两步验证码"
v-model="mfaParam.authcode"
:maxLength="6"
>
<a-icon
slot="prefix"
type="safety-certificate"
style="color: rgba(0,0,0,.25)"
/>
</a-input>
</a-form-item>
</a-form>
<div v-else>
<p>
1.请扫描二维码或导入 key
<img
width="235"
:src="mfaParam.qrImage"
/>
</p>
<p style="font-size: 12px;">
MFAKey:<br>{{ mfaParam.mfaKey }}
</p>
<p>
2.验证两步验证码
<a-form-item>
<a-input
placeholder="两步验证码"
v-model="mfaParam.authcode"
:maxLength="6"
>
<a-icon
slot="prefix"
type="safety-certificate"
style="color: rgba(0,0,0,.25)"
/>
</a-input>
</a-form-item>
</p>
<p style="font-size: 12px;">* 推荐使用 Authy | 微软验证器</p>
</div>
</a-modal>
</div>
</template>
@ -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
}
}
}

Loading…
Cancel
Save