From 8e2bb90797e5a6d174328ca29795af8909498e38 Mon Sep 17 00:00:00 2001 From: Nick Downing Date: Sun, 16 Jan 2022 11:04:47 +1100 Subject: [PATCH] Add password reset and email verification, change nomenclature so that "verification email" becomes "email verification link" similar to "password reset link" --- _lib/navbar.jst | 33 ++- api/password_reset.json.jst | 99 +++++++++ ... => send_email_verification_link.json.jst} | 2 +- api/sign_up/verify_email.json.jst | 6 +- api/verify_password.json.jst | 78 +++++++ my_account/password_reset/index.html.jst | 195 +++++++++++++++++ .../send_verification_email/index.html.jst | 12 +- my_account/sign_up/index.html.jst | 10 +- my_account/verify_password/index.html.jst | 202 ++++++++++++++++++ 9 files changed, 616 insertions(+), 21 deletions(-) create mode 100644 api/password_reset.json.jst rename api/sign_up/{send_verification_email.json.jst => send_email_verification_link.json.jst} (98%) create mode 100644 api/verify_password.json.jst create mode 100644 my_account/password_reset/index.html.jst create mode 100644 my_account/verify_password/index.html.jst diff --git a/_lib/navbar.jst b/_lib/navbar.jst index ea1f056..c4bef68 100644 --- a/_lib/navbar.jst +++ b/_lib/navbar.jst @@ -360,15 +360,24 @@ return async (env, head, body, scripts) => { ) } catch (error) { - if ( - error instanceof Problem && - error.title === 'Email not yet verified' - ) { + let problem = + error instanceof Problem ? + error : + new Problem( + // title + 'Bad request', + // details + (error.stack || error.message).toString() + // status + 400 + ) + + if (problem.title === 'Email not yet verified') { location.href = `/my_account/send_verification_email?email=${encodeURIComponent(email)}` return } - document.getElementById('message-modal-message').textContent = error.message + document.getElementById('message-modal-message').textContent = problem.detail $('#sign-in-modal').modal('hide') $('#message-modal').modal('show') return @@ -393,7 +402,19 @@ return async (env, head, body, scripts) => { await sign_out() } catch (error) { - document.getElementById('message-modal-message').textContent = error.message + let problem = + error instanceof Problem ? + error : + new Problem( + // title + 'Bad request', + // details + (error.stack || error.message).toString() + // status + 400 + ) + + document.getElementById('message-modal-message').textContent = problem.detail $('#sign-in-modal').modal('hide') $('#message-modal').modal('show') return diff --git a/api/password_reset.json.jst b/api/password_reset.json.jst new file mode 100644 index 0000000..6f778fc --- /dev/null +++ b/api/password_reset.json.jst @@ -0,0 +1,99 @@ +let crypto = require('crypto') +let logjson = (await import('@ndcode/logjson')).default +let XDate = require('xdate') + +return async env => { + let globals = await env.site.get_json('/_config/globals.json') + let nodemailer_noreply = await env.site.get_nodemailer( + '/_config/nodemailer_noreply.json' + ) + let post_request = await _require('/_lib/post_request.jst') + let session_cookie = await _require('/_lib/session_cookie.jst') + let Problem = await _require('/_lib/Problem.jst') + + post_request( + // env + env, + // endpoint + '/api/password_reset.json', + // handler + async (email, password) => { + // coerce and/or validate + email = email.slice(0, 256).toLowerCase() + password = password.slice(0, 256) + if (email.length === 0 || password.length < 8) + throw new Problem( + 'Bad request', + 'Minimum length check failed', + 400 + ) + + let transaction = await env.site.database.Transaction() + try { + // initialize env.session_key, set cookie in env.response + await session_cookie(env, transaction) + + let account = await ( + await ( + await transaction.get({}) + ).get('accounts', {}) + ).get(email) + if (account === undefined) + throw new Problem( + 'Account does not exist', + `Please create the account for "${email}" before attempting to reset its password.` + 421 + ) + + let link_code = crypto.randomBytes(16).toString('hex') + let expires = new XDate() + expires.addDays(1) + account.set( + 'verify_password', + transaction.json_to_logjson( + { + password, + link_code, + expires: expires.getTime() + } + ) + ) + + let given_names = await logjson.logjson_to_json( + await account.get('given_names', '') + ) + let family_name = await logjson.logjson_to_json( + await account.get('family_name', '') + ) + let name = + family_name.length ? `${given_names} ${family_name}` : given_names + + await nodemailer_noreply.sendMail( + { + from: globals.noreply_from, + to: `${name} <${email}>`, + subject: 'Password reset', + text: `Dear ${given_names}, + +We have received a request to reset the account password for your email address. + +If this request is valid, please verify the new password by visiting the below link: +${globals.site_url}/my_account/verify_password/index.html?email=${encodeURIComponent(email)}&link_code=${encodeURIComponent(link_code)} + +The link is valid for 24 hours. + +Thanks, +${globals.noreply_signature} +` + } + ) + + await transaction.commit() + } + catch (error) { + transaction.rollback() + throw error + } + } + ) +} diff --git a/api/sign_up/send_verification_email.json.jst b/api/sign_up/send_email_verification_link.json.jst similarity index 98% rename from api/sign_up/send_verification_email.json.jst rename to api/sign_up/send_email_verification_link.json.jst index 3ab682c..92a77f6 100644 --- a/api/sign_up/send_verification_email.json.jst +++ b/api/sign_up/send_email_verification_link.json.jst @@ -40,7 +40,7 @@ return async env => { if (account === undefined) throw new Problem( 'Account does not exist', - `Please create the account for "${email}" before attempting to send a verification email.` + `Please create the account for "${email}" before attempting to send a email verification link.` 421 ) diff --git a/api/sign_up/verify_email.json.jst b/api/sign_up/verify_email.json.jst index dc70f77..d7f915c 100644 --- a/api/sign_up/verify_email.json.jst +++ b/api/sign_up/verify_email.json.jst @@ -37,7 +37,7 @@ return async env => { if (account === undefined) throw new Problem( 'Account does not exist', - `Please create the account for "${email}" before attempting to verify the email link.` + `Please create the account for "${email}" before attempting to verify the email verification link.` 421 ) @@ -61,7 +61,7 @@ return async env => { ) throw new Problem( 'Link code missing', - `Link code for email "${email}" does not exist or has expired.`, + `Email verification link code for account "${email}" does not exist or has expired.`, 423 ) if ( @@ -71,7 +71,7 @@ return async env => { ) throw new Problem( 'Link code mismatch', - `Provided link code "${link_code}" does not match expected value.`, + `Provided email verification link code "${link_code}" does not match expected value.`, 423 ) diff --git a/api/verify_password.json.jst b/api/verify_password.json.jst new file mode 100644 index 0000000..3e58569 --- /dev/null +++ b/api/verify_password.json.jst @@ -0,0 +1,78 @@ +let crypto = require('crypto') +let logjson = (await import('@ndcode/logjson')).default +let XDate = require('xdate') + +return async env => { + let post_request = await _require('/_lib/post_request.jst') + let session_cookie = await _require('/_lib/session_cookie.jst') + let Problem = await _require('/_lib/Problem.jst') + + post_request( + // env + env, + // endpoint + '/api/verify_password.json', + // handler + async (email, link_code) => { + // coerce and/or validate + email = email.slice(0, 256).toLowerCase() + link_code = link_code.slice(0, 256).toLowerCase() + if (email.length === 0 || link_code.length < 32) + throw new Problem( + 'Bad request', + 'Minimum length check failed', + 400 + ) + + let transaction = await env.site.database.Transaction() + try { + // initialize env.session_key, set cookie in env.response + await session_cookie(env, transaction) + + let account = await ( + await ( + await transaction.get({}) + ).get('accounts', {}) + ).get(email) + if (account === undefined) + throw new Problem( + 'Account does not exist', + `Please create the account for "${email}" before attempting to verify the password reset link.` + 421 + ) + + let verify_password = await account.get('verify_password') + if ( + verify_password === undefined || + XDate.now() >= await logjson.logjson_to_json( + await verify_password.get('expires') + ) + ) + throw new Problem( + 'Link code missing', + `Password reset link code for account "${email}" does not exist or has expired.`, + 423 + ) + if ( + link_code !== await logjson.logjson_to_json( + await verify_password.get('link_code') + ) + ) + throw new Problem( + 'Link code mismatch', + `Provided password reset link code "${link_code}" does not match expected value.`, + 423 + ) + + await account.delete('verify_password') + await account.set('password', await verify_password.get('password')) + + await transaction.commit() + } + catch (error) { + transaction.rollback() + throw error + } + } + ) +} diff --git a/my_account/password_reset/index.html.jst b/my_account/password_reset/index.html.jst new file mode 100644 index 0000000..0295275 --- /dev/null +++ b/my_account/password_reset/index.html.jst @@ -0,0 +1,195 @@ +let logjson = (await import('@ndcode/logjson')).default + +return async env => { + let breadcrumbs = await _require('/_lib/breadcrumbs.jst') + let icon_cross = await env.site.get_min_svg('/_svg/icon_cross.svg') + let icon_tick = await env.site.get_min_svg('/_svg/icon_tick.svg') + let navbar = await _require('/_lib/navbar.jst') + let session_cookie = await _require('/_lib/session_cookie.jst') + + // preload draft details if any + let details = {} + if (Object.prototype.hasOwnProperty.call(env.parsed_url.query, 'email')) + details.email = decodeURIComponent(env.parsed_url.query.email) + console.log('details', JSON.stringify(details)) + + await navbar( + env, + // head + async _out => {}, + // body + async _out => { + await breadcrumbs(env, _out) + + p {'To reset your password, please enter new details below and we will send you a password reset link.'} + + div.accordion#accordion.mb-5(role="tablist" aria-multiselectable="true") { + div.card#step-1 { + div.card-header#step-1-heading(role="tab") { + span#step-1-tick(style="display: none;") { + span.icon-color.pr-3 {_out.push(icon_tick)} + } + span#step-1-cross(style="display: none;") { + span.icon-color.pr-3 {_out.push(icon_cross)} + } + //span#step-1-spinner(style="display: none;") { + // span.icon-color.pr-3 { + // div.spinner-border(role="status") { + // span.sr-only {'Loading...'} + // } + // } + //} + a.h5(data-toggle="collapse" data-parent="#accordion" href="#step-1-collapse" aria-expanded="true" aria-controls="step-1-collapse") { + 'Account details' + } + } + div#step-1-collapse.collapse.show(role="tabpanel" aria-labelledby="step-1-heading" data-parent="#accordion") { + div.card-body { + div.row { + div.col-md-6 { + div.form-group { + label.form-label(for="email") {'Email *'} + input.form-control#email(type="email" value=details.email || '' placeholder="Account email address" required="required" maxlength=256) {} + } + } + div.col-md-6 { + div.form-group { + label.form-label(for="password") {'Password *'} + input.form-control#password(type="password" placeholder="New password" required="required" minlength=8 maxlength=256) {} + } + } + } + + input.btn.btn-success#step-1-continue(type="button" value="Continue") {} + p.'mt-3'.mb-0 {'* This field is required.'} + } + } + } + div.card#step-2 { + div.card-header#step-2-heading(role="tab") { + span#step-2-tick(style="display: none;") { + span.icon-color.pr-3 {_out.push(icon_tick)} + } + span#step-2-cross(style="display: none;") { + span.icon-color.pr-3 {_out.push(icon_cross)} + } + span#step-2-spinner(style="display: none;") { + span.icon-color.pr-3 { + div.spinner-border(role="status") { + span.sr-only {'Loading...'} + } + } + } + a.h5.collapsed(data-toggle="collapse" data-parent="#accordion" href="#step-2-collapse" aria-expanded="false" aria-controls="step-2-collapse") { + 'Send password reset link' + } + } + div#step-2-collapse.collapse(role="tabpanel" aria-labelledby="step-2-heading" data-parent="#accordion") { + div.card-body { + p#step-2-message {'Please enter account details first.'} + + input.btn.btn-outline-secondary#step-2-back(type="button" value="Back") {} + input.btn.btn-outline-secondary.ml-3#step-2-resend-email(type="button" value="Re-send email") {} + } + } + } + } + }, + // scripts + async _out => { + script(src="/js/api_call.js") {} + + script { + let password_reset = async (...arguments) => api_call( + '/api/password_reset.json', + ...arguments + ) + + let step_1 = async () => { + if ( + !document.getElementById('email').reportValidity() || + !document.getElementById('password').reportValidity() + ) { + $('#step-1-tick').hide() + $('#step-1-cross').show() + //$('#step-1-spinner').hide() + return false + } + $('#step-1-tick').show() + $('#step-1-cross').hide() + //$('#step-1-spinner').hide() + return true + } + + let step_2 = async () => { + $('#step-2-tick').hide() + $('#step-2-cross').hide() + $('#step-2-spinner').show() + document.getElementById('step-1').scrollIntoView() + + let email + try { + email = document.getElementById('email').value.slice(0, 256).toLowerCase() + await password_reset( + email, + document.getElementById('password').value.slice(0, 256) + ) + } + catch (error) { + let problem = + error instanceof Problem ? + error : + new Problem( + // title + 'Bad request', + // details + (error.stack || error.message).toString() + // status + 400 + ) + + $('#step-2-tick').hide() + $('#step-2-cross').show() + $('#step-2-spinner').hide() + + document.getElementById('step-2-message').textContent = problem.detail + $('#step-2-collapse').collapse('show') + return false + } + $('#step-2-tick').show() + $('#step-2-cross').hide() + $('#step-2-spinner').hide() + + document.getElementById('step-2-message').textContent = `Password reset link has been sent to "${email}". Please check your email for next steps.` + return true + } + + document.addEventListener( + 'DOMContentLoaded', + () => { + document.getElementById('step-1-continue').addEventListener( + 'click', + async () => { + if (await step_1() && await step_2()) + $('#step-2-collapse').collapse('show') + } + ) + + document.getElementById('step-2-back').addEventListener( + 'click', + () => {$('#step-1-collapse').collapse('show')} + ) + + document.getElementById('step-2-resend-email').addEventListener( + 'click', + async () => { + if (await step_2()) + $('#step-3-collapse').collapse('show') + } + ) + } + ) + } + } + ) +} diff --git a/my_account/send_verification_email/index.html.jst b/my_account/send_verification_email/index.html.jst index 5d7c960..9b5d588 100644 --- a/my_account/send_verification_email/index.html.jst +++ b/my_account/send_verification_email/index.html.jst @@ -21,7 +21,7 @@ return async env => { async _out => { await breadcrumbs(env, _out) - p {'Please verify your email address before signing in. Check your email for next steps, or re-send the verification email below.'} + p {'Please verify your email address before signing in. Check your email for next steps, or re-send the email verification link below.'} div.accordion#accordion.mb-5(role="tablist" aria-multiselectable="true") { div.card#step-1 { @@ -75,7 +75,7 @@ return async env => { } } a.h5.collapsed(data-toggle="collapse" data-parent="#accordion" href="#step-2-collapse" aria-expanded="false" aria-controls="step-2-collapse") { - 'Send verification email' + 'Send email verification link' } } div#step-2-collapse.collapse(role="tabpanel" aria-labelledby="step-2-heading" data-parent="#accordion") { @@ -94,8 +94,8 @@ return async env => { script(src="/js/api_call.js") {} script { - let sign_up_send_verification_email = async (...arguments) => api_call( - '/api/sign_up/send_verification_email.json', + let sign_up_send_email_verification_link = async (...arguments) => api_call( + '/api/sign_up/send_email_verification_link.json', ...arguments ) @@ -121,7 +121,7 @@ return async env => { let email try { email = document.getElementById('email').value.slice(0, 256).toLowerCase() - await sign_up_send_verification_email(email) + await sign_up_send_email_verification_link(email) } catch (error) { let problem = @@ -148,7 +148,7 @@ return async env => { $('#step-2-cross').hide() $('#step-2-spinner').hide() - document.getElementById('step-2-message').textContent = `Verification email has been sent to "${email}". Please check your email for next steps.` + document.getElementById('step-2-message').textContent = `Email verification link has been sent to "${email}". Please check your email for next steps.` return true } diff --git a/my_account/sign_up/index.html.jst b/my_account/sign_up/index.html.jst index 4038b2e..179a407 100644 --- a/my_account/sign_up/index.html.jst +++ b/my_account/sign_up/index.html.jst @@ -162,7 +162,7 @@ return async env => { } } a.h5.collapsed(data-toggle="collapse" data-parent="#accordion" href="#step-3-collapse" aria-expanded="false" aria-controls="step-3-collapse") { - 'Send verification email' + 'Send email verification link' } } div#step-3-collapse.collapse(role="tabpanel" aria-labelledby="step-3-heading" data-parent="#accordion") { @@ -193,8 +193,8 @@ return async env => { '/api/sign_up/set_draft.json', ...arguments ) - let sign_up_send_verification_email = async (...arguments) => api_call( - '/api/sign_up/send_verification_email.json', + let sign_up_send_email_verification_link = async (...arguments) => api_call( + '/api/sign_up/send_email_verification_link.json', ...arguments ) @@ -291,7 +291,7 @@ return async env => { document.getElementById('step-3').scrollIntoView() try { - await sign_up_send_verification_email(step_2_details.email) + await sign_up_send_email_verification_link(step_2_details.email) } catch (error) { let problem = @@ -318,7 +318,7 @@ return async env => { $('#step-3-cross').hide() $('#step-3-spinner').hide() - document.getElementById('step-3-message').textContent = `Verification email has been sent to "${step_2_details.email}". Please check your email for next steps.` + document.getElementById('step-3-message').textContent = `Email verification link has been sent to "${step_2_details.email}". Please check your email for next steps.` return true } diff --git a/my_account/verify_password/index.html.jst b/my_account/verify_password/index.html.jst new file mode 100644 index 0000000..67d441c --- /dev/null +++ b/my_account/verify_password/index.html.jst @@ -0,0 +1,202 @@ +let logjson = (await import('@ndcode/logjson')).default + +return async env => { + let breadcrumbs = await _require('/_lib/breadcrumbs.jst') + let icon_cross = await env.site.get_min_svg('/_svg/icon_cross.svg') + let icon_tick = await env.site.get_min_svg('/_svg/icon_tick.svg') + let navbar = await _require('/_lib/navbar.jst') + let session_cookie = await _require('/_lib/session_cookie.jst') + + // preload draft details if any + let details = {} + if (Object.prototype.hasOwnProperty.call(env.parsed_url.query, 'email')) + details.email = decodeURIComponent(env.parsed_url.query.email) + if ( + Object.prototype.hasOwnProperty.call( + env.parsed_url.query, + 'link_code' + ) + ) + details.link_code = + decodeURIComponent(env.parsed_url.query.link_code) + console.log('details', JSON.stringify(details)) + + await navbar( + env, + // head + async _out => {}, + // body + async _out => { + await breadcrumbs(env, _out) + + p {'You will need to verify your new password via an emailed link before you can use it to sign in to your account.'} + + div.accordion#accordion.mb-5(role="tablist" aria-multiselectable="true") { + div.card#step-1 { + div.card-header#step-1-heading(role="tab") { + span#step-1-tick(style="display: none;") { + span.icon-color.pr-3 {_out.push(icon_tick)} + } + span#step-1-cross(style="display: none;") { + span.icon-color.pr-3 {_out.push(icon_cross)} + } + //span#step-1-spinner(style="display: none;") { + // span.icon-color.pr-3 { + // div.spinner-border(role="status") { + // span.sr-only {'Loading...'} + // } + // } + //} + a.h5(data-toggle="collapse" data-parent="#accordion" href="#step-1-collapse" aria-expanded="true" aria-controls="step-1-collapse") { + 'Link details' + } + } + div#step-1-collapse.collapse.show(role="tabpanel" aria-labelledby="step-1-heading" data-parent="#accordion") { + div.card-body { + div.row { + div.col-md-6 { + div.form-group { + label.form-label(for="email") {'Email *'} + input.form-control#email(type="email" value=details.email || '' placeholder="Account email address" required="required" maxlength=256) {} + } + } + div.col-md-6 { + div.form-group { + label.form-label(for="link-code") {'Link code *'} + input.form-control#link-code(type="text" value=details.link_code || '' placeholder="Type the code from the email link" required="required" minlength=32 maxlength=32) {} + } + } + } + + input.btn.btn-success#step-1-continue(type="button" value="Continue") {} + p.'mt-3'.mb-0 {'* These fields are required.'} + } + } + } + div.card#step-2 { + div.card-header#step-2-heading(role="tab") { + span#step-2-tick(style="display: none;") { + span.icon-color.pr-3 {_out.push(icon_tick)} + } + span#step-2-cross(style="display: none;") { + span.icon-color.pr-3 {_out.push(icon_cross)} + } + span#step-2-spinner(style="display: none;") { + span.icon-color.pr-3 { + div.spinner-border(role="status") { + span.sr-only {'Loading...'} + } + } + } + a.h5.collapsed(data-toggle="collapse" data-parent="#accordion" href="#step-2-collapse" aria-expanded="false" aria-controls="step-2-collapse") { + 'Verify password' + } + } + div#step-2-collapse.collapse(role="tabpanel" aria-labelledby="step-2-heading" data-parent="#accordion") { + div.card-body { + p#step-2-message {'Please enter link details first.'} + + input.btn.btn-outline-secondary#step-2-back(type="button" value="Back") {} + input.btn.btn-outline-secondary.ml-2#step-2-sign-in(type="button" value="Sign in") {} + } + } + } + } + }, + // scripts + async _out => { + script(src="/js/api_call.js") {} + + script { + let verify_password = async (...arguments) => api_call( + '/api/verify_password.json', + ...arguments + ) + + let step_1 = async () => { + if ( + !document.getElementById('email').reportValidity() || + !document.getElementById('link-code').reportValidity() + ) { + $('#step-1-tick').hide() + $('#step-1-cross').show() + //$('#step-1-spinner').hide() + return false + } + $('#step-1-tick').show() + $('#step-1-cross').hide() + //$('#step-1-spinner').hide() + return true + } + + let step_2 = async () => { + $('#step-2-tick').hide() + $('#step-2-cross').hide() + $('#step-2-spinner').show() + document.getElementById('step-1').scrollIntoView() + + let email + try { + email = document.getElementById('email').value.slice(0, 256).toLowerCase() + await verify_password( + // email + email, + // link_code + document.getElementById('link-code').value.slice(0, 32).toLowerCase() + ) + } + catch (error) { + let problem = + error instanceof Problem ? + error : + new Problem( + // title + 'Bad request', + // details + (error.stack || error.message).toString() + // status + 400 + ) + + $('#step-2-tick').hide() + $('#step-2-cross').show() + $('#step-2-spinner').hide() + + document.getElementById('step-2-message').textContent = problem.detail + $('#step-2-collapse').collapse('show') + return false + } + $('#step-2-tick').show() + $('#step-2-cross').hide() + $('#step-2-spinner').hide() + + document.getElementById('step-2-message').textContent = `New password for "${email}" has been verified. You can now sign in.` + return true + } + + document.addEventListener( + 'DOMContentLoaded', + () => { + document.getElementById('step-1-continue').addEventListener( + 'click', + async () => { + if (await step_1() && await step_2()) + $('#step-2-collapse').collapse('show') + } + ) + + document.getElementById('step-2-back').addEventListener( + 'click', + () => {$('#step-1-collapse').collapse('show')} + ) + + document.getElementById('step-2-sign-in').addEventListener( + 'click', + () => {document.getElementById('sign-in').click()} + ) + } + ) + } + } + ) +} -- 2.34.1