Add MFA support

This commit is contained in:
garrettmills
2020-04-22 16:56:39 -05:00
parent d68d5141c8
commit e3ecfb0d37
30 changed files with 802 additions and 75 deletions

View File

@@ -0,0 +1,73 @@
import { Component } from '../../lib/vues6/vues6.js'
import { auth_api } from '../service/AuthApi.service.js'
import { location_service } from '../service/Location.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">
Your account has multi-factor authentication enabled. Please enter the code generated by your authenticator app to continue.
</div>
<div class="form-group">
<input
class="form-control"
type="number"
placeholder="2FA Code"
v-model="verify_code"
@keyup="on_key_up"
name="verify_code"
maxlength="6"
:disabled="verify_success"
ref="verify_input"
autofocus
>
</div>
<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>
</div>
</div>
`
export default class MFAChallengePage extends Component {
static get selector() { return 'coreid-mfa-challenge-page' }
static get props() { return ['app_name'] }
static get template() { return template }
loading = false
verify_code = ''
verify_success = false
error_message = ''
other_message = ''
async watch_verify_code(new_verify_code, old_verify_code) {
if ( new_verify_code.length === 6 ) {
this.loading = true
const result = await auth_api.mfa_attempt(new_verify_code)
this.verify_success = result && result.is_valid
if ( !this.verify_success ) {
this.other_message = ''
this.error_message = `Uh, oh! It looks like that's not the right code. Please try again.`
this.loading = false
} else {
this.error_message = ''
this.other_message = `Success! Redirecting...`
await location_service.redirect(result.next_destination, 1500)
}
} else if ( new_verify_code.length > 6 ) {
this.verify_code = old_verify_code
}
}
async on_key_up(event) {
if ( event.keyCode === 13 ) {
// Enter was pressed - don't submit the form
event.preventDefault()
event.stopPropagation()
return false
}
}
}

View File

@@ -0,0 +1,148 @@
import { Component } from '../../lib/vues6/vues6.js'
import { auth_api } from '../service/AuthApi.service.js'
import { location_service } from '../service/Location.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>
<span v-if="step === 0">
<div class="coreid-message">
We're going to walk you through setting up multi-factor authentication for your account.
<br><br>
Once this is completed, you will need to provide your second factor of authentication whenever you sign in with your {{ app_name }} account.
<br><br>
You'll need some kind of MFA token generator such as Google Authenticator.
</div>
<div class="buttons text-right pad-top">
<button type="button" class="btn btn-primary" @click="back_click">Cancel</button>
<button type="button" class="btn btn-primary" @click="continue_click">Continue</button>
</div>
</span>
<span v-if="step === 1">
<div class="coreid-message">
Scan the QR code below with your authenticator app to add your {{ app_name }} account.
<br><br>
Once you've done this, we'll ask for one of the generated codes to verify that MFA is working correctly.
</div>
<div class="coreid-auth-image text-center">
<img class="img-fluid" :src="qr_data">
<br><br><small>Secret: {{ secret }}</small>
</div>
<div class="buttons text-right pad-top">
<button class="btn btn-primary" type="button" @click="continue_click">Continue</button>
</div>
</span>
<span v-if="step === 2">
<div class="coreid-message">
Now, enter the code displayed in your authenticator app. {{ app_name }} will verify that the code is
correct. Then, you can enable MFA for your account.
</div>
<div class="form-group">
<input
class="form-control"
type="number"
placeholder="2FA Code"
v-model="verify_code"
@keyup="on_key_up"
name="verify_code"
maxlength="6"
:disabled="verify_success"
ref="verify_input"
>
</div>
<div v-if="error_message" class="error-message">{{ error_message }}</div>
<div v-if="other_message" class="other-message">{{ other_message }}</div>
<div class="buttons text-right pad-top">
<button class="btn btn-primary" type="button" @click="back_click">Back</button>
<button class="btn btn-primary" type="button" @click="continue_click" :disabled="!verify_success">Enable 2FA</button>
</div>
</span>
<div class="coreid-loading-spinner" v-if="loading"><div class="inner"></div></div>
</div>
</div>
`
export default class MFASetupPage extends Component {
static get selector() { return 'coreid-mfa-setup-page' }
static get props() { return ['app_name'] }
static get template() { return template }
loading = false
step = 0
qr_data = ''
otpauth_url = ''
secret = ''
verify_code = ''
verify_success = false
error_message = ''
other_message = ''
async watch_verify_code(new_verify_code, old_verify_code) {
if ( new_verify_code.length === 6 ) {
this.loading = true
const result = await auth_api.mfa_attempt(new_verify_code)
this.verify_success = result && result.is_valid
this.loading = false
if ( !this.verify_success ) {
this.other_message = ''
this.error_message = `Uh, oh! It looks like that's not the right code. Please try again.`
} else {
this.error_message = ''
this.other_message = `Success! That code matched what ${this.app_name} was expecting. You can now enable multi-factor authentication for your account.`
}
} else if ( new_verify_code.length > 6 ) {
this.verify_code = old_verify_code
}
}
async back_click() {
if ( this.step === 0 ) await location_service.back()
else this.step -= 1
}
async on_key_up(event) {
if ( event.keyCode === 13 ) {
// Enter was pressed - don't submit the form
event.preventDefault()
event.stopPropagation()
return false
}
}
async continue_click() {
if ( this.step === 0 ) {
// Get the MFA token
// TODO try/catch this
this.loading = true
const result = await auth_api.mfa_generate()
this.qr_data = result.qr_code
this.otpauth_url = result.otpauth_url
this.secret = result.secret
this.step = 1
this.loading = false
} else if ( this.step === 1 ) {
this.step = 2
this.error_message = ''
this.other_message = ''
this.$nextTick(() => {
this.$refs.verify_input.focus()
})
} else if ( this.step === 2 ) {
this.loading = true
try {
await auth_api.mfa_enable()
this.error_message = ''
this.other_message = 'MFA has been enabled for your account! For security purposes, you will be asked to sign in again.'
await location_service.redirect('/auth/login', 3000)
} catch(e) {
this.loading = false
this.error_message = 'Sorry, an unknown error occurred, and we were unable to continue.'
this.other_message = ''
}
}
}
}

