diff --git a/app/assets/app/auth/MFAChallenge.component.js b/app/assets/app/auth/MFAChallenge.component.js new file mode 100644 index 0000000..2a16d50 --- /dev/null +++ b/app/assets/app/auth/MFAChallenge.component.js @@ -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 = ` +
+
+
{{ app_name }}
+
+ Your account has multi-factor authentication enabled. Please enter the code generated by your authenticator app to continue. +
+
+ +
+
{{ error_message }}
+
{{ other_message }}
+
+
+
+` + +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 + } + } +} diff --git a/app/assets/app/auth/MFASetup.component.js b/app/assets/app/auth/MFASetup.component.js new file mode 100644 index 0000000..d52f66b --- /dev/null +++ b/app/assets/app/auth/MFASetup.component.js @@ -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 = ` +
+
+
{{ app_name }}
+ +
+ We're going to walk you through setting up multi-factor authentication for your account. +

+ Once this is completed, you will need to provide your second factor of authentication whenever you sign in with your {{ app_name }} account. +

+ You'll need some kind of MFA token generator such as Google Authenticator. +
+
+ + +
+
+ +
+ Scan the QR code below with your authenticator app to add your {{ app_name }} account. +

+ Once you've done this, we'll ask for one of the generated codes to verify that MFA is working correctly. +
+
+ +

Secret: {{ secret }} +
+
+ +
+
+ +
+ 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. +
+
+ +
+
{{ error_message }}
+
{{ other_message }}
+
+ + +
+
+
+
+
+` + +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 = '' + } + } + } +} diff --git a/app/assets/app/auth/Page.component.js b/app/assets/app/auth/Page.component.js new file mode 100644 index 0000000..4af0b4f --- /dev/null +++ b/app/assets/app/auth/Page.component.js @@ -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 = ` +
+
+
{{ app_name }}
+
{{ message }}
+
+ +
+
+
+
+` + +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]) + } +} diff --git a/app/assets/app/auth/login/Form.component.js b/app/assets/app/auth/login/Form.component.js index 0539bb6..7ac2fe3 100644 --- a/app/assets/app/auth/login/Form.component.js +++ b/app/assets/app/auth/login/Form.component.js @@ -6,43 +6,43 @@ const template = `
- -
-
- -
-
- -
-
{{ error_message }}
-
{{ other_message }}
-
- - -
-
+ +
+
+ +
+
+ +
+
{{ error_message }}
+
{{ other_message }}
+
+ + +
+
+
-
` diff --git a/app/assets/app/components.js b/app/assets/app/components.js index 6df8968..0a52d25 100644 --- a/app/assets/app/components.js +++ b/app/assets/app/components.js @@ -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 } diff --git a/app/assets/app/service/Action.service.js b/app/assets/app/service/Action.service.js new file mode 100644 index 0000000..6bbc8a7 --- /dev/null +++ b/app/assets/app/service/Action.service.js @@ -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 } diff --git a/app/assets/app/service/AuthApi.service.js b/app/assets/app/service/AuthApi.service.js index 3036e19..42ef293 100644 --- a/app/assets/app/service/AuthApi.service.js +++ b/app/assets/app/service/AuthApi.service.js @@ -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() diff --git a/app/assets/app/service/Location.service.js b/app/assets/app/service/Location.service.js index 6192653..a746fcc 100644 --- a/app/assets/app/service/Location.service.js +++ b/app/assets/app/service/Location.service.js @@ -7,6 +7,10 @@ class LocationService { }, delay) }) } + + async back() { + return window.history.back() + } } const location_service = new LocationService() diff --git a/app/assets/less/form.less b/app/assets/less/form.less index 6e8ab2c..e369c01 100644 --- a/app/assets/less/form.less +++ b/app/assets/less/form.less @@ -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; diff --git a/app/controllers/Home.controller.js b/app/controllers/Home.controller.js index 42c3d57..c623c66 100644 --- a/app/controllers/Home.controller.js +++ b/app/controllers/Home.controller.js @@ -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', + }) } } diff --git a/app/controllers/api/v1/Auth.controller.js b/app/controllers/api/v1/Auth.controller.js index a74d8fa..39fc3cc 100644 --- a/app/controllers/api/v1/Auth.controller.js +++ b/app/controllers/api/v1/Auth.controller.js @@ -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 diff --git a/app/controllers/auth/Forms.controller.js b/app/controllers/auth/Forms.controller.js index 4b6029f..ca1d308 100644 --- a/app/controllers/auth/Forms.controller.js +++ b/app/controllers/auth/Forms.controller.js @@ -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 diff --git a/app/controllers/auth/MFA.controller.js b/app/controllers/auth/MFA.controller.js new file mode 100644 index 0000000..fceaad5 --- /dev/null +++ b/app/controllers/auth/MFA.controller.js @@ -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 diff --git a/app/models/auth/MFAToken.model.js b/app/models/auth/MFAToken.model.js new file mode 100644 index 0000000..f292d6e --- /dev/null +++ b/app/models/auth/MFAToken.model.js @@ -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 diff --git a/app/models/auth/User.model.js b/app/models/auth/User.model.js index 9864ba4..54a8b00 100644 --- a/app/models/auth/User.model.js +++ b/app/models/auth/User.model.js @@ -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}, }} } diff --git a/app/routing/middleware/auth/DMZOnly.middleware.js b/app/routing/middleware/auth/DMZOnly.middleware.js new file mode 100644 index 0000000..8a0bc82 --- /dev/null +++ b/app/routing/middleware/auth/DMZOnly.middleware.js @@ -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 diff --git a/app/routing/middleware/auth/UserOnly.middleware.js b/app/routing/middleware/auth/UserOnly.middleware.js index 65386ef..4113284 100644 --- a/app/routing/middleware/auth/UserOnly.middleware.js +++ b/app/routing/middleware/auth/UserOnly.middleware.js @@ -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') + } + + } } diff --git a/app/routing/routers/api/v1/auth.routes.js b/app/routing/routers/api/v1/auth.routes.js index 55a96e5..75e770a 100644 --- a/app/routing/routers/api/v1/auth.routes.js +++ b/app/routing/routers/api/v1/auth.routes.js @@ -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'], }, } diff --git a/app/routing/routers/auth/forms.routes.js b/app/routing/routers/auth/forms.routes.js index d4911e0..ed4e722 100644 --- a/app/routing/routers/auth/forms.routes.js +++ b/app/routing/routers/auth/forms.routes.js @@ -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', ], diff --git a/app/routing/routers/auth/mfa.routes.js b/app/routing/routers/auth/mfa.routes.js new file mode 100644 index 0000000..f52685f --- /dev/null +++ b/app/routing/routers/auth/mfa.routes.js @@ -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 diff --git a/app/services/MFA.service.js b/app/services/MFA.service.js new file mode 100644 index 0000000..75f49c9 --- /dev/null +++ b/app/services/MFA.service.js @@ -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 diff --git a/app/services/Vue.service.js b/app/services/Vue.service.js index 5051b7a..6d9b093 100644 --- a/app/services/Vue.service.js +++ b/app/services/Vue.service.js @@ -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 diff --git a/app/views/auth/mfa/challenge.pug b/app/views/auth/mfa/challenge.pug new file mode 100644 index 0000000..046a86a --- /dev/null +++ b/app/views/auth/mfa/challenge.pug @@ -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") diff --git a/app/views/auth/mfa/setup.pug b/app/views/auth/mfa/setup.pug new file mode 100644 index 0000000..42cec68 --- /dev/null +++ b/app/views/auth/mfa/setup.pug @@ -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") diff --git a/app/views/public/message.pug b/app/views/public/message.pug new file mode 100644 index 0000000..8fee909 --- /dev/null +++ b/app/views/public/message.pug @@ -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") diff --git a/app/views/theme/base.pug b/app/views/theme/base.pug index 9f2e1f1..7ae7b90 100644 --- a/app/views/theme/base.pug +++ b/app/views/theme/base.pug @@ -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') diff --git a/app/views/tmpl.pug b/app/views/tmpl.pug index dfc2770..be08749 100644 --- a/app/views/tmpl.pug +++ b/app/views/tmpl.pug @@ -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") diff --git a/config/auth.config.js b/config/auth.config.js index c214582..4b0df46 100644 --- a/config/auth.config.js +++ b/config/auth.config.js @@ -3,6 +3,10 @@ const auth_config = { default_provider: env('AUTH_DEFAULT_PROVIDER', 'flitter'), default_login_route: '/dash', + mfa: { + secret_length: env('MFA_SECRET_LENGTH', 20) + }, + servers: { // OAuth2 authorization server oauth2: { diff --git a/package.json b/package.json index 7c631c4..ccdc9cd 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,11 @@ "flitter-flap": "^0.5.2", "flitter-forms": "^0.8.1", "flitter-less": "^0.5.3", - "flitter-orm": "^0.2.4", + "flitter-orm": "^0.2.5", "flitter-upload": "^0.8.0", "ldapjs": "^1.0.2", - "libflitter": "^0.50.0" + "libflitter": "^0.50.1", + "qrcode": "^1.4.4", + "speakeasy": "^2.0.0" } } diff --git a/yarn.lock b/yarn.lock index 32da1ba..c672e6d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -192,13 +192,20 @@ resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.2.tgz#26520bf09abe4a5644cd5414e37125a8954241dd" integrity sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw== -"@sinonjs/commons@^1", "@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0": +"@sinonjs/commons@^1", "@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.7.2": version "1.7.2" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.7.2.tgz#505f55c74e0272b43f6c52d81946bed7058fc0e2" integrity sha512-+DUO6pnp3udV/v2VfUWgaY5BIE1IfT7lLfeDzPVeMT1XKkaAp9LgSI9x5RtrFQoZ9Oi0PgXQQHPaoKu7dCjVxw== dependencies: type-detect "4.0.8" +"@sinonjs/fake-timers@^6.0.0", "@sinonjs/fake-timers@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz#293674fccb3262ac782c7aadfdeca86b10c75c40" + integrity sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA== + dependencies: + "@sinonjs/commons" "^1.7.0" + "@sinonjs/formatio@^4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-4.0.1.tgz#50ac1da0c3eaea117ca258b06f4f88a471668bdb" @@ -207,6 +214,14 @@ "@sinonjs/commons" "^1" "@sinonjs/samsam" "^4.2.0" +"@sinonjs/formatio@^5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-5.0.1.tgz#f13e713cb3313b1ab965901b01b0828ea6b77089" + integrity sha512-KaiQ5pBf1MpS09MuA0kp6KBQt2JUOQycqVG1NZXvzeaXe5LGFqAKueIS0bw4w0P9r7KuBSVdUk5QjXsUdu2CxQ== + dependencies: + "@sinonjs/commons" "^1" + "@sinonjs/samsam" "^5.0.2" + "@sinonjs/samsam@^4.2.0", "@sinonjs/samsam@^4.2.2": version "4.2.2" resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-4.2.2.tgz#0f6cb40e467865306d8a20a97543a94005204e23" @@ -216,6 +231,15 @@ lodash.get "^4.4.2" type-detect "^4.0.8" +"@sinonjs/samsam@^5.0.2", "@sinonjs/samsam@^5.0.3": + version "5.0.3" + resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-5.0.3.tgz#86f21bdb3d52480faf0892a480c9906aa5a52938" + integrity sha512-QucHkc2uMJ0pFGjJUDP3F9dq5dx8QIaqISl9QgwLOh6P9yv877uONPGXh/OH/0zmM3tW1JjuJltAZV2l7zU+uQ== + dependencies: + "@sinonjs/commons" "^1.6.0" + lodash.get "^4.4.2" + type-detect "^4.0.8" + "@sinonjs/text-encoding@^0.7.1": version "0.7.1" resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz#8da5c6530915653f3a1f38fd5f101d8c3f8079c5" @@ -696,6 +720,16 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= +base32.js@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/base32.js/-/base32.js-0.0.1.tgz#d045736a57b1f6c139f0c7df42518a84e91bb2ba" + integrity sha1-0EVzalex9sE58MffQlGKhOkbsro= + +base64-js@^1.0.2: + version "1.3.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" + integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g== + basic-auth@~0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-0.0.1.tgz#31ddb65843f6c35c6fea7beb46a987cb8ce18924" @@ -790,6 +824,37 @@ bson@^1.1.1: resolved "https://registry.yarnpkg.com/bson/-/bson-1.1.1.tgz#4330f5e99104c4e751e7351859e2d408279f2f13" integrity sha512-jCGVYLoYMHDkOsbwJZBCqwMHyH4c+wzgI9hG7Z6SZJRXWr+x58pdIbm2i9a/jFGCkRJqRUr8eoI7lDWa0hTkxg== +buffer-alloc-unsafe@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0" + integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg== + +buffer-alloc@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec" + integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow== + dependencies: + buffer-alloc-unsafe "^1.1.0" + buffer-fill "^1.0.0" + +buffer-fill@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c" + integrity sha1-+PeLdniYiO858gXNY39o5wISKyw= + +buffer-from@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" + integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== + +buffer@^5.4.3: + version "5.6.0" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.6.0.tgz#a31749dc7d81d84db08abf937b6b8c4033f62786" + integrity sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw== + dependencies: + base64-js "^1.0.2" + ieee754 "^1.1.4" + bunyan@1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/bunyan/-/bunyan-1.3.3.tgz#bf4e301c1f0bf888ec678829531f7b5d212e9e81" @@ -1329,6 +1394,11 @@ diff@^4.0.2: resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== +dijkstrajs@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.1.tgz#d3cd81221e3ea40742cfcde556d4e99e98ddc71b" + integrity sha1-082BIh4+pAdCz83lVtTpnpjdxxs= + doctypes@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/doctypes/-/doctypes-1.1.0.tgz#ea80b106a87538774e8a3a4a5afe293de489e0a9" @@ -1339,6 +1409,11 @@ dotenv@^6.2.0: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-6.2.0.tgz#941c0410535d942c8becf28d3f357dbd9d476064" integrity sha512-HygQCKUBSFl8wKQZBSemMywRWcEDNidvNbjGVyZu3nbZ8qq9ubiPoGLMdRDpfSrpkkm9BXYFkpKxxFX38o/76w== +dotenv@^8.2.0: + version "8.2.0" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a" + integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw== + dtrace-provider@0.4.0, dtrace-provider@~0.4: version "0.4.0" resolved "https://registry.yarnpkg.com/dtrace-provider/-/dtrace-provider-0.4.0.tgz#0b67bc1cc77e79bf88b87ad20664f4a753ce3f26" @@ -1744,15 +1819,19 @@ flitter-less@^0.5.3: dependencies: express-less "^0.1.0" -flitter-orm@^0.2.4: - version "0.2.4" - resolved "https://registry.yarnpkg.com/flitter-orm/-/flitter-orm-0.2.4.tgz#539f7631fd286955b01ce6034a0bb68142540f5d" - integrity sha512-7yhwwzzBpPIyW4VC9nHY+Pe9pM+EFYwljYKkK1BEMy8XNk6JADhcLiwZGJmxK38vQ8D7SEdFpZiux3fB68uVnQ== +flitter-orm@^0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/flitter-orm/-/flitter-orm-0.2.5.tgz#425bc6f2899e3eb689a4b3398c758470ca2c1f60" + integrity sha512-bTFmCItZcLpQPc6m86iQUHa3xtn5/uQ+/Zm/JQ2+Eqjw+7HPu4BtskzdT2xAMaQl0ylmKz3Zj9ZKyi7MtuZXHQ== dependencies: + chai "^4.2.0" + dotenv "^8.2.0" flitter-di "^0.4.0" json-stringify-safe "^5.0.1" + mocha "^7.1.0" mongodb "^3.5.1" object-hash "^2.0.1" + sinon "^9.0.0" uuid "^3.4.0" flitter-upload@^0.8.0: @@ -2106,6 +2185,11 @@ iconv-lite@0.4.24, iconv-lite@^0.4.4, iconv-lite@^0.4.5: dependencies: safer-buffer ">= 2.1.2 < 3" +ieee754@^1.1.4: + version "1.1.13" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" + integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== + ignore-walk@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8" @@ -2317,6 +2401,11 @@ isarray@0.0.1: resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= +isarray@^2.0.1: + version "2.0.5" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" + integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== + isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" @@ -2603,10 +2692,10 @@ leven@^1.0.2: resolved "https://registry.yarnpkg.com/leven/-/leven-1.0.2.tgz#9144b6eebca5f1d0680169f1a6770dcea60b75c3" integrity sha1-kUS27ryl8dBoAWnxpncNzqYLdcM= -libflitter@^0.50.0: - version "0.50.0" - resolved "https://registry.yarnpkg.com/libflitter/-/libflitter-0.50.0.tgz#fdf00f13e806559c50c98cbcfd87d18b5119df40" - integrity sha512-pA8BkvEWdrinAAI4Ef/IOgDajsHyHYPpDVywPc87FerJqIpLXD0vtx1hIE5HAzzK+Tf/n11gceaxBFajVTpkeA== +libflitter@^0.50.1: + version "0.50.1" + resolved "https://registry.yarnpkg.com/libflitter/-/libflitter-0.50.1.tgz#afaab6bc4ae9afe1855b2cc00b22544c47a13c85" + integrity sha512-bHTwhCg5kXDYbXRBaGbiR/4QW35g9YUIho7mX+Yirj1ZJSOcXUDN0mCHd9x4v235ss2Im7QxUxjgTz8zMxba2g== dependencies: colors "^1.3.3" connect-mongodb-session "^2.2.0" @@ -2618,7 +2707,7 @@ libflitter@^0.50.0: express-graphql "^0.9.0" express-session "^1.15.6" flitter-di "^0.5.0" - flitter-orm "^0.2.4" + flitter-orm "^0.2.5" graphql "^14.5.4" http-status "^1.4.2" pug "^2.0.3" @@ -2865,7 +2954,7 @@ mkdirp@^1.0.3: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.3.tgz#4cf2e30ad45959dddea53ad97d518b6c8205e1ea" integrity sha512-6uCP4Qc0sWsgMLy1EOqqS/3rjDHOEnsStVr/4vtAIK2Y5i2kA7lFFejYrpIyiN9w0pYf4ckeCYT9f1r1P9KX5g== -mocha@^7.0.1: +mocha@^7.0.1, mocha@^7.1.0: version "7.1.1" resolved "https://registry.yarnpkg.com/mocha/-/mocha-7.1.1.tgz#89fbb30d09429845b1bb893a830bf5771049a441" integrity sha512-3qQsu3ijNS3GkWcccT5Zw0hf/rWvu1fTN9sPvEd81hlwsr30GX2GcDSSoBxo24IR8FelmrAydGC6/1J5QQP4WA== @@ -3020,6 +3109,17 @@ nise@^3.0.1: lolex "^5.0.1" path-to-regexp "^1.7.0" +nise@^4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/nise/-/nise-4.0.3.tgz#9f79ff02fa002ed5ffbc538ad58518fa011dc913" + integrity sha512-EGlhjm7/4KvmmE6B/UFsKh7eHykRl9VH+au8dduHLCyWUO/hr7+N+WtTvDUwc9zHuM1IaIJs/0lQ6Ag1jDkQSg== + dependencies: + "@sinonjs/commons" "^1.7.0" + "@sinonjs/fake-timers" "^6.0.0" + "@sinonjs/text-encoding" "^0.7.1" + just-extend "^4.0.2" + path-to-regexp "^1.7.0" + node-environment-flags@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/node-environment-flags/-/node-environment-flags-1.0.6.tgz#a30ac13621f6f7d674260a54dede048c3982c088" @@ -3401,6 +3501,11 @@ pkg-dir@^4.1.0: dependencies: find-up "^4.0.0" +pngjs@^3.3.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.4.0.tgz#99ca7d725965fb655814eaf65f38f12bbdbf555f" + integrity sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w== + precond@0.2: version "0.2.3" resolved "https://registry.yarnpkg.com/precond/-/precond-0.2.3.tgz#aa9591bcaa24923f1e0f4849d240f47efc1075ac" @@ -3573,6 +3678,19 @@ q@^1.1.2: resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= +qrcode@^1.4.4: + version "1.4.4" + resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.4.4.tgz#f0c43568a7e7510a55efc3b88d9602f71963ea83" + integrity sha512-oLzEC5+NKFou9P0bMj5+v6Z40evexeE29Z9cummZXZ9QXyMr3lphkURzxjXgPJC5azpxcshoDWV1xE46z+/c3Q== + dependencies: + buffer "^5.4.3" + buffer-alloc "^1.2.0" + buffer-from "^1.1.1" + dijkstrajs "^1.0.1" + isarray "^2.0.1" + pngjs "^3.3.0" + yargs "^13.2.4" + qs@6.5.2, qs@~6.5.2: version "6.5.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" @@ -4027,6 +4145,19 @@ sinon@^8.1.1: nise "^3.0.1" supports-color "^7.1.0" +sinon@^9.0.0: + version "9.0.2" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-9.0.2.tgz#b9017e24633f4b1c98dfb6e784a5f0509f5fd85d" + integrity sha512-0uF8Q/QHkizNUmbK3LRFqx5cpTttEVXudywY9Uwzy8bTfZUhljZ7ARzSxnRHWYWtVTeh4Cw+tTb3iU21FQVO9A== + dependencies: + "@sinonjs/commons" "^1.7.2" + "@sinonjs/fake-timers" "^6.0.1" + "@sinonjs/formatio" "^5.0.1" + "@sinonjs/samsam" "^5.0.3" + diff "^4.0.2" + nise "^4.0.1" + supports-color "^7.1.0" + slash@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" @@ -4075,6 +4206,13 @@ spawn-wrap@^2.0.0: signal-exit "^3.0.2" which "^2.0.1" +speakeasy@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/speakeasy/-/speakeasy-2.0.0.tgz#85c91a071b09a5cb8642590d983566165f57613a" + integrity sha1-hckaBxsJpcuGQlkNmDVmFl9XYTo= + dependencies: + base32.js "0.0.1" + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" @@ -4674,7 +4812,7 @@ yargs-unparser@1.6.0: lodash "^4.17.15" yargs "^13.3.0" -yargs@13.3.2, yargs@^13.3.0: +yargs@13.3.2, yargs@^13.2.4, yargs@^13.3.0: version "13.3.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==