409 lines
11 KiB
JavaScript
409 lines
11 KiB
JavaScript
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({})
|
|
}
|
|
}
|