Fix: Restore email change functionality
This commit is contained in:
@ -16,10 +16,19 @@ import generate from 'nanoid/generate';
|
|||||||
|
|
||||||
import { apiLocation } from '../../../config/env';
|
import { apiLocation } from '../../../config/env';
|
||||||
|
|
||||||
import { fixCompletedChallengeItem } from '../utils';
|
import {
|
||||||
import { saveUser, observeMethod } from '../../server/utils/rx.js';
|
fixCompletedChallengeItem,
|
||||||
|
getEncodedEmail,
|
||||||
|
getWaitMessage,
|
||||||
|
renderEmailChangeEmail,
|
||||||
|
renderSignUpEmail,
|
||||||
|
renderSignInEmail
|
||||||
|
} from '../utils';
|
||||||
|
|
||||||
import { blacklistedUsernames } from '../../server/utils/constants.js';
|
import { blacklistedUsernames } from '../../server/utils/constants.js';
|
||||||
import { wrapHandledError } from '../../server/utils/create-handled-error.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 {
|
import {
|
||||||
normaliseUserFields,
|
normaliseUserFields,
|
||||||
getProgress,
|
getProgress,
|
||||||
@ -437,6 +446,145 @@ export default function(User) {
|
|||||||
|
|
||||||
User.prototype.requestCompletedChallenges = requestCompletedChallenges;
|
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(
|
User.prototype.requestUpdateFlags = async function requestUpdateFlags(
|
||||||
values
|
values
|
||||||
) {
|
) {
|
||||||
|
80
api-server/common/utils/auth.js
Normal file
80
api-server/common/utils/auth.js
Normal 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();
|
@ -1,5 +1,15 @@
|
|||||||
import { pick } from 'lodash';
|
import { pick } from 'lodash';
|
||||||
|
|
||||||
|
export {
|
||||||
|
getEncodedEmail,
|
||||||
|
decodeEmail,
|
||||||
|
getWaitMessage,
|
||||||
|
getWaitPeriod,
|
||||||
|
renderEmailChangeEmail,
|
||||||
|
renderSignUpEmail,
|
||||||
|
renderSignInEmail
|
||||||
|
} from './auth';
|
||||||
|
|
||||||
export function dashify(str) {
|
export function dashify(str) {
|
||||||
return ('' + str)
|
return ('' + str)
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
import passport from 'passport';
|
import passport from 'passport';
|
||||||
|
import dedent from 'dedent';
|
||||||
|
import { check } from 'express-validator/check';
|
||||||
|
import { isEmail } from 'validator';
|
||||||
|
|
||||||
import { homeLocation } from '../../../config/env';
|
import { homeLocation } from '../../../config/env';
|
||||||
import {
|
import {
|
||||||
@ -6,20 +9,34 @@ import {
|
|||||||
saveResponseAuthCookies,
|
saveResponseAuthCookies,
|
||||||
loginRedirect
|
loginRedirect
|
||||||
} from '../component-passport';
|
} from '../component-passport';
|
||||||
import { ifUserRedirectTo } from '../utils/middleware';
|
import { ifUserRedirectTo, ifNoUserRedirectTo } from '../utils/middleware';
|
||||||
import { wrapHandledError } from '../utils/create-handled-error.js';
|
import { wrapHandledError } from '../utils/create-handled-error.js';
|
||||||
import { removeCookies } from '../utils/getSetAccessToken';
|
import { removeCookies } from '../utils/getSetAccessToken';
|
||||||
|
import { decodeEmail } from '../../common/utils';
|
||||||
|
|
||||||
const isSignUpDisabled = !!process.env.DISABLE_SIGNUP;
|
const isSignUpDisabled = !!process.env.DISABLE_SIGNUP;
|
||||||
if (isSignUpDisabled) {
|
if (isSignUpDisabled) {
|
||||||
console.log('fcc:boot:auth - Sign up is disabled');
|
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) {
|
module.exports = function enableAuthentication(app) {
|
||||||
// enable loopback access control authentication. see:
|
// enable loopback access control authentication. see:
|
||||||
// loopback.io/doc/en/lb2/Authentication-authorization-and-permissions.html
|
// loopback.io/doc/en/lb2/Authentication-authorization-and-permissions.html
|
||||||
app.enableAuth();
|
app.enableAuth();
|
||||||
const ifUserRedirect = ifUserRedirectTo();
|
const ifUserRedirect = ifUserRedirectTo();
|
||||||
|
const ifNoUserRedirectHome = ifNoUserRedirectTo(homeLocation);
|
||||||
const saveAuthCookies = saveResponseAuthCookies();
|
const saveAuthCookies = saveResponseAuthCookies();
|
||||||
const loginSuccessRedirect = loginRedirect();
|
const loginSuccessRedirect = loginRedirect();
|
||||||
const api = app.loopback.Router();
|
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);
|
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)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
9
api-server/server/views/emails/user-request-sign-in.ejs
Normal file
9
api-server/server/views/emails/user-request-sign-in.ejs
Normal 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
|
13
api-server/server/views/emails/user-request-sign-up.ejs
Normal file
13
api-server/server/views/emails/user-request-sign-up.ejs
Normal 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
|
@ -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
|
Reference in New Issue
Block a user