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:
committed by
mrugesh mohapatra
parent
44c2eb65d5
commit
750c9f1eab
@ -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
|
||||||
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -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(
|
||||||
|
@ -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 you’re 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);
|
||||||
|
21
server/boot/extend-request.js
Normal file
21
server/boot/extend-request.js
Normal 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$;
|
||||||
|
};
|
@ -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
|
||||||
|
}
|
||||||
|
);
|
||||||
|
@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user