diff --git a/app/assets/app/service/Profile.service.js b/app/assets/app/service/Profile.service.js index 5c41590..8ef3065 100644 --- a/app/assets/app/service/Profile.service.js +++ b/app/assets/app/service/Profile.service.js @@ -1,3 +1,5 @@ +import {message_service} from './Message.service.js' + class ProfileService { async get_profile(user_id = 'me') { @@ -11,7 +13,10 @@ class ProfileService { } async update_profile({ user_id, first_name, last_name, email, login_shell = undefined, tagline = undefined }) { - await axios.patch(`/api/v1/profile/${user_id}`, { first_name, last_name, email, tagline, login_shell }) + const results = await axios.patch(`/api/v1/profile/${user_id}`, { first_name, last_name, email, tagline, login_shell }) + if ( results && results.data && results.data.data && results.data.data.force_message_refresh ) { + await message_service._listener_tick() + } } async update_notify({ user_id = 'me', app_key, gateway_url }) { diff --git a/app/controllers/api/v1/Profile.controller.js b/app/controllers/api/v1/Profile.controller.js index 742ce26..56ee3c9 100644 --- a/app/controllers/api/v1/Profile.controller.js +++ b/app/controllers/api/v1/Profile.controller.js @@ -124,6 +124,8 @@ class ProfileController extends Controller { async update(req, res, next) { const User = this.models.get('auth:User') + const Message = this.models.get('Message') + const Setting = this.models.get('Setting') let user if ( req.params.user_id === 'me' ) user = req.user @@ -155,6 +157,11 @@ class ProfileController extends Controller { .api() // Update the user's profile + if ( user.email !== req.body.email && (await Setting.get('auth.require_email_verify')) ) { + await req.trap.begin('verify_email', { session_only: false }) + await Message.create(req.user, 'Your e-mail address has changed, and a verification e-mail has been sent. You must complete this process to continue.') + } + user.first_name = req.body.first_name user.last_name = req.body.last_name user.email = req.body.email @@ -163,7 +170,9 @@ class ProfileController extends Controller { // Save the record await user.save() - return res.api() + return res.api({ + force_message_refresh: true, + }) } async update_photo(req, res, next) { diff --git a/app/controllers/auth/Forms.controller.js b/app/controllers/auth/Forms.controller.js index efab877..41ef42d 100644 --- a/app/controllers/auth/Forms.controller.js +++ b/app/controllers/auth/Forms.controller.js @@ -7,7 +7,7 @@ const FormController = require('flitter-auth/controllers/Forms') */ class Forms extends FormController { static get services() { - return [...super.services, 'Vue', 'models'] + return [...super.services, 'Vue', 'models', 'jobs'] } async registration_provider_get(req, res, next) { @@ -20,6 +20,50 @@ class Forms extends FormController { }) } + async email_verify_keyaction(req, res, next) { + if ( !req.trap.has_trap('verify_email') ) return res.redirect(req.session.email_verify_flow || '/dash/profile') + req.user.email_verified = true + await req.user.save() + await req.trap.end() + const url = req.session.email_verify_flow || '/dash/profile' + return res.redirect(url) + } + + async show_verify_email(req, res, next) { + if ( !req.trap.has_trap('verify_email') ) return res.redirect(req.session.email_verify_flow || '/dash/profile') + const verify_queue = this.jobs.queue('verifications') + await verify_queue.add('SendVerificationEmail', { user_id: req.user.id }) + + return res.page('public:message', { + ...this.Vue.data({ + message: req.T('auth.must_verify_email'), + actions: [ + { + text: 'Send Verification E-Mail', + action: 'redirect', + next: '/auth/verify-email/sent', + }, + ], + }) + }) + } + + async send_verify_email(req, res, next) { + if ( !req.trap.has_trap('verify_email') ) return res.redirect(req.session.email_verify_flow || '/dash/profile') + return res.page('public:message', { + ...this.Vue.data({ + message: req.T('auth.verify_email_sent'), + actions: [ + { + text: 'Re-send Verification E-Mail', + action: 'redirect', + next: '/auth/verify-email/sent', + }, + ], + }) + }) + } + async finish_registration(req, res, next) { if ( req.trap.has_trap() && req.trap.get_trap() === 'registrant_flow' ) await req.trap.end() const dest = req.session.registrant_flow || '/dash/profile' diff --git a/app/jobs/SendVerificationEmail.job.js b/app/jobs/SendVerificationEmail.job.js new file mode 100644 index 0000000..92ff254 --- /dev/null +++ b/app/jobs/SendVerificationEmail.job.js @@ -0,0 +1,62 @@ +const { Job } = require('flitter-jobs') + +class SendVerificationEmailJob extends Job { + static get services() { + return [...super.services, 'models', 'jobs', 'output', 'configs'] + } + + async execute(job) { + const {data} = job + const {user_id} = data + + try { + const User = this.models.get('auth:User') + const user = await User.findById(user_id) + if (!user) { + this.error(`Unable to find user with ID: ${user_id}`) + throw new Error('Unable to find user with that ID.') + } + + this.info(`Sending verification email for user: ${user.uid}`) + + // Create an authenticated key-action + const key_action = await this.key_action(user) + + this.info(`Created verification keyaction ${key_action.id} (key: ${key_action.key}, handler: ${key_action.handler})`) + + await this.jobs.queue('mailer').add('EMail', { + to: user.email, + subject: 'Confirm Your E-mail | ' + this.configs.get('app.name'), + email_params: { + header_text: 'Confirm Your E-mail', + body_paragraphs: [ + 'The e-mail address for your ' + this.configs.get('app.name') + ' was set or changed. Click the link below to verify this change.', + 'If you didn\'t request this e-mail, please contact your system administrator.', + ], + button_text: 'Confirm E-mail', + button_link: key_action.url(), + } + }) + + this.info('Logged e-mail job.') + } catch (e) { + this.error(e) + throw e + } + } + + async key_action(user) { + const KeyAction = this.models.get('auth:KeyAction') + const ka_data = { + handler: 'controller::auth:Forms.email_verify_keyaction', + used: false, + user_id: user._id, + auto_login: true, + no_auto_logout: false, + } + + return (new KeyAction(ka_data)).save() + } +} + +module.exports = exports = SendVerificationEmailJob diff --git a/app/models/auth/User.model.js b/app/models/auth/User.model.js index cf55acd..7279c61 100644 --- a/app/models/auth/User.model.js +++ b/app/models/auth/User.model.js @@ -26,6 +26,7 @@ class User extends AuthUser { last_name: String, tagline: String, email: String, + email_verified: {type: Boolean, default: false}, ldap_visible: {type: Boolean, default: true}, active: {type: Boolean, default: true}, mfa_token: MFAToken, diff --git a/app/routing/middleware/Traps.middleware.js b/app/routing/middleware/Traps.middleware.js index 5c242b6..0075075 100644 --- a/app/routing/middleware/Traps.middleware.js +++ b/app/routing/middleware/Traps.middleware.js @@ -58,7 +58,29 @@ class TrapUtility { allows(route) { const config = this.config() - return route.startsWith('/assets') || config.allowed_routes.includes(route.toLowerCase().trim()) + const allowed = route.startsWith('/assets') || config.allowed_routes.includes(route.toLowerCase().trim()) + if ( allowed ) return true + + for ( const allowed_route of config.allowed_routes ) { + console.log('comparing', allowed_route, 'to', route) + const allowed_parts = allowed_route.split('/') + const parts = route.split('/') + + let matches = true + for ( let i = 0; i < allowed_parts.length; i += 1 ) { + if ( allowed_parts[i] !== parts[i] && allowed_parts[i] !== '*' ) { + matches = false + } + } + + if ( matches ) { + console.log('allows true') + return true + } + } + + console.log('allows false') + return false } } @@ -68,8 +90,19 @@ class TrapsMiddleware extends Middleware { } async test(req, res, next, args = {}) { + const Setting = this.models.get('Setting') req.trap = new TrapUtility(req, res, this.configs.get('traps.types')) + if ( + !req.trap.has_trap() + && req.user + && !req.user.email_verified + && (await Setting.get('auth.require_email_verify')) + ) { + req.session.email_verify_flow = req.originalUrl + await req.trap.begin('verify_email', { session_only: false }) + } + if ( !req.trap.has_trap() ) return next() else if ( req.trap.allows(req.path) ) return next() else return req.trap.redirect() diff --git a/app/routing/routers/auth/forms.routes.js b/app/routing/routers/auth/forms.routes.js index fe04125..1441667 100644 --- a/app/routing/routers/auth/forms.routes.js +++ b/app/routing/routers/auth/forms.routes.js @@ -72,6 +72,16 @@ const index = { 'controller::auth:Forms.finish_registration', ], + '/verify-email': [ + 'middleware::auth:UserOnly', + 'controller::auth:Forms.show_verify_email', + ], + + '/verify-email/sent': [ + 'middleware::auth:UserOnly', + 'controller::auth:Forms.send_verify_email', + ], + '/login-message': [ 'middleware::auth:UserOnly', 'controller::api:v1:System.show_login_message', diff --git a/config/jobs.config.js b/config/jobs.config.js index f222f3c..db8654a 100644 --- a/config/jobs.config.js +++ b/config/jobs.config.js @@ -15,6 +15,7 @@ const jobs_config = { 'mailer', 'password_resets', 'notifications', + 'verifications', ], // Mapping of worker name => worker config @@ -23,7 +24,7 @@ const jobs_config = { // The name of the worker is "main" main: { // This worker will process these queues - queues: ['mailer', 'password_resets', 'notifications'], + queues: ['mailer', 'password_resets', 'notifications', 'verifications'], }, // You can have many workers, and multiple workers can diff --git a/config/setting.config.js b/config/setting.config.js index 9457e21..e616052 100644 --- a/config/setting.config.js +++ b/config/setting.config.js @@ -1,6 +1,7 @@ const setting_config = { settings: { 'auth.allow_registration': true, + 'auth.require_email_verify': false, 'auth.default_roles': [ 'base_user' ], 'home.allow_landing': true, 'home.redirect_authenticated': true, diff --git a/config/traps.config.js b/config/traps.config.js index 7129bea..50a80c9 100644 --- a/config/traps.config.js +++ b/config/traps.config.js @@ -41,6 +41,22 @@ const traps_config = { '/api/v1/vault/get-trust-payload', ], }, + verify_email: { + redirect_to: '/auth/verify-email', + allowed_routes: [ + '/auth/verify-email', + '/auth/verify-email/sent', + '/auth/logout', + '/auth/login', + '/api/v1/locale/batch', + '/api/v1/auth/validate/username', + '/api/v1/auth/attempt', + '/api/v1/vault/get-trust-payload', + '/auth/action/*', + '/api/v1/message/banners', + '/api/v1/message/banners/read/*', + ], + }, }, } diff --git a/locale/en_US/auth.locale.js b/locale/en_US/auth.locale.js index ee4a3a8..6482161 100644 --- a/locale/en_US/auth.locale.js +++ b/locale/en_US/auth.locale.js @@ -26,4 +26,6 @@ module.exports = exports = { oauth_prompt: 'CLIENT_NAME is requesting access to your APP_NAME account. Once you grant it, you may not be prompted for permission again.', will_redirect: 'You will be redirected to:', reauth_to_continue: 'Please re-authenticate to continue.', + must_verify_email: 'You must verify your e-mail address to continue. Click below to send the verification e-mail.', + verify_email_sent: 'Check your e-mail for the link to verify your account.', }