diff --git a/config.js b/config.js index d84b37a..cbb7f08 100644 --- a/config.js +++ b/config.js @@ -1,12 +1,15 @@ module.exports = { - name: 'wEvent API', + name: 'Eventment.io API', env: process.env.NODE_ENV || 'development', port: process.env.PORT || 3001, - base_url: process.env.BASE_URL || 'http://localhost:3000', + base_url: process.env.BASE_URL || 'http://localhost:3001', + api: { + url: 'http://localhost:3001', + }, db: { uri: process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/wEvent-dev', }, - version: '0.0.1', + version: '0.1.0', assetStoreUrl: 'https://www.google.com/', services: { apple: {}, @@ -21,10 +24,14 @@ module.exports = { }, security: { jwt: { - audience: 'wEvents.io', + audience: 'Eventment.io', daysValid: 365, - issuer: 'patrons.wEvents.io', + issuer: 'patrons.eventment.io', secret: 'Th!sIs a d3v3lopm3nt server $#cr¢T.', }, + reset: { + route: '/reset', + tokenPlaceholder: ':reset_token', + }, }, }; diff --git a/fixtures/item.js b/fixtures/item.js new file mode 100644 index 0000000..ae8e0b5 --- /dev/null +++ b/fixtures/item.js @@ -0,0 +1,37 @@ +const item = { + eventId: 'x9374bdH93u3ihds453s', + title: 'Stay Local, Get Away!', + subtitle: 'A romantic weekend for two at the Malden Econolodge!', + donor: 'Charlie Baker', + description: 'There may be bedbugs, there is absolutely no continental breakfast, and bring earplugs to sleep through police actions. A luxurious courtyard double.', + images: [ + { url: 'https://random.pics/chdgfj' }, + { url: 'https://random.pics/745gdf' }, + { url: 'https://random.pics/34sd56' }, + ], + type: 'auction', + quantityAvailable: 1, + soldCount: 0, + currentPrice: 125, + startingPrice: 100, + reservePrice: 199, + estimatedValue: 225.49, + currentWinner: 'mifi', + bidders: [ + { name: 'mifi' }, + { name: 'mifi79' }, + ], + bidCount: 2, + bidIncrement: 5, + catalogNumber: 16, + start: Date.now(), + end: Date.now() + (3*60*60*1000), + hideBeforeStart: false, + hideAfterEnd: true, + notifyOnAvailable: false, + isShippable: true, + shippingCost: 25.68, + organizationTake: 0.75, +}; + +module.exports = item; diff --git a/index.js b/index.js index a94adb0..94ee7ef 100644 --- a/index.js +++ b/index.js @@ -13,6 +13,7 @@ const validateJsonData = require('./lib/validateType.js'); * Initialize Server */ const server = restify.createServer({ + maxParamLength: 500, name: config.name, version: config.version, }); diff --git a/models/common/address.js b/models/common/address.js index 25e696c..34f808e 100644 --- a/models/common/address.js +++ b/models/common/address.js @@ -1,6 +1,5 @@ const mongoose = require('mongoose'); -const mongooseStringQuery = require('mongoose-string-query'); -const mongooseTimestamps = require('mongoose-timestamp'); +const timestamps = require('mongoose-timestamp'); const AddressSchema = new mongoose.Schema( { @@ -38,7 +37,6 @@ const AddressSchema = new mongoose.Schema( { minimize: false }, ); -AddressSchema.plugin(mongooseStringQuery); -AddressSchema.plugin(mongooseTimestamps); +AddressSchema.plugin(timestamps); module.exports = AddressSchema; diff --git a/models/common/email.js b/models/common/email.js index f4db21e..4695d43 100644 --- a/models/common/email.js +++ b/models/common/email.js @@ -1,6 +1,5 @@ const mongoose = require('mongoose'); -const mongooseStringQuery = require('mongoose-string-query'); -const mongooseTimestamps = require('mongoose-timestamp'); +const timestamps = require('mongoose-timestamp'); const EmailSchema = new mongoose.Schema( { @@ -24,7 +23,6 @@ EmailSchema.virtual('address').get(function() { return this.user + '@' + this.domain; }); -EmailSchema.plugin(mongooseStringQuery); -EmailSchema.plugin(mongooseTimestamps); +EmailSchema.plugin(timestamps); module.exports = EmailSchema; diff --git a/models/common/phone.js b/models/common/phone.js index 22d4147..11b9d5e 100644 --- a/models/common/phone.js +++ b/models/common/phone.js @@ -1,6 +1,5 @@ const mongoose = require('mongoose'); -const mongooseStringQuery = require('mongoose-string-query'); -const mongooseTimestamps = require('mongoose-timestamp'); +const timestamps = require('mongoose-timestamp'); const PhoneSchema = new mongoose.Schema( { @@ -23,7 +22,6 @@ const PhoneSchema = new mongoose.Schema( { minimize: false }, ); -PhoneSchema.plugin(mongooseStringQuery); -PhoneSchema.plugin(mongooseTimestamps); +PhoneSchema.plugin(timestamps); module.exports = PhoneSchema; diff --git a/models/item.js b/models/item.js index bf0f611..63434ac 100644 --- a/models/item.js +++ b/models/item.js @@ -2,7 +2,6 @@ const { ITEM_TYPES } = require('./constants.js'); const config = require('../config.js'); const mongoose = require('mongoose'); -const mongooseStringQuery = require('mongoose-string-query'); const timestamps = require('mongoose-timestamp'); const ItemSchema = new mongoose.Schema( diff --git a/models/user.js b/models/user.js index ee28dd7..67d7dd5 100644 --- a/models/user.js +++ b/models/user.js @@ -24,6 +24,10 @@ const LoginSchema = new mongoose.Schema( required: true, trim: true, }, + associatedEmail: { + type: String, + trim: true, + }, secret: { type: String, trim: true, @@ -66,6 +70,10 @@ const UserSchema = new mongoose.Schema( phone: [ PhoneSchema ], credentials: [ LoginSchema ], + tokenCheckBit: { + type: String, + trim: true, + }, organizationIdentifier: { type: String, @@ -88,6 +96,11 @@ const UserSchema = new mongoose.Schema( type: Boolean, default: false, }, + + resetCheckBit: { + type: String, + default: null, + }, }, { minimize: false }, @@ -114,6 +127,10 @@ UserSchema.methods.authenticate = function (username, password) { return false; }; +UserSchema.methods.isNomAvailable = function (nom) { + return !!!this.model('User').findOne({ nomDeBid }); +}; + UserSchema.methods.generateJWT = function (props = {}) { const { exp, iss } = props; const today = new Date(); @@ -143,6 +160,23 @@ UserSchema.methods.getNomDeBid = function () { return this.nomDeBid || `${this.firstName} ${this.lastName.charAt(0)}`; }; +UserSchema.methods.generateResetToken = function (callback = () => {}) { + const resetCheckBit = crypto.randomBytes(16).toString('hex'); + const token = jwt.sign({ + sub: this.id, + key: resetCheckBit, + iss:config.security.jwt.issuer, + aud: config.security.jwt.audience, + iat: Date.now(), + exp: (Date.now() + (24*60*60*1000)), + }, config.security.jwt.secret); + + this.resetCheckBit = resetCheckBit; + this.save(); + + return token; +}; + UserSchema.methods.isEventManager = function () { return this.isOrganizationEmployee || false; }; @@ -151,6 +185,28 @@ UserSchema.methods.isRegistrationVerified = function () { return this.isVerified || false; }; +UserSchema.methods.sendPasswordReset = function () { + const resetToken = this.generateResetToken(); + + let resetRoute = config.security.resetRoute; + resetRoute = resetRoute.replace(':user_id', this.id); + resetRoute = resetRoute.replace(':reset_token?', resetToken); + + const resetUrl = `${config.api.url}${resetRoute}`; + console.log('[sendPasswordReset] resetUrl:', resetUrl); +}; + +UserSchema.methods.setNomDeBid = function (nomDeBid, callback = () => {}) { + const alreadyExists = this.isNomAvailable(nomDeBid); + + if (this.isNomAvailable(nomDeBid)) { + this.nomDeBid = nomDeBid; + return this.save(callback); + } + + callback({ success: false, info: 'Nom de Bid already exists!' }, false); +}; + UserSchema.methods.setPassword = function (password, callback = () => {}) { const hasLocalStrategy = !!this.credentials.length && !!this.credentials.filter(strategy => strategy.method === 'local').length; @@ -167,7 +223,7 @@ UserSchema.methods.setPassword = function (password, callback = () => {}) { if (hasLocalStrategy) { this.model('User').findOneAndUpdate( { _id: this._id, 'credentials.method': 'local' }, - { $set: { 'credentials.$': strategy } }, + { $set: { 'credentials.$': strategy, resetCheckBit: null } }, { upsert: true }, callback, ); @@ -175,6 +231,7 @@ UserSchema.methods.setPassword = function (password, callback = () => {}) { if (!hasLocalStrategy) { this.credentials.push(strategy); + this.resetCheckBit = null; this.save(callback); } }; @@ -270,11 +327,46 @@ UserSchema.statics.findOneAndUpdateOrCreate = function ( }); }; +UserSchema.statics.verifyResetToken = function (token, callback) { + jwt.verify(token, config.security.jwt.secret, (err, decoded) => { + if (err) { + return callback(err); + } + + const { sub, key } = decoded; + this.findOne({ _id: sub, resetCheckBit: key }, (err, user) => { + if (err) { + return callback(err); + } + + if (!user) { + return callback(err, false, 'The reset token was not valid.'); + } + + callback(err, user); + }); + }); +}; + +UserSchema.statics.verifyTokenAndResetPassword = function (token, password, callback) { + this.verifyResetToken(token, (err, user, info) => { + if (err) { + return callback(err); + } + + if (!user) { + return callback(err, false, info); + } + + user.setPassword(password, callback); + }); +}; + /** * PATH OPERATIONS */ -UserSchema.path('avatar').get(v => `${config.assetStoreUrl}${v}`); +UserSchema.path('avatar').get(v => (v ? `${config.assetStoreUrl}${v}` : null)); /** * Export diff --git a/routes/auth.js b/routes/auth.js index ac126d5..701c610 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -2,17 +2,17 @@ const errors = require('restify-errors'); const config = require('../config'); -const handlePassportResponse = (req, res, next) => (err, passportUser, info) => { +const handlePassportResponse = (req, res, next) => (err, user, info) => { if (err) { return next(err); } - const isVerifiedUser = passportUser.isRegistrationVerified(); - if (passportUser && isVerifiedUser) { - const user = passportUser; - user.token = passportUser.generateJWT(); + const isVerifiedUser = user && + user.isRegistrationVerified(); + + if (user && isVerifiedUser) { return res.send({ ...user.toAuthJSON() }); - } else if (passportUser && !isVerifiedUser){ + } else if (user && !isVerifiedUser){ return res.send({ registrationSuccess: true, nextSteps: 'Check your email for our confirmation email, you will not be able to login without confirming.' @@ -71,7 +71,7 @@ module.exports = function (server, auth) { /* Facebook */ server.get( - '/auth/facebook', + '/auth/facebook/login', passport.authenticate('facebook', { scope: ['email', 'public_profile'], session: false, @@ -79,7 +79,7 @@ module.exports = function (server, auth) { ); server.get( - '/auth/facebook/callback', + '/auth/facebook/loggedin', (req, res, next) => { const callback = handlePassportResponse(req, res, next); return passport.authenticate( @@ -89,4 +89,50 @@ module.exports = function (server, auth) { )(req, res, next); } ); + + server.get( + '/auth/facebook/link', + auth.secure, + (req, res, next) => { + req.user.record.setLinkCheckBit((err, linkCheckBit) => { + passport.authenticate('facebookLink', { + scope: ['email', 'public_profile'], + session: false, + state: linkCheckbit, + })(req, res, next), + }); + }, + ); + + server.get( + '/auth/facebook/linked', + (req, res, next) => { + const linkCheckBit = req.query.state; + + return passport.authenticate( + 'facebook', + { failureRedirect: '/profile' }, + (err, profile) => { + if (err) { + return next(err); + } + + User.linkFacebookProfile(linkCheckBit, profile, (err, user) => { + if (err) { + return next(err); + } + + if (!user) { + return next(err, false, 'Linking the account to Facebook was unsuccessful, please try again.'); + } + + res.send({ + success: true, + info: 'Facerbook account successfully linked', + }); + }); + }, + )(req, res, next); + } + ); }; diff --git a/routes/events.js b/routes/events.js index cec6c84..189e042 100644 --- a/routes/events.js +++ b/routes/events.js @@ -4,7 +4,6 @@ const Event = require('../models/event'); module.exports = function (server, auth) { server.post('/events', auth.manager, (req, res, next) => { - let data = req.body || {}; let event = new Event(data); diff --git a/routes/index.js b/routes/index.js index 9da8d31..b94ca5f 100644 --- a/routes/index.js +++ b/routes/index.js @@ -4,6 +4,7 @@ module.exports = function(server, auth) { require('./events.js')(server, auth); require('./installs.js')(server, auth); require('./items.js')(server, auth); + require('./reset.js')(server, auth); require('./sales.js')(server, auth); require('./users.js')(server, auth); }; diff --git a/routes/reset.js b/routes/reset.js new file mode 100644 index 0000000..a92d46c --- /dev/null +++ b/routes/reset.js @@ -0,0 +1,68 @@ +const errors = require('restify-errors'); +const jwt = require('jsonwebtoken'); + +const config = require('../config'); +const User = require('../models/user'); + +const { + api: { url }, + security: { reset: { route, tokenPlaceholder } }, +} = config; + +const routes = { + resetWithToken: `${route}/${tokenPlaceholder}([A-Za-z0-9_]+\.{3})`, + getTestToken: `${route}/generate`, +}; + +module.exports = function (server, auth) { + server.get(routes.getTestToken, auth.secure, function (req, res, next) { + const { record: user } = req.user; + const resetToken = user.generateResetToken(); + const resetUrl = `${url}${route}/${resetToken}`; + + res.send({ resetToken, resetUrl }); + next(); + }); + + server.post(routes.resetWithToken, auth.bypass, function (req, res, next) { + const { reset_token } = req.params; + const { password } = req.body; + + if (!reset_token) { + return next( + new errors.InvalidContentError('A reset token was not provided.'), + ); + } + + if (!password) { + return next( + new errors.InvalidContentError('Password cannot be empty.'), + ); + } + + User.verifyTokenAndResetPassword(reset_token, password, (err, user, info) => { + if (err) { + console.error(err); + return next( + new errors.InvalidContentError(err), + ); + } + + if (!user) { + console.error(err); + res.send({ + success: false, + info: 'Password reset failed. ' + info, + }); + return next(); + } + + res.send({ + success: true, + info: 'Password reset successful.', + ...user.toAuthJSON() + }); + next(); + }); + }); +}; diff --git a/routes/signup.js b/routes/signup.js index a727b24..488f0e7 100644 --- a/routes/signup.js +++ b/routes/signup.js @@ -26,17 +26,55 @@ module.exports = function (server, auth) { if (info) { res.send(200, { - registrationSuccess: false, + success: false, nextSteps: 'Please fix the problems indicated and try again.' ...info }); + return next(); } res.send(200, { - registrationSuccess: true, + success: true, nextSteps: 'Check your email for our confirmation email, you will not be able to login without confirming.' - }) + }); + + next(); }); }); + + server.post('/signup/verify/resend', (req, res, next) => { + const { body: { email = null } = {} } = req; + + User.resendVerificationEmail(email, (err, user, info) => { + if (err) { + next(err); + } + + if (!user) { + res.send(200, { + success: false, + nextSteps: 'There was no user located with the email address provided. Please try again.', + }); + + return next(); + } + + if (user && info.success) { + res.send(200, { + success: true, + nextSteps: 'Check your email for our confirmation email, you will not be able to login without confirming.', + }); + + return next(); + } + + res.send(200, { + success: false, + nextSteps: 'There was a problem resending the verification email. Please try again later.', + }); + + next(); + }); + }); }; diff --git a/routes/users.js b/routes/users.js index a969b51..4bce319 100644 --- a/routes/users.js +++ b/routes/users.js @@ -1,6 +1,7 @@ const aqp = require('api-query-params'); const errors = require('restify-errors'); +const config = require('../config'); const User = require('../models/user'); const { PUBLIC, STAFF } = require('../strategies/selects/user'); @@ -93,66 +94,6 @@ module.exports = function (server, auth) { }); }); - server.put('/users/password/:user_id/:reset_token?', function (req, res, next) { - let { - currentPassword = null, - newPassword = null, - ...data - } = req.body || {}; - - if (!newPassword) { - return next( - new errors.InvalidContentError('Password cannot be empty.'), - ); - } - - let filter = { _id: req.params.user_id }; - let resetToken = req.params.reset_token || null; - if (resetToken) { - fiter.resetToken = resetToken; - } - - User.findOne(filter, function (err, user) { - if (err) { - console.error(err); - return next( - new errors.InvalidContentError(err), - ); - } - - if (!user) { - return next( - new errors.ResourceNotFoundError( - 'The user you requested could not be found.', - ), - ); - } - - if (!resetToken && - !!user.getAuthStrategy('local') && - !user.validatePassword(currentPassword) - ) { - return next( - new errors.InvalidContentError( - 'The current password was incorrect.', - ), - ); - } - - user.setPassword(newPassword, function (err) { - if (err) { - console.error(err); - return next( - new errors.InvalidContentError(err), - ); - } - - res.send(200, data); - next(); - }); - }); - }); - server.del('/users/:user_id', auth.manager, (req, res, next) => { User.deleteOne({ _id: req.params.user_id }, function (err) { if (err) { diff --git a/strategies/auth/facebook.js b/strategies/auth/facebook.js index baf57fd..ce574db 100644 --- a/strategies/auth/facebook.js +++ b/strategies/auth/facebook.js @@ -5,11 +5,12 @@ const config = require('../../config'); const User = require('../../models/user'); module.exports = function(passport) { - passport.use(new FacebookStrategy( + + passport.use('facebook', new FacebookStrategy( { clientID: config.services.facebook.appId, clientSecret: config.services.facebook.appSecret, - callbackURL: 'http://localhost:3001/auth/facebook/callback', + callbackURL: 'http://localhost:3001/auth/facebook/loggedin', profileFields: ['id', 'email', 'first_name', 'last_name', 'picture'], }, (accessToken, refreshToken, profile, done) => { @@ -28,6 +29,7 @@ module.exports = function(passport) { }, { accessToken, + associatedEmail: email, method: profile.provider, userId, }, @@ -43,4 +45,32 @@ module.exports = function(passport) { ); } )); + + passport.use('facebookLink', new FacebookStrategy( + { + clientID: config.services.facebook.appId, + clientSecret: config.services.facebook.appSecret, + callbackURL: 'http://localhost:3001/auth/facebook/linked', + profileFields: ['id', 'email', 'first_name', 'last_name', 'picture'], + }, + (accessToken, refreshToken, profile, done) => { + const { + email, + first_name: firstName, + id: userId, + last_name: lastName, + picture: { data: { url = null } = {} } = {}, + } = profile._json; + const avatar = url; + + const strategy = { + accessToken, + associatedEmail: email, + method: profile.provider, + userId, + }; + + return done(null, { avatar, strategy }); + } + )); }; diff --git a/strategies/auth/index.js b/strategies/auth/index.js index e40279d..29d8c3c 100644 --- a/strategies/auth/index.js +++ b/strategies/auth/index.js @@ -74,6 +74,7 @@ module.exports = function (passport) { return { basic: authenticateBasic(passport), + bypass: (req, res, next) => next(), manager: authenticateEventManager(passport), managerOrSelf: authenticateEventManagerOrSelf(passport), passport,