Fix: Restore email change functionality

This commit is contained in:
Bouncey
2019-09-09 22:43:51 +01:00
committed by mrugesh
parent 41c9ff4cb4
commit 7271e82503
7 changed files with 392 additions and 3 deletions

View File

@ -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
) {

View File

@ -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();

View File

@ -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()

View File

@ -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)
);
};
}

View File

@ -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

View File

@ -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

View File

@ -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