From ea77402750b789ca56b7b1357df20f72c2c9cb83 Mon Sep 17 00:00:00 2001 From: garrettmills Date: Wed, 20 May 2020 09:56:03 -0500 Subject: [PATCH] Add traps; user registration --- TODO.text | 4 +- app/assets/app/auth/login/Form.component.js | 12 +- .../app/auth/register/Form.component.js | 217 ++++++++++++++++++ app/assets/app/components.js | 2 + app/assets/app/service/AuthApi.service.js | 20 ++ app/controllers/api/v1/Auth.controller.js | 87 +++++++ app/controllers/api/v1/Password.controller.js | 1 + app/controllers/auth/Forms.controller.js | 13 +- app/models/Setting.model.js | 2 +- app/models/auth/User.model.js | 1 + app/routing/Middleware.js | 1 + app/routing/middleware/Traps.middleware.js | 61 +++++ .../auth/TrustTokenUtility.middleware.js | 17 +- .../middleware/util/Setting.middleware.js | 19 ++ app/routing/routers/api/v1/auth.routes.js | 14 ++ app/routing/routers/auth/forms.routes.js | 8 +- app/views/auth/login.pug | 2 +- app/views/auth/register.pug | 21 +- config/traps.config.js | 15 ++ 19 files changed, 493 insertions(+), 24 deletions(-) create mode 100644 app/assets/app/auth/register/Form.component.js create mode 100644 app/routing/middleware/Traps.middleware.js create mode 100644 app/routing/middleware/util/Setting.middleware.js create mode 100644 config/traps.config.js diff --git a/TODO.text b/TODO.text index 2935cae..f518a19 100644 --- a/TODO.text +++ b/TODO.text @@ -1,7 +1,6 @@ - App setup wizard - SAML IAM handling - LDAP IAM handling -- User registration - Cobalt form JSON field type - Setting resource - MFA recovery codes handling - Forgot password handling @@ -14,3 +13,6 @@ - IAM manage user API scopes - Eliminate LDAP group model, make LDAP server use standard auth group - OAuth2 -> support refresh tokens +- Traps -> support session traps; convert MFA challenge to use session trap + - Allow setting user trap from web UI + - Don't allow external logins if trap is set diff --git a/app/assets/app/auth/login/Form.component.js b/app/assets/app/auth/login/Form.component.js index 4921da3..faed648 100644 --- a/app/assets/app/auth/login/Form.component.js +++ b/app/assets/app/auth/login/Form.component.js @@ -37,6 +37,10 @@ const template = `
{{ error_message }}
{{ other_message }}
+ Need an account?
@@ -49,7 +53,7 @@ const template = ` export default class AuthLoginForm extends Component { static get selector() { return 'coreid-login-form' } static get props() { return [ - 'app_name', 'login_message', 'additional_params', 'no_session', + 'app_name', 'login_message', 'additional_params', 'no_session', 'registration_enabled', ] } static get template() { return template } @@ -126,5 +130,11 @@ export default class AuthLoginForm extends Component { } } + on_register_click() { + this.loading = true + this.other_message = 'Okay! Let\'s get started...' + location_service.redirect('/auth/register', 1500) // TODO get this dynamically + } + do_nothing() {} } diff --git a/app/assets/app/auth/register/Form.component.js b/app/assets/app/auth/register/Form.component.js new file mode 100644 index 0000000..e0dac27 --- /dev/null +++ b/app/assets/app/auth/register/Form.component.js @@ -0,0 +1,217 @@ +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 = ` +
+ +
+` +// Required: First Name, Last Name, Username, E-Mail, Password (Confirm) +export default class RegistrationFormComponent extends Component { + static get selector() { return 'coreid-registration-form' } + static get template() { return template } + static get props() { return ['app_name'] } + + loading = false + step = 1 + other_message = '' + error_message = '' + message = '' + btn_disabled = true + button_text = 'Continue' + + first_name = '' + last_name = '' + username = '' + email = '' + + async vue_on_create() { + this.message = 'Create an account to continue:' + } + + async step_click() { + this.loading = true + this.error_message = '' + this.other_message = '' + if ( this.step === 1 ) { + if ( !this.first_name.trim() || !this.last_name.trim() ) { + this.error_message = 'Please provide your first and last name.' + this.loading = false + return + } + + this.message = `Hi, ${this.first_name.trim()}. Now, you need to choose a username:` + this.$nextTick(() => { + this.$refs.input_username.focus() + }) + } else if ( this.step === 2 ) { + if ( await auth_api.username_taken(this.username) ) { + this.error_message = 'That username is already taken.' + this.loading = false + return + } + + this.$nextTick(function() { + this.$nextTick(() => { + this.$refs.input_email.focus() + }) + }) + } else if ( this.step === 3 ) { + if ( !(await auth_api.validate_email(this.email)) ) { + this.error_message = 'Please provide a valid e-mail address.' + this.loading = false + return + } + + if ( await auth_api.email_taken(this.email) ) { + this.error_message = 'That e-mail address is already taken.' + this.loading = false + return + } + + try { + const user = await auth_api.register_user({ + first_name: this.first_name, + last_name: this.last_name, + uid: this.username, + email: this.email, + }) + + if ( !user ) this.error_message = 'Sorry, an unknown error has occurred and we are unable to continue at this time.' + else this.other_message = 'Welcome! Let\'s get your password set up...' + this.btn_disabled = true + return location_service.redirect('/dash', 2000) + } catch (e) { + this.error_message = e.message || 'Sorry, an unknown error has occurred and we are unable to continue at this time.' + this.loading = false + return + } + } + + if ( this.step < 3 ) this.step += 1 + this.loading = false + this.btn_disabled = true + } + + async back_click() { + this.error_message = '' + this.other_message = '' + this.step -= 1 + this.on_key_up() + + if ( this.step === 1 ) { + this.message = 'Create an account to continue:' + } + } + + on_key_up(event) { + if ( this.step === 1 ) { + this.btn_disabled = !(this.first_name.trim() && this.last_name.trim()) + } else if ( this.step === 2 ) { + this.btn_disabled = !this.username.trim() || !this.username.match(/^([A-Z]|[a-z]|[0-9]|_|-|\.)+$/) + } else if ( this.step === 3 ) { + this.btn_disabled = !this.email.trim() + } + + if ( event.keyCode === 13 ) { + // Enter was pressed + event.preventDefault() + event.stopPropagation() + if ( !this.btn_disabled ) return this.step_click() + } + } + + on_login_click() { + this.loading = true + this.other_message = 'Okay! We\'ll have you login instead...' + location_service.redirect('/auth/login', 1500) + } + + do_nothing() {} +} diff --git a/app/assets/app/components.js b/app/assets/app/components.js index 25cd76e..b19d3de 100644 --- a/app/assets/app/components.js +++ b/app/assets/app/components.js @@ -5,6 +5,7 @@ import MFAChallengePage from './auth/MFAChallenge.component.js' import MFADisableComponent from './auth/MFADisable.component.js' import PasswordResetComponent from './auth/PasswordReset.component.js' import InvokeActionComponent from './InvokeAction.component.js' +import RegistrationFormComponent from './auth/register/Form.component.js' const components = { AuthLoginForm, @@ -14,6 +15,7 @@ const components = { MFADisableComponent, PasswordResetComponent, InvokeActionComponent, + RegistrationFormComponent, } export { components } diff --git a/app/assets/app/service/AuthApi.service.js b/app/assets/app/service/AuthApi.service.js index 38e2d87..5aff1c0 100644 --- a/app/assets/app/service/AuthApi.service.js +++ b/app/assets/app/service/AuthApi.service.js @@ -4,6 +4,21 @@ class AuthAPI { return result && result.data && result.data.data && result.data.data.is_valid } + async validate_email(email) { + const result = await axios.post('/api/v1/auth/validate/email', { email }) + return result && result.data && result.data.data && result.data.data.is_valid + } + + async username_taken(username) { + const result = await axios.post('/api/v1/auth/validate/user_exists', { username }) + return result && result.data && result.data.data && result.data.data.username_taken + } + + async email_taken(email) { + const result = await axios.post('/api/v1/auth/validate/user_exists', { email }) + return result && result.data && result.data.data && result.data.data.email_taken + } + async attempt({ username, password, create_session, ...others }) { try { const result = await axios.post('/api/v1/auth/attempt', { @@ -56,6 +71,11 @@ class AuthAPI { async delete_app_password(uuid) { await axios.delete(`/api/v1/password/app_passwords/${uuid}`) } + + async register_user({ first_name, last_name, uid, email }) { + const result = await axios.post('/api/v1/auth/registration', { first_name, last_name, uid, email }) + if ( result && result.data && result.data.data ) return result.data.data + } } const auth_api = new AuthAPI() diff --git a/app/controllers/api/v1/Auth.controller.js b/app/controllers/api/v1/Auth.controller.js index 9a15979..cadd459 100644 --- a/app/controllers/api/v1/Auth.controller.js +++ b/app/controllers/api/v1/Auth.controller.js @@ -1,11 +1,70 @@ const { Controller } = require('libflitter') const zxcvbn = require('zxcvbn') +const email_validator = require('email-validator') class AuthController extends Controller { static get services() { return [...super.services, 'models', 'auth', 'MFA', 'output', 'configs', 'utility'] } + async registration(req, res, next) { + const User = this.models.get('auth:User') + const required_fields = ['first_name', 'last_name', 'uid', 'email'] + const unique_fields = ['uid', 'email'] + + for ( const field of required_fields ) { + if ( !req.body[field] ) + return res.status(400) + .message(`Missing required field: ${field}`) + .api() + } + + if ( !req.body.uid.match(/^([A-Z]|[a-z]|[0-9]|_|-|\.)+$/) ) + return res.status(400) + .message('Invalid field: uid (should be alphanumeric with "_", "-", and "." allowed)') + .api() + + if ( !email_validator.validate(req.body.email) ) + return res.status(400) + .message('Invalid field: email') + .api() + + for ( const field of unique_fields ) { + const params = {} + params[field] = req.body[field] + const match_user = await User.findOne(params) + if ( match_user ) + return res.status(400) + .message(`A user already exists with that ${field}.`) + .api() + } + + const user = new User({ + first_name: req.body.first_name, + last_name: req.body.last_name, + uid: req.body.uid, + email: req.body.email, + trap: 'password_reset', // Force user to reset password + }) + + user.promote('base_user') + await user.save() + + // Log in the user automatically + await this.auth.get_provider().session(req, user) + return res.api(await user.to_api()) + } + + async validate_email(req, res, next) { + let is_valid = !!req.body.email + + if ( is_valid ) { + is_valid = email_validator.validate(req.body.email) + } + + return res.api({ is_valid }) + } + async get_users(req, res, next) { const User = this.models.get('auth:User') const users = await User.find() @@ -346,6 +405,34 @@ class AuthController extends Controller { return res.api({ is_valid }) } + async user_exists(req, res, next) { + const User = this.models.get('auth:User') + + if ( !req.body.username && !req.body.email ) + return res.status(400) + .message('Please provide one of: username, email') + .api() + + const data = {} + if ( req.body.username ) { + const existing_user = await User.findOne({ + uid: req.body.username, + }) + + data.username_taken = !!existing_user + } + + if ( req.body.email ) { + const existing_user = await User.findOne({ + email: req.body.email, + }) + + data.email_taken = !!existing_user + } + + return res.api(data) + } + // TODO XSRF Token /* * Request Params: diff --git a/app/controllers/api/v1/Password.controller.js b/app/controllers/api/v1/Password.controller.js index 05eecf2..b0002a9 100644 --- a/app/controllers/api/v1/Password.controller.js +++ b/app/controllers/api/v1/Password.controller.js @@ -86,6 +86,7 @@ class PasswordController extends Controller { // Create the password reset const reset = await req.user.reset_password(req.body.password) await req.user.save() + if ( req.trap.has_trap() && req.trap.get_trap() === 'password_reset' ) await req.trap.end() // invalidate existing tokens and other logins const flitter = await this.auth.get_provider('flitter') diff --git a/app/controllers/auth/Forms.controller.js b/app/controllers/auth/Forms.controller.js index fd02a0a..92d16f9 100644 --- a/app/controllers/auth/Forms.controller.js +++ b/app/controllers/auth/Forms.controller.js @@ -7,13 +7,22 @@ const FormController = require('flitter-auth/controllers/Forms') */ class Forms extends FormController { static get services() { - return [...super.services, 'Vue'] + return [...super.services, 'Vue', 'models'] + } + + async registration_provider_get(req, res, next) { + return res.page('auth:register', { + ...this.Vue.data({}) + }) } async login_provider_get(req, res, next) { + const Setting = this.models.get('Setting') + return res.page('auth:login', { ...this.Vue.data({ - login_message: req.session?.auth?.message || 'Please sign-in to continue.' + login_message: req.session?.auth?.message || 'Please sign-in to continue.', + registration_enabled: await Setting.get('auth.allow_registration') }), }) } diff --git a/app/models/Setting.model.js b/app/models/Setting.model.js index 6ee3403..fc50ce3 100644 --- a/app/models/Setting.model.js +++ b/app/models/Setting.model.js @@ -23,7 +23,7 @@ class SettingModel extends Model { static async get(key) { const inst = await this.findOne({ key }) - return inst.get() + return inst?.get() } static async set(key, value) { diff --git a/app/models/auth/User.model.js b/app/models/auth/User.model.js index 0a051b7..3340b0a 100644 --- a/app/models/auth/User.model.js +++ b/app/models/auth/User.model.js @@ -35,6 +35,7 @@ class User extends AuthUser { mfa_enable_date: Date, create_date: {type: Date, default: () => new Date}, photo_file_id: String, + trap: String, }} } diff --git a/app/routing/Middleware.js b/app/routing/Middleware.js index e4281c5..49be22c 100644 --- a/app/routing/Middleware.js +++ b/app/routing/Middleware.js @@ -12,6 +12,7 @@ const Middleware = [ "auth:Utility", "auth:TrustTokenUtility", "SAMLUtility", + "Traps", // 'MiddlewareName', diff --git a/app/routing/middleware/Traps.middleware.js b/app/routing/middleware/Traps.middleware.js new file mode 100644 index 0000000..895a180 --- /dev/null +++ b/app/routing/middleware/Traps.middleware.js @@ -0,0 +1,61 @@ +const { Middleware } = require('libflitter') + +class TrapUtility { + constructor(req, res, configs) { + this.request = req + this.response = res + this.user = req.user + this.configs = configs + } + + async begin(trap_name) { + this.user.trap = trap_name + this.request.trust.assume() + await this.user.save() + } + + redirect() { + this.request.trust.assume() + return this.response.redirect(this.config().redirect_to) + } + + async end() { + this.user.trap = '' + this.request.trust.unassume() + await this.user.save() + } + + has_trap() { + return !!this.user.trap + } + + get_trap() { + return this.user.trap + } + + config() { + return this.configs[this.get_trap()] + } + + allows(route) { + const config = this.config() + return route.startsWith('/assets') || config.allowed_routes.includes(route.toLowerCase().trim()) + } +} + +class TrapsMiddleware extends Middleware { + static get services() { + return [...super.services, 'models', 'configs'] + } + + async test(req, res, next, args = {}) { + if ( !req?.user ) return next() + req.trap = new TrapUtility(req, res, this.configs.get('traps.types')) + + if ( !req.trap.has_trap() ) return next() + else if ( req.trap.allows(req.path) ) return next() + else return req.trap.redirect() + } +} + +module.exports = exports = TrapsMiddleware diff --git a/app/routing/middleware/auth/TrustTokenUtility.middleware.js b/app/routing/middleware/auth/TrustTokenUtility.middleware.js index e32e849..ef59c25 100644 --- a/app/routing/middleware/auth/TrustTokenUtility.middleware.js +++ b/app/routing/middleware/auth/TrustTokenUtility.middleware.js @@ -3,6 +3,8 @@ const moment = require('moment') const uuid = require('uuid/v4') class TrustManager { + assume_trust = false + constructor(request, response) { this.request = request this.response = response @@ -18,6 +20,19 @@ class TrustManager { this.request.session.trust_tokens = this.request.session.trust_tokens.filter(x => { return moment(new Date(x.expires)) > now }) + + this.assume_trust = !!this.request.session.trust_assume_trust + } + + assume() { + this.request.session.trust_assume_trust = true + this.assume_trust = true + } + + unassume() { + this.request.session.trust_assume_trust = false + this.assume_trust = false + this.purge() } init_flow(scope, next) { @@ -66,7 +81,7 @@ class TrustManager { } has(scope) { - return this.request.session.trust_tokens.some(x => x.scope === scope) + return this.assume_trust || this.request.session.trust_tokens.some(x => x.scope === scope) } grant(scope) { diff --git a/app/routing/middleware/util/Setting.middleware.js b/app/routing/middleware/util/Setting.middleware.js new file mode 100644 index 0000000..3949497 --- /dev/null +++ b/app/routing/middleware/util/Setting.middleware.js @@ -0,0 +1,19 @@ +const { Middleware, HTTPError } = require('libflitter') + +class SettingMiddleware extends Middleware { + static get services() { + return [...super.services, 'models'] + } + + async test(req, res, next, { key, value = true }) { + const Setting = this.models.get('Setting') + const actual_value = await Setting.get(key) + + if ( actual_value !== value ) + throw new HTTPError(404) + + return next() + } +} + +module.exports = exports = SettingMiddleware diff --git a/app/routing/routers/api/v1/auth.routes.js b/app/routing/routers/api/v1/auth.routes.js index b5ead43..77cb294 100644 --- a/app/routing/routers/api/v1/auth.routes.js +++ b/app/routing/routers/api/v1/auth.routes.js @@ -40,6 +40,14 @@ const auth_routes = { 'controller::api:v1:Auth.validate_username' ], + '/validate/user_exists': [ + 'controller::api:v1:Auth.user_exists', + ], + + '/validate/email': [ + 'controller::api:v1:Auth.validate_email', + ], + '/attempt': [ 'controller::api:v1:Auth.attempt' ], @@ -77,6 +85,12 @@ const auth_routes = { ['middleware::api:Permission', { check: 'v1:auth:users:create' }], 'controller::api:v1:Auth.create_user', ], + + '/registration': [ + ['middleware::util:Setting', { key: 'auth.allow_registration' }], + 'middleware::auth:GuestOnly', + 'controller::api:v1:Auth.registration', + ], }, patch: { diff --git a/app/routing/routers/auth/forms.routes.js b/app/routing/routers/auth/forms.routes.js index ed4e722..69c68c5 100644 --- a/app/routing/routers/auth/forms.routes.js +++ b/app/routing/routers/auth/forms.routes.js @@ -24,12 +24,14 @@ const index = { get: { '/:provider/register': [ + ['middleware::util:Setting', { key: 'auth.allow_registration' }], 'middleware::auth:ProviderRoute', 'middleware::auth:GuestOnly', 'middleware::auth:ProviderRegistrationEnabled', 'controller::auth:Forms.registration_provider_get', ], '/register': [ + ['middleware::util:Setting', { key: 'auth.allow_registration' }], 'middleware::auth:ProviderRoute', 'middleware::auth:GuestOnly', 'middleware::auth:ProviderRegistrationEnabled', @@ -67,7 +69,8 @@ const index = { }, post: { - '/:provider/register': [ + /*'/:provider/register': [ + ['middleware::util:Setting', { key: 'auth.allow_registration' }], 'middleware::auth:ProviderRoute', 'middleware::auth:GuestOnly', 'middleware::auth:ProviderRegistrationEnabled', @@ -75,12 +78,13 @@ const index = { 'controller::auth:Forms.registration_provider_present_user_created', ], '/register': [ + ['middleware::util:Setting', { key: 'auth.allow_registration' }], 'middleware::auth:ProviderRoute', 'middleware::auth:GuestOnly', 'middleware::auth:ProviderRegistrationEnabled', 'controller::auth:Forms.registration_provider_create_user', 'controller::auth:Forms.registration_provider_present_user_created', - ], + ],*/ '/:provider/login': [ 'middleware::auth:ProviderRoute', diff --git a/app/views/auth/login.pug b/app/views/auth/login.pug index 784050a..e682977 100644 --- a/app/views/auth/login.pug +++ b/app/views/auth/login.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-login-form(v-bind:app_name="app_name" v-bind:login_message="login_message" v-bind:registration_enabled="registration_enabled") diff --git a/app/views/auth/register.pug b/app/views/auth/register.pug index adaded1..3b0f1a4 100644 --- a/app/views/auth/register.pug +++ b/app/views/auth/register.pug @@ -1,16 +1,7 @@ -extends ./form +extends ../theme/public/base -block form - .form-label-group - input#inputUsername.form-control(type='text' name='username' value=(form_data ? form_data.username : '') required placeholder='Username' autofocus) - label(for='inputUsername') Username - .form-label-group - input#inputPassword.form-control(type='password' name='password' required placeholder='Password') - label(for='inputPassword') Password - button.btn.btn-lg.btn-primary.btn-block.btn-login.text-uppercase.font-weight-bold.mb-2.form-submit-button(type='submit') Register - .text-center - span.small Already registered?  - a(href='./login') Log-in here. - .text-center - span.small(style="color: #999999;") Provider: #{provider_name} - +block append style + link(rel='stylesheet' href='/style-asset/form.css') + +block vue + coreid-registration-form(v-bind:app_name="app_name") diff --git a/config/traps.config.js b/config/traps.config.js new file mode 100644 index 0000000..d7b64d5 --- /dev/null +++ b/config/traps.config.js @@ -0,0 +1,15 @@ +const traps_config = { + types: { + password_reset: { + redirect_to: '/password/reset', + assume_trust: true, + allowed_routes: [ + '/password/reset', + '/api/v1/password/resets', + '/auth/logout', + ], + }, + }, +} + +module.exports = exports = traps_config