import passport from "passport" import credential from "credential" import createError from "http-errors" import config from "config" import crypto from "crypto" import urlSafeBase64 from "urlsafe-base64" import util from "util" import * as loginToken from "./loginToken" import autobind from "autobind-decorator" import url from "url" import { catchAll } from "." @autobind export class AuthRoutes { constructor(container) { const app = container.app this.log = container.log this.mq = container.mq this.db = container.db this.maxEmailTokenAgeInHours = config.get("email.maxEmailTokenAgeInHours") this.maxPasswordTokenAgeInHours = config.get( "email.maxPasswordTokenAgeInHours" ) this.sendEmailDelayInSeconds = config.get("email.sendEmailDelayInSeconds") this.supportEmail = config.get("email.supportEmail") this.sendEmail = config.get("email.sendEmail") this.appName = config.get("email.appName") this.emailServiceName = config.get("serviceName.email") app .route("/auth/login") // Used to login. Email must be confirmed. .post(catchAll(this.login)) // Used to logout .delete( passport.authenticate("bearer", { session: false }), catchAll(this.logout) ) // Send change email confirmation email app .route("/auth/email/send") .post( passport.authenticate("bearer", { session: false }), catchAll(this.sendChangeEmailEmail) ) // Confirm email address app.route("/auth/email/confirm").post(catchAll(this.confirmEmail)) // Change the logged in users password, leaving user still logged in app .route("/auth/password/change") .post( passport.authenticate("bearer", { session: false }), catchAll(this.changePassword) ) // Send a password reset email app.route("/auth/password/send").post(catchAll(this.sendPasswordResetEmail)) // Confirm a password reset token is valid app .route("/auth/password/confirm") .post(catchAll(this.confirmPasswordToken)) // Finish a password reset app.route("/auth/password/reset").post(catchAll(this.resetPassword)) // Indicate who the currently logged in user is app .route("/auth/who") .get( passport.authenticate("bearer", { session: false }), catchAll(this.whoAmI) ) } async login(req, res, next) { const email = req.body.email const password = req.body.password let User = this.db.User if (!email || !password) { throw createError.BadRequest("Must supply user name and password") } // Lookup the user const user = await User.findOne({ email }) if (!user) { // NOTE: Don't return NotFound as that gives too much information away to hackers throw createError.BadRequest("Email or password incorrect") } else if (user.emailToken || !user.passwordHash) { throw createError.Forbidden("Must confirm email and set password") } let cr = credential() const isValid = await cr.verify( JSON.stringify(user.passwordHash), req.body.password ) if (isValid) { user.loginToken = loginToken.pack(user._id.toString(), user.email) } else { user.loginToken = undefined // A bad login removes existing token for this user... } const savedUser = await user.save() if (savedUser.loginToken) { res.set("Authorization", `Bearer ${savedUser.loginToken}`) res.json(savedUser.toClient()) } else { throw createError.BadRequest("Email or password incorrect") } } async logout(req, res, next) { let User = this.db.User const user = await User.findById({ _id: req.user._id }) if (user) { user.loginToken = undefined await user.save() } res.json({}) } whoAmI(req, res, next) { res.json(req.user.toClient()) } async sendChangeEmailEmail(req, res, next) { let existingEmail = req.body.existingEmail const newEmail = req.body.newEmail let User = this.db.User const isAdmin = !!req.user.administrator if (existingEmail) { if (!isAdmin) { throw createError.Forbidden( "Only admins can resend change email to any user" ) } } else { existingEmail = req.user.email } const user = await User.findOne({ email: existingEmail }) let conflictingUser = null if (newEmail) { conflictingUser = await User.findOne({ email: newEmail }) } if (!user) { throw createError.NotFound( `User with email '${existingEmail}' was not found` ) } else if (conflictingUser) { throw createError.BadRequest(`A user with '${newEmail}' already exists`) } else if ( !isAdmin && user.emailToken && new Date() - user.emailToken.created < this.sendEmailDelayInSeconds ) { throw createError.BadRequest( "Cannot request email confirmation again so soon" ) } const buf = await util.promisify(crypto.randomBytes)(32) user.emailToken = { value: urlSafeBase64.encode(buf), created: new Date(), } if (newEmail) { user.newEmail = newEmail } const savedUser = await user.save() const userFullName = `${savedUser.firstName} ${savedUser.lastName}` const siteUrl = url.parse(req.headers.referer) let msgs = [] if (savedUser.newEmail) { msgs.push({ toEmail: savedUser.email, templateName: "changeEmailOld", templateData: { recipientFullName: userFullName, recipientNewEmail: savedUser.newEmail, supportEmail: this.supportEmail, }, }) } msgs.push({ toEmail: savedUser.newEmail || savedUser.email, templateName: "changeEmailNew", templateData: { recipientFullName: userFullName, confirmEmailLink: `${siteUrl.protocol}//${ siteUrl.host }/confirm-email?email-token%3D${savedUser.emailToken.value}`, supportEmail: this.supportEmail, appName: this.appName, }, }) if (this.sendEmail) { await this.mq.request(this.emailServiceName, "sendEmail", msgs) } res.json({}) } async confirmEmail(req, res, next) { const token = req.body.emailToken let User = this.db.User if (!token) { throw createError.BadRequest("Invalid request parameters") } const user = await User.findOne({ "emailToken.value": token }) if (!user) { throw createError.BadRequest(`The token was not found`) } // Token must not be too old const ageInHours = (new Date() - user.emailToken.created) / 3600000 if (ageInHours > this.maxEmailTokenAgeInHours) { throw createError.BadRequest(`Token has expired`) } // Remove the email token & any login token as it will become invalid user.emailToken = undefined user.loginToken = undefined // Switch in any new email now if (user.newEmail) { user.email = user.newEmail user.newEmail = undefined } let buf = null // If user has no password, create reset token for them if (!user.passwordHash) { buf = await util.promisify(crypto.randomBytes)(32) user.passwordToken = { value: urlSafeBase64.encode(buf), created: new Date(), } } const savedUser = await user.save() let obj = {} // Only because the user has sent us a valid email reset token // can we respond with an password reset token. if (savedUser.passwordToken) { obj.passwordToken = savedUser.passwordToken.value } res.json(obj) } async confirmPasswordToken(req, res, next) { const token = req.body.passwordToken let User = this.db.User if (!token) { throw createError.BadRequest("Invalid request parameters") } const user = await User.findOne({ "passwordToken.value": token }) if (!user) { throw createError.BadRequest(`The token was not found`) } // Token must not be too old const ageInHours = (new Date() - user.passwordToken.created) / (3600 * 1000) if (ageInHours > this.maxPasswordTokenAgeInHours) { throw createError.BadRequest(`Token has expired`) } res.json({}) } async resetPassword(req, res, next) { const token = req.body.passwordToken const newPassword = req.body.newPassword let User = this.db.User let cr = credential() if (!token || !newPassword) { throw createError.BadRequest("Invalid request parameters") } const user = await User.findOne({ "passwordToken.value": token }) if (!user) { throw createError.BadRequest(`The token was not found`) } // Token must not be too old const ageInHours = (new Date() - user.passwordToken.created) / (3600 * 1000) if (ageInHours > this.maxPasswordTokenAgeInHours) { throw createError.BadRequest(`Token has expired`) } // Remove the password token & any login token user.passwordToken = undefined user.loginToken = undefined const json = await cr.hash(newPassword) user.passwordHash = JSON.parse(json) await user.save() res.json({}) } async changePassword(req, res, next) { let User = this.db.User let cr = credential() const user = await User.findById({ _id: req.user._id }) if (!user) { throw createError.NotFound(`User ${req.user._id} not found`) } const ok = await cr.verify( JSON.stringify(user.passwordHash), req.body.oldPassword ) const obj = await cr.hash(req.body.newPassword) user.passwordHash = JSON.parse(obj) await user.save() res.json({}) } async sendPasswordResetEmail(req, res, next) { const email = req.body.email let User = this.db.User if (!email) { throw createError.BadRequest("Invalid request parameters") } const user = await User.findOne({ email }) // User must exist and their email must be confirmed if (!user || user.emailToken) { // Don't give away any information about why we rejected the request throw createError.BadRequest("Not a valid request") } else if ( user.passwordToken && user.passwordToken.created && new Date() - user.passwordToken.created < this.sendEmailDelayInSeconds ) { throw createError.BadRequest("Cannot request password reset so soon") } const buf = await util.promisify(crypto.randomBytes)(32) user.passwordToken = { value: urlSafeBase64.encode(buf), created: new Date(), } const savedUser = await user.save() const userFullName = `${savedUser.firstName} ${savedUser.lastName}` const siteUrl = url.parse(req.headers.referer) const msg = { toEmail: savedUser.email, templateName: "forgotPassword", templateData: { recipientFullName: userFullName, resetPasswordLink: `${siteUrl.protocol}//${ siteUrl.host }/reset-password?password-token%3D${savedUser.passwordToken.value}`, supportEmail: this.supportEmail, appName: this.appName, }, } if (this.sendEmail) { await this.mq.request(this.emailServiceName, "sendEmail", msg) } res.json({}) } }