View File

@@ -0,0 +1,35 @@
import { Component } from '../../lib/vues6/vues6.js'
import { auth_api } from '../service/AuthApi.service.js'
import { action_service } from '../service/Action.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">{{ message }}</div>
<div class="buttons text-right pad-top">
<button
type="button"
class="btn btn-primary"
v-for="(action, index) in actions"
:key="index"
@click="action_click(index)"
>{{ action.text }}</button>
</div>
<div class="coreid-loading-spinner" v-if="loading"><div class="inner"></div></div>
</div>
</div>
`
export default class AuthPage extends Component {
static get selector() { return 'coreid-auth-page' }
static get props() { return ['app_name', 'message', 'actions'] }
static get template() { return template }
loading = false
async action_click(index) {
this.loading = true
await action_service.perform(this.actions[index])
}
}

View File

@@ -6,43 +6,43 @@ const template = `
<div class="coreid-login-form 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-login-form-inner">
<div class="coreid-login-form-header font-weight-light">{{ app_name }}</div>
<div class="coreid-login-form-message">{{ login_message }}</div>
<form class="coreid-form" v-on:submit.prevent="do_nothing">
<div class="form-group">
<input
type="text"
id="coreid-login-form-username"
name="username"
class="form-control"
placeholder="Username"
v-model="username"
autofocus
@keyup="on_key_up"
:disabled="loading || step_two"
>
</div>
<div class="form-group" v-if="step_two">
<input
type="password"
id="coreid-login-form-password"
name="password"
class="form-control"
placeholder="Password"
v-model="password"
:disabled="loading"
@keyup="on_key_up"
ref="password_input"
>
</div>
<div v-if="error_message" class="error-message">{{ error_message }}</div>
<div v-if="other_message" class="other-message">{{ other_message }}</div>
<div class="buttons text-right">
<button type="button" class="btn btn-primary" :disabled="loading" v-if="step_two" v-on:click="back_click">Back</button>
<button type="button" class="btn btn-primary" :disabled="loading || btn_disabled" v-on:click="step_click">{{ button_text }}</button>
</div>
</form>
<div class="coreid-login-form-message">{{ login_message }}</div>
<form class="coreid-form" v-on:submit.prevent="do_nothing">
<div class="form-group">
<input
type="text"
id="coreid-login-form-username"
name="username"
class="form-control"
placeholder="Username"
v-model="username"
autofocus
@keyup="on_key_up"
:disabled="loading || step_two"
>
</div>
<div class="form-group" v-if="step_two">
<input
type="password"
id="coreid-login-form-password"
name="password"
class="form-control"
placeholder="Password"
v-model="password"
:disabled="loading"
@keyup="on_key_up"
ref="password_input"
>
</div>
<div v-if="error_message" class="error-message">{{ error_message }}</div>
<div v-if="other_message" class="other-message">{{ other_message }}</div>
<div class="buttons text-right">
<button type="button" class="btn btn-primary" :disabled="loading" v-if="step_two" v-on:click="back_click">Back</button>
<button type="button" class="btn btn-primary" :disabled="loading || btn_disabled" v-on:click="step_click">{{ button_text }}</button>
</div>
</form>
<div class="coreid-loading-spinner" v-if="loading"><div class="inner"></div></div>
</div>
<div class="coreid-loading-spinner" v-if="loading"><div class="inner"></div></div>
</div>
`

View File

@@ -1,7 +1,13 @@
import AuthLoginForm from "./auth/login/Form.component.js"
import AuthPage from './auth/Page.component.js'
import MFASetupPage from './auth/MFASetup.component.js'
import MFAChallengePage from './auth/MFAChallenge.component.js'
const components = {
AuthLoginForm
AuthLoginForm,
AuthPage,
MFASetupPage,
MFAChallengePage,
}
export { components }

View File

@@ -0,0 +1,16 @@
import { location_service } from './Location.service.js'
class ActionService {
async perform({ text, action, ...args }) {
if ( action === 'redirect' ) {
if ( args.next ) {
return location_service.redirect(args.next, 1500)
}
} else {
throw new TypeError(`Unknown action type: ${action}`)
}
}
}
const action_service = new ActionService()
export { action_service }

View File

@@ -17,6 +17,21 @@ class AuthAPI {
return { success: false }
}
async mfa_generate() {
const result = await axios.post('/api/v1/auth/mfa/generate')
return result && result.data && result.data.data
}
async mfa_attempt(verify_code) {
const result = await axios.post('/api/v1/auth/mfa/attempt', { verify_code })
return result && result.data && result.data.data
}
async mfa_enable() {
const result = await axios.post('/api/v1/auth/mfa/enable')
return result && result.data && result.data.data && result.data.data.success && result.data.data.mfa_enabled
}
}
const auth_api = new AuthAPI()

View File

@@ -7,6 +7,10 @@ class LocationService {
}, delay)
})
}
async back() {
return window.history.back()
}
}
const location_service = new LocationService()

View File

@@ -109,22 +109,25 @@
}
}
.coreid-login-form {
.coreid-login-form, .coreid-auth-page {
border: 2px solid #ddd;
border-radius: 7px;
display: flex;
align-items: center;
min-height: 65vh;
background-color: #f8f8f8;
.coreid-login-form-inner {
.coreid-login-form-inner, .coreid-auth-page-inner {
padding: 30px;
padding-top: 170px;
padding-bottom: 160px;
width: 100%;
}
.coreid-login-form-header {
.coreid-login-form-header, .coreid-header {
font-size: 2.5em;
margin-bottom: 10px;
}
.coreid-login-form-message {
.coreid-login-form-message, .coreid-message {
margin-bottom: 40px;
}
@@ -132,6 +135,14 @@
margin-top: 40px;
margin-bottom: 0;
&.pad-top {
padding-top: 20px;
}
button {
margin-right: 5px;
}
.btn {
background: #666;
border-color: #444;
@@ -149,13 +160,13 @@
.coreid-loading-spinner {
overflow: hidden;
background-color: #ddd;
background-color: rgba(0,0,0,0);
height: 7px;
margin: 0;
padding: 0;
width: calc(100% + 30px);
margin-left: -15px;
border-radius: 0 0 5px 5px;
margin-top: 70px;
.inner {
height: 7px;
@@ -163,7 +174,6 @@
background-color: #bbb;
position: absolute;
left: 0;
border-radius: 0 0 5px 5px;
animation-name: loading-bar;
animation-duration: 1.5s;
animation-fill-mode: both;