diff --git a/api-server/common/models/user.js b/api-server/common/models/user.js index d92514b4659..136562d7166 100644 --- a/api-server/common/models/user.js +++ b/api-server/common/models/user.js @@ -11,24 +11,24 @@ import moment from 'moment'; import dedent from 'dedent'; import debugFactory from 'debug'; import { isEmail } from 'validator'; -import path from 'path'; -import loopback from 'loopback'; import _ from 'lodash'; -import jwt from 'jsonwebtoken'; import generate from 'nanoid/generate'; -import { homeLocation, apiLocation } from '../../../config/env'; +import { apiLocation } from '../../../config/env'; import { fixCompletedChallengeItem } from '../utils'; import { saveUser, observeMethod } from '../../server/utils/rx.js'; import { blacklistedUsernames } from '../../server/utils/constants.js'; import { wrapHandledError } from '../../server/utils/create-handled-error.js'; -import { getEmailSender } from '../../server/utils/url-utils.js'; import { normaliseUserFields, getProgress, publicUserProps } from '../../server/utils/publicUserProps'; +import { + setAccessTokenToResponse, + removeCookies +} from '../../server/utils/getSetAccessToken'; const log = debugFactory('fcc:models:user'); const BROWNIEPOINTS_TIMEOUT = [1, 'hour']; @@ -93,42 +93,6 @@ function isTheSame(val1, val2) { return val1 === val2; } -const renderSignUpEmail = loopback.template( - path.join( - __dirname, - '..', - '..', - 'server', - 'views', - 'emails', - 'user-request-sign-up.ejs' - ) -); - -const renderSignInEmail = loopback.template( - path.join( - __dirname, - '..', - '..', - 'server', - 'views', - 'emails', - 'user-request-sign-in.ejs' - ) -); - -const renderEmailChangeEmail = loopback.template( - path.join( - __dirname, - '..', - '..', - 'server', - 'views', - 'emails', - 'user-request-update-email.ejs' - ) -); - function getAboutProfile({ username, githubProfile: github, @@ -147,37 +111,34 @@ function nextTick(fn) { return process.nextTick(fn); } -function getWaitPeriod(ttl) { - const fiveMinutesAgo = moment().subtract(5, 'minutes'); - const lastEmailSentAt = moment(new Date(ttl || null)); - const isWaitPeriodOver = ttl - ? lastEmailSentAt.isBefore(fiveMinutesAgo) - : true; - - if (!isWaitPeriodOver) { - const minutesLeft = 5 - (moment().minutes() - lastEmailSentAt.minutes()); - return minutesLeft; - } - - return 0; -} - -function getWaitMessage(ttl) { - const minutesLeft = getWaitPeriod(ttl); - if (minutesLeft <= 0) { - return null; - } - const timeToWait = minutesLeft - ? `${minutesLeft} minute${minutesLeft > 1 ? 's' : ''}` - : 'a few seconds'; - - return dedent` - Please wait ${timeToWait} to resend an authentication link. - `; -} const getRandomNumber = () => Math.random(); -module.exports = function(User) { +function populateRequiredFields(user) { + user.username = user.username.trim().toLowerCase(); + user.email = + typeof user.email === 'string' + ? user.email.trim().toLowerCase() + : user.email; + + if (!user.progressTimestamps) { + user.progressTimestamps = []; + } + + if (user.progressTimestamps.length === 0) { + user.progressTimestamps.push(Date.now()); + } + + if (!user.externalId) { + user.externalId = uuid(); + } + + if (!user.unsubscribeId) { + user.unsubscribeId = generate(nanoidCharSet, 20); + } + return; +} + +export default function(User) { // set salt factor for passwords User.settings.saltWorkFactor = 5; // set user.rand to random number @@ -217,29 +178,13 @@ module.exports = function(User) { throw createEmailError(); } // assign random username to new users - // actual usernames will come from github - // use full uuid to ensure uniqueness user.username = 'fcc' + uuid(); - - if (!user.externalId) { - user.externalId = uuid(); - } - if (!user.unsubscribeId) { - user.unsubscribeId = generate(nanoidCharSet, 20); - } - - if (!user.progressTimestamps) { - user.progressTimestamps = []; - } - - if (user.progressTimestamps.length === 0) { - user.progressTimestamps.push(Date.now()); - } + populateRequiredFields(user); return Observable.fromPromise(User.doesExist(null, user.email)).do( exists => { if (exists) { throw wrapHandledError(new Error('user already exists'), { - redirectTo: `${homeLocation}/signin`, + redirectTo: `${apiLocation}/signin`, message: dedent` The ${user.email} email address is already associated with an account. Try signing in with it here instead. @@ -263,28 +208,7 @@ module.exports = function(User) { if (user.email && !isEmail(user.email)) { throw createEmailError(); } - - user.username = user.username.trim().toLowerCase(); - user.email = - typeof user.email === 'string' - ? user.email.trim().toLowerCase() - : user.email; - - if (!user.progressTimestamps) { - user.progressTimestamps = []; - } - - if (user.progressTimestamps.length === 0) { - user.progressTimestamps.push(Date.now()); - } - - if (!user.externalId) { - user.externalId = uuid(); - } - - if (!user.unsubscribeId) { - user.unsubscribeId = generate(nanoidCharSet, 20); - } + populateRequiredFields(user); }) .ignoreElements(); return Observable.merge(beforeCreate, updateOrSave).toPromise(); @@ -364,32 +288,13 @@ module.exports = function(User) { }); }; - function manualReload() { - this.reload((err, instance) => { - if (err) { - throw Error('failed to reload user instance'); - } - Object.assign(this, instance); - log('user reloaded from db'); - }); - } - User.prototype.manualReload = manualReload; - User.prototype.loginByRequest = function loginByRequest(req, res) { const { query: { emailChange } } = req; const createToken = this.createAccessToken$().do(accessToken => { - const config = { - signed: !!req.signedCookies, - maxAge: accessToken.ttl, - domain: process.env.COOKIE_DOMAIN || 'localhost' - }; if (accessToken && accessToken.id) { - const jwtAccess = jwt.sign({ accessToken }, process.env.JWT_SECRET); - res.cookie('jwt_access_token', jwtAccess, config); - res.cookie('access_token', accessToken.id, config); - res.cookie('userId', accessToken.userId, config); + setAccessTokenToResponse({ accessToken }, req, res); } }); let data = { @@ -421,14 +326,7 @@ module.exports = function(User) { }; User.afterRemote('logout', function({ req, res }, result, next) { - const config = { - signed: !!req.signedCookies, - domain: process.env.COOKIE_DOMAIN || 'localhost' - }; - res.clearCookie('jwt_access_token', config); - res.clearCookie('access_token', config); - res.clearCookie('userId', config); - res.clearCookie('_csrf', config); + removeCookies(req, res); next(); }); @@ -533,154 +431,6 @@ module.exports = function(User) { ); }; - User.prototype.getEncodedEmail = function getEncodedEmail(email) { - if (!email) { - return null; - } - return Buffer(email).toString('base64'); - }; - - User.decodeEmail = email => Buffer(email, 'base64').toString(); - - function requestAuthEmail(isSignUp, newEmail) { - return Observable.defer(() => { - const messageOrNull = getWaitMessage(this.emailAuthLinkTTL); - if (messageOrNull) { - throw wrapHandledError(new Error('request is throttled'), { - type: 'info', - message: messageOrNull - }); - } - - // create a temporary access token with ttl for 15 minutes - return this.createAuthToken({ ttl: 15 * 60 * 1000 }); - }) - .flatMap(token => { - let renderAuthEmail = renderSignInEmail; - let subject = 'Your sign in link for freeCodeCamp.org'; - if (isSignUp) { - renderAuthEmail = renderSignUpEmail; - subject = 'Your sign in link for your new freeCodeCamp.org account'; - } - if (newEmail) { - renderAuthEmail = renderEmailChangeEmail; - subject = dedent` - Please confirm your updated email address for freeCodeCamp.org - `; - } - const { id: loginToken, created: emailAuthLinkTTL } = token; - const loginEmail = this.getEncodedEmail(newEmail ? newEmail : null); - const host = apiLocation; - const mailOptions = { - type: 'email', - to: newEmail ? newEmail : this.email, - from: getEmailSender(), - subject, - text: renderAuthEmail({ - host, - loginEmail, - loginToken, - emailChange: !!newEmail - }) - }; - const userUpdate = new Promise((resolve, reject) => - this.updateAttributes({ emailAuthLinkTTL }, err => { - if (err) { - return reject(err); - } - return resolve(); - }) - ); - return Observable.forkJoin( - User.email.send$(mailOptions), - Observable.fromPromise(userUpdate) - ); - }) - .map( - () => - 'Check your email and click the link we sent you to confirm' + - ' your new email address.' - ); - } - - User.prototype.requestAuthEmail = requestAuthEmail; - - User.prototype.requestUpdateEmail = function requestUpdateEmail(newEmail) { - const currentEmail = this.email; - const isOwnEmail = isTheSame(newEmail, currentEmail); - const isResendUpdateToSameEmail = isTheSame(newEmail, this.newEmail); - const isLinkSentWithinLimit = getWaitMessage(this.emailVerifyTTL); - const isVerifiedEmail = this.emailVerified; - - if (isOwnEmail && isVerifiedEmail) { - // email is already associated and verified with this account - throw wrapHandledError(new Error('email is already verified'), { - type: 'info', - message: ` - ${newEmail} is already associated with this account. - You can update a new email address instead.` - }); - } - if (isResendUpdateToSameEmail && isLinkSentWithinLimit) { - // trying to update with the same newEmail and - // confirmation email is still valid - throw wrapHandledError(new Error(), { - type: 'info', - message: dedent` - We have already sent an email confirmation request to ${newEmail}. - ${isLinkSentWithinLimit}` - }); - } - if (!isEmail('' + newEmail)) { - throw createEmailError(); - } - - // newEmail is not associated with this user, and - // this attempt to change email is the first or - // previous attempts have expired - if ( - !isOwnEmail || - (isOwnEmail && !isVerifiedEmail) || - (isResendUpdateToSameEmail && !isLinkSentWithinLimit) - ) { - const updateConfig = { - newEmail, - emailVerified: false, - emailVerifyTTL: new Date() - }; - - // defer prevents the promise from firing prematurely (before subscribe) - return Observable.defer(() => User.doesExist(null, newEmail)) - .do(exists => { - if (exists && !isOwnEmail) { - // newEmail is not associated with this account, - // but is associated with different account - throw wrapHandledError(new Error('email already in use'), { - type: 'info', - message: `${newEmail} is already associated with another account.` - }); - } - }) - .flatMap(() => { - const updatePromise = new Promise((resolve, reject) => - this.updateAttributes(updateConfig, err => { - if (err) { - return reject(err); - } - return resolve(); - }) - ); - return Observable.forkJoin( - Observable.fromPromise(updatePromise), - this.requestAuthEmail(false, newEmail), - (_, message) => message - ); - }); - } else { - return 'Something unexpected happened while updating your email.'; - } - }; - function requestCompletedChallenges() { return this.getCompletedChallenges$(); } @@ -1117,4 +867,4 @@ module.exports = function(User) { } ] }); -}; +} diff --git a/api-server/server/boot/authentication.js b/api-server/server/boot/authentication.js index 00f6fb79f8d..a915e2fff8d 100644 --- a/api-server/server/boot/authentication.js +++ b/api-server/server/boot/authentication.js @@ -1,9 +1,4 @@ -import _ from 'lodash'; -import { Observable } from 'rx'; -import dedent from 'dedent'; import passport from 'passport'; -import { isEmail } from 'validator'; -import { check } from 'express-validator/check'; import { homeLocation } from '../../../config/env'; import { @@ -11,11 +6,7 @@ import { saveResponseAuthCookies, loginRedirect } from '../component-passport'; -import { - ifUserRedirectTo, - ifNoUserRedirectTo, - createValidatorErrorHandler -} from '../utils/middleware'; +import { ifUserRedirectTo } from '../utils/middleware'; import { wrapHandledError } from '../utils/create-handled-error.js'; import { removeCookies } from '../utils/getSetAccessToken'; @@ -31,9 +22,7 @@ module.exports = function enableAuthentication(app) { const ifUserRedirect = ifUserRedirectTo(); const saveAuthCookies = saveResponseAuthCookies(); const loginSuccessRedirect = loginRedirect(); - const ifNoUserRedirectHome = ifNoUserRedirectTo(homeLocation); const api = app.loopback.Router(); - const { AuthToken, User } = app.models; // Use a local mock strategy for signing in if we are in dev mode. // Otherwise we use auth0 login. We use a string for 'true' because values @@ -73,167 +62,5 @@ module.exports = function enableAuthentication(app) { }); }); - const defaultErrorMsg = dedent` - Oops, something is not right, - 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) { - const { - query: { email: encodedEmail, token: authTokenId, emailChange } = {} - } = req; - - const email = User.decodeEmail(encodedEmail); - if (!isEmail(email)) { - return next( - wrapHandledError(new TypeError('decoded email is invalid'), { - type: 'info', - message: 'The email encoded in the link is incorrectly formatted', - redirectTo: `${homeLocation}/signin` - }) - ); - } - // first find - return ( - AuthToken.findOne$({ where: { id: authTokenId } }) - .flatMap(authToken => { - if (!authToken) { - throw wrapHandledError( - new Error(`no token found for id: ${authTokenId}`), - { - type: 'info', - message: defaultErrorMsg, - redirectTo: `${homeLocation}/signin` - } - ); - } - // find user then validate and destroy email validation token - // finally retun user instance - return User.findOne$({ where: { id: authToken.userId } }).flatMap( - user => { - if (!user) { - throw wrapHandledError( - new Error(`no user found for token: ${authTokenId}`), - { - type: 'info', - message: defaultErrorMsg, - redirectTo: `${homeLocation}/signin` - } - ); - } - if (user.email !== email) { - if (!emailChange || (emailChange && user.newEmail !== email)) { - throw wrapHandledError( - new Error('user email does not match'), - { - type: 'info', - message: defaultErrorMsg, - redirectTo: `${homeLocation}/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: `${homeLocation}/signin` - }); - } - return authToken.destroy$(); - }) - .map(() => user); - } - ); - }) - // at this point token has been validated and destroyed - // update user and log them in - .map(user => user.loginByRequest(req, res)) - .do(() => { - req.flash( - 'success', - 'Success! You have signed in to your account. Happy Coding!' - ); - return res.redirectWithFlash(`${homeLocation}/welcome`); - }) - .subscribe(() => {}, next) - ); - } - - api.get( - '/passwordless-auth', - ifUserRedirect, - passwordlessGetValidators, - createValidatorErrorHandler('errors', `${homeLocation}/signin`), - getPasswordlessAuth - ); - - api.get('/passwordless-change', (req, res) => - res.redirect(301, '/confirm-email') - ); - - api.get( - '/confirm-email', - ifNoUserRedirectHome, - passwordlessGetValidators, - getPasswordlessAuth - ); - - const passwordlessPostValidators = [ - check('email') - .isEmail() - .withMessage('Email is not a valid email address.') - ]; - function postPasswordlessAuth(req, res, next) { - const { body: { email } = {} } = req; - - return User.findOne$({ where: { email } }) - .flatMap(_user => - Observable.if( - // if no user found create new user and save to db - _.constant(_user), - Observable.of(_user), - User.create$({ email }) - ).flatMap(user => user.requestAuthEmail(!_user)) - ) - .do(msg => { - let redirectTo = homeLocation; - - if (req.session && req.session.returnTo) { - redirectTo = req.session.returnTo; - } - - req.flash('info', msg); - return res.redirect(redirectTo); - }) - .subscribe(_.noop, next); - } - - api.post( - '/passwordless-auth', - ifUserRedirect, - passwordlessPostValidators, - createValidatorErrorHandler('errors', `${homeLocation}/signin`), - postPasswordlessAuth - ); - app.use(api); }; diff --git a/api-server/server/utils/getSetAccessToken.js b/api-server/server/utils/getSetAccessToken.js index b915ecb04e9..82b5b681cd8 100644 --- a/api-server/server/utils/getSetAccessToken.js +++ b/api-server/server/utils/getSetAccessToken.js @@ -25,8 +25,6 @@ export function setAccessTokenToResponse( }; const jwtAccess = jwt.sign({ accessToken }, jwtSecret); res.cookie(jwtCookieNS, jwtAccess, cookieConfig); - res.cookie('access_token', accessToken.id, cookieConfig); - res.cookie('userId', accessToken.userId, cookieConfig); return; } diff --git a/api-server/server/utils/getSetAccessToken.test.js b/api-server/server/utils/getSetAccessToken.test.js index 937165194c1..0337944b452 100644 --- a/api-server/server/utils/getSetAccessToken.test.js +++ b/api-server/server/utils/getSetAccessToken.test.js @@ -121,8 +121,7 @@ describe('getSetAccessToken', () => { }); describe('setAccessTokenToResponse', () => { - it('sets three cookies in the response', () => { - expect.assertions(3); + it('sets a jwt access token cookie in the response', () => { const req = mockReq(); const res = mockRes(); @@ -139,24 +138,6 @@ describe('getSetAccessToken', () => { maxAge: accessToken.ttl } ]); - expect(res.cookie.getCall(1).args).toEqual([ - 'access_token', - accessToken.id, - { - signed: false, - domain: 'localhost', - maxAge: accessToken.ttl - } - ]); - expect(res.cookie.getCall(2).args).toEqual([ - 'userId', - accessToken.userId, - { - signed: false, - domain: 'localhost', - maxAge: accessToken.ttl - } - ]); }); }); diff --git a/api-server/server/views/emails/user-request-sign-in.ejs b/api-server/server/views/emails/user-request-sign-in.ejs deleted file mode 100644 index ea9c39b320a..00000000000 --- a/api-server/server/views/emails/user-request-sign-in.ejs +++ /dev/null @@ -1,9 +0,0 @@ -Here's your sign in link. It will instantly sign you into freeCodeCamp.org - no password necessary: - -<%= host %>/internal/passwordless-auth/?email=<%= loginEmail %>&token=<%= loginToken %> - -Note: this sign in link will expire after 15 minutes. If you need a new sign in link, go to https://www.freecodecamp.org/signin - -See you soon! - -- The freeCodeCamp.org Team diff --git a/api-server/server/views/emails/user-request-sign-up.ejs b/api-server/server/views/emails/user-request-sign-up.ejs deleted file mode 100644 index bf591dc5019..00000000000 --- a/api-server/server/views/emails/user-request-sign-up.ejs +++ /dev/null @@ -1,13 +0,0 @@ -Welcome to the freeCodeCamp community! - -We have created a new account for you. - -Here's your sign in link. It will instantly sign you into freeCodeCamp.org - no password necessary: - -<%= host %>/internal/passwordless-auth/?email=<%= loginEmail %>&token=<%= loginToken %> - -Note: this sign in link will expire after 15 minutes. If you need a new sign in link, go to https://www.freecodecamp.org/signin - -See you soon! - -- The freeCodeCamp.org Team diff --git a/api-server/server/views/emails/user-request-update-email.ejs b/api-server/server/views/emails/user-request-update-email.ejs deleted file mode 100644 index 50c5e6c99e4..00000000000 --- a/api-server/server/views/emails/user-request-update-email.ejs +++ /dev/null @@ -1,7 +0,0 @@ -Please confirm this address for freeCodeCamp: - -<%= host %>/internal/confirm-email?email=<%= loginEmail %>&token=<%= loginToken %>&emailChange=<%= emailChange %> - -Happy coding! - -- The freeCodeCamp.org Team