fix(passwordless): Reduce db calls, run in parallel

Adds validations, reduces the number of database calls, separates
concers. reduces logic
This commit is contained in:
Berkeley Martinez
2017-12-27 10:11:17 -08:00
committed by mrugesh mohapatra
parent 44c2eb65d5
commit 750c9f1eab
6 changed files with 287 additions and 194 deletions

View File

@ -8,5 +8,11 @@ module.exports = AccessToken => {
AccessToken.findOne$ = Observable.fromNodeCallback( AccessToken.findOne$ = Observable.fromNodeCallback(
AccessToken.findOne.bind(AccessToken) AccessToken.findOne.bind(AccessToken)
); );
AccessToken.prototype.validate$ = Observable.fromNodeCallback(
AccessToken.prototype.validate
);
AccessToken.prototype.destroy$ = Observable.fromNodeCallback(
AccessToken.prototype.destroy
);
}); });
}; };

View File

@ -243,6 +243,14 @@ module.exports = function(User) {
ctx.req.flash('error', { ctx.req.flash('error', {
msg: dedent`Oops, something went wrong, please try again later` msg: dedent`Oops, something went wrong, please try again later`
}); });
const err = wrapHandledError(
new Error('Theme is not valid.'),
{
Type: 'info',
message: err.message
}
);
return ctx.res.redirect('/'); return ctx.res.redirect('/');
} }
@ -475,22 +483,23 @@ module.exports = function(User) {
`); `);
} }
// email verified will be false if the user instance has just been created // create a temporary access token with ttl for 15 minutes
return this.createAccessToken$({ ttl: 15 * 60 * 1000 });
})
.flatMap(token => {
// email verified will be false if the user instance
// has just been created
const renderAuthEmail = this.emailVerified === false ? const renderAuthEmail = this.emailVerified === false ?
renderSignInEmail : renderSignInEmail :
renderSignUpEmail; renderSignUpEmail;
const { id: loginToken, created: emailAuthLinkTTL } = token;
// create a temporary access token with ttl for 15 minutes
return this.createAccessToken$({ ttl: 15 * 60 * 1000 })
.flatMap(token => {
const { id: loginToken } = token;
const loginEmail = this.getEncodedEmail(); const loginEmail = this.getEncodedEmail();
const host = getServerFullURL(); const host = getServerFullURL();
const mailOptions = { const mailOptions = {
type: 'email', type: 'email',
to: this.email, to: this.email,
from: getEmailSender(), from: getEmailSender(),
subject: 'freeCodeCamp - Authentication Request!', subject: 'Login Requested - freeCodeCamp',
text: renderAuthEmail({ text: renderAuthEmail({
host, host,
loginEmail, loginEmail,
@ -498,25 +507,15 @@ module.exports = function(User) {
}) })
}; };
return this.email.send$(mailOptions) return Observable.combineLatest(
.flatMap(() => { this.email.send$(mailOptions),
const emailAuthLinkTTL = token.created; this.update$({ emailAuthLinkTTL })
return this.update$({ );
emailAuthLinkTTL
}) })
.map(() => dedent` .map(() => dedent`
If you entered a valid email, a magic link is on its way. If you entered a valid email, a magic link is on its way.
Please follow that link to sign in. Please follow that link to sign in.
`); `);
});
});
})
.catch(err => {
if (err) { debug(err); }
return dedent`
Oops, something is not right, please try again later.
`;
});
}; };
User.prototype.requestUpdateEmail = function requestUpdateEmail( User.prototype.requestUpdateEmail = function requestUpdateEmail(

View File

@ -1,29 +1,32 @@
import _ from 'lodash';
import { Observable } from 'rx';
import dedent from 'dedent'; import dedent from 'dedent';
import debugFactory from 'debug'; // import debugFactory from 'debug';
import { isEmail } from 'validator';
import { check, validationResults } from 'express-validator/check';
import { ifUserRedirectTo } from '../utils/middleware';
import {
wrapHandledError,
createValidatorErrorFormatter
} from '../utils/create-handled-error.js';
const isSignUpDisabled = !!process.env.DISABLE_SIGNUP; const isSignUpDisabled = !!process.env.DISABLE_SIGNUP;
const debug = debugFactory('fcc:boot:auth'); // const debug = debugFactory('fcc:boot:auth');
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 router = app.loopback.Router(); const router = app.loopback.Router();
const api = app.loopback.Router(); const api = app.loopback.Router();
const { AccessToken, User } = app.models; const { AccessToken, User } = app.models;
router.get('/login', function(req, res) { router.get('/login', (req, res) => res.redirect(301, '/signin'));
res.redirect(301, '/signin'); router.get('/logout', (req, res) => res.redirect(301, '/signout'));
});
router.get('/logout', function(req, res) {
res.redirect(301, '/signout');
});
function getEmailSignin(req, res) { function getEmailSignin(req, res) {
if (req.user) {
return res.redirect('/');
}
if (isSignUpDisabled) { if (isSignUpDisabled) {
return res.render('account/beta', { return res.render('account/beta', {
title: 'New sign ups are disabled' title: 'New sign ups are disabled'
@ -34,152 +37,177 @@ module.exports = function enableAuthentication(app) {
}); });
} }
router.get('/signup', getEmailSignin); router.get('/signup', ifUserRedirect, getEmailSignin);
router.get('/signin', getEmailSignin); router.get('/signin', ifUserRedirect, getEmailSignin);
router.get('/email-signin', getEmailSignin); router.get('/email-signin', ifUserRedirect, getEmailSignin);
function signout(req, res) { router.get('/signout', (req, res) => {
req.logout(); req.logout();
res.redirect('/'); res.redirect('/');
} });
router.get('/signout', signout);
function getDepSignin(req, res) { router.get(
if (req.user) { '/deprecated-signin',
return res.redirect('/'); ifUserRedirect,
} (req, res) => res.render('account/deprecated-signin', {
return res.render('account/deprecated-signin', {
title: 'Sign in to freeCodeCamp using a Deprecated Login' title: 'Sign in to freeCodeCamp using a Deprecated Login'
});
}
router.get('/deprecated-signin', getDepSignin);
function invalidateAuthToken(req, res, next) {
if (req.user) {
return res.redirect('/');
}
if (!req.query || !req.query.email || !req.query.token) {
req.flash('info', { msg: defaultErrorMsg });
return res.redirect('/email-signin');
}
const authTokenId = req.query.token;
const authEmailId = new Buffer(req.query.email, 'base64').toString();
return AccessToken.findOne$({ where: {id: authTokenId} })
.map(authToken => {
if (!authToken) {
req.flash('info', { msg: defaultErrorMsg });
return res.redirect('/email-signin');
}
const userId = authToken.userId;
return User.findById(userId, (err, user) => {
if (err || !user || user.email !== authEmailId) {
debug(err);
req.flash('info', { msg: defaultErrorMsg });
return res.redirect('/email-signin');
}
return authToken.validate((err, isValid) => {
if (err) { throw err; }
if (!isValid) {
req.flash('info', { msg: [ 'Looks like the link you clicked has',
'expired, please request a fresh link, to sign in.'].join('')
});
return res.redirect('/email-signin');
}
return authToken.destroy((err) => {
if (err) { debug(err); }
next();
});
});
});
}) })
.subscribe(
() => {},
next
); );
}
const defaultErrorMsg = dedent` const defaultErrorMsg = dedent`
Oops, something is not right, Oops, something is not right,
please request a fresh link to sign in / sign up. please request a fresh link to sign in / sign up.
`; `;
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')
];
function getPasswordlessAuth(req, res, next) { function getPasswordlessAuth(req, res, next) {
if (req.user) { const {
req.flash('info', { query: {
msg: 'Hey, looks like youre already signed in.' email: encodedEmail,
}); token: authTokenId
return res.redirect('/'); } = {}
} = req;
const validation = validationResults(req)
.formatWith(createValidatorErrorFormatter('info', '/email-signup'));
if (!validation.isEmpty()) {
const errors = validation.array();
return next(errors.pop());
} }
if (!req.query || !req.query.email || !req.query.token) { const email = User.decodeEmail(encodedEmail);
req.flash('info', { msg: defaultErrorMsg }); if (!isEmail(email)) {
return res.redirect('/email-signin'); return next(wrapHandledError(
new TypeError('decoded email is invalid'),
{
type: 'info',
message: 'The email encoded in the link is incorrectly formatted',
redirectTo: '/email-sign'
} }
));
const email = new Buffer(req.query.email, 'base64').toString(); }
// first find
return User.findOne$({ where: { email }}) return AccessToken.findOne$({ where: { id: authTokenId } })
.map(user => { .flatMap(authToken => {
if (authToken) {
throw wrapHandledError(
new Error(`no token found for id: ${authTokenId}`),
{
type: 'info',
message: defaultErrorMsg,
redirectTo: '/email-signin'
}
);
}
// find user then validate and destroy email validation token
// finally retun user instance
return Observable.fromNodeCallback(authToken.user.bind(authToken))
.flatMap(user => {
if (!user) { if (!user) {
debug(`did not find a valid user with email: ${email}`); throw wrapHandledError(
req.flash('info', { msg: defaultErrorMsg }); new Error(`no user found for token: ${authTokenId}`),
return res.redirect('/email-signin'); {
type: 'info',
message: defaultErrorMsg,
redirectTo: '/email-signin'
} }
);
}
if (user.email !== email) {
throw wrapHandledError(
new Error('user email does not match'),
{
type: 'info',
message: defaultErrorMsg,
redirectTo: '/email-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: '/email-signin'
}
);
}
return authToken.destroy$();
})
.map(() => user);
});
})
// at this point token has been validated and destroyed
// update user and log them in
.map(user => {
const emailVerified = true; const emailVerified = true;
const emailAuthLinkTTL = null; const emailAuthLinkTTL = null;
const emailVerifyTTL = null; const emailVerifyTTL = null;
user.update$({
emailVerified, emailAuthLinkTTL, emailVerifyTTL const updateUser = user.update$({
emailVerified,
emailAuthLinkTTL,
emailVerifyTTL
}) })
.do((user) => { .do((user) => {
// update$ does not update in place
// update user instance to reflect db
user.emailVerified = emailVerified; user.emailVerified = emailVerified;
user.emailAuthLinkTTL = emailAuthLinkTTL; user.emailAuthLinkTTL = emailAuthLinkTTL;
user.emailVerifyTTL = emailVerifyTTL; user.emailVerifyTTL = emailVerifyTTL;
}); });
return user.createAccessToken( const createToken = user.createAccessToken()
{ ttl: User.settings.ttl }, (err, accessToken) => { .do(accessToken => {
if (err) { throw err; } const config = {
var config = {
signed: !!req.signedCookies, signed: !!req.signedCookies,
maxAge: accessToken.ttl maxAge: accessToken.ttl
}; };
if (accessToken && accessToken.id) { if (accessToken && accessToken.id) {
debug('setting cookies');
res.cookie('access_token', accessToken.id, config); res.cookie('access_token', accessToken.id, config);
res.cookie('userId', accessToken.userId, config); res.cookie('userId', accessToken.userId, config);
} }
});
return req.logIn({ return Observable.combineLatest(
id: accessToken.userId.toString() }, err => { updateUser,
if (err) { return next(err); } createToken,
req.logIn(user),
);
})
.do(() => {
let redirectTo = '/';
debug('user logged in'); if (
req.session &&
if (req.session && req.session.returnTo) { req.session.returnTo
var redirectTo = req.session.returnTo; ) {
if (redirectTo === '/map-aside') { redirectTo = req.session.returnTo;
redirectTo = '/map';
}
return res.redirect(redirectTo);
} }
req.flash('success', { msg: req.flash('success', { msg:
'Success! You have signed in to your account. Happy Coding!' 'Success! You have signed in to your account. Happy Coding!'
}); });
return res.redirect('/');
}); return res.redirect(redirectTo);
});
}) })
.subscribe( .subscribe(
() => {}, () => {},
@ -187,24 +215,43 @@ module.exports = function enableAuthentication(app) {
); );
} }
router.get('/passwordless-auth', invalidateAuthToken, getPasswordlessAuth); router.get(
'/passwordless-auth',
ifUserRedirect,
passwordlessGetValidators,
getPasswordlessAuth
);
function postPasswordlessAuth(req, res) { const passwordlessPostValidators = [
if (req.user || !(req.body && req.body.email)) { check('email')
return res.redirect('/'); .isEmail()
.withMessage('email is not a valid email address')
];
function postPasswordlessAuth(req, res, next) {
const { body: { email } = {} } = req;
const validation = validationResults(req)
.formatWith(createValidatorErrorFormatter('info', '/email-signup'));
if (!validation.isEmpty()) {
const errors = validation.array();
return next(errors.pop());
} }
return User.requestAuthEmail(req.body.email) return User.findOne$({ where: { email } })
.then(msg => { .flatMap(user => (
return res.status(200).send({ message: msg }); // if no user found create new user and save to db
}) user ? Observable.of(user) : User.create$({ email })
.catch(err => { ))
debug(err); .flatMap(user => user.requestAuthEmail())
return res.status(200).send({ message: defaultErrorMsg }); .do(msg => res.status(200).send({ message: msg }))
}); .subscribe(_.noop, next);
} }
api.post('/passwordless-auth', postPasswordlessAuth); api.post(
'/passwordless-auth',
ifUserRedirect,
passwordlessPostValidators,
postPasswordlessAuth
);
app.use('/:lang', router); app.use('/:lang', router);
app.use(api); app.use(api);

View File

@ -0,0 +1,21 @@
import _ from 'lodash';
import http from 'http';
import { Observable } from 'rx';
import { login } from 'passport/lib/http/request';
// make login polymorphic
// if supplied callback it works as normal
// if called without callback it returns an observable
// login(user, options?, cb?) => Void|Observable
function login$(...args) {
if (_.isFunction(_.last(args))) {
return login.apply(this, args);
}
return Observable.fromNodeCallback(login).apply(this, args);
}
module.exports = function extendRequest() {
// see: jaredhanson/passport/blob/master/lib/framework/connect.js#L33
http.IncomingMessage.prototype.login = login$;
http.IncomingMessage.prototype.logIn = login$;
};

View File

@ -16,3 +16,13 @@ export function wrapHandledError(err, {
err[_handledError] = { type, message, redirectTo }; err[_handledError] = { type, message, redirectTo };
return err; return err;
} }
export const createValidatorErrorFormatter = (type, redirectTo) =>
({ msg }) => wrapHandledError(
new Error(msg),
{
type,
message: msg,
redirectTo
}
);

View File

@ -43,3 +43,13 @@ export function ifNotVerifiedRedirectToSettings(req, res, next) {
} }
return next(); return next();
} }
export function ifUserRedirectTo(path = '/', status) {
status = status === 302 ? 302 : 301;
return (req, res, next) => {
if (req.user) {
return res.status(status).redirect(path);
}
return next();
};
}