Implement passwordless login

* Created a new rest API to create and save a temporary token that
  can be exchanged for a access token.
* Updated the sign in view
* Add email template for sign in links
* Add route to request a access token and login user
* Make the email views conistent
This commit is contained in:
Mrugesh Mohapatra
2016-06-26 21:34:01 +05:30
committed by Berkeley Martinez
parent 900617613f
commit f8c818e7e7
9 changed files with 482 additions and 59 deletions

View File

@ -5,6 +5,7 @@ import dedent from 'dedent';
import debugFactory from 'debug'; import debugFactory from 'debug';
import { isEmail } from 'validator'; import { isEmail } from 'validator';
import path from 'path'; import path from 'path';
import loopback from 'loopback';
import { saveUser, observeMethod } from '../../server/utils/rx.js'; import { saveUser, observeMethod } from '../../server/utils/rx.js';
import { blacklistedUsernames } from '../../server/utils/constants.js'; import { blacklistedUsernames } from '../../server/utils/constants.js';
@ -250,7 +251,7 @@ module.exports = function(User) {
if (!user.verificationToken && !user.emailVerified) { if (!user.verificationToken && !user.emailVerified) {
ctx.req.flash('info', { ctx.req.flash('info', {
msg: dedent`Looks like we have your email. But you haven't msg: dedent`Looks like we have your email. But you haven't
verified it yet, please login and request a fresh verification verified it yet, please sign in and request a fresh verification
link.` link.`
}); });
return ctx.res.redirect(redirect); return ctx.res.redirect(redirect);
@ -259,7 +260,7 @@ module.exports = function(User) {
if (!user.verificationToken && user.emailVerified) { if (!user.verificationToken && user.emailVerified) {
ctx.req.flash('info', { ctx.req.flash('info', {
msg: dedent`Looks like you have already verified your email. msg: dedent`Looks like you have already verified your email.
Please login to continue.` Please sign in to continue.`
}); });
return ctx.res.redirect(redirect); return ctx.res.redirect(redirect);
} }
@ -267,7 +268,7 @@ module.exports = function(User) {
if (user.verificationToken && user.verificationToken !== token) { if (user.verificationToken && user.verificationToken !== token) {
ctx.req.flash('info', { ctx.req.flash('info', {
msg: dedent`Looks like you have clicked an invalid link. msg: dedent`Looks like you have clicked an invalid link.
Please login and request a fresh one.` Please sign in and request a fresh one.`
}); });
return ctx.res.redirect(redirect); return ctx.res.redirect(redirect);
} }
@ -487,6 +488,113 @@ module.exports = function(User) {
} }
); );
User.requestAuthLink = function requestAuthLink(email, emailTemplate) {
if (!isEmail(email)) {
return Promise.reject(
new Error('The submitted email not valid.')
);
}
const filter = {
where: { email },
// remove password from the query
fields: { password: null }
};
return User.findOne$(filter)
.map(user => {
if (!user) {
debug(`no user found with the email ${email}.`);
// do not let the user know if an email is not found
// this is to avoid sending spam requests to valid users
return dedent`
If you entered a valid email, a magic link is on its way.
Please click that link to sign in.`;
}
// Todo : Break this below chunk to a separate function
const fiveMinutesAgo = moment().subtract(5, 'minutes');
const lastEmailSentAt = moment(new Date(user.emailAuthLinkTTL || null));
const isWaitPeriodOver = user.emailAuthLinkTTL ?
lastEmailSentAt.isBefore(fiveMinutesAgo) : true;
if (!isWaitPeriodOver) {
const minutesLeft = 5 -
(moment().minutes() - lastEmailSentAt.minutes());
const timeToWait = minutesLeft ?
`${minutesLeft} minute${minutesLeft > 1 ? 's' : ''}` :
'a few seconds';
debug('request before wait time : ' + timeToWait);
return dedent`
Please wait ${timeToWait} to resend email verification.`;
}
// create a temporary access token with ttl for 1 hour
user.createAccessToken({ ttl: 60 * 60 * 1000 }, (err, token) => {
if (err) { throw err; }
const { id: loginToken } = token;
const loginEmail = user.email;
const renderAuthEmail = loopback.template(path.join(
__dirname,
'..',
'..',
'server',
'views',
'emails',
emailTemplate
));
const mailOptions = {
type: 'email',
to: user.email,
from: 'Team@freecodecamp.com',
subject: 'Free Code Camp - Sign in Request!',
text: renderAuthEmail({
loginEmail,
loginToken
})
};
this.email.send(mailOptions, err =>{
if (err) { throw err; }
});
user.emailAuthLinkTTL = token.created;
user.save(err =>{ if (err) { throw err; }});
});
return dedent`
If you entered a valid email, a magic link is on its way.
Please follow that link to sign in.`;
})
.map((msg) => {
if (msg) { return msg; }
return dedent`
Oops, something is not right, please try again later.`;
})
.catch(error => {
debug(error);
return Observable.throw(
'Oops, something went wrong, please try again later.'
);
})
.toPromise();
};
User.remoteMethod(
'requestAuthLink',
{
description: 'request a link on email with temporary token to sign in',
accepts: [{
arg: 'email', type: 'string', required: true
}, {
arg: 'emailTemplate', type: 'string', required: true
}],
returns: [{
arg: 'message', type: 'string'
}],
http: {
path: '/request-auth-link', verb: 'POST'
}
}
);
User.prototype.updateEmail = function updateEmail(email) { User.prototype.updateEmail = function updateEmail(email) {
const fiveMinutesAgo = moment().subtract(5, 'minutes'); const fiveMinutesAgo = moment().subtract(5, 'minutes');
const lastEmailSentAt = moment(new Date(this.emailVerifyTTL || null)); const lastEmailSentAt = moment(new Date(this.emailVerifyTTL || null));

View File

@ -19,6 +19,9 @@
"emailVerifyTTL": { "emailVerifyTTL": {
"type": "date" "type": "date"
}, },
"emailAuthLinkTTL": {
"type": "date"
},
"password": { "password": {
"type": "string" "type": "string"
}, },
@ -288,6 +291,13 @@
"principalId": "$owner", "principalId": "$owner",
"permission": "ALLOW", "permission": "ALLOW",
"property": "updateLanguage" "property": "updateLanguage"
},
{
"accessType": "EXECUTE",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW",
"property": "requestAuthLink"
} }
], ],
"methods": {} "methods": {}

