Support MFA recovery tokens
This commit is contained in:
@@ -26,6 +26,9 @@ const template = `
|
||||
<div v-if="error_message" class="error-message">{{ error_message }}</div>
|
||||
<div v-if="other_message" class="other-message">{{ other_message }}</div>
|
||||
<div class="coreid-loading-spinner" v-if="loading"><div class="inner"></div></div>
|
||||
<small
|
||||
class="mr-3"
|
||||
><a href="#" class="text-secondary" @click="on_do_recovery">Lost your MFA device?</a></small>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -70,4 +73,8 @@ export default class MFAChallengePage extends Component {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async on_do_recovery(event) {
|
||||
await location_service.redirect('/auth/mfa/recovery', 0)
|
||||
}
|
||||
}
|
||||
|
||||
72
app/assets/app/auth/MFARecovery.component.js
Normal file
72
app/assets/app/auth/MFARecovery.component.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Component } from '../../lib/vues6/vues6.js'
|
||||
import { location_service } from '../service/Location.service.js'
|
||||
import { auth_api } from '../service/AuthApi.service.js'
|
||||
|
||||
const template = `
|
||||
<div class="coreid-auth-page col-lg-6 col-md-8 col-sm-10 col-xs-12 offset-lg-3 offset-md-2 offset-sm-1 offset-xs-0 text-left">
|
||||
<div class="coreid-auth-page-inner">
|
||||
<div class="coreid-header font-weight-light">{{ app_name }}</div>
|
||||
<div class="coreid-message">
|
||||
To recover access to your account, you can enter one of the generated MFA recovery codes:
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input
|
||||
class="form-control"
|
||||
type="text"
|
||||
placeholder="Recovery Code"
|
||||
v-model="recovery_code"
|
||||
@keyup="on_key_up"
|
||||
name="recovery_code"
|
||||
:disabled="verify_success"
|
||||
ref="verify_input"
|
||||
maxlength="36"
|
||||
autofocus
|
||||
>
|
||||
</div>
|
||||
<div v-if="error_message" class="error-message">{{ error_message }}</div>
|
||||
<div v-if="other_message" class="other-message">{{ other_message }}</div>
|
||||
<small
|
||||
class="mr-3" v-if="!loading"
|
||||
><a href="#" class="text-secondary" @click="on_do_challenge">Have a normal MFA code?</a></small>
|
||||
<div class="coreid-loading-spinner" v-if="loading"><div class="inner"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
export default class MFARecoveryComponent extends Component {
|
||||
static get selector() { return 'coreid-mfa-recovery-page' }
|
||||
static get template() { return template }
|
||||
static get props() { return ['app_name'] }
|
||||
|
||||
verify_success = false
|
||||
loading = false
|
||||
recovery_code = ''
|
||||
error_message = ''
|
||||
other_message = ''
|
||||
|
||||
async vue_on_create() {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.verify_input.focus()
|
||||
})
|
||||
}
|
||||
|
||||
async on_key_up($event) {
|
||||
if ( this.recovery_code.length === 36 ) {
|
||||
this.error_message = ''
|
||||
this.other_message = ''
|
||||
this.loading = true
|
||||
const result = await auth_api.attempt_mfa_recovery(this.recovery_code)
|
||||
if ( result && result.success ) {
|
||||
this.other_message = `Success! There are only ${result.remaining_codes} recovery codes remaining. Let's get you on your way...`
|
||||
await location_service.redirect(result.next_destination, 5000)
|
||||
} else {
|
||||
this.loading = false
|
||||
this.error_message = 'Hm. It doesn\'t look like that code is valid.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async on_do_challenge() {
|
||||
await location_service.redirect('/auth/mfa/challenge', 0)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import PasswordResetComponent from './auth/PasswordReset.component.js'
|
||||
import InvokeActionComponent from './InvokeAction.component.js'
|
||||
import RegistrationFormComponent from './auth/register/Form.component.js'
|
||||
import MessageContainerComponent from './dash/message/MessageContainer.component.js'
|
||||
import MFARecoveryComponent from './auth/MFARecovery.component.js'
|
||||
|
||||
const components = {
|
||||
AuthLoginForm,
|
||||
@@ -18,6 +19,7 @@ const components = {
|
||||
InvokeActionComponent,
|
||||
RegistrationFormComponent,
|
||||
MessageContainerComponent,
|
||||
MFARecoveryComponent,
|
||||
}
|
||||
|
||||
export { components }
|
||||
|
||||
@@ -35,7 +35,7 @@ const template = `
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{{ modal.message }}
|
||||
<span v-html="modal.message"></span>
|
||||
<div v-if="Array.isArray(modal.inputs)" class="mt-4 mb-3">
|
||||
<span v-for="input of modal.inputs">
|
||||
<input
|
||||
|
||||
@@ -133,8 +133,14 @@ const template = `
|
||||
|
||||
<h6 class="pad-top">Recovery Codes</h6>
|
||||
<p>Recovery codes can be used to regain access to your account in the event that you lose access to the device that generates your MFA codes.</p>
|
||||
<p class="font-italic">No recovery codes have been generated for your account.</p>
|
||||
<button class="btn btn-sm btn-success">Generate Recovery Codes</button>
|
||||
<span v-if="!has_mfa_recovery">
|
||||
<p class="font-italic">No recovery codes have been generated for your account.</p>
|
||||
<button class="btn btn-sm btn-success" @click="on_mfa_recovery_generate">Generate Recovery Codes</button>
|
||||
</span>
|
||||
<span v-if="has_mfa_recovery">
|
||||
<p class="font-italic">Recovery codes were generate for your account on {{ mfa_recovery_date }}. <span v-if="mfa_recovery_codes === 1">There is only 1 recovery code remaining.</span><span v-if="mfa_recovery_codes !== 1">There are {{ mfa_recovery_codes }} recovery codes remaining.</span></p>
|
||||
<button class="btn btn-sm btn-success" @click="on_mfa_recovery_generate">Re-generate Recovery Codes</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -163,6 +169,10 @@ export default class EditProfileComponent extends Component {
|
||||
last_reset = ''
|
||||
mfa_enable_date = ''
|
||||
|
||||
has_mfa_recovery = false
|
||||
mfa_recovery_date = ''
|
||||
mfa_recovery_codes = 0
|
||||
|
||||
form_message = 'No changes.'
|
||||
|
||||
has_mfa = false
|
||||
@@ -248,7 +258,16 @@ export default class EditProfileComponent extends Component {
|
||||
|
||||
const mfa = await auth_api.has_mfa()
|
||||
this.has_mfa = mfa && mfa.mfa_enabled
|
||||
if (this.has_mfa) this.mfa_enable_date = (new Date(mfa.mfa_enable_date)).toLocaleDateString()
|
||||
if (this.has_mfa) {
|
||||
this.mfa_enable_date = (new Date(mfa.mfa_enable_date)).toLocaleDateString()
|
||||
|
||||
const result = await auth_api.has_mfa_recovery()
|
||||
if ( result && result.has_recovery ) {
|
||||
this.has_mfa_recovery = true
|
||||
this.mfa_recovery_date = (new Date(result.generated)).toLocaleDateString()
|
||||
this.mfa_recovery_codes = result.remaining_codes
|
||||
}
|
||||
}
|
||||
|
||||
await this.load_app_passwords()
|
||||
}
|
||||
@@ -301,5 +320,65 @@ export default class EditProfileComponent extends Component {
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
async on_mfa_recovery_generate($event) {
|
||||
if ( !this.has_mfa ) return
|
||||
if ( !this.has_mfa_recovery ) {
|
||||
await this.generate_mfa_recovery()
|
||||
} else {
|
||||
message_service.modal({
|
||||
title: 'Are you sure?',
|
||||
message: 'There are already MFA recovery codes associated with your account. If you re-generate them, you will be unable to use the old ones. Continue?',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
type: 'close',
|
||||
},
|
||||
{
|
||||
text: 'Re-generate',
|
||||
type: 'close',
|
||||
class: ['btn', 'btn-warning'],
|
||||
on_click: async () => {
|
||||
await this.generate_mfa_recovery()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async generate_mfa_recovery() {
|
||||
const codes = await auth_api.generate_mfa_recovery()
|
||||
if ( codes ) {
|
||||
this.display_mfa_recovery_modal(codes)
|
||||
} else {
|
||||
message_service.alert({
|
||||
type: 'error',
|
||||
message: 'An unknown error occurred while attempting to generate MFA recovery codes.'
|
||||
})
|
||||
}
|
||||
|
||||
await this.load()
|
||||
}
|
||||
|
||||
display_mfa_recovery_modal(codes) {
|
||||
const code_display = codes.map(x => `<li><code>${x}</code></li>`).join('\n')
|
||||
message_service.modal({
|
||||
title: 'MFA Recovery Codes',
|
||||
message: `We've generated recovery codes for your account. You can use these to recover access to your account in the event that you lose your MFA device.
|
||||
<br><br>
|
||||
Be sure to put these somewhere safe. After you close this modal, they will disappear:
|
||||
<br><br>
|
||||
<ul>
|
||||
${code_display}
|
||||
</ul>`,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Close',
|
||||
type: 'close',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,21 @@ class AuthAPI {
|
||||
if ( result && result.data && result.data.data ) return result.data.data
|
||||
}
|
||||
|
||||
async has_mfa_recovery() {
|
||||
const result = await axios.get('/api/v1/auth/mfa/recovery')
|
||||
if ( result && result.data && result.data.data ) return result.data.data
|
||||
}
|
||||
|
||||
async generate_mfa_recovery() {
|
||||
const result = await axios.post('/api/v1/auth/mfa/recovery')
|
||||
if ( result && result.data && result.data.data && result.data.data.codes ) return result.data.data.codes
|
||||
}
|
||||
|
||||
async attempt_mfa_recovery(code) {
|
||||
const result = await axios.post('/api/v1/auth/mfa/recovery/attempt', { code })
|
||||
if ( result && result.data && result.data.data ) return result.data.data
|
||||
}
|
||||
|
||||
async app_passwords() {
|
||||
const result = await axios.get('/api/v1/password/app_passwords')
|
||||
if ( result && result.data && Array.isArray(result.data.data) ) return result.data.data
|
||||
|
||||
Reference in New Issue
Block a user