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