Add MFA support
This commit is contained in:
73
app/assets/app/auth/MFAChallenge.component.js
Normal file
73
app/assets/app/auth/MFAChallenge.component.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
148
app/assets/app/auth/MFASetup.component.js
Normal file
148
app/assets/app/auth/MFASetup.component.js
Normal 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 = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
35
app/assets/app/auth/Page.component.js
Normal file
35
app/assets/app/auth/Page.component.js
Normal 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])
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
16
app/assets/app/service/Action.service.js
Normal file
16
app/assets/app/service/Action.service.js
Normal 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 }
|
||||
@@ -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()
|
||||
|
||||
@@ -7,6 +7,10 @@ class LocationService {
|
||||
}, delay)
|
||||
})
|
||||
}
|
||||
|
||||
async back() {
|
||||
return window.history.back()
|
||||
}
|
||||
}
|
||||
|
||||
const location_service = new LocationService()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -28,9 +28,11 @@ class Home extends Controller {
|
||||
}
|
||||
|
||||
async tmpl(req, res) {
|
||||
return res.page('tmpl', this.Vue.data({
|
||||
login_message: 'Please sign-in to continue.'
|
||||
}))
|
||||
return this.Vue.auth_message(res, {
|
||||
message: 'This is a test message. Hello, baby girl; I love you very very much!.',
|
||||
next_destination: '/auth/login',
|
||||
button_text: 'Continue',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ const { Controller } = require('libflitter')
|
||||
|
||||
class AuthController extends Controller {
|
||||
static get services() {
|
||||
return [...super.services, 'models', 'auth']
|
||||
return [...super.services, 'models', 'auth', 'MFA', 'output']
|
||||
}
|
||||
|
||||
async validate_username(req, res, next) {
|
||||
@@ -50,9 +50,14 @@ class AuthController extends Controller {
|
||||
await flitter.session(req, user)
|
||||
|
||||
let destination = this.configs.get('auth.default_login_route')
|
||||
if ( req?.session?.auth?.flow ) {
|
||||
if ( req.session.auth.flow ) {
|
||||
destination = req.session.auth.flow
|
||||
req.session.auth.flow = false
|
||||
}
|
||||
|
||||
// TODO remember-device feature
|
||||
if ( user.mfa_enabled && !req.session.mfa_remember ) {
|
||||
req.session.auth.in_dmz = true
|
||||
destination = '/auth/mfa/challenge'
|
||||
}
|
||||
|
||||
return res.api({
|
||||
@@ -62,6 +67,72 @@ class AuthController extends Controller {
|
||||
})
|
||||
}
|
||||
|
||||
async generate_mfa_key(req, res, next) {
|
||||
if ( req.user.mfa_enabled )
|
||||
return res.status(400)
|
||||
.message(`MFA already configured for user. Cannot fetch key.`)
|
||||
.api()
|
||||
|
||||
const MFAToken = this.models.get('auth:MFAToken')
|
||||
const secret = await this.MFA.secret(req.user)
|
||||
|
||||
req.user.mfa_token = new MFAToken({
|
||||
secret: secret.base32,
|
||||
otpauth_url: secret.otpauth_url
|
||||
}, req.user)
|
||||
await req.user.save()
|
||||
|
||||
return res.api({
|
||||
success: true,
|
||||
secret: secret.base32,
|
||||
otpauth_url: secret.otpauth_url,
|
||||
qr_code: await this.MFA.qr_code(secret)
|
||||
})
|
||||
}
|
||||
|
||||
async attempt_mfa(req, res, next) {
|
||||
if ( !req.user.mfa_token )
|
||||
return res.status(400)
|
||||
.message(`The user does not have MFA configured.`)
|
||||
.api()
|
||||
|
||||
const code = req.body.verify_code
|
||||
const token = req.user.mfa_token
|
||||
const is_valid = token.verify(code)
|
||||
|
||||
let next_destination = undefined
|
||||
if ( is_valid ) {
|
||||
req.session.auth.in_dmz = false
|
||||
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,
|
||||
verify_code: code,
|
||||
is_valid,
|
||||
next_destination,
|
||||
})
|
||||
}
|
||||
|
||||
async enable_mfa(req, res, next) {
|
||||
if ( !req.user.mfa_token )
|
||||
return res.status(400)
|
||||
.message(`The user does not have an MFA token configured.`)
|
||||
.api()
|
||||
|
||||
req.user.mfa_enabled = true
|
||||
req.user.save()
|
||||
|
||||
// TODO invalidate existing tokens and other logins
|
||||
const flitter = await this.auth.get_provider('flitter')
|
||||
await flitter.logout(req)
|
||||
|
||||
return res.api({success: true, mfa_enabled: req.user.mfa_enabled})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = exports = AuthController
|
||||
|
||||
@@ -17,6 +17,13 @@ class Forms extends FormController {
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async logout_provider_present_success(req, res, next) {
|
||||
return this.Vue.auth_message(res, {
|
||||
message: 'You have been successfully logged out.',
|
||||
next_destination: '/',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = exports = Forms
|
||||
|
||||
44
app/controllers/auth/MFA.controller.js
Normal file
44
app/controllers/auth/MFA.controller.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const { Controller } = require('libflitter')
|
||||
|
||||
class MFAController extends Controller {
|
||||
static get services() {
|
||||
return [...super.services, 'Vue', 'configs']
|
||||
}
|
||||
|
||||
async setup(req, res, next) {
|
||||
if ( req.user.mfa_enabled ) {
|
||||
// Already set up!
|
||||
return this.Vue.auth_message(res, {
|
||||
message: 'It looks like your account is already set up for multi-factor authentication. Unable to continue with MFA setup.',
|
||||
next_destination: '/', // TODO update this
|
||||
button_text: 'Okay',
|
||||
})
|
||||
}
|
||||
|
||||
// Display the token setup page
|
||||
return res.page('auth:mfa:setup', {
|
||||
...this.Vue.data()
|
||||
})
|
||||
}
|
||||
|
||||
async challenge(req, res, next) {
|
||||
if ( !req.user.mfa_enabled ) {
|
||||
return this.Vue.auth_message(res, {
|
||||
message: 'Your account is not configured to use multi-factor authentication. Would you like to configure it now?',
|
||||
next_destination: '/auth/mfa/setup',
|
||||
button_text: 'Setup MFA',
|
||||
})
|
||||
}
|
||||
|
||||
if ( !req.session.auth.in_dmz ) {
|
||||
return res.redirect(req.session.auth.flow)
|
||||
}
|
||||
|
||||
// Display the MFA challenge page
|
||||
return res.page('auth:mfa:challenge', {
|
||||
...this.Vue.data()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = exports = MFAController
|
||||
25
app/models/auth/MFAToken.model.js
Normal file
25
app/models/auth/MFAToken.model.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const { Model } = require('flitter-orm')
|
||||
const speakeasy = require('speakeasy')
|
||||
|
||||
class MFATokenModel extends Model {
|
||||
static get services() {
|
||||
return [...super.services, 'MFA']
|
||||
}
|
||||
|
||||
static get schema() {
|
||||
return {
|
||||
secret: String,
|
||||
otpauth_url: String,
|
||||
}
|
||||
}
|
||||
|
||||
verify(value) {
|
||||
return speakeasy.totp.verify({
|
||||
secret: this.secret,
|
||||
encoding: 'base32',
|
||||
token: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = exports = MFATokenModel
|
||||
@@ -2,6 +2,7 @@ const AuthUser = require('flitter-auth/model/User')
|
||||
const LDAP = require('ldapjs')
|
||||
|
||||
const ActiveScope = require('../scopes/ActiveScope')
|
||||
const MFAToken = require('./MFAToken.model')
|
||||
|
||||
/*
|
||||
* Auth user model. This inherits fields and methods from the default
|
||||
@@ -21,6 +22,8 @@ class User extends AuthUser {
|
||||
email: String,
|
||||
ldap_visible: {type: Boolean, default: true},
|
||||
active: {type: Boolean, default: true},
|
||||
mfa_token: MFAToken,
|
||||
mfa_enabled: {type: Boolean, default: false},
|
||||
}}
|
||||
}
|
||||
|
||||
|
||||
17
app/routing/middleware/auth/DMZOnly.middleware.js
Normal file
17
app/routing/middleware/auth/DMZOnly.middleware.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const Middleware = require('libflitter/middleware/Middleware')
|
||||
class DMZOnly extends Middleware {
|
||||
|
||||
async test(req, res, next, args = {}){
|
||||
|
||||
if ( req.is_auth ) return next()
|
||||
else {
|
||||
// If not signed in, save the target url so we can redirect back here after auth
|
||||
req.session.auth.flow = req.originalUrl
|
||||
return res.redirect('/auth/login')
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = DMZOnly
|
||||
@@ -7,8 +7,25 @@
|
||||
*/
|
||||
const Middleware = require('flitter-auth/middleware/UserOnly')
|
||||
class UserOnly extends Middleware {
|
||||
static get services() {
|
||||
return [...super.services, 'output']
|
||||
}
|
||||
|
||||
|
||||
async test(req, res, next, args = {}){
|
||||
|
||||
if ( req.is_auth && !req.session.auth.in_dmz ) return next()
|
||||
else if ( req.is_auth ) { // Need an MFA challenge
|
||||
if ( !req.session.auth.flow ) req.session.auth.flow = req.originalUrl
|
||||
return res.redirect('/auth/mfa/challenge')
|
||||
}
|
||||
else {
|
||||
// If not signed in, save the target url so we can redirect back here after auth
|
||||
req.session.auth.flow = req.originalUrl
|
||||
this.output.debug('Set auth flow: '+req.originalUrl)
|
||||
return res.redirect('/auth/login')
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ const auth_routes = {
|
||||
post: {
|
||||
'/validate/username': ['controller::api:v1:Auth.validate_username'],
|
||||
'/attempt': [ 'controller::api:v1:Auth.attempt' ],
|
||||
'/mfa/generate': ['middleware::auth:UserOnly', 'controller::api:v1:Auth.generate_mfa_key'],
|
||||
'/mfa/attempt': ['middleware::auth:DMZOnly', 'controller::api:v1:Auth.attempt_mfa'],
|
||||
'/mfa/enable': ['middleware::auth:UserOnly', 'controller::api:v1:Auth.enable_mfa'],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* The general structure is as follows:
|
||||
*
|
||||
* /auth/{provider name}/{action}
|
||||
|
||||
*
|
||||
* Individual providers may be interacted with individually, therefore:
|
||||
*
|
||||
* /auth/flitter/register
|
||||
@@ -49,7 +49,7 @@ const index = {
|
||||
|
||||
'/:provider/logout': [
|
||||
'middleware::auth:ProviderRoute',
|
||||
'middleware::auth:UserOnly',
|
||||
'middleware::auth:DMZOnly',
|
||||
'controller::auth:Forms.logout_provider_clean_session',
|
||||
|
||||
// Note, this separation is between when the auth action has happened properly
|
||||
@@ -60,7 +60,7 @@ const index = {
|
||||
],
|
||||
'/logout': [
|
||||
'middleware::auth:ProviderRoute',
|
||||
'middleware::auth:UserOnly',
|
||||
'middleware::auth:DMZOnly',
|
||||
'controller::auth:Forms.logout_provider_clean_session',
|
||||
'controller::auth:Forms.logout_provider_present_success',
|
||||
],
|
||||
@@ -94,16 +94,15 @@ const index = {
|
||||
'controller::auth:Forms.login_provider_authenticate_user',
|
||||
'controller::auth:Forms.login_provider_present_success',
|
||||
],
|
||||
|
||||
'/:provider/logout': [
|
||||
'middleware::auth:ProviderRoute',
|
||||
'middleware::auth:UserOnly',
|
||||
'middleware::auth:DMZOnly',
|
||||
'controller::auth:Forms.logout_provider_clean_session',
|
||||
'controller::auth:Forms.logout_provider_present_success',
|
||||
],
|
||||
'/logout': [
|
||||
'middleware::auth:ProviderRoute',
|
||||
'middleware::auth:UserOnly',
|
||||
'middleware::auth:DMZOnly',
|
||||
'controller::auth:Forms.logout_provider_clean_session',
|
||||
'controller::auth:Forms.logout_provider_present_success',
|
||||
],
|
||||
|
||||
24
app/routing/routers/auth/mfa.routes.js
Normal file
24
app/routing/routers/auth/mfa.routes.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const mfa_routes = {
|
||||
prefix: '/auth/mfa',
|
||||
|
||||
middleware: [
|
||||
|
||||
],
|
||||
|
||||
get: {
|
||||
'/setup': [
|
||||
'middleware::auth:UserOnly',
|
||||
'controller::auth:MFA.setup',
|
||||
],
|
||||
'/challenge': [
|
||||
'middleware::auth:DMZOnly',
|
||||
'controller::auth:MFA.challenge',
|
||||
],
|
||||
},
|
||||
|
||||
post: {
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = exports = mfa_routes
|
||||
27
app/services/MFA.service.js
Normal file
27
app/services/MFA.service.js
Normal file
@@ -0,0 +1,27 @@
|
||||
const { Service } = require('flitter-di')
|
||||
const speakeasy = require('speakeasy')
|
||||
const qrcode = require('qrcode')
|
||||
|
||||
class MFAService extends Service {
|
||||
static get services() {
|
||||
return [...super.services, 'configs']
|
||||
}
|
||||
|
||||
secret(user) {
|
||||
return speakeasy.generateSecret({
|
||||
length: this.configs.get('auth.mfa.secret_length') ?? 20,
|
||||
name: `${this.configs.get('app.name')} (${user.uid})`,
|
||||
})
|
||||
}
|
||||
|
||||
async qr_code(secret) {
|
||||
return new Promise((resolve, reject) => {
|
||||
qrcode.toDataURL(secret.otpauth_url, (err, image_data) => {
|
||||
if ( err ) reject(err)
|
||||
else resolve(image_data)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = exports = MFAService
|
||||
@@ -14,6 +14,22 @@ class VueService extends Service {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auth_message(res, {message, next_destination, ...args}) {
|
||||
const text = args.button_text || 'Continue'
|
||||
return res.page('public:message', {
|
||||
...this.data({
|
||||
message,
|
||||
actions: [
|
||||
{
|
||||
text,
|
||||
action: 'redirect',
|
||||
next: next_destination,
|
||||
}
|
||||
],
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = exports = VueService
|
||||
|
||||
7
app/views/auth/mfa/challenge.pug
Normal file
7
app/views/auth/mfa/challenge.pug
Normal file
@@ -0,0 +1,7 @@
|
||||
extends ../../theme/public/base
|
||||
|
||||
block append style
|
||||
link(rel='stylesheet' href='/style-asset/form.css')
|
||||
|
||||
block vue
|
||||
coreid-mfa-challenge-page(v-bind:app_name="app_name")
|
||||
7
app/views/auth/mfa/setup.pug
Normal file
7
app/views/auth/mfa/setup.pug
Normal file
@@ -0,0 +1,7 @@
|
||||
extends ../../theme/public/base
|
||||
|
||||
block append style
|
||||
link(rel='stylesheet' href='/style-asset/form.css')
|
||||
|
||||
block vue
|
||||
coreid-mfa-setup-page(v-bind:app_name="app_name")
|
||||
7
app/views/public/message.pug
Normal file
7
app/views/public/message.pug
Normal file
@@ -0,0 +1,7 @@
|
||||
extends ../theme/public/base
|
||||
|
||||
block append style
|
||||
link(rel='stylesheet' href='/style-asset/form.css')
|
||||
|
||||
block vue
|
||||
coreid-auth-page(v-bind:app_name="app_name" v-bind:message="message" v-bind:actions="actions")
|
||||
@@ -20,4 +20,4 @@ html(lang='en')
|
||||
script(src='/assets/lib/popper/popper-1.16.0.min.js')
|
||||
script(src='/assets/lib/bootstrap/bootstrap-4.4.1.min.js')
|
||||
script(src='/assets/lib/vue/vue-2.6.11.js')
|
||||
script(src='/assets/lib/vues6/vues6.js')
|
||||
script(src='/assets/lib/vues6/vues6.js' type='module')
|
||||
|
||||
@@ -4,4 +4,4 @@ block append style
|
||||
link(rel='stylesheet' href='/style-asset/form.css')
|
||||
|
||||
block vue
|
||||
coreid-login-form(v-bind:app_name="app_name" v-bind:login_message="login_message")
|
||||
coreid-auth-page(v-bind:app_name="app_name" v-bind:message="message" v-bind:actions="actions")
|
||||
|
||||
Reference in New Issue
Block a user