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 { isEmail } from 'validator';
import path from 'path';
import loopback from 'loopback';
import { saveUser, observeMethod } from '../../server/utils/rx.js';
import { blacklistedUsernames } from '../../server/utils/constants.js';
@ -250,7 +251,7 @@ module.exports = function(User) {
if (!user.verificationToken && !user.emailVerified) {
ctx.req.flash('info', {
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.`
});
return ctx.res.redirect(redirect);
@ -259,7 +260,7 @@ module.exports = function(User) {
if (!user.verificationToken && user.emailVerified) {
ctx.req.flash('info', {
msg: dedent`Looks like you have already verified your email.
Please login to continue.`
Please sign in to continue.`
});
return ctx.res.redirect(redirect);
}
@ -267,7 +268,7 @@ module.exports = function(User) {
if (user.verificationToken && user.verificationToken !== token) {
ctx.req.flash('info', {
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);
}
@ -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) {
const fiveMinutesAgo = moment().subtract(5, 'minutes');
const lastEmailSentAt = moment(new Date(this.emailVerifyTTL || null));

View File

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

View File

@ -3,6 +3,7 @@ import moment from 'moment-timezone';
import { Observable } from 'rx';
import debugFactory from 'debug';
import emoji from 'node-emoji';
import uuid from 'node-uuid';
import {
frontEndChallengeId,
@ -168,6 +169,10 @@ module.exports = function(app) {
router.get('/email-signin', getEmailSignin);
router.get('/deprecated-signin', getDepSignin);
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(
'/delete-my-account',
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) {
req.logout();
res.redirect('/');

View File

@ -1,18 +1,65 @@
extends ../layout
block content
.row
.col-xs-12
h2.text-center Sign in with an email address here:
.col-sm-6.col-sm-offset-3
form(method='POST', action='/api/users/login')
input(type='hidden', name='_csrf', value=_csrf)
.form-group
input.input-lg.form-control(type='email', name='email', id='email', placeholder='Email', autofocus=true)
.form-group
input.input-lg.form-control(type='password', name='password', id='password', placeholder='Password')
button.btn.btn-primary.btn-lg.btn-block(type='submit')
span.ion-android-hand
| Login
.button-spacer
.button-spacer
a.btn.btn-info.btn-lg.btn-block(href='/forgot') Forgot your password?
.container
.col-xs-12
.row
.col-sm-6.col-sm-offset-3.flashMessage.negative-30
#flash-board.alert.fade.in(style='display: none;')
button.close(type='button', data-dismiss='alert')
span.ion-close-circled#flash-close
#flash-content
.row
.text-center
h2 Sign in with your Email here:
.button-spacer
.col-sm-6.col-sm-offset-3
form(method='POST', action='/passwordless-signin')
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 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
block content
script.
var challengeName = 'Email Signup'
h2.text-center Sign up with an email address here:
form.form-horizontal(method='POST', action='/api/users', name="signupForm")
.container
.col-xs-12
.row
.col-sm-6.col-sm-offset-3
input(type='hidden', name='_csrf', value=_csrf)
.form-group
input.input-lg.form-control(type='email', name='email', id='email', placeholder='email', autofocus, required, autocomplete="off")
.form-group
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.")
.form-group
button.btn.btn-lg.btn-primary.btn-block(type='submit')
span.ion-person-add
| Signup
.row
.col-sm-6.col-sm-offset-3
p.text-center
a(href="/signin") Click here if you already have an account and want to sign in.
.col-sm-6.col-sm-offset-3.flashMessage.negative-30
#flash-board.alert.fade.in(style='display: none;')
button.close(type='button', data-dismiss='alert')
span.ion-close-circled#flash-close
#flash-content
.row
.text-center
h2 Are you new to Free Code Camp?
br
.button-spacer
| Sign up with an Email here:
.button-spacer
.col-sm-6.col-sm-offset-3
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!
<br>
<br>
Thank you for joining our community.
<br>
<br>
Please verify your email by following the link below:
<br>
<br>
<a href="<%= verifyHref %>"><%= verifyHref %></a>
<br>
<br>
Feel free to email us at this address if you have any questions about freeCodeCamp.
<br>
<br>
@ -17,7 +13,7 @@ And if you have a moment, check out our blog: https://medium.freecodecamp.org.
<br>
<br>
Good luck with the challenges!
<br>
<br>
<br>
- the freeCodeCamp Team.
Thanks,
The freeCodeCamp Team.
team@freecodecamp.com

View File

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