diff --git a/api/email.js b/api/email.js index 249336b..0f3331e 100644 --- a/api/email.js +++ b/api/email.js @@ -1,9 +1,7 @@ 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) +const email = require('../utils/email') router.get('/links', ensureUser, async (req, res) => { const links = await db.user.getOpenEmailLinks(req.user.uuid) @@ -18,19 +16,8 @@ router.post('/', ensureUser, async (req, res) => { 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); - + await email.sendAccountConfirmation(req.user) + res.json({sent: true}) }) @@ -42,7 +29,7 @@ router.get('/confirm/:uuid', ensureUser, async (req, res) => { if(!validLink) return res.redirect('/account/email/invalid') - await db.user.markLoginLinkUsed(validLink.uuid) + await db.user.markLinkUsed(validLink.uuid) await db.user.markEmailVerified(validLink.user_uuid) return res.redirect('/account') diff --git a/api/users.js b/api/users.js index ba7f989..3a2eb9d 100644 --- a/api/users.js +++ b/api/users.js @@ -3,6 +3,7 @@ const parseJSON = require('body-parser').json() const db = require('../db') const ensureAdmin = require('./middleware/ensureAdmin') const ensureUser = require('./middleware/ensureUser') +const email = require('../utils/email') const sendgrid = require('@sendgrid/mail') sendgrid.setApiKey(process.env.SENDGRID_KEY) @@ -36,22 +37,39 @@ router.post('/', parseJSON, registerValidation, async (req, res) => { } await db.session.create(req, user) + await email.sendAccountConfirmation(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) + res.json(user) +}) - const msg = { - to: user.email, - from: {email: 'registration@email.societyofsocks.us', name: 'Society of Socks'}, - templateId: 'd-33407f1dd1b14b7b84dd779511039c95', - dynamic_template_data: { - confirmUrl: confirmUrl - } - }; +router.post('/recover', parseJSON, validate.validEmail('email'), async (req, res) => { + const user = await db.user.findByEmail(req.body.email) - await sendgrid.send(msg); + if(user) + email.sendPasswordReset(user) - res.json(user) + + res.end() +}) + +router.post('/recover/password', parseJSON, +validate.validPassword('password'), +validate.bothPasswordsMatch, async (req, res) => { + const {password, link_uuid, link_key} = req.body + + const user = await db.user.verifyPasswordReset(link_uuid, link_key) + if(!user){ + return res.status(422).json({errors: [{ + param: 'password', + msg: 'Invalid reset link' + }]}) + } + + const updatedUser = await db.user.overwritePassword(user.uuid, password) + await db.user.markLinkUsed(link_uuid) + await db.session.create(req, updatedUser) + + res.json(updatedUser) }) router.get('/', ensureAdmin, async (req, res) => { diff --git a/db/mappings/user.js b/db/mappings/user.js index 55b1007..04fa37c 100644 --- a/db/mappings/user.js +++ b/db/mappings/user.js @@ -32,6 +32,7 @@ module.exports = [ mapId: 'emailLinkMap', idProperty: 'uuid', properties: [ + 'type', 'time_created', 'timeout_length', 'login_hash', diff --git a/db/models/user.js b/db/models/user.js index b4316dc..8d5601c 100644 --- a/db/models/user.js +++ b/db/models/user.js @@ -87,7 +87,11 @@ user.changePassword = async (user_uuid, oldPassword, newPassword) => { if(!passwordCorrect) return null - const newHash = await bcrypt.hash(newPassword, saltRounds) + return await user.overwritePassword(user_uuid, newPassword) +} + +user.overwritePassword = async (user_uuid, new_password) => { + const newHash = await bcrypt.hash(new_password, saltRounds) const query = { text: 'select * from sos.change_password($1, $2)', @@ -101,6 +105,7 @@ user.changePassword = async (user_uuid, oldPassword, newPassword) => { const {rows} = await pg.query(query) return joinjs.map(rows, mappings, 'userMap', 'user_')[0]; + } user.getOpenEmailLinks = (user_uuid) => @@ -124,7 +129,8 @@ user.createLoginLink = async (user_uuid) => { params: [ user_uuid, '2 hours', - hash + hash, + 'email_confirm' ], returnType: 'emailLink', tablePrefix: 'email_link_', @@ -137,7 +143,7 @@ user.createLoginLink = async (user_uuid) => { user.verifyLoginLink = async (link_uuid, key) => { const link_record = await dbUtil.executeQuery({ query: { - text: 'select * from sos.email_link where email_link_uuid = $1', + text: 'select * from sos.email_link where email_link_uuid = $1 and email_link_time_used is null', values: [link_uuid] }, returnType: 'emailLink', @@ -154,11 +160,58 @@ user.verifyLoginLink = async (link_uuid, key) => { const valid = await bcrypt.compare(key, link_record.login_hash) if(!valid) return null + if(link_record.type !== 'email_confirm') return null return link_record } -user.markLoginLinkUsed = link_uuid => +user.createPasswordReset = 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, + 'password_reset' + ], + returnType: 'emailLink', + tablePrefix: 'email_link_', + single: true + }) + + return `${process.env.EXTERNAL_URL}/account/reset-password?id=${link_record.uuid}&key=${linkCode}` +} + +user.verifyPasswordReset = async (link_uuid, key) => { + const link_record = await dbUtil.executeQuery({ + query: { + text: 'select * from sos.email_link where email_link_uuid = $1 and email_link_time_used is null', + 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 + if(link_record.type !== 'password_reset') return null + + return user.findById(link_record.user_uuid) +} + +user.markLinkUsed = link_uuid => dbUtil.executeFunction({ name: 'set_link_used', params: [link_uuid], diff --git a/db/sql/1-tables.sql b/db/sql/1-tables.sql index 3d8ec4c..e50f4fc 100644 --- a/db/sql/1-tables.sql +++ b/db/sql/1-tables.sql @@ -3,6 +3,7 @@ create type sos."transaction_state_enum" as enum ('started', 'completed', 'canc create type sos."payment_type_enum" as enum ('ks_reward', 'stripe', 'paypal', 'account_credit'); create type sos."stockchange_type_enum" as enum ('purchase', 'shipment', 'admin'); create type sos."stock_change_dir_enum" as enum ('added', 'subtracted'); +create type sos."email_link_type_enum" as enum ('email_confirm', 'email_change', 'password_reset'); create table sos."user" ( user_uuid uuid primary key default uuid_generate_v4(), @@ -17,6 +18,7 @@ create table sos."user" ( create table sos."email_link" ( email_link_uuid uuid primary key default uuid_generate_v4(), + email_link_type sos.email_link_type_enum not null, 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, diff --git a/db/sql/3-functions.sql b/db/sql/3-functions.sql index a265184..396bf6d 100644 --- a/db/sql/3-functions.sql +++ b/db/sql/3-functions.sql @@ -1165,7 +1165,7 @@ begin 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) +create or replace function sos.create_login_link(_user_uuid uuid, _timeout_length interval, _link_hash text, _type sos.email_link_type_enum) returns setof sos."email_link" language plpgsql as $function$ @@ -1175,11 +1175,13 @@ begin insert into sos."email_link" ( email_link_user_uuid, email_link_timeout_length, - email_link_login_hash + email_link_login_hash, + email_link_type ) values ( _user_uuid, _timeout_length, - _link_hash + _link_hash, + _type ) returning email_link_uuid into _link_uuid; return query select * from sos."email_link" where email_link_uuid = _link_uuid; diff --git a/pages/account/index.js b/pages/account/index.js index 390c8e8..5bfbd61 100644 --- a/pages/account/index.js +++ b/pages/account/index.js @@ -41,7 +41,9 @@ export default function AccountPage({orders}) {

Email and Password

-

Email: {user.email} Change

+

Email: {user.email} + {/* Change */} +

{/* TODO: Store date password was set so we can show "Set on [date]"? */}

Password: { diff --git a/pages/account/recover.js b/pages/account/recover.js new file mode 100644 index 0000000..943b2eb --- /dev/null +++ b/pages/account/recover.js @@ -0,0 +1,40 @@ +import React, {useState} from 'react' +import Link from 'next/link' +import Router from 'next/router' +import Head from 'next/head' +import isEmail from 'validator/lib/isEmail' + +import {FormController, Input, Button} from '~/components/form' +import useAccountRedirect from '~/hooks/useAccountRedirect' + +export default function ResetPassword(){ + useAccountRedirect() + + const [submitted, setSubmitted] = useState(false) + + return ( + <> + + Reset Password | Society of Socks + + {submitted + ? ( + +

Reset Password

+

+ An email has been sent to the provided email address - check your + email for further instructions in resetting your password. +

+ + ) + : ( + setSubmitted(true)}> +

Reset Password

+ isEmail(value)} hint="Enter a valid email address" /> + +
+ ) + } + + ) +} diff --git a/pages/account/reset-password.js b/pages/account/reset-password.js new file mode 100644 index 0000000..395fc62 --- /dev/null +++ b/pages/account/reset-password.js @@ -0,0 +1,54 @@ +import React from 'react' +import Link from 'next/link' +import Router from 'next/router' +import axios from 'axios' +import Head from 'next/head' + +import {useSetUser} from '~/hooks/useUser' +import {FormController, Input, Button} from '~/components/form' +import useAccountRedirect from '~/hooks/useAccountRedirect' + +ResetPassword.getInitialProps = async ({ctx}) => { + const {id, key} = ctx.query + + return { + link_uuid: id, + link_key: key + } +} + +export default function ResetPassword({link_uuid, link_key}){ + useAccountRedirect() + const setUser = useSetUser() + + async function submitReset({password, password2}) { + const {data: user} = await axios.post(`/api/users/recover/password`, { + password, + password2, + link_uuid, + link_key, + }) + + setUser(user) + + if (user.is_admin) + Router.push('/admin') + else + Router.push('/account') + + } + + return ( + <> + + Reset Password | Society of Socks + + +

Reset Password

+ (value.length >= 8)} hint="Password must be at least 8 characters long" /> + (value === fields.password.value)} hint="Passwords must match" /> + +
+ + ) +} diff --git a/pages/login.js b/pages/login.js index 81b71b7..2678272 100644 --- a/pages/login.js +++ b/pages/login.js @@ -7,10 +7,8 @@ import isEmail from 'validator/lib/isEmail' import {useSetUser} from '~/hooks/useUser' import {FormController, Input, Button} from '~/components/form' import useAccountRedirect from '~/hooks/useAccountRedirect' -import {useSetUser} from '~/hooks/useUser' export default function Login(){ - const setUser = useSetUser() useAccountRedirect() const setUser = useSetUser() @@ -33,6 +31,7 @@ export default function Login(){ isEmail(value)} hint="Enter a valid email address" /> (value.length >= 8)} hint="Password must be at least 8 characters long" /> +

Forgot your password? Reset your password.

Need an account? Register here.

diff --git a/utils/email.js b/utils/email.js new file mode 100644 index 0000000..6dab7d6 --- /dev/null +++ b/utils/email.js @@ -0,0 +1,35 @@ +const db = require('../db') +const sendgrid = require('@sendgrid/mail') +sendgrid.setApiKey(process.env.SENDGRID_KEY) + +const email = module.exports = {} + +email.sendAccountConfirmation = async user => { + 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); +} + +email.sendPasswordReset = async user => { + const resetURL = await db.user.createPasswordReset(user.uuid) + + const msg = { + to: user.email, + from: {email: 'accounts@email.societyofsocks.us', name: 'Society of Socks'}, + templateId: 'd-90d751cfc8cd4047be39c994ff9d0f5c', + dynamic_template_data: { + resetPasswordURL: resetURL + } + } + + await sendgrid.send(msg) +} \ No newline at end of file