From fc2666364d30a0fcdd7843280d86b4f50d9db6c7 Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Wed, 21 Aug 2019 14:46:14 +0100 Subject: [PATCH] Add backend services for password reset --- app/src/api/services/passwordReset.ts | 121 ++++++++++++++++++++++++++ app/src/api/services/user.ts | 16 ++++ 2 files changed, 137 insertions(+) create mode 100644 app/src/api/services/passwordReset.ts diff --git a/app/src/api/services/passwordReset.ts b/app/src/api/services/passwordReset.ts new file mode 100644 index 00000000..07ede0a0 --- /dev/null +++ b/app/src/api/services/passwordReset.ts @@ -0,0 +1,121 @@ +import url, { URL } from 'url'; +import { errors } from 'pg-promise'; +import nodemailer from 'nodemailer'; + +import db from '../../db'; +import * as userService from './user'; +import { transporter } from './email'; + + +/** + * Generate a password reset token for the specified account and send the password reset link by email + * @param email the email address for which to generate a password reset token + * @param siteOrigin the origin of the website, without a path element - e.g. https://beta.colouring.london + */ +export async function sendPasswordResetToken(email: string, siteOrigin: string): Promise { + const user = await userService.getUserByEmail(email); + + // if no user found for email, do nothing + if (user == undefined) return; + + const token = await createPasswordResetToken(user.user_id); + const message = getPasswordResetEmail(email, token, siteOrigin); + await transporter.sendMail(message); +} + +export async function resetPassword(passwordResetToken: string, newPassword: string): Promise { + const userId = await verifyPasswordResetToken(passwordResetToken); + if (userId != undefined) { + await updatePasswordForUser(userId, newPassword); + //TODO: expire other tokens of the user? + } else { + throw new TokenVerificationError('Password reset token could not be verified'); + } +} + +export class TokenVerificationError extends Error { + constructor(message: string) { + super(message); + this.name = "TokenVerificationError"; + } +} + +function getPasswordResetEmail(email: string, token: string, siteOrigin: string): nodemailer.SendMailOptions { + const linkUrl = new URL(siteOrigin); + linkUrl.pathname = '/password-reset.html'; + linkUrl.search = `?token=${token}`; + + const linkString = url.format(linkUrl); + + const messageBody = `Hi there, + + Someone has requested a password reset for the Colouring London account associated with this email address. + Click on the following link within the next 24 hours to reset your password: + + ${linkString} + `; + + return { + text: messageBody, + subject: 'Reset your Colouring London password', + to: email, + from: 'no-reply@colouring.london' + }; +} + +async function createPasswordResetToken(userId: string) { + const { token } = await db.one( + `INSERT INTO + user_password_reset_tokens (user_id, expires_on) + VALUES + ($1::uuid, now() at time zone 'utc' + INTERVAL '$2 day') + RETURNING + token + `, [userId, 1] + ); + + return token; +} + +/** + * Verify that the password reset token is valid and expire the token + * @param token password reset token to verify and use + * @returns the UUID of the user whose token was used + */ +async function verifyPasswordResetToken(token: string): Promise { + try { + // verify and deactivate the token in one operation + const usedToken = await db.one( + `UPDATE + user_password_reset_tokens + SET used = true + WHERE + token = $1::uuid + AND NOT used + AND now() at time zone 'utc' < expires_on + RETURNING *`, [token] + ); + console.log('verified'); + + return usedToken.user_id; + } catch (err) { + if (err instanceof errors.QueryResultError) { + console.log(err.code); + if (err.code === errors.queryResultErrorCode.noData) return undefined; + } + + throw err; + } +} + +async function updatePasswordForUser(userId: string, newPassword: string): Promise { + return db.none( + `UPDATE + users + SET + pass = crypt($1, gen_salt('bf')) + WHERE + user_id = $2::uuid + `, [newPassword, userId]); +} + diff --git a/app/src/api/services/user.ts b/app/src/api/services/user.ts index 3297720d..0e471aa4 100644 --- a/app/src/api/services/user.ts +++ b/app/src/api/services/user.ts @@ -86,6 +86,21 @@ function getUserById(id) { }); } +function getUserByEmail(email: string) { + return db.one( + `SELECT + user_id, username, email + FROM + users + WHERE + email = $1 + `, [email] + ).catch(function(error) { + console.error('Error:', error); + return undefined; + }); +} + function getNewUserAPIKey(id) { return db.one( `UPDATE @@ -152,6 +167,7 @@ function logout(session: Express.Session) { export { getUserById, + getUserByEmail, createUser, authUser, getNewUserAPIKey,