From f8c818e7e73763f7673925887ceb8aa57bf97319 Mon Sep 17 00:00:00 2001 From: Mrugesh Mohapatra Date: Sun, 26 Jun 2016 21:34:01 +0530 Subject: [PATCH] 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 --- common/models/user.js | 114 ++++++++++- common/models/user.json | 10 + server/boot/user.js | 179 ++++++++++++++++++ server/views/account/email-signin.jade | 79 ++++++-- server/views/account/email-signup.jade | 83 ++++++-- server/views/emails/a-extend-user-welcome.ejs | 20 +- server/views/emails/user-email-verify.ejs | 19 +- server/views/emails/user-request-sign-in.ejs | 15 ++ server/views/emails/user-request-sign-up.ejs | 22 +++ 9 files changed, 482 insertions(+), 59 deletions(-) create mode 100644 server/views/emails/user-request-sign-in.ejs create mode 100644 server/views/emails/user-request-sign-up.ejs diff --git a/common/models/user.js b/common/models/user.js index 526c37474e..991e5b48d2 100644 --- a/common/models/user.js +++ b/common/models/user.js @@ -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)); diff --git a/common/models/user.json b/common/models/user.json index a1d7b3f782..5874419451 100644 --- a/common/models/user.json +++ b/common/models/user.json @@ -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": {} diff --git a/server/boot/user.js b/server/boot/user.js index 597073bd56..eb9b0c4f41 100644 --- a/server/boot/user.js +++ b/server/boot/user.js @@ -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 you’re 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 you’re 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('/'); diff --git a/server/views/account/email-signin.jade b/server/views/account/email-signin.jade index fed5792311..c9bcdc1128 100644 --- a/server/views/account/email-signin.jade +++ b/server/views/account/email-signin.jade @@ -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(); + } + }); + }); + }); diff --git a/server/views/account/email-signup.jade b/server/views/account/email-signup.jade index 03b8614495..1e9cf67dcd 100644 --- a/server/views/account/email-signup.jade +++ b/server/views/account/email-signup.jade @@ -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(); + } + }); + }); + }); diff --git a/server/views/emails/a-extend-user-welcome.ejs b/server/views/emails/a-extend-user-welcome.ejs index ca18e34a54..a86828caf8 100644 --- a/server/views/emails/a-extend-user-welcome.ejs +++ b/server/views/emails/a-extend-user-welcome.ejs @@ -1,15 +1,11 @@ Greetings from San Francisco! -
-
+ Thank you for joining our community. -
-
+ Please verify your email by following the link below: -
-
+ <%= verifyHref %> -
-
+ Feel free to email us at this address if you have any questions about freeCodeCamp.

@@ -17,7 +13,7 @@ And if you have a moment, check out our blog: https://medium.freecodecamp.org.

Good luck with the challenges! -
-
-
-- the freeCodeCamp Team. + +Thanks, +The freeCodeCamp Team. +team@freecodecamp.com diff --git a/server/views/emails/user-email-verify.ejs b/server/views/emails/user-email-verify.ejs index b858b5b090..db8df54f39 100644 --- a/server/views/emails/user-email-verify.ejs +++ b/server/views/emails/user-email-verify.ejs @@ -1,14 +1,13 @@ Thank you for updating your contact details. -
-
+ Please verify your email by following the link below: -
-
+ <%= verifyHref %> -
-
+ Please email us at this address if you have any questions about freeCodeCamp. -
-
-
-- the freeCodeCamp Team + +Good luck with the challenges! + +Thanks, +The freeCodeCamp Team. +team@freecodecamp.com diff --git a/server/views/emails/user-request-sign-in.ejs b/server/views/emails/user-request-sign-in.ejs new file mode 100644 index 0000000000..7543b67712 --- /dev/null +++ b/server/views/emails/user-request-sign-in.ejs @@ -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 diff --git a/server/views/emails/user-request-sign-up.ejs b/server/views/emails/user-request-sign-up.ejs new file mode 100644 index 0000000000..b5f59aeee7 --- /dev/null +++ b/server/views/emails/user-request-sign-up.ejs @@ -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