diff --git a/api-server/common/models/user.js b/api-server/common/models/user.js index 136562d716..65d8a18499 100644 --- a/api-server/common/models/user.js +++ b/api-server/common/models/user.js @@ -16,10 +16,19 @@ import generate from 'nanoid/generate'; import { apiLocation } from '../../../config/env'; -import { fixCompletedChallengeItem } from '../utils'; -import { saveUser, observeMethod } from '../../server/utils/rx.js'; +import { + fixCompletedChallengeItem, + getEncodedEmail, + getWaitMessage, + renderEmailChangeEmail, + renderSignUpEmail, + renderSignInEmail +} from '../utils'; + import { blacklistedUsernames } from '../../server/utils/constants.js'; import { wrapHandledError } from '../../server/utils/create-handled-error.js'; +import { saveUser, observeMethod } from '../../server/utils/rx.js'; +import { getEmailSender } from '../../server/utils/url-utils'; import { normaliseUserFields, getProgress, @@ -437,6 +446,145 @@ export default function(User) { User.prototype.requestCompletedChallenges = requestCompletedChallenges; + function requestAuthEmail(isSignUp, newEmail) { + return Observable.defer(() => { + const messageOrNull = getWaitMessage(this.emailAuthLinkTTL); + if (messageOrNull) { + throw wrapHandledError(new Error('request is throttled'), { + type: 'info', + message: messageOrNull + }); + } + + // create a temporary access token with ttl for 15 minutes + return this.createAuthToken({ ttl: 15 * 60 * 1000 }); + }) + .flatMap(token => { + let renderAuthEmail = renderSignInEmail; + let subject = 'Your sign in link for freeCodeCamp.org'; + if (isSignUp) { + renderAuthEmail = renderSignUpEmail; + subject = 'Your sign in link for your new freeCodeCamp.org account'; + } + if (newEmail) { + renderAuthEmail = renderEmailChangeEmail; + subject = dedent` + Please confirm your updated email address for freeCodeCamp.org + `; + } + const { id: loginToken, created: emailAuthLinkTTL } = token; + const loginEmail = getEncodedEmail(newEmail ? newEmail : null); + const host = apiLocation; + const mailOptions = { + type: 'email', + to: newEmail ? newEmail : this.email, + from: getEmailSender(), + subject, + text: renderAuthEmail({ + host, + loginEmail, + loginToken, + emailChange: !!newEmail + }) + }; + const userUpdate = new Promise((resolve, reject) => + this.updateAttributes({ emailAuthLinkTTL }, err => { + if (err) { + return reject(err); + } + return resolve(); + }) + ); + return Observable.forkJoin( + User.email.send$(mailOptions), + Observable.fromPromise(userUpdate) + ); + }) + .map( + () => + 'Check your email and click the link we sent you to confirm' + + ' your new email address.' + ); + } + + User.prototype.requestAuthEmail = requestAuthEmail; + + User.prototype.requestUpdateEmail = function requestUpdateEmail(newEmail) { + const currentEmail = this.email; + const isOwnEmail = isTheSame(newEmail, currentEmail); + const isResendUpdateToSameEmail = isTheSame(newEmail, this.newEmail); + const isLinkSentWithinLimit = getWaitMessage(this.emailVerifyTTL); + const isVerifiedEmail = this.emailVerified; + + if (isOwnEmail && isVerifiedEmail) { + // email is already associated and verified with this account + throw wrapHandledError(new Error('email is already verified'), { + type: 'info', + message: ` + ${newEmail} is already associated with this account. + You can update a new email address instead.` + }); + } + if (isResendUpdateToSameEmail && isLinkSentWithinLimit) { + // trying to update with the same newEmail and + // confirmation email is still valid + throw wrapHandledError(new Error(), { + type: 'info', + message: dedent` + We have already sent an email confirmation request to ${newEmail}. + ${isLinkSentWithinLimit}` + }); + } + if (!isEmail('' + newEmail)) { + throw createEmailError(); + } + + // newEmail is not associated with this user, and + // this attempt to change email is the first or + // previous attempts have expired + if ( + !isOwnEmail || + (isOwnEmail && !isVerifiedEmail) || + (isResendUpdateToSameEmail && !isLinkSentWithinLimit) + ) { + const updateConfig = { + newEmail, + emailVerified: false, + emailVerifyTTL: new Date() + }; + + // defer prevents the promise from firing prematurely (before subscribe) + return Observable.defer(() => User.doesExist(null, newEmail)) + .do(exists => { + if (exists && !isOwnEmail) { + // newEmail is not associated with this account, + // but is associated with different account + throw wrapHandledError(new Error('email already in use'), { + type: 'info', + message: `${newEmail} is already associated with another account.` + }); + } + }) + .flatMap(() => { + const updatePromise = new Promise((resolve, reject) => + this.updateAttributes(updateConfig, err => { + if (err) { + return reject(err); + } + return resolve(); + }) + ); + return Observable.forkJoin( + Observable.fromPromise(updatePromise), + this.requestAuthEmail(false, newEmail), + (_, message) => message + ); + }); + } else { + return 'Something unexpected happened while updating your email.'; + } + }; + User.prototype.requestUpdateFlags = async function requestUpdateFlags( values ) { diff --git a/api-server/common/utils/auth.js b/api-server/common/utils/auth.js new file mode 100644 index 0000000000..8a1914e920 --- /dev/null +++ b/api-server/common/utils/auth.js @@ -0,0 +1,80 @@ +import path from 'path'; + +import dedent from 'dedent'; +import loopback from 'loopback'; +import moment from 'moment'; + +export const renderSignUpEmail = loopback.template( + path.join( + __dirname, + '..', + '..', + 'server', + 'views', + 'emails', + 'user-request-sign-up.ejs' + ) +); + +export const renderSignInEmail = loopback.template( + path.join( + __dirname, + '..', + '..', + 'server', + 'views', + 'emails', + 'user-request-sign-in.ejs' + ) +); + +export const renderEmailChangeEmail = loopback.template( + path.join( + __dirname, + '..', + '..', + 'server', + 'views', + 'emails', + 'user-request-update-email.ejs' + ) +); + +export function getWaitPeriod(ttl) { + const fiveMinutesAgo = moment().subtract(5, 'minutes'); + const lastEmailSentAt = moment(new Date(ttl || null)); + const isWaitPeriodOver = ttl + ? lastEmailSentAt.isBefore(fiveMinutesAgo) + : true; + + if (!isWaitPeriodOver) { + const minutesLeft = 5 - (moment().minutes() - lastEmailSentAt.minutes()); + return minutesLeft; + } + + return 0; +} + +export function getWaitMessage(ttl) { + const minutesLeft = getWaitPeriod(ttl); + if (minutesLeft <= 0) { + return null; + } + + const timeToWait = minutesLeft + ? `${minutesLeft} minute${minutesLeft > 1 ? 's' : ''}` + : 'a few seconds'; + + return dedent` + Please wait ${timeToWait} to resend an authentication link. + `; +} + +export function getEncodedEmail(email) { + if (!email) { + return null; + } + return Buffer(email).toString('base64'); +} + +export const decodeEmail = email => Buffer(email, 'base64').toString(); diff --git a/api-server/common/utils/index.js b/api-server/common/utils/index.js index fbdb98d6f6..52351d0863 100644 --- a/api-server/common/utils/index.js +++ b/api-server/common/utils/index.js @@ -1,5 +1,15 @@ import { pick } from 'lodash'; +export { + getEncodedEmail, + decodeEmail, + getWaitMessage, + getWaitPeriod, + renderEmailChangeEmail, + renderSignUpEmail, + renderSignInEmail +} from './auth'; + export function dashify(str) { return ('' + str) .toLowerCase() diff --git a/api-server/server/boot/authentication.js b/api-server/server/boot/authentication.js index b39523a4ae..0d005b3e07 100644 --- a/api-server/server/boot/authentication.js +++ b/api-server/server/boot/authentication.js @@ -1,4 +1,7 @@ import passport from 'passport'; +import dedent from 'dedent'; +import { check } from 'express-validator/check'; +import { isEmail } from 'validator'; import { homeLocation } from '../../../config/env'; import { @@ -6,20 +9,34 @@ import { saveResponseAuthCookies, loginRedirect } from '../component-passport'; -import { ifUserRedirectTo } from '../utils/middleware'; +import { ifUserRedirectTo, ifNoUserRedirectTo } from '../utils/middleware'; import { wrapHandledError } from '../utils/create-handled-error.js'; import { removeCookies } from '../utils/getSetAccessToken'; +import { decodeEmail } from '../../common/utils'; const isSignUpDisabled = !!process.env.DISABLE_SIGNUP; if (isSignUpDisabled) { console.log('fcc:boot:auth - Sign up is disabled'); } +const passwordlessGetValidators = [ + check('email') + .isBase64() + .withMessage('Email should be a base64 encoded string.'), + check('token') + .exists() + .withMessage('Token should exist.') + // based on strongloop/loopback/common/models/access-token.js#L15 + .isLength({ min: 64, max: 64 }) + .withMessage('Token is not the right length.') +]; + module.exports = function enableAuthentication(app) { // enable loopback access control authentication. see: // loopback.io/doc/en/lb2/Authentication-authorization-and-permissions.html app.enableAuth(); const ifUserRedirect = ifUserRedirectTo(); + const ifNoUserRedirectHome = ifNoUserRedirectTo(homeLocation); const saveAuthCookies = saveResponseAuthCookies(); const loginSuccessRedirect = loginRedirect(); const api = app.loopback.Router(); @@ -62,5 +79,110 @@ module.exports = function enableAuthentication(app) { }); }); + api.get( + '/confirm-email', + ifNoUserRedirectHome, + passwordlessGetValidators, + createGetPasswordlessAuth(app) + ); + app.use(api); }; + +const defaultErrorMsg = dedent` + Oops, something is not right, + please request a fresh link to sign in / sign up. + `; + +function createGetPasswordlessAuth(app) { + const { + models: { AuthToken, User } + } = app; + return function getPasswordlessAuth(req, res, next) { + const { + query: { email: encodedEmail, token: authTokenId, emailChange } = {} + } = req; + + const email = decodeEmail(encodedEmail); + if (!isEmail(email)) { + return next( + wrapHandledError(new TypeError('decoded email is invalid'), { + type: 'info', + message: 'The email encoded in the link is incorrectly formatted', + redirectTo: `${homeLocation}/signin` + }) + ); + } + // first find + return ( + AuthToken.findOne$({ where: { id: authTokenId } }) + .flatMap(authToken => { + if (!authToken) { + throw wrapHandledError( + new Error(`no token found for id: ${authTokenId}`), + { + type: 'info', + message: defaultErrorMsg, + redirectTo: `${homeLocation}/signin` + } + ); + } + // find user then validate and destroy email validation token + // finally retun user instance + return User.findOne$({ where: { id: authToken.userId } }).flatMap( + user => { + if (!user) { + throw wrapHandledError( + new Error(`no user found for token: ${authTokenId}`), + { + type: 'info', + message: defaultErrorMsg, + redirectTo: `${homeLocation}/signin` + } + ); + } + if (user.email !== email) { + if (!emailChange || (emailChange && user.newEmail !== email)) { + throw wrapHandledError( + new Error('user email does not match'), + { + type: 'info', + message: defaultErrorMsg, + redirectTo: `${homeLocation}/signin` + } + ); + } + } + return authToken + .validate$() + .map(isValid => { + if (!isValid) { + throw wrapHandledError(new Error('token is invalid'), { + type: 'info', + message: ` + Looks like the link you clicked has expired, + please request a fresh link, to sign in. + `, + redirectTo: `${homeLocation}/signin` + }); + } + return authToken.destroy$(); + }) + .map(() => user); + } + ); + }) + // at this point token has been validated and destroyed + // update user and log them in + .map(user => user.loginByRequest(req, res)) + .do(() => { + req.flash( + 'success', + 'Success! You have signed in to your account. Happy Coding!' + ); + return res.redirectWithFlash(`${homeLocation}`); + }) + .subscribe(() => {}, next) + ); + }; +} diff --git a/api-server/server/views/emails/user-request-sign-in.ejs b/api-server/server/views/emails/user-request-sign-in.ejs new file mode 100644 index 0000000000..300999f9e1 --- /dev/null +++ b/api-server/server/views/emails/user-request-sign-in.ejs @@ -0,0 +1,9 @@ +Here's your sign in link. It will instantly sign you into freeCodeCamp.org - no password necessary: + +<%= host %>/internal/passwordless-auth/?email=<%= loginEmail %>&token=<%= loginToken %> + +Note: this sign in link will expire after 15 minutes. If you need a new sign in link, go to https://www.freecodecamp.org/signin + +See you soon! + +- The freeCodeCamp.org Team \ No newline at end of file diff --git a/api-server/server/views/emails/user-request-sign-up.ejs b/api-server/server/views/emails/user-request-sign-up.ejs new file mode 100644 index 0000000000..414da690bb --- /dev/null +++ b/api-server/server/views/emails/user-request-sign-up.ejs @@ -0,0 +1,13 @@ +Welcome to the freeCodeCamp community! + +We have created a new account for you. + +Here's your sign in link. It will instantly sign you into freeCodeCamp.org - no password necessary: + +<%= host %>/internal/passwordless-auth/?email=<%= loginEmail %>&token=<%= loginToken %> + +Note: this sign in link will expire after 15 minutes. If you need a new sign in link, go to https://www.freecodecamp.org/signin + +See you soon! + +- The freeCodeCamp.org Team \ No newline at end of file diff --git a/api-server/server/views/emails/user-request-update-email.ejs b/api-server/server/views/emails/user-request-update-email.ejs new file mode 100644 index 0000000000..0ad946b1a9 --- /dev/null +++ b/api-server/server/views/emails/user-request-update-email.ejs @@ -0,0 +1,7 @@ +lease confirm this address for freeCodeCamp: + +<%= host %>/confirm-email?email=<%= loginEmail %>&token=<%= loginToken %>&emailChange=<%= emailChange %> + +Happy coding! + +- The freeCodeCamp.org Team \ No newline at end of file