Email verification

main
Ashelyn Dawn 4 years ago
parent 59bca0bfc1
commit be0776b9ea

@ -1,12 +1,16 @@
# DB Config
DB_HOST=
DB_USER=
DB_NAME=
DB_PASS=
# Application Config
PW_SALTROUNDS=10
COOKIE_SECRET=
EASYPOST_API_KEY=
EXTERNAL_URL=http://localhost:3000
# Api Keys
EASYPOST_API_KEY=
STRIPE_PUBLIC_KEY=
STRIPE_PRIVATE_KEY=
EXTERNAL_URL=http://localhost:3000
SENDGRID_KEY=

@ -0,0 +1,56 @@
const router = module.exports = require('express-promise-router')()
const ensureUser = require('./middleware/ensureUser')
const db = require('../db')
const sendgrid = require('@sendgrid/mail')
sendgrid.setApiKey(process.env.SENDGRID_KEY)
router.get('/links', ensureUser, async (req, res) => {
const links = await db.user.getOpenEmailLinks(req.user.uuid)
res.json(links.map(stripLink))
})
router.post('/', ensureUser, async (req, res) => {
if(req.user.time_email_confirmed)
return res.status(400).json({errors: [{
param: 'email',
msg: 'Email address already verified'
}]})
const confirmUrl = await db.user.createLoginLink(req.user.uuid)
const msg = {
to: req.user.email,
from: {email: 'registration@email.societyofsocks.us', name: 'Society of Socks'},
templateId: 'd-33407f1dd1b14b7b84dd779511039c95',
dynamic_template_data: {
confirmUrl: confirmUrl
}
};
await sendgrid.send(msg);
res.json({sent: true})
})
router.get('/confirm/:uuid', ensureUser, async (req, res) => {
if(!req.query || !req.query.key)
return res.redirect('/account/email/invalid')
const validLink = await db.user.verifyLoginLink(req.params.uuid, req.query.key)
if(!validLink)
return res.redirect('/account/email/invalid')
await db.user.markLoginLinkUsed(validLink.uuid)
await db.user.markEmailVerified(validLink.user_uuid)
return res.redirect('/account')
})
function stripLink(link) {
return {
...link,
login_hash: undefined
}
}

@ -24,6 +24,7 @@ router.use('/images/', require('./images'))
router.use('/categories/', require('./categories'))
router.use('/orders/', require('./orders'))
router.use('/shipments/', require('./shipments'))
router.use('/email/', require('./email'))
router.use((req, res, next)=>{
const err = new Error('Not found')

@ -0,0 +1,9 @@
module.exports = async (req, res) => {
if(!req.user) {
const err = new Error('Unauthorized')
err.status = 401
throw err;
}
return 'next'
}

@ -2,6 +2,9 @@ const router = require('express-promise-router')()
const parseJSON = require('body-parser').json()
const db = require('../db')
const sendgrid = require('@sendgrid/mail')
sendgrid.setApiKey(process.env.SENDGRID_KEY)
const validate = require('./middleware/validators')
const registerValidation = [
@ -32,6 +35,20 @@ router.post('/', parseJSON, registerValidation, async (req, res) => {
await db.session.create(req, user)
// Send login email TODO: Abstract this so api/email and this route use the same function
const confirmUrl = await db.user.createLoginLink(user.uuid)
const msg = {
to: user.email,
from: {email: 'registration@email.societyofsocks.us', name: 'Society of Socks'},
templateId: 'd-33407f1dd1b14b7b84dd779511039c95',
dynamic_template_data: {
confirmUrl: confirmUrl
}
};
await sendgrid.send(msg);
res.json(user)
})

@ -22,16 +22,17 @@ module.exports = [
'is_admin'
],
collections: [
{name: 'login_links', mapId: 'loginLinkMap', columnPrefix: 'login_link_'},
{name: 'sessions', mapId: 'sessionMap', columnPrefix: 'session_'}
]
},{
mapId: 'loginLinkMap',
mapId: 'emailLinkMap',
idProperty: 'uuid',
properties: [
'time_created',
'timeout_length',
'login_hash'
'login_hash',
'time_used',
'user_uuid'
]
},{
mapId: 'sessionMap',
@ -45,7 +46,6 @@ module.exports = [
'referer'
],
associations: [
{name: 'originating_link', mapId: 'loginLinkMap', columnPrefix: 'login_link_'},
{name: 'user', mapId: 'userMap', columnPrefix: 'session_user_'},
{name: 'cart', mapId: 'cartMap', columnPrefix: 'cart_'}
]

@ -5,15 +5,14 @@ const mappings = require('../mappings')
const session = module.exports = {}
session.create = async (req, _user, login_link) => {
session.create = async (req, _user) => {
const user = _user ? _user : req.user
const session = await createSessionInternal(
user ? user.uuid : null,
req.ip,
req.get('User Agent') || "",
req.get('Referrer') || "",
login_link ? login_link.uuid : null
req.get('Referrer') || ""
)
req.session.uuid = session.uuid
@ -22,16 +21,15 @@ session.create = async (req, _user, login_link) => {
return session
}
const createSessionInternal = async (user_uuid, ip_address, user_agent, referer, origin_link_uuid) => {
const createSessionInternal = async (user_uuid, ip_address, user_agent, referer) => {
const query = {
text: 'select * from sos.login_user_session($1, $2, $3, $4, $5, $6)',
text: 'select * from sos.login_user_session($1, $2, $3, $4, $5)',
values: [
user_uuid,
'2 hours',
ip_address,
user_agent,
referer,
origin_link_uuid
referer
]
}

@ -2,7 +2,9 @@ const pg = require('../pg')
const joinjs = require('join-js').default;
const debug = require('debug')('sos:db:user')
const mappings = require('../mappings')
const dbUtil = require('../util')
const uuid = require('uuid')
const bcrypt = require('bcrypt')
const session = require('./session')
@ -70,3 +72,75 @@ user.login = async (email, password) => {
return _user
}
user.getOpenEmailLinks = (user_uuid) =>
dbUtil.executeFunction({
name: 'get_open_email_links_for_user',
params: [
user_uuid
],
returnType: 'emailLink',
tablePrefix: 'email_link_',
single: false
})
user.createLoginLink = async (user_uuid) => {
const linkCode = uuid.v4()
const hash = await bcrypt.hash(linkCode, saltRounds)
const link_record = await dbUtil.executeFunction({
name: 'create_login_link',
params: [
user_uuid,
'2 hours',
hash
],
returnType: 'emailLink',
tablePrefix: 'email_link_',
single: true
})
return `${process.env.EXTERNAL_URL}/api/email/confirm/${link_record.uuid}?key=${linkCode}`
}
user.verifyLoginLink = async (link_uuid, key) => {
const link_record = await dbUtil.executeQuery({
query: {
text: 'select * from sos.email_link where email_link_uuid = $1',
values: [link_uuid]
},
returnType: 'emailLink',
tablePrefix: 'email_link_',
single: true
})
if(!link_record){
// Avoid early exit timing difference
await bcrypt.hash(key, saltRounds)
return null
}
const valid = await bcrypt.compare(key, link_record.login_hash)
if(!valid) return null
return link_record
}
user.markLoginLinkUsed = link_uuid =>
dbUtil.executeFunction({
name: 'set_link_used',
params: [link_uuid],
returnType: 'emailLink',
tablePrefix: 'email_link_',
single: true
})
user.markEmailVerified = user_uuid =>
dbUtil.executeFunction({
name: 'set_user_email_verified',
params: [user_uuid],
returnType: 'user',
single: true
})

@ -14,12 +14,13 @@ create table sos."user" (
user_is_admin bool not null default false
);
create table sos."login_link" (
login_link_uuid uuid primary key default uuid_generate_v4(),
login_link_user_uuid uuid not null references sos."user" (user_uuid),
login_link_time_created timestamptz not null default now(),
login_link_timeout_length interval not null,
login_link_login_hash varchar(60) not null
create table sos."email_link" (
email_link_uuid uuid primary key default uuid_generate_v4(),
email_link_user_uuid uuid not null references sos."user" (user_uuid),
email_link_time_created timestamptz not null default now(),
email_link_timeout_length interval not null,
email_link_login_hash varchar(60) not null,
email_link_time_used timestamptz
);
create table sos."cart" (
@ -36,7 +37,6 @@ create table sos."session" (
session_user_agent varchar(500) not null,
session_referer varchar(500) not null,
session_user_uuid uuid references sos."user" (user_uuid),
session_originating_link uuid references sos."login_link" (login_link_uuid),
session_cart uuid references sos."cart" (cart_uuid)
);

@ -42,18 +42,11 @@ create or replace view sos.v_session as
"session_user".user_time_registered as session_user_time_registered,
"session_user".user_time_email_confirmed as session_user_time_email_confirmed,
"session_user".user_is_admin as session_user_is_admin,
"login_link".*,
v_cart.*
from sos."session"
left join sos."user" "session_user" on "session".session_user_uuid = "session_user".user_uuid
left join sos."login_link" on "session".session_originating_link = "login_link".login_link_uuid
left join sos.v_cart on v_cart.cart_uuid = "session".session_cart;
create or replace view sos.v_login_link as
select
*
from sos."login_link"
left join sos."user" on "login_link".login_link_user_uuid = "user".user_uuid;
create or replace view sos.v_category as
select

@ -41,7 +41,7 @@ begin
return query select * from sos.validate_session(_session_uuid);
end; $function$;
create or replace function sos.login_user_session(_user_uuid uuid, _timeout_length interval, _ip_addr varchar(50), _user_agent varchar(500), _referer varchar(500), _link uuid)
create or replace function sos.login_user_session(_user_uuid uuid, _timeout_length interval, _ip_addr varchar(50), _user_agent varchar(500), _referer varchar(500))
returns setof sos.v_session
language plpgsql
as $function$
@ -53,15 +53,13 @@ begin
session_timeout_length,
session_ip_address,
session_user_agent,
session_referer,
session_originating_link
session_referer
) values (
_user_uuid,
_timeout_length,
_ip_addr,
_user_agent,
_referer,
_link
_referer
) returning session_uuid into _session_uuid;
return query select * from sos.validate_session(_session_uuid);
@ -1064,3 +1062,62 @@ begin
return query select * from sos.v_order where order_uuid = _order_uuid;
end; $function$;
create or replace function sos.get_open_email_links_for_user(_user_uuid uuid)
returns setof sos."email_link"
language plpgsql
as $function$
begin
return query select * from sos."email_link"
where email_link_user_uuid = _user_uuid
and email_link_time_used is null
and email_link_time_created + email_link_timeout_length > now();
end; $function$;
create or replace function sos.create_login_link(_user_uuid uuid, _timeout_length interval, _link_hash text)
returns setof sos."email_link"
language plpgsql
as $function$
declare
_link_uuid uuid;
begin
insert into sos."email_link" (
email_link_user_uuid,
email_link_timeout_length,
email_link_login_hash
) values (
_user_uuid,
_timeout_length,
_link_hash
) returning email_link_uuid into _link_uuid;
return query select * from sos."email_link" where email_link_uuid = _link_uuid;
end; $function$;
create or replace function sos.set_link_used(_link_uuid uuid)
returns setof sos."email_link"
language plpgsql
as $function$
begin
update sos."email_link" set
email_link_time_used = now()
where email_link_uuid = _link_uuid;
return query select * from sos."email_link" where email_link_uuid = _link_uuid;
end; $function$;
create or replace function sos.set_user_email_verified(_user_uuid uuid)
returns setof sos."user"
language plpgsql
as $function$
begin
update sos."user" set (
user_email_confirmed,
user_time_email_confirmed
) = (
true,
now()
) where user_uuid = _user_uuid;
return query select * from sos."user" where user_uuid = _user_uuid;
end; $function$;

@ -7,19 +7,19 @@ const util = module.exports = {};
const validateFunctionName = name => /^[a-z_]+$/.test(name)
const getParamString = (length) => Array.from({length}, (_,i)=>i+1).map(i => '$' + i).join(', ')
util.executeQuery = async function({query, returnType, single = false}){
util.executeQuery = async function({query, returnType, tablePrefix, single = false}){
debug(query)
const {rows} = await pg.query(query)
const mappedObjs = joinjs.map(rows, mappings, returnType + 'Map', returnType + '_')
const mappedObjs = joinjs.map(rows, mappings, returnType + 'Map', tablePrefix || (returnType + '_'))
if(single)
return mappedObjs[0]
return mappedObjs
}
util.executeFunction = async function({name, params, returnType, single}) {
util.executeFunction = async function({name, params, returnType, single, tablePrefix}) {
if(!validateFunctionName(name)) throw new Error("Invalid function name: " + name);
const query = {
@ -27,5 +27,5 @@ util.executeFunction = async function({name, params, returnType, single}) {
values: params
}
return util.executeQuery({query, returnType, single})
return util.executeQuery({query, returnType, single, tablePrefix})
}

@ -23,7 +23,8 @@ class CustomResolver {
'hooks',
'components',
'images',
'pages'
'pages',
'utils'
]
// If it's not the right pattern, or it's outside our list of directories

45
package-lock.json generated

@ -1273,6 +1273,33 @@
"resolved": "https://registry.npmjs.org/@rmwc/types/-/types-6.0.5.tgz",
"integrity": "sha512-G4NZxakwsTMFxxpEWX90MOlZtVNzCwooLUg7FH7Oh5OJCnKWGmXK8ZMF59vvIEn7zCiEpVroKhOxNpMw9TIUTw=="
},
"@sendgrid/client": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@sendgrid/client/-/client-7.1.1.tgz",
"integrity": "sha512-V2BmOO81wHNmbTDwTJ07Olb9dWrj1G19xK4crwds68b9R0w05aOWDddZTvpn9mZnHwIJYqcZcBJuhdHDejuSHg==",
"requires": {
"@sendgrid/helpers": "^7.0.1",
"axios": "^0.19.2"
}
},
"@sendgrid/helpers": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@sendgrid/helpers/-/helpers-7.0.1.tgz",
"integrity": "sha512-i/zsissq1upgdywtuJKysaplJJZC24GdtEKiJC1IRlXvBHzIjH4eU+rqUFO8h+hGji3UMURGgMFuLUXTUYvZ9w==",
"requires": {
"chalk": "^2.0.1",
"deepmerge": "^4.2.2"
}
},
"@sendgrid/mail": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@sendgrid/mail/-/mail-7.1.1.tgz",
"integrity": "sha512-VXdJ9J6vBNMw+wMIGFRvms6EmV6pvoRHMWoLJGweHlsZDnvmK3rWUnnNaS3OdDQ3A8B5bMv2WKsEnHsMZ6iDUg==",
"requires": {
"@sendgrid/client": "^7.1.1",
"@sendgrid/helpers": "^7.0.1"
}
},
"@stripe/stripe-js": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-1.4.0.tgz",
@ -3121,6 +3148,11 @@
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="
},
"deepmerge": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
"integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg=="
},
"defaults": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz",
@ -8495,9 +8527,9 @@
"integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM="
},
"uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.1.0.tgz",
"integrity": "sha512-CI18flHDznR0lq54xBycOVmphdCYnQLKn8abKn7PXUiKUGdEd+/l9LWNJmugXel4hXq7S+RMNl34ecyC9TntWg=="
},
"validate-npm-package-license": {
"version": "3.0.4",
@ -9278,6 +9310,13 @@
"requires": {
"ansi-colors": "^3.0.0",
"uuid": "^3.3.2"
},
"dependencies": {
"uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="
}
}
},
"webpack-merge": {

@ -15,6 +15,7 @@
"@easypost/api": "^3.8.1",
"@rmwc/button": "^6.0.14",
"@rmwc/icon": "^6.0.12",
"@sendgrid/mail": "^7.1.1",
"@stripe/stripe-js": "^1.4.0",
"axios": "^0.19.2",
"babel-plugin-inline-react-svg": "^1.1.1",
@ -41,6 +42,7 @@
"sharp": "^0.24.1",
"stripe": "^8.44.0",
"use-measure": "^0.3.0",
"uuid": "^8.1.0",
"validator": "^12.2.0"
}
}

@ -44,7 +44,7 @@ Layout.getInitialProps = async ({Component, ctx}) => {
let pageProps = {};
if(Component.getInitialProps)
pageProps = await Component.getInitialProps({ctx})
pageProps = await Component.getInitialProps({ctx, user})
return {pageProps, user, cart}
}

@ -0,0 +1,96 @@
import {DateTime} from 'luxon'
import Link from 'next/link'
import {Button, FormController} from '~/components/form'
import useUser from '~/hooks/useUser'
import redirect from '~/utils/redirectGetInitialProps'
ConfirmEmail.getInitialProps = async ({ctx, user}) => {
const {axios} = ctx
const {data: links} = await axios.get('/api/email/links')
if(!user)
return redirect(ctx, 302, '/login')
if(user.email_confirmed)
return redirect(ctx, 302, '/account')
return {links}
}
export default function ConfirmEmail({links}) {
const user = useUser()
const lastLink = getLastLinkTime(links)
return (
<>
<h2>Confirm Email Address</h2>
<p>
In order to make use of account related features, we require you to
confirm your email address.
</p>
{lastLink ? (
<>
<p>
We last sent an email to <strong>{user.email}</strong> at:
</p>
<p style={{textAlign: 'center'}}>
{lastLink.time_created.toFormat('LLLL dd, h:mm a')}
<br/>
<em>This email will be valid until {lastLink.time_created.plus(lastLink.timeout_length).toFormat('LLLL dd, h:mm a')}</em>
</p>
<p>
If you haven't received it yet, please be patient. Emails can take several
minutes to be delivered, and depending on your email provider may also
be subject to additional scans or verification before it shows up in
your inbox.
</p>
<p>
Also, be sure to check your spam or junk folders - registration email
like the one we sent can occasionally be caught in those.
</p>
<p>
If you've waited a few minutes, and you're sure it won't arrive, you can
click the button below to send another one. If you still have issues,
please feel free to <Link href="/contact"><a>contact us</a></Link>.
</p>
<FormController url="/api/email" afterSubmit={() => window.location.reload()}>
<Button type="submit">Resend Email</Button>
</FormController>
</>
) : (
<>
<p>
Click the button below to send a confirmation email.
</p>
<FormController url="/api/email" afterSubmit={() => window.location.reload()}>
<Button type="submit">Resend Email</Button>
</FormController>
</>
)}
</>
)
}
function getLastLinkTime(links) {
if(links.length < 1)
return null;
let lastLink = links[0]
lastLink.time_created = DateTime.fromISO(lastLink.time_created)
for(const current of links){
current.time_created = DateTime.fromISO(current.time_created)
if(current.time_created.diff(lastLink.time_created).as('seconds') > 0)
lastLink = current;
}
return lastLink
}

@ -0,0 +1,32 @@
import Link from 'next/link'
import {Button, FormController} from '~/components/form'
import Router from 'next/router'
import useUser from '~/hooks/useUser'
export default function InvalidEmail() {
const user = useUser()
if(user.email_confirmed)
Router.push('/account')
return (
<>
<h2>Invalid Confirmation Link</h2>
<p>
Sorry, but the email confirmation link you used was invalid or expired.
</p>
<p>
If you've gotten this error before,
please <Link href="/contact"><a>contact us</a></Link> so we can help
resolve it. Otherwise, feel free to try sending the email again.
</p>
<FormController url="/api/email" afterSubmit={() => {window.location.href = '/account/email/confirm'}}>
<Button type="submit">Resend Email</Button>
</FormController>
</>
)
}

@ -4,7 +4,17 @@ import Router from 'next/router'
import Table from '~/components/table'
import useUser from '~/hooks/useUser'
AccountPage.getInitialProps = async function({ctx: {axios}}) {
import redirect from '~/utils/redirectGetInitialProps'
AccountPage.getInitialProps = async function({ctx, user}) {
const {axios} = ctx;
if(!user)
return redirect(ctx, 302, '/login')
if(!user.email_confirmed)
return redirect(ctx, 302, '/account/email/confirm')
const {data} = await axios.get(`/api/orders`)
return {orders: data.sort(sortOrders)}
}

@ -0,0 +1,14 @@
import Router from 'next/router'
export default function (ctx, status, url) {
const {res} = ctx
if(res) {
res.writeHead(status, {Location: url})
res.end();
} else if (window) {
Router.push(url)
} else {
console.error("Could not redirect for unknown reason")
}
}
Loading…
Cancel
Save