Support MFA recovery tokens

This commit is contained in:
garrettmills
2020-05-30 17:21:47 -05:00
parent a1a70e0548
commit 8680242349
25 changed files with 393 additions and 10 deletions

View File

@@ -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)
}
}

View 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)
}
}

View File

@@ -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 }

View File

@@ -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

View File

@@ -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',
},
],
})
}
}

View File

@@ -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

View File

@@ -84,6 +84,41 @@ class AuthController extends Controller {
return res.api(await user.to_api())
}
async attempt_mfa_recovery(req, res, next) {
if (
!req.user.mfa_enabled
|| !Array.isArray(req.user.mfa_token.recovery_codes)
|| req.user.mfa_token.recovery_codes.length < 1
)
return res.status(400)
.message('Your user is not configured to use MFA, or has no recovery codes.')
.api()
if ( !req.body.code )
return res.status(400)
.message('Missing required field: code')
.api()
const success = await req.user.mfa_token.attempt_recovery(req.body.code)
if ( !success )
return res.api({ success })
if ( req.trap.has_trap('mfa_challenge') )
await req.trap.end()
let next_destination = req.session.auth.flow || this.configs.get('auth.default_login_route')
delete req.session.auth.flow
req.session.mfa_remember = true
return res.api({
success: true,
next_destination,
remaining_codes: req.user.mfa_token.recovery_codes.filter(x => !x.used).length,
})
}
async validate_email(req, res, next) {
let is_valid = !!req.body.email
@@ -562,6 +597,37 @@ class AuthController extends Controller {
})
}
async get_mfa_recovery(req, res, next) {
if ( !req.user.mfa_enabled )
return res.status(400)
.message('Your user does not have MFA enabled.')
.api()
const token = req.user.mfa_token
if ( !Array.isArray(token.recovery_codes) || token.recovery_codes.length < 1 )
return res.api({ has_recovery: false })
return res.api({
has_recovery: true,
generated: token.recovery_codes[0].generated,
remaining_codes: token.recovery_codes.filter(x => !x.used).length,
})
}
async generate_mfa_recovery(req, res, next) {
if ( !req.user.mfa_enabled )
return res.status(400)
.message('Your user does not have MFA enabled.')
.api()
const token = req.user.mfa_token
const codes = await token.generate_recovery()
await req.user.save()
return res.api({
codes,
})
}
async generate_mfa_key(req, res, next) {
if ( req.user.mfa_enabled )
return res.status(400)

View File

@@ -54,6 +54,23 @@ class MFAController extends Controller {
...this.Vue.session(req),
})
}
async get_recovery(req, res, next) {
if (
!req.user.mfa_enabled
|| !Array.isArray(req.user.mfa_token.recovery_codes)
|| req.user.mfa_token.recovery_codes.length < 1
) return this.Vue.auth_message(res, {
message: 'Unfortunately, it looks like your account does not have any MFA recovery codes generated.',
next_destination: '/auth/mfa/challenge',
button_text: 'Go Back',
})
return res.page('auth:mfa:recovery', {
...this.Vue.data(),
...this.Vue.session(req),
})
}
}
module.exports = exports = MFAController

View File

@@ -0,0 +1,23 @@
const { Model } = require('flitter-orm')
const bcrypt = require('bcrypt')
class MFARecoveryCodeModel extends Model {
static get schema() {
return {
code: String,
used: { type: Boolean, default: false },
generated: { type: Date, default: () => new Date },
}
}
static async create(value) {
const code = await bcrypt.hash(value, 10)
return new this({ code })
}
async verify(code) {
return await bcrypt.compare(code, this.code)
}
}
module.exports = exports = MFARecoveryCodeModel

View File

@@ -1,5 +1,7 @@
const { Model } = require('flitter-orm')
const speakeasy = require('speakeasy')
const MFARecoveryCode = require('./MFARecoveryCode.model')
const uuid = require('uuid/v4')
class MFATokenModel extends Model {
static get services() {
@@ -10,9 +12,34 @@ class MFATokenModel extends Model {
return {
secret: String,
otpauth_url: String,
recovery_codes: [MFARecoveryCode],
}
}
async attempt_recovery(code) {
for ( const token of this.recovery_codes ) {
if ( await token.verify(code) && !token.used ) {
token.used = true
return true
}
}
return false
}
async generate_recovery() {
this.recovery_codes = []
const values = []
for ( let i = 0; i < 4; i++ ) {
const value = uuid()
values.push(value)
this.recovery_codes.push(await MFARecoveryCode.create(value))
}
return values
}
verify(value) {
return speakeasy.totp.verify({
secret: this.secret,

View File

@@ -9,6 +9,7 @@
* routes file.
*/
const Middleware = [
"i18n:Localize",
"auth:Utility",
"auth:TrustTokenUtility",
"Traps",

View File

@@ -0,0 +1,7 @@
const Middleware = require('flitter-i18n/src/middleware/Localize')
class LocalizeMiddleware extends Middleware {
}
module.exports = exports = LocalizeMiddleware

View File

@@ -0,0 +1,7 @@
const Middleware = require('flitter-i18n/src/middleware/Scope')
class ScopeMiddleware extends Middleware {
}
module.exports = exports = ScopeMiddleware

View File

@@ -41,6 +41,11 @@ const auth_routes = {
['middleware::api:Permission', { check: 'v1:auth:groups:get' }],
'controller::api:v1:Auth.get_group',
],
'/mfa/recovery': [
'middleware::auth:APIRoute',
['middleware::api:Permission', { check: 'v1:auth:mfa:recovery:get' }],
'controller::api:v1:Auth.get_mfa_recovery',
],
},
post: {
@@ -70,6 +75,11 @@ const auth_routes = {
'controller::api:v1:Auth.attempt_mfa'
],
'/mfa/recovery/attempt': [
'middleware::auth:UserOnly',
'controller::api:v1:Auth.attempt_mfa_recovery'
],
'/mfa/enable': [
'middleware::auth:UserOnly',
['middleware::auth:RequireTrust', { scope: 'mfa.enable', deplete: true }],
@@ -99,6 +109,11 @@ const auth_routes = {
'middleware::auth:GuestOnly',
'controller::api:v1:Auth.registration',
],
'/mfa/recovery': [
'middleware::auth:APIRoute',
['middleware::api:Permission', { check: 'v1:auth:mfa:recovery:create' }],
'controller::api:v1:Auth.generate_mfa_recovery',
],
},
patch: {

View File

@@ -20,6 +20,9 @@ const mfa_routes = {
['middleware::auth:RequireTrust', { scope: 'mfa.disable' }],
'controller::auth:MFA.do_disable',
],
'/recovery': [
'controller::auth:MFA.get_recovery',
],
},
post: {

View File

@@ -0,0 +1,7 @@
extends ../../theme/public/base
block append style
link(rel='stylesheet' href='/style-asset/form.css')
block vue
coreid-mfa-recovery-page(v-bind:app_name="app_name")