View File

@ -3,6 +3,7 @@ import moment from 'moment-timezone';
import { Observable } from 'rx'; import { Observable } from 'rx';
import debugFactory from 'debug'; import debugFactory from 'debug';
import emoji from 'node-emoji'; import emoji from 'node-emoji';
import uuid from 'node-uuid';
import { import {
frontEndChallengeId, frontEndChallengeId,
@ -168,6 +169,10 @@ module.exports = function(app) {
router.get('/email-signin', getEmailSignin); router.get('/email-signin', getEmailSignin);
router.get('/deprecated-signin', getDepSignin); router.get('/deprecated-signin', getDepSignin);
router.get('/update-email', getUpdateEmail); router.get('/update-email', getUpdateEmail);
router.get('/passwordless-signin', getPasswordlessSignin);
router.get('/passwordless-signup', getPasswordlessSignup);
api.post('/passwordless-signin', postPasswordlessSignin);
api.post('/passwordless-signup', postPasswordlessSignup);
router.get( router.get(
'/delete-my-account', '/delete-my-account',
sendNonUserToMap, sendNonUserToMap,
@ -247,6 +252,180 @@ module.exports = function(app) {
}); });
} }
function postPasswordlessSignup(req, res) {
if (req.user) {
return res.redirect('/');
}
if (req.body && req.body.email) {
var userObj = {
username: 'fcc' + uuid.v4().slice(0, 8),
email: req.body.email,
emailVerified: false
};
var data = { or: [
{ username: userObj.username },
{ email: userObj.email },
{ emailVerified: userObj.emailVerified }
]};
return User.findOrCreate({where: data}, userObj, function(err, user) {
if (err) {
throw err;
}
User.requestAuthLink(user.email, 'user-request-sign-up.ejs');
});
} else {
return res.redirect('/');
}
}
function postPasswordlessSignin(req, res) {
if (req.user) {
return res.redirect('/');
}
if (req.body && req.body.email) {
var data = { or: [
{ email: req.body.email },
{ emailVerified: true }
]};
return User.findOne$({ where: { data }})
.map(user => {
User.requestAuthLink(user.email, 'user-request-sign-in.ejs');
});
} else {
return res.redirect('/');
}
}
function getPasswordlessSignup(req, res, next) {
if (req.user) {
req.flash('info', {
msg: 'Hey, looks like youre already signed in.'
});
return res.redirect('/');
}
const defaultErrorMsg = [
'Oops, something is not right, ',
'please request a fresh link to sign in.'].join('');
if (!req.query || !req.query.email || !req.query.token) {
req.flash('info', { msg: defaultErrorMsg });
return res.redirect('/email-signup');
}
const email = req.query.email;
/* const tokenId = req.query.token; */
return User.findOne$({ where: { email }})
.map(user => {
return user.createAccessToken(
{ ttl: User.settings.ttl }, (err, accessToken) => {
if (err) { throw err; }
var config = {
signed: !!req.signedCookies,
maxAge: accessToken.ttl
};
if (accessToken && accessToken.id) {
debug('setting cookies');
res.cookie('access_token', accessToken.id, config);
res.cookie('userId', accessToken.userId, config);
}
return req.logIn({
id: accessToken.userId.toString() }, err => {
if (err) { return next(err); }
debug('user logged in');
if (req.session && req.session.returnTo) {
var redirectTo = req.session.returnTo;
if (redirectTo === '/map-aside') {
redirectTo = '/map';
}
return res.redirect(redirectTo);
}
req.flash('success', { msg:
'Success! You have signed in to your account. Happy Coding!'
});
return res.redirect('/');
});
});
})
.subscribe(
() => {},
next
);
}
function getPasswordlessSignin(req, res, next) {
if (req.user) {
req.flash('info', {
msg: 'Hey, looks like youre already signed in.'
});
return res.redirect('/');
}
const defaultErrorMsg = [
'Oops, something is not right, ',
'please request a fresh link to sign in.'].join('');
if (!req.query || !req.query.email || !req.query.token) {
req.flash('info', { msg: defaultErrorMsg });
return res.redirect('/email-signin');
}
const email = req.query.email;
/* const tokenId = req.query.token; */
return User.findOne$({ where: { email }})
.map(user => {
return user.createAccessToken(
{ ttl: User.settings.ttl }, (err, accessToken) => {
if (err) { throw err; }
var config = {
signed: !!req.signedCookies,
maxAge: accessToken.ttl
};
if (accessToken && accessToken.id) {
debug('setting cookies');
res.cookie('access_token', accessToken.id, config);
res.cookie('userId', accessToken.userId, config);
}
return req.logIn({
id: accessToken.userId.toString() }, err => {
if (err) { return next(err); }
debug('user logged in');
if (req.session && req.session.returnTo) {
var redirectTo = req.session.returnTo;
if (redirectTo === '/map-aside') {
redirectTo = '/map';
}
return res.redirect(redirectTo);
}
req.flash('success', { msg:
'Success! You have signed in to your account. Happy Coding!'
});
return res.redirect('/');
});
});
})
.subscribe(
() => {},
next
);
}
function signout(req, res) { function signout(req, res) {
req.logout(); req.logout();
res.redirect('/'); res.redirect('/');

View File

@ -1,18 +1,65 @@
extends ../layout extends ../layout
block content block content
.row .container
.col-xs-12 .col-xs-12
h2.text-center Sign in with an email address here: .row
.col-sm-6.col-sm-offset-3 .col-sm-6.col-sm-offset-3.flashMessage.negative-30
form(method='POST', action='/api/users/login') #flash-board.alert.fade.in(style='display: none;')
input(type='hidden', name='_csrf', value=_csrf) button.close(type='button', data-dismiss='alert')
.form-group span.ion-close-circled#flash-close
input.input-lg.form-control(type='email', name='email', id='email', placeholder='Email', autofocus=true) #flash-content
.form-group .row
input.input-lg.form-control(type='password', name='password', id='password', placeholder='Password') .text-center
button.btn.btn-primary.btn-lg.btn-block(type='submit') h2 Sign in with your Email here:
span.ion-android-hand .button-spacer
| Login .col-sm-6.col-sm-offset-3
.button-spacer form(method='POST', action='/passwordless-signin')
.button-spacer input(type='hidden', name='_csrf', value=_csrf)
a.btn.btn-info.btn-lg.btn-block(href='/forgot') Forgot your password? .form-group
input.input-lg.form-control(type='email', name='email', id='email', placeholder='Email', autofocus=true)
.button-spacer
button.btn.btn-primary.btn-lg.btn-block(type='submit')
span.fa.fa-envelope
| Get a magic link to sign in.
.row
.col-sm-6.col-sm-offset-3
br
p.text-center
a(href="/signup") Or Click here if you want to sign up.
script.
$(document).ready(function() {
$('form').submit(function(event){
event.preventDefault();
$('#flash-board').hide();
var $form = $(event.target);
$.ajax({
type : 'POST',
url : $form.attr('action'),
data : $form.serialize(),
dataType : 'json',
encode : true,
xhrFields : { withCredentials: true }
})
.fail(error => {
if (error.responseText){
var data = JSON.parse(error.responseText);
if(data.error && data.error.message)
$('#flash-content').html(data.error.message);
$('#flash-board')
.removeClass('alert-success')
.addClass('alert-info')
.fadeIn();
}
})
.done(data =>{
if(data && data.message){
$('#flash-content').html(data.message);
$('#flash-board')
.removeClass('alert-info')
.addClass('alert-success')
.fadeIn();
}
});
});
});

View File

@ -1,21 +1,68 @@
extends ../layout extends ../layout
block content block content
script. .container
var challengeName = 'Email Signup' .col-xs-12
h2.text-center Sign up with an email address here:
form.form-horizontal(method='POST', action='/api/users', name="signupForm")
.row .row
.col-sm-6.col-sm-offset-3 .col-sm-6.col-sm-offset-3.flashMessage.negative-30
input(type='hidden', name='_csrf', value=_csrf) #flash-board.alert.fade.in(style='display: none;')
.form-group button.close(type='button', data-dismiss='alert')
input.input-lg.form-control(type='email', name='email', id='email', placeholder='email', autofocus, required, autocomplete="off") span.ion-close-circled#flash-close
.form-group #flash-content
input.input-lg.form-control(type='password', name='password', id='password', placeholder='password', required, pattern=".{8,50}", title="Must be at least 8 characters and no longer than 50 characters.") .row
.form-group .text-center
button.btn.btn-lg.btn-primary.btn-block(type='submit') h2 Are you new to Free Code Camp?
span.ion-person-add br
| Signup .button-spacer
.row | Sign up with an Email here:
.col-sm-6.col-sm-offset-3 .button-spacer
p.text-center .col-sm-6.col-sm-offset-3
a(href="/signin") Click here if you already have an account and want to sign in. form(method='POST', action='/passwordless-signup')
input(type='hidden', name='_csrf', value=_csrf)
.form-group
input.input-lg.form-control(type='email', name='email', id='email', placeholder='Email', autofocus=true)
.button-spacer
button.btn.btn-primary.btn-lg.btn-block(type='submit')
span.fa.fa-envelope
| Get a magic link to sign up.
.row
.col-sm-6.col-sm-offset-3
br
p.text-center
a(href="/signin") Click here if you already have an account and want to sign in.
script.
$(document).ready(function() {
$('form').submit(function(event){
event.preventDefault();
$('#flash-board').hide();
var $form = $(event.target);
$.ajax({
type : 'POST',
url : $form.attr('action'),
data : $form.serialize(),
dataType : 'json',
encode : true,
xhrFields : { withCredentials: true }
})
.fail(error => {
if (error.responseText){
var data = JSON.parse(error.responseText);
if(data.error && data.error.message)
$('#flash-content').html(data.error.message);
$('#flash-board')
.removeClass('alert-success')
.addClass('alert-info')
.fadeIn();
}
})
.done(data =>{
if(data && data.message){
$('#flash-content').html(data.message);
$('#flash-board')
.removeClass('alert-info')
.addClass('alert-success')
.fadeIn();
}
});
});
});

View File

@ -1,15 +1,11 @@
Greetings from San Francisco! Greetings from San Francisco!
<br>
<br>
Thank you for joining our community. Thank you for joining our community.
<br>
<br>
Please verify your email by following the link below: Please verify your email by following the link below:
<br>
<br>
<a href="<%= verifyHref %>"><%= verifyHref %></a> <a href="<%= verifyHref %>"><%= verifyHref %></a>
<br>
<br>
Feel free to email us at this address if you have any questions about freeCodeCamp. Feel free to email us at this address if you have any questions about freeCodeCamp.
<br> <br>
<br> <br>
@ -17,7 +13,7 @@ And if you have a moment, check out our blog: https://medium.freecodecamp.org.
<br> <br>
<br> <br>
Good luck with the challenges! Good luck with the challenges!
<br>
<br> Thanks,
<br> The freeCodeCamp Team.
- the freeCodeCamp Team. team@freecodecamp.com

View File

@ -1,14 +1,13 @@
Thank you for updating your contact details. Thank you for updating your contact details.
<br>
<br>
Please verify your email by following the link below: Please verify your email by following the link below:
<br>
<br>
<a href="<%= verifyHref %>"><%= verifyHref %></a> <a href="<%= verifyHref %>"><%= verifyHref %></a>
<br>
<br>
Please email us at this address if you have any questions about freeCodeCamp. Please email us at this address if you have any questions about freeCodeCamp.
<br>
<br> Good luck with the challenges!
<br>
- the freeCodeCamp Team Thanks,
The freeCodeCamp Team.
team@freecodecamp.com

View File

@ -0,0 +1,15 @@
Greetings from San Francisco!
You can now sign in to Free Code Camp, without a password. Just follow the link below:
https://freecodecamp.com/passwordless-signin?email=<%= loginEmail %>&token=<%= loginToken %>
IMPORTANT NOTE:
You can simply delete or ignore this email, if you did not make any such request.
Do not share this email with anyone, doing so may give them access to your account.
Good luck with the challenges!
Thanks,
The Free Code Camp Team.
team@freecodecamp.com

View File

@ -0,0 +1,22 @@
Greetings from San Francisco!
Welcome to Free Code Camp. We've created a new account for you.
To verify and start using your profile just follow the link below:
https://freecodecamp.com/passwordless-signup?email=<%= loginEmail %>&token=<%= loginToken %>
Next steps:
1. Visit the settings page and link your account to Github.
2. Follow our Medium Publication: https://medium.freecodecamp.com
3. Checkout our forum: https://forum.freecodecamp.com
4. Join the conversation: https://gitter.im/FreeCodeCamp/FreeCodeCamp
IMPORTANT NOTE:
You can simply delete or ignore this email, if you did not make any such request.
Do not share this email with anyone, doing so may give them access to your account.
Good luck with the challenges!
Thanks,
The Free Code Camp Team.
team@freecodecamp.com