From 8ec0533eb441c81b65666bed183d58cea06f6c8e Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Wed, 26 Jun 2013 16:25:51 -0700 Subject: [PATCH 01/11] Add schema skeletons for built-in models --- lib/models/acl.js | 17 ++++++++++++++++ lib/models/application.js | 41 ++++++++++++++++++++++++++++++++++++++ lib/models/installation.js | 4 ++++ lib/models/role.js | 11 ++++++++++ lib/models/user.js | 11 ++++++++++ 5 files changed, 84 insertions(+) create mode 100644 lib/models/acl.js create mode 100644 lib/models/application.js create mode 100644 lib/models/installation.js create mode 100644 lib/models/role.js create mode 100644 lib/models/user.js diff --git a/lib/models/acl.js b/lib/models/acl.js new file mode 100644 index 00000000..d10f2c3c --- /dev/null +++ b/lib/models/acl.js @@ -0,0 +1,17 @@ +// Schema ACL options + + +// Object level permissions + +// open: no protection +// none: always rejected +// owner: only the owner +// loggedIn: any logged in user +// roles: logged in users with the roles +// related: owner of the related objects + +// Class level permissions + +// scopes + +// URL level permissions \ No newline at end of file diff --git a/lib/models/application.js b/lib/models/application.js new file mode 100644 index 00000000..173d2b97 --- /dev/null +++ b/lib/models/application.js @@ -0,0 +1,41 @@ +// Application model +var ApplicationSchema = { + + // Basic information + id: {type: String, required: true}, + name: {type: String, required: true}, + description: String, // description + icon: String, // The icon url + public: Boolean, + permissions: [String], + + userId: String, + + status: String, + + // Keys + clientKey: String, + javaScriptKey: String, + restApiKey: String, + windowsKey: String, + masterKey: String, + + // Push notification + pushPlatforms: [String], + pushCredentials: [], + + // Authentication + authenticationEnabled: Boolean, + anonymousAllowed: Boolean, + schemes: [String], // Basic, facebook, github, google + attachedCredentials: [], + + // email + email: String, // e-mail address + emailVerified: Boolean, // Is the e-mail verified + + collaborators: [String], // A list of users ids who have permissions to work on this app + + created: Date, + lastUpdated: Date +}; diff --git a/lib/models/installation.js b/lib/models/installation.js new file mode 100644 index 00000000..37e8bd0d --- /dev/null +++ b/lib/models/installation.js @@ -0,0 +1,4 @@ +// Device registration +var InstallationSchema = { + +}; diff --git a/lib/models/role.js b/lib/models/role.js new file mode 100644 index 00000000..a9546777 --- /dev/null +++ b/lib/models/role.js @@ -0,0 +1,11 @@ +// Role model +var RoleSchema = { + id: {type: String, required: true}, + name: {type: String, required: true}, + roles: [String], + users: [String], + acl: [], + + created: Date, + lastUpdated: Date +} \ No newline at end of file diff --git a/lib/models/user.js b/lib/models/user.js new file mode 100644 index 00000000..38d20452 --- /dev/null +++ b/lib/models/user.js @@ -0,0 +1,11 @@ +// User model +var UserSchema = { + id: {type: String, required: true}, + username: {type: String, required: true}, + password: String, + authData: [], + email: String, + emailVerified: Boolean, + created: Date, + lastUpdated: Date +} \ No newline at end of file From 6f6e0fe3536b3f5c0ffc1345bd6b258add381c37 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Thu, 27 Jun 2013 18:26:44 -0700 Subject: [PATCH 02/11] Add normalized properties to Models --- README.md | 37 +++++++++++++++++++++++++++++++++++++ test/model.test.js | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/README.md b/README.md index 3b331579..608c2620 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,43 @@ Validate the model instance. } }); +#### Model.properties + +The an object containing a normalized set of properties supplied to `asteroid.createModel(name, properties)`. + +Example: + + var props = { + a: String, + b: {type: 'Number'}, + c: {type: 'String', min: 10, max: 100}, + d: Date, + e: asteroid.GeoPoint + }; + + var MyModel = asteroid.createModel('foo', props); + + console.log(MyModel.properties); + +Outputs: + + { + "a": {type: String}, + "b": {type: Number}, + "c": { + "type": String, + "min": 10, + "max": 100 + }, + "d": {type: Date}, + "e": {type: GeoPoint}, + "id": { + "id": 1 + } + } + +assert(MyModel.properties); + #### Model.attachTo(dataSource) Attach a model to a [DataSource](#data-source). Attaching a [DataSource](#data-source) updates the model with additional methods and behaviors. diff --git a/test/model.test.js b/test/model.test.js index 60a7b659..44bad7cf 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -431,6 +431,38 @@ describe('Model', function() { }); }); }); + + describe('Model.properties', function(){ + it('Normalized properties passed in originally by asteroid.createModel().', function() { + var props = { + s: String, + n: {type: 'Number'}, + o: {type: 'String', min: 10, max: 100}, + d: Date, + g: asteroid.GeoPoint + }; + + var MyModel = asteroid.createModel('foo', props); + + Object.keys(MyModel.properties).forEach(function (key) { + var p = MyModel.properties[key]; + var o = MyModel.properties[key]; + assert(p); + assert(o); + assert(typeof p.type === 'function'); + + if(typeof o === 'function') { + // the normalized property + // should match the given property + assert( + p.type.name === o.name + || + p.type.name === o + ) + } + }); + }); + }); // describe('Model.hasAndBelongsToMany()', function() { // it("TODO: implement / document", function(done) { From fa8740fac79d0cd53be1fd9e98a59d0eb23eb585 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Thu, 27 Jun 2013 18:28:21 -0700 Subject: [PATCH 03/11] Fix type in docs --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 608c2620..697f7b48 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ Validate the model instance. #### Model.properties -The an object containing a normalized set of properties supplied to `asteroid.createModel(name, properties)`. +An object containing a normalized set of properties supplied to `asteroid.createModel(name, properties)`. Example: From 85900da5b3c11a541a5b2f36bb724cd5eefecb21 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Thu, 27 Jun 2013 18:28:58 -0700 Subject: [PATCH 04/11] Fix type in docs --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 697f7b48..5b0f6c10 100644 --- a/README.md +++ b/README.md @@ -174,8 +174,6 @@ Outputs: "id": 1 } } - -assert(MyModel.properties); #### Model.attachTo(dataSource) From 12761fb3fee1068d0252d4e684113e0e72822670 Mon Sep 17 00:00:00 2001 From: cgole Date: Fri, 28 Jun 2013 14:01:49 -0700 Subject: [PATCH 05/11] Update README.md Added limit:3 for GeoPoint find function example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5b0f6c10..2df247bd 100644 --- a/README.md +++ b/README.md @@ -742,7 +742,7 @@ Find the 3 nearest coffee shops. CoffeeShop.attachTo(oracle); var here = new GeoPoint({lat: 10.32424, lng: 5.84978}); - CoffeeShop.find({where: {location: {near: here}}}, function(err, nearbyShops) { + CoffeeShop.find({where: {location: {near: here}}, limit:3}, function(err, nearbyShops) { console.info(nearbyShops); // [CoffeeShop, ...] }); From 044d2c4bccb2a41c955f595b4a7f3aae6b782ac1 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Sun, 30 Jun 2013 17:38:53 -0700 Subject: [PATCH 06/11] Add more user model docs --- README.md | 166 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 157 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 2df247bd..0baadbca 100644 --- a/README.md +++ b/README.md @@ -794,36 +794,184 @@ Various APIs in Asteroid accept type descriptions (eg. [remote methods](#remote- - `Buffer` - a node.js Buffer object - [GeoPoint](#geopoint) - an asteroid GeoPoint object. TODO +#### Bundled Models + +The Asteroid library is unopinioned in the way you define your app's data and logic. Asteroid also bundles useful pre-built models for common use cases. + + - User - _TODO_ register and authenticate users of your app locally or against 3rd party services. + - Notification - _TODO_ create, store, schedule and send push notifications to your app users. + - Email - _TODO_ schedule and send emails to your app users using smtp or 3rd party services. + - Job - _TODO_ schedule arbitrary code to run at a given time. + ### User Model -Allow users of your asteroid app to register and authenticate. +Register and authenticate users of your app locally or against 3rd party services. + +#### Create a User Model + +Extend a vanilla Asteroid model using the built in User model. // define a User model var User = asteroid.createModel( 'user', { - email: 'EmailAddress', + email: { + type: 'EmailAddress', + username: true + }, password: { + hideRemotely: true, // default for Password type: 'Password', min: 4, max: 26 } }, { - username: 'email', - extend: 'User' + extend: 'User', } ); - + // attach to the memory connector User.attachTo(memory); - // create a user - User.create({ - email: 'foo@bar.com', - password: '123456' + // expose over the app's api + app.model(User); + +#### User Creation + +Create a user like any other model. + + // username and password are not required + User.create({email: 'foo@bar.com', password: 'bar'}, function(err, user) { + console.log(user); + }); + +#### Authentication Strategies (Using Passport.js) + +Setup an authentication strategy. + +[See all available providers from passport.js](http://passportjs.org/guide/providers/). + + // first add your model to your app + app.model(User); + + // by default your User model has a local strategy similar to below + + // customize your own + // disable the default local strategy + User.useLocalStrategy(false); + + // create a custom strategy + var LocalStrategy = require('passport-local').Strategy; + User.use(new LocalStrategy(function(username, password, done) { + User.findOne({ username: username }, function(err, user) { + if (err) { return done(err); } + if (!user) { return done(null, false, { message: 'Unknown user ' + username }); } + user.comparePassword(password, function(err, isMatch) { + if (err) return done(err); + if(isMatch) { + return done(null, user); + } else { + return done(null, false, { message: 'Invalid password' }); + } + }); + }); + })); + + +#### Login a User + +Create a session for a user. When called remotely the password is required. + + User.login({username: 'foo', password: 'bar'}, function(err, session) { + console.log(session); }); +**REST** + +You must provide a username and password over rest. To ensure these values are encrypted, include these as part of the body and make sure you are serving your app over https (through a proxy or using the https node server). + + POST + + /users/login + ... + { + "email": "foo@bar.com", + "password": "bar" + } + + ... + + 200 OK + { + "sid": "1234abcdefg", + "uid": "123" + } + +**Note:** The `uid` type will be the same type you specify when creating your model. In this case it is a string. + +#### Logout a User + + User.logout({username: 'foo'}, function(err) { + console.log(err); + }); + +**Note:** When calling this method remotely, the first argument will automatically be populated with the current user's id. If the caller is not logged in the method will fail with an error status code `401`. + +#### Verify Email Addresses + +To require email verification before a user is allowed to login, supply a verification property with a `verify` settings object. + + // define a User model + var User = asteroid.createModel( + 'user', + { + email: { + type: 'EmailAddress', + username: true + }, + password: { + hideRemotely: true, // default for Password + type: 'Password', + min: 4, + max: 26 + }, + verified: { + hideRemotely: true, + type: 'Boolean', + verify: { + // the model field + // that contains the email + // to verify + email: 'email', + template: 'email.ejs' + } + } + }, + { + extend: 'User', + // the model field + // that contains the user's email + // for verification and password reset + // defaults to 'email' + email: 'email', + resetTemplate: 'reset.ejs' + } + ); + +When a user is created (on the server or remotely) an email is sent to the field that corresponds to `verify.email` or `options.email`. The email contains a link the user must navigate to in order to verify their email address. Once they verify, users are allowed to login normally. Otherwise login attempts will respond with a 'must verify' error. + +#### Send Reset Password Email + +Send an email to the user's supplied email address containing a link to reset their password. + + User.sendResetPasswordEmail(email, function(err) { + // email sent + }); + +#### Remote Password Reset + +The password reset email will send users to a page rendered by asteroid with fields required to reset the user's password. You may customize this template by providing a `resetTemplate` option when defining your user model. ### Email Model From 2a30efe23f52bee1ee5a50aac8af427c9d04404b Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Mon, 1 Jul 2013 11:46:41 -0700 Subject: [PATCH 07/11] Remove app.modelBuilder() --- README.md | 12 +++++------- lib/application.js | 8 -------- test/model.test.js | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 0baadbca..0d68a77e 100644 --- a/README.md +++ b/README.md @@ -802,6 +802,8 @@ The Asteroid library is unopinioned in the way you define your app's data and lo - Notification - _TODO_ create, store, schedule and send push notifications to your app users. - Email - _TODO_ schedule and send emails to your app users using smtp or 3rd party services. - Job - _TODO_ schedule arbitrary code to run at a given time. + +Defining a model with `asteroid.createModel()` is really just extending the base `asteroid.Model` type using `asteroid.Model.extend()`. The bundled models extend from the base `asteroid.Model` allowing you to extend them arbitrarily. ### User Model @@ -812,7 +814,7 @@ Register and authenticate users of your app locally or against 3rd party service Extend a vanilla Asteroid model using the built in User model. // define a User model - var User = asteroid.createModel( + var User = asteroid.User.extend( 'user', { email: { @@ -825,9 +827,6 @@ Extend a vanilla Asteroid model using the built in User model. min: 4, max: 26 } - }, - { - extend: 'User', } ); @@ -923,7 +922,7 @@ You must provide a username and password over rest. To ensure these values are e To require email verification before a user is allowed to login, supply a verification property with a `verify` settings object. // define a User model - var User = asteroid.createModel( + var User = asteroid.User.extend( 'user', { email: { @@ -949,7 +948,6 @@ To require email verification before a user is allowed to login, supply a verifi } }, { - extend: 'User', // the model field // that contains the user's email // for verification and password reset @@ -959,7 +957,7 @@ To require email verification before a user is allowed to login, supply a verifi } ); -When a user is created (on the server or remotely) an email is sent to the field that corresponds to `verify.email` or `options.email`. The email contains a link the user must navigate to in order to verify their email address. Once they verify, users are allowed to login normally. Otherwise login attempts will respond with a 'must verify' error. +When a user is created (on the server or remotely) and the verification property exists, an email is sent to the field that corresponds to `verify.email` or `options.email`. The email contains a link the user must navigate to in order to verify their email address. Once they verify, users are allowed to login normally. Otherwise login attempts will respond with a 'must verify' error. #### Send Reset Password Email diff --git a/lib/application.js b/lib/application.js index 5094ae74..c292c8d8 100644 --- a/lib/application.js +++ b/lib/application.js @@ -39,14 +39,6 @@ app.disuse = function (route) { } } -/** - * Get ModelBuilder. - */ - -app.modelBuilder = function () { - return this._modelBuilder || (this._modelBuilder = new ModelBuilder()) -} - /** * App models. */ diff --git a/test/model.test.js b/test/model.test.js index 44bad7cf..bf2a5228 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -463,6 +463,40 @@ describe('Model', function() { }); }); }); + + // describe('Model.extend()', function(){ + // it('Create a new model by extending an existing model.', function(done) { + // var User = asteroid.Model.extend('user', { + // email: String + // }); + // + // User.foo = function () { + // return 'bar'; + // } + // + // User.prototype.bar = function () { + // return 'foo'; + // } + // + // var MyUser = User.extend('my-user', { + // foo: String, + // bar: String + // }); + // + // assert(MyUser.prototype.bar === User.prototype.bar); + // assert(MyUser.foo === User.foo); + // + // var user = new MyUser({ + // email: 'foo@bar.com', + // foo: 'foo', + // bar: 'bar' + // }); + // + // assert.equal(user.email, 'foo@bar.com'); + // assert.equal(user.foo, 'foo'); + // assert.equal(user.bar, 'bar'); + // }); + // }); // describe('Model.hasAndBelongsToMany()', function() { // it("TODO: implement / document", function(done) { From 643877b677394c8a771e6a5fa275e676f3d736f4 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Mon, 1 Jul 2013 16:50:03 -0700 Subject: [PATCH 08/11] Add initial User model --- README.md | 21 +++----------- lib/asteroid.js | 6 ++++ lib/models/user.js | 24 ++++++++-------- test/model.test.js | 69 +++++++++++++++++++++++----------------------- 4 files changed, 58 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 0d68a77e..2d772ee4 100644 --- a/README.md +++ b/README.md @@ -814,21 +814,7 @@ Register and authenticate users of your app locally or against 3rd party service Extend a vanilla Asteroid model using the built in User model. // define a User model - var User = asteroid.User.extend( - 'user', - { - email: { - type: 'EmailAddress', - username: true - }, - password: { - hideRemotely: true, // default for Password - type: 'Password', - min: 4, - max: 26 - } - } - ); + var User = asteroid.User.extend('user'); // attach to the memory connector User.attachTo(memory); @@ -862,7 +848,7 @@ Setup an authentication strategy. // create a custom strategy var LocalStrategy = require('passport-local').Strategy; - User.use(new LocalStrategy(function(username, password, done) { + passport.use(new LocalStrategy(function(username, password, done) { User.findOne({ username: username }, function(err, user) { if (err) { return done(err); } if (!user) { return done(null, false, { message: 'Unknown user ' + username }); } @@ -943,7 +929,8 @@ To require email verification before a user is allowed to login, supply a verifi // that contains the email // to verify email: 'email', - template: 'email.ejs' + template: 'email.ejs', + redirect: '/' } } }, diff --git a/lib/asteroid.js b/lib/asteroid.js index 4cb569bf..7c2c46d4 100644 --- a/lib/asteroid.js +++ b/lib/asteroid.js @@ -260,3 +260,9 @@ asteroid.remoteMethod = function (fn, options) { fn.http = fn.http || {verb: 'get'}; } +/* + * Built in models + */ + +asteroid.Model = asteroid.createModel('model'); +asteroid.User = require('./models/user'); diff --git a/lib/models/user.js b/lib/models/user.js index 38d20452..531606d4 100644 --- a/lib/models/user.js +++ b/lib/models/user.js @@ -1,11 +1,13 @@ -// User model -var UserSchema = { - id: {type: String, required: true}, - username: {type: String, required: true}, - password: String, - authData: [], - email: String, - emailVerified: Boolean, - created: Date, - lastUpdated: Date -} \ No newline at end of file +/** + * Module Dependencies. + */ + +var Model = require('../asteroid').Model; + + +/** + * Extends from the built in `asteroid.Model` type. + */ + +var User = module.exports = Model.extend('user'); + diff --git a/test/model.test.js b/test/model.test.js index bf2a5228..4ecf4d28 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -463,40 +463,41 @@ describe('Model', function() { }); }); }); - - // describe('Model.extend()', function(){ - // it('Create a new model by extending an existing model.', function(done) { - // var User = asteroid.Model.extend('user', { - // email: String - // }); - // - // User.foo = function () { - // return 'bar'; - // } - // - // User.prototype.bar = function () { - // return 'foo'; - // } - // - // var MyUser = User.extend('my-user', { - // foo: String, - // bar: String - // }); - // - // assert(MyUser.prototype.bar === User.prototype.bar); - // assert(MyUser.foo === User.foo); - // - // var user = new MyUser({ - // email: 'foo@bar.com', - // foo: 'foo', - // bar: 'bar' - // }); - // - // assert.equal(user.email, 'foo@bar.com'); - // assert.equal(user.foo, 'foo'); - // assert.equal(user.bar, 'bar'); - // }); - // }); + + describe('Model.extend()', function(){ + it('Create a new model by extending an existing model.', function() { + var User = asteroid.Model.extend('user', { + email: String + }); + + User.foo = function () { + return 'bar'; + } + + User.prototype.bar = function () { + return 'foo'; + } + + var MyUser = User.extend('my-user', { + a: String, + b: String + }); + + assert.equal(MyUser.prototype.bar, User.prototype.bar); + assert.equal(MyUser.foo, User.foo); + + debugger; + var user = new MyUser({ + email: 'foo@bar.com', + a: 'foo', + b: 'bar' + }); + + assert.equal(user.email, 'foo@bar.com'); + assert.equal(user.a, 'foo'); + assert.equal(user.b, 'bar'); + }); + }); // describe('Model.hasAndBelongsToMany()', function() { // it("TODO: implement / document", function(done) { From c14ef9af8c36e2bfa4d03d7d8a67b6cb4c87013e Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Mon, 1 Jul 2013 17:01:26 -0700 Subject: [PATCH 09/11] Add default user properties --- lib/models/user.js | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/lib/models/user.js b/lib/models/user.js index 531606d4..f13d0be1 100644 --- a/lib/models/user.js +++ b/lib/models/user.js @@ -4,10 +4,41 @@ var Model = require('../asteroid').Model; +/** + * Default User properties. + */ + +var properties = { + id: {type: String, required: true}, + realm: {type: String}, + username: {type: String, required: true}, + // password: {type: String, transient: true}, // Transient property + hash: {type: String}, // Hash code calculated from sha256(realm, username, password, salt, macKey) + salt: {type: String}, + macKey: {type: String}, // HMAC to calculate the hash code + email: String, + emailVerified: Boolean, + credentials: [ + 'UserCredential' // User credentials, private or public, such as private/public keys, Kerberos tickets, oAuth tokens, facebook, google, github ids + ], + challenges: [ + 'Challenge' // Security questions/answers + ], + // https://en.wikipedia.org/wiki/Multi-factor_authentication + /* + factors: [ + 'AuthenticationFactor' + ], + */ + status: String, + created: Date, + lastUpdated: Date +} + /** * Extends from the built in `asteroid.Model` type. */ -var User = module.exports = Model.extend('user'); +var User = module.exports = Model.extend('user', properties); From 2f13c53161fa892085aa1e46df2bf2c84d9c42f1 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Tue, 2 Jul 2013 16:51:38 -0700 Subject: [PATCH 10/11] Initial users --- lib/application.js | 4 +- lib/asteroid.js | 10 ++--- lib/middleware/auth.js | 34 +++++++++++++++ lib/models/session.js | 22 ++++++++++ lib/models/user.js | 93 +++++++++++++++++++++++++++++++++++++++++- package.json | 5 ++- test/user.test.js | 38 +++++++++++++++++ 7 files changed, 194 insertions(+), 12 deletions(-) create mode 100644 lib/middleware/auth.js create mode 100644 lib/models/session.js create mode 100644 test/user.test.js diff --git a/lib/application.js b/lib/application.js index c292c8d8..703e630e 100644 --- a/lib/application.js +++ b/lib/application.js @@ -55,9 +55,7 @@ app.model = function (Model) { this._models.push(Model); Model.shared = true; Model.app = this; - if(Model._remoteHooks) { - Model._remoteHooks.emit('attached', app); - } + Model.emit('attached', this); } /** diff --git a/lib/asteroid.js b/lib/asteroid.js index 7c2c46d4..3ca416d4 100644 --- a/lib/asteroid.js +++ b/lib/asteroid.js @@ -216,8 +216,8 @@ asteroid.createModel = function (name, properties, options) { }); } else { var args = arguments; - this._remoteHooks.once('attached', function () { - self.beforeRemote.apply(ModelCtor, args); + this.once('attached', function () { + self.beforeRemote.apply(self, args); }); } } @@ -232,15 +232,12 @@ asteroid.createModel = function (name, properties, options) { }); } else { var args = arguments; - this._remoteHooks.once('attached', function () { + this.once('attached', function () { self.afterRemote.apply(ModelCtor, args); }); } } - // allow hooks to be added before attaching to an app - ModelCtor._remoteHooks = new EventEmitter(); - return ModelCtor; } @@ -266,3 +263,4 @@ asteroid.remoteMethod = function (fn, options) { asteroid.Model = asteroid.createModel('model'); asteroid.User = require('./models/user'); +asteroid.Session = require('./models/session'); diff --git a/lib/middleware/auth.js b/lib/middleware/auth.js new file mode 100644 index 00000000..79f8acd9 --- /dev/null +++ b/lib/middleware/auth.js @@ -0,0 +1,34 @@ +/** + * Module dependencies. + */ + +var asteroid = require('../asteroid') + , passport = require('passport'); + +/** + * Export the middleware. + */ + +module.exports = auth; + +/** + * Build a temp app for mounting resources. + */ + +function auth() { + return function (req, res, next) { + var sub = asteroid(); + + // TODO clean this up + sub._models = req.app._models; + sub._remotes = req.app._remotes; + + sub.use(asteroid.session({secret: 'change me'})) + sub.use(passport.initialize()); + sub.use(passport.session()); + + sub.handle(req, res, next); + } +} + + diff --git a/lib/models/session.js b/lib/models/session.js new file mode 100644 index 00000000..eadfeb5e --- /dev/null +++ b/lib/models/session.js @@ -0,0 +1,22 @@ +/** + * Module Dependencies. + */ + +var Model = require('../asteroid').Model + , asteroid = require('../asteroid'); + +/** + * Default Session properties. + */ + +var properties = { + id: {type: String, required: true}, + uid: {type: String}, + ttl: {type: Number, ttl: true} +}; + +/** + * Extends from the built in `asteroid.Model` type. + */ + +var Session = module.exports = Model.extend('session', properties); \ No newline at end of file diff --git a/lib/models/user.js b/lib/models/user.js index f13d0be1..21118917 100644 --- a/lib/models/user.js +++ b/lib/models/user.js @@ -2,7 +2,10 @@ * Module Dependencies. */ -var Model = require('../asteroid').Model; +var Model = require('../asteroid').Model + , asteroid = require('../asteroid') + , passport = require('passport') + , LocalStrategy = require('passport-local').Strategy; /** * Default User properties. @@ -12,7 +15,7 @@ var properties = { id: {type: String, required: true}, realm: {type: String}, username: {type: String, required: true}, - // password: {type: String, transient: true}, // Transient property + password: {type: String, transient: true}, // Transient property hash: {type: String}, // Hash code calculated from sha256(realm, username, password, salt, macKey) salt: {type: String}, macKey: {type: String}, // HMAC to calculate the hash code @@ -42,3 +45,89 @@ var properties = { var User = module.exports = Model.extend('user', properties); +/** + * Login a user by with the given `credentials`. + * + * User.login({username: 'foo', password: 'bar'}, function (err, session) { + * console.log(session.id); + * }); + * + * @param {Object} credentials + */ + +User.login = function (credentials, fn) { + var UserCtor = this; + + this.findOne({username: credentials.username}, function(err, user) { + var defaultError = new Error('login failed'); + + if(err) { + fn(defaultError); + } else if(user) { + user.hasPassword(credentials.password, function(err, isMatch) { + if(err) { + fn(defaultError); + } else if(isMatch) { + createSession(user, fn); + } else { + fn(defaultError); + } + }); + } else { + fn(defaultError); + } + }); + + function createSession(user, fn) { + var Session = UserCtor.settings.session || asteroid.Session; + + Session.create({uid: user.id}, function (err, session) { + if(err) { + fn(err); + } else { + fn(null, session) + } + }); + } +} + +/** + * Compare the given `password` with the users hashed password. + * + * @param {String} password The plain text password + * @returns {Boolean} + */ + +User.prototype.hasPassword = function (plain, fn) { + // TODO - bcrypt + fn(null, this.password === plain); +} + +/** + * Override the extend method to setup any extended user models. + */ + +User.extend = function () { + var EUser = Model.extend.apply(User, arguments); + + setup(EUser); + + return EUser; +} + +function setup(UserModel) { + asteroid.remoteMethod( + UserModel.login, + { + accepts: [ + {arg: 'credentials', type: 'object', required: true, http: {source: 'body'}} + ], + returns: {arg: 'session', type: 'object'}, + http: {verb: 'post'} + } + ); + + return UserModel; +} + +setup(User); \ No newline at end of file diff --git a/package.json b/package.json index 127cad16..80b32837 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,10 @@ "express": "~3.1.1", "jugglingdb": "git+ssh://git@github.com:strongloop/jugglingdb.git", "sl-remoting": "git+ssh://git@github.com:strongloop/sl-remoting.git", - "inflection": "~1.2.5" + "inflection": "~1.2.5", + "bcrypt": "~0.7.6", + "passport": "~0.1.17", + "passport-local": "~0.1.6" }, "devDependencies": { "mocha": "latest", diff --git a/test/user.test.js b/test/user.test.js new file mode 100644 index 00000000..55b6bf1b --- /dev/null +++ b/test/user.test.js @@ -0,0 +1,38 @@ +var User = asteroid.User; +var passport = require('passport'); + +describe('User', function(){ + + beforeEach(function (done) { + var memory = asteroid.createDataSource({ + connector: asteroid.Memory + }); + asteroid.User.attachTo(memory); + asteroid.Session.attachTo(memory); + app.use(asteroid.cookieParser()); + app.use(asteroid.auth()); + app.use(asteroid.rest()); + app.model(asteroid.User); + + asteroid.User.create({email: 'foo@bar.com', password: 'bar'}, done); + }); + + describe('User.login', function(){ + it('Login a user by providing credentials.', function(done) { + request(app) + .post('/users/login') + .expect('Content-Type', /json/) + .expect(200) + .send({email: 'foo@bar.com', password: 'bar'}) + .end(function(err, res){ + if(err) return done(err); + var session = res.body; + + assert(session.uid); + assert(session.id); + + done(); + }); + }); + }); +}); \ No newline at end of file From 42766a28c0ce5e77efd5019a085611adc7e0e5bf Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Tue, 2 Jul 2013 22:37:31 -0700 Subject: [PATCH 11/11] Add basic email verification --- README.md | 77 ++++++++---------- lib/asteroid.js | 24 +++++- lib/models/email.js | 109 ++++++++++++++++++++++++++ lib/models/user.js | 180 ++++++++++++++++++++++++++++++++++++++++--- package.json | 4 +- templates/verify.ejs | 9 +++ test/model.test.js | 13 ++-- test/user.test.js | 150 ++++++++++++++++++++++++++++++++++-- 8 files changed, 493 insertions(+), 73 deletions(-) create mode 100644 lib/models/email.js create mode 100644 templates/verify.ejs diff --git a/README.md b/README.md index 2d772ee4..61053b45 100644 --- a/README.md +++ b/README.md @@ -866,7 +866,7 @@ Setup an authentication strategy. #### Login a User -Create a session for a user. When called remotely the password is required. +Create a session for a user. User.login({username: 'foo', password: 'bar'}, function(err, session) { console.log(session); @@ -893,70 +893,55 @@ You must provide a username and password over rest. To ensure these values are e "uid": "123" } -**Note:** The `uid` type will be the same type you specify when creating your model. In this case it is a string. - #### Logout a User User.logout({username: 'foo'}, function(err) { console.log(err); }); -**Note:** When calling this method remotely, the first argument will automatically be populated with the current user's id. If the caller is not logged in the method will fail with an error status code `401`. +**Note:** When calling this method remotely, the first argument will be populated with the current user's id. If the caller is not logged in the method will fail with an error status code `401`. #### Verify Email Addresses -To require email verification before a user is allowed to login, supply a verification property with a `verify` settings object. +Require a user to verify their email address before being able to login. This will send an email to the user containing a link to verify their address. Once the user follows the link they will be redirected to `/` and be able to login normally. - // define a User model - var User = asteroid.User.extend( - 'user', - { - email: { - type: 'EmailAddress', - username: true - }, - password: { - hideRemotely: true, // default for Password - type: 'Password', - min: 4, - max: 26 - }, - verified: { - hideRemotely: true, - type: 'Boolean', - verify: { - // the model field - // that contains the email - // to verify - email: 'email', - template: 'email.ejs', - redirect: '/' - } - } - }, - { - // the model field - // that contains the user's email - // for verification and password reset - // defaults to 'email' - email: 'email', - resetTemplate: 'reset.ejs' - } - ); + User.requireEmailVerfication = true; + User.afterRemote('create', function(ctx, user, next) { + var options = { + type: 'email', + to: user.email, + from: 'noreply@myapp.com', + subject: 'Thanks for Registering at FooBar', + text: 'Please verify your email address!' + template: 'verify.ejs', + redirect: '/' + }; + + user.verify(options, next); + }); -When a user is created (on the server or remotely) and the verification property exists, an email is sent to the field that corresponds to `verify.email` or `options.email`. The email contains a link the user must navigate to in order to verify their email address. Once they verify, users are allowed to login normally. Otherwise login attempts will respond with a 'must verify' error. #### Send Reset Password Email Send an email to the user's supplied email address containing a link to reset their password. - - User.sendResetPasswordEmail(email, function(err) { - // email sent + + User.reset(email, function(err) { + console.log('email sent'); }); #### Remote Password Reset -The password reset email will send users to a page rendered by asteroid with fields required to reset the user's password. You may customize this template by providing a `resetTemplate` option when defining your user model. +The password reset email will send users to a page rendered by asteroid with fields required to reset the user's password. You may customize this template by defining a `resetTemplate` setting. + + User.settings.resetTemplate = 'reset.ejs'; + +#### Remote Password Reset Confirmation + +Confirm the password reset. + + User.confirmReset(token, function(err) { + console.log(err || 'your password was reset'); + }); ### Email Model diff --git a/lib/asteroid.js b/lib/asteroid.js index 3ca416d4..6eb733e6 100644 --- a/lib/asteroid.js +++ b/lib/asteroid.js @@ -4,6 +4,7 @@ var express = require('express') , fs = require('fs') + , ejs = require('ejs') , EventEmitter = require('events').EventEmitter , path = require('path') , proto = require('./application') @@ -212,7 +213,7 @@ asteroid.createModel = function (name, properties, options) { if(this.app) { var remotes = this.app.remotes(); remotes.before(self.pluralModelName + '.' + name, function (ctx, next) { - fn(ctx, ctx.instance, next); + fn(ctx, ctx.result, next); }); } else { var args = arguments; @@ -228,7 +229,7 @@ asteroid.createModel = function (name, properties, options) { if(this.app) { var remotes = this.app.remotes(); remotes.after(self.pluralModelName + '.' + name, function (ctx, next) { - fn(ctx, ctx.instance, next); + fn(ctx, ctx.result, next); }); } else { var args = arguments; @@ -257,10 +258,27 @@ asteroid.remoteMethod = function (fn, options) { fn.http = fn.http || {verb: 'get'}; } +/** + * Create a template helper. + * + * var render = asteroid.template('foo.ejs'); + * var html = render({foo: 'bar'}); + * + * @param {String} path Path to the template file. + * @returns {Function} + */ + +asteroid.template = function (file) { + var templates = this._templates || (this._templates = {}); + var str = templates[file] || (templates[file] = fs.readFileSync(file, 'utf8')); + return ejs.compile(str); +} + /* - * Built in models + * Built in models / services */ asteroid.Model = asteroid.createModel('model'); +asteroid.Email = require('./models/email'); asteroid.User = require('./models/user'); asteroid.Session = require('./models/session'); diff --git a/lib/models/email.js b/lib/models/email.js new file mode 100644 index 00000000..bcc9cb39 --- /dev/null +++ b/lib/models/email.js @@ -0,0 +1,109 @@ +/** + * Module Dependencies. + */ + +var Model = require('../asteroid').Model + , asteroid = require('../asteroid') + , mailer = require("nodemailer"); + +/** + * Default Email properties. + */ + +var properties = { + to: {type: String, required: true}, + from: {type: String, required: true}, + subject: {type: String, required: true}, + text: {type: String}, + html: {type: String} +}; + +/** + * Extends from the built in `asteroid.Model` type. + */ + +var Email = module.exports = Model.extend('email', properties); + +/*! + * Setup the Email class after extension. + */ + +Email.setup = function (settings) { + settings = settings || this.settings; + var transports = settings.transports || []; + + transports.forEach(this.setupTransport.bind(this)); +} + +/** + * Add a transport to the available transports. See https://github.com/andris9/Nodemailer#setting-up-a-transport-method. + * + * Example: + * + * Email.setupTransport({ + * type: 'SMTP', + * host: "smtp.gmail.com", // hostname + * secureConnection: true, // use SSL + * port: 465, // port for secure SMTP + * auth: { + * user: "gmail.user@gmail.com", + * pass: "userpass" + * } + * }); + * + */ + +Email.setupTransport = function (setting) { + var Email = this; + Email.transports = Email.transports || []; + Email.transportsIndex = Email.transportsIndex || {}; + var transport = mailer.createTransport(setting.type, setting); + Email.transportsIndex[setting.type] = transport; + Email.transports.push(transport); +} + +/** + * Send an email with the given `options`. + * + * Example Options: + * + * { + * from: "Fred Foo ✔ ", // sender address + * to: "bar@blurdybloop.com, baz@blurdybloop.com", // list of receivers + * subject: "Hello ✔", // Subject line + * text: "Hello world ✔", // plaintext body + * html: "Hello world ✔" // html body + * } + * + * See https://github.com/andris9/Nodemailer for other supported options. + * + * @param {Object} options + * @param {Function} callback Called after the e-mail is sent or the sending failed + */ + +Email.send = function (options, fn) { + var transport = this.transportsIndex[options.transport || 'SMTP'] || this.transports[0]; + assert(transport, 'You must supply an Email.settings.transports array containing at least one transport'); + + transport.sendMail(options, fn); +} + +/** + * Access the node mailer object. + * + * Email.mailer + * // or + * var email = new Email({to: 'foo@bar.com', from: 'bar@bat.com'}); + * email.mailer + */ + +Email.mailer = +Email.prototype.mailer = mailer; + +/** + * Send an email instance using `Email.send()`. + */ + +Email.prototype.send = function (fn) { + this.constructor.send(this, fn); +} \ No newline at end of file diff --git a/lib/models/user.js b/lib/models/user.js index 21118917..bdd3d1ca 100644 --- a/lib/models/user.js +++ b/lib/models/user.js @@ -4,6 +4,8 @@ var Model = require('../asteroid').Model , asteroid = require('../asteroid') + , path = require('path') + , crypto = require('crypto') , passport = require('passport') , LocalStrategy = require('passport-local').Strategy; @@ -13,7 +15,7 @@ var Model = require('../asteroid').Model var properties = { id: {type: String, required: true}, - realm: {type: String}, + realm: {type: String, }, username: {type: String, required: true}, password: {type: String, transient: true}, // Transient property hash: {type: String}, // Hash code calculated from sha256(realm, username, password, salt, macKey) @@ -21,6 +23,7 @@ var properties = { macKey: {type: String}, // HMAC to calculate the hash code email: String, emailVerified: Boolean, + verificationToken: String, credentials: [ 'UserCredential' // User credentials, private or public, such as private/public keys, Kerberos tickets, oAuth tokens, facebook, google, github ids ], @@ -91,6 +94,32 @@ User.login = function (credentials, fn) { } } +/** + * Logout a user with the given session id. + * + * User.logout('asd0a9f8dsj9s0s3223mk', function (err) { + * console.log(err || 'Logged out'); + * }); + * + * @param {String} sessionID + */ + +User.logout = function (sid, fn) { + var UserCtor = this; + + var Session = UserCtor.settings.session || asteroid.Session; + + Session.findById(sid, function (err, session) { + if(err) { + fn(err); + } else if(session) { + session.destroy(fn); + } else { + fn(new Error('could not find session')); + } + }); +} + /** * Compare the given `password` with the users hashed password. * @@ -103,19 +132,114 @@ User.prototype.hasPassword = function (plain, fn) { fn(null, this.password === plain); } +/** + * Verify a user's identity. + * + * var options = { + * type: 'email', + * to: user.email, + * template: 'verify.ejs', + * redirect: '/' + * }; + * + * user.verify(options, next); + * + * @param {Object} options + */ + +User.prototype.verify = function (options, fn) { + var user = this; + assert(typeof options === 'object', 'options required when calling user.verify()'); + assert(options.type, 'You must supply a verification type (options.type)'); + assert(options.type === 'email', 'Unsupported verification type'); + assert(options.to || this.email, 'Must include options.to when calling user.verify() or the user must have an email property'); + assert(options.from, 'Must include options.from when calling user.verify() or the user must have an email property'); + + options.redirect = options.redirect || '/'; + options.template = path.resolve(options.template || path.join(__dirname, '..', '..', 'templates', 'verify.ejs')); + options.user = this; + options.protocol = options.protocol || 'http'; + options.host = options.host || 'localhost'; + options.verifyHref = options.verifyHref || + options.protocol + + '://' + + options.host + + (User.sharedCtor.http.path || '/' + User.pluralModelName) + + User.confirm.http.path; + + + + // Email model + var Email = options.mailer || this.constructor.settings.email || asteroid.Email; + + crypto.randomBytes(64, function(err, buf) { + if(err) { + fn(err); + } else { + user.verificationToken = buf.toString('base64'); + user.save(function (err) { + if(err) { + fn(err); + } else { + sendEmail(user); + } + }); + } + }); + + // TODO - support more verification types + function sendEmail(user) { + options.verifyHref += '?token=' + user.verificationToken; + + options.text = options.text || 'Please verify your email by opening this link in a web browser:\n\t{href}'; + + options.text = options.text.replace('{href}', options.verifyHref); + + var template = asteroid.template(options.template); + Email.send({ + to: options.to || user.email, + subject: options.subject || 'Thanks for Registering', + text: options.text, + html: template(options) + }, function (err, email) { + if(err) { + fn(err); + } else { + fn(null, {email: email, token: user.verificationToken, uid: user.id}); + } + }); + } +} + +User.confirm = function (uid, token, redirect, fn) { + this.findById(uid, function (err, user) { + if(err) { + fn(err); + } else { + if(user.verificationToken === token) { + user.verificationToken = undefined; + user.emailVerified = true; + user.save(function (err) { + if(err) { + fn(err) + } else { + fn(); + } + }); + } else { + fn(new Error('invalid token')); + } + } + }); +} + /** * Override the extend method to setup any extended user models. */ -User.extend = function () { - var EUser = Model.extend.apply(User, arguments); +User.setup = function () { + var UserModel = this; - setup(EUser); - - return EUser; -} - -function setup(UserModel) { asteroid.remoteMethod( UserModel.login, { @@ -127,7 +251,43 @@ function setup(UserModel) { } ); + asteroid.remoteMethod( + UserModel.logout, + { + accepts: [ + {arg: 'sid', type: 'string', required: true} + ], + http: {verb: 'all'} + } + ); + + asteroid.remoteMethod( + UserModel.confirm, + { + accepts: [ + {arg: 'uid', type: 'string', required: true}, + {arg: 'token', type: 'string', required: true}, + {arg: 'redirect', type: 'string', required: true} + ], + http: {verb: 'get', path: '/confirm'} + } + ); + + UserModel.on('attached', function () { + UserModel.afterRemote('confirm', function (ctx, inst, next) { + if(ctx.req) { + ctx.res.redirect(ctx.req.param('redirect')); + } else { + fn(new Error('transport unsupported')); + } + }); + }); + return UserModel; } -setup(User); \ No newline at end of file +/*! + * Setup the base user. + */ + +User.setup(); \ No newline at end of file diff --git a/package.json b/package.json index 80b32837..5f83d169 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "inflection": "~1.2.5", "bcrypt": "~0.7.6", "passport": "~0.1.17", - "passport-local": "~0.1.6" + "passport-local": "~0.1.6", + "nodemailer": "~0.4.4", + "ejs": "~0.8.4" }, "devDependencies": { "mocha": "latest", diff --git a/templates/verify.ejs b/templates/verify.ejs new file mode 100644 index 00000000..708c1139 --- /dev/null +++ b/templates/verify.ejs @@ -0,0 +1,9 @@ +

Thank You

+ +

+ Thanks for registering. Please follow the link below to complete your registration. +

+ +

+ <%= verifyHref %> +

\ No newline at end of file diff --git a/test/model.test.js b/test/model.test.js index 4ecf4d28..2b08634f 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -288,7 +288,7 @@ describe('Model', function() { it('Run a function before a remote method is called by a client.', function(done) { var hookCalled = false; - User.beforeRemote('*.save', function(ctx, user, next) { + User.beforeRemote('create', function(ctx, user, next) { hookCalled = true; next(); }); @@ -312,12 +312,12 @@ describe('Model', function() { var beforeCalled = false; var afterCalled = false; - User.beforeRemote('*.save', function(ctx, user, next) { + User.beforeRemote('create', function(ctx, user, next) { assert(!afterCalled); beforeCalled = true; next(); }); - User.afterRemote('*.save', function(ctx, user, next) { + User.afterRemote('create', function(ctx, user, next) { assert(beforeCalled); afterCalled = true; next(); @@ -349,7 +349,7 @@ describe('Model', function() { it("The express ServerRequest object", function(done) { var hookCalled = false; - User.beforeRemote('*.save', function(ctx, user, next) { + User.beforeRemote('create', function(ctx, user, next) { hookCalled = true; assert(ctx.req); assert(ctx.req.url); @@ -378,7 +378,7 @@ describe('Model', function() { it("The express ServerResponse object", function(done) { var hookCalled = false; - User.beforeRemote('*.save', function(ctx, user, next) { + User.beforeRemote('create', function(ctx, user, next) { hookCalled = true; assert(ctx.req); assert(ctx.req.url); @@ -466,7 +466,7 @@ describe('Model', function() { describe('Model.extend()', function(){ it('Create a new model by extending an existing model.', function() { - var User = asteroid.Model.extend('user', { + var User = asteroid.Model.extend('test-user', { email: String }); @@ -486,7 +486,6 @@ describe('Model', function() { assert.equal(MyUser.prototype.bar, User.prototype.bar); assert.equal(MyUser.foo, User.foo); - debugger; var user = new MyUser({ email: 'foo@bar.com', a: 'foo', diff --git a/test/user.test.js b/test/user.test.js index 55b6bf1b..39997d8c 100644 --- a/test/user.test.js +++ b/test/user.test.js @@ -1,14 +1,17 @@ var User = asteroid.User; +var Session = asteroid.Session; var passport = require('passport'); +var userMemory = asteroid.createDataSource({ + connector: asteroid.Memory +}); +asteroid.User.attachTo(userMemory); +asteroid.Session.attachTo(userMemory); +asteroid.Email.setup({transports: [{type: 'STUB'}]}); + describe('User', function(){ beforeEach(function (done) { - var memory = asteroid.createDataSource({ - connector: asteroid.Memory - }); - asteroid.User.attachTo(memory); - asteroid.Session.attachTo(memory); app.use(asteroid.cookieParser()); app.use(asteroid.auth()); app.use(asteroid.rest()); @@ -17,7 +20,11 @@ describe('User', function(){ asteroid.User.create({email: 'foo@bar.com', password: 'bar'}, done); }); - describe('User.login', function(){ + afterEach(function (done) { + Session.destroyAll(done); + }); + + describe('User.login', function() { it('Login a user by providing credentials.', function(done) { request(app) .post('/users/login') @@ -35,4 +42,135 @@ describe('User', function(){ }); }); }); + + describe('User.logout', function() { + it('Logout a user by providing the current session id.', function(done) { + login(logout); + + function login(fn) { + request(app) + .post('/users/login') + .expect('Content-Type', /json/) + .expect(200) + .send({email: 'foo@bar.com', password: 'bar'}) + .end(function(err, res){ + if(err) return done(err); + var session = res.body; + + assert(session.uid); + assert(session.id); + + fn(null, session.id); + }); + } + + function logout(err, sid) { + request(app) + .post('/users/logout') + .expect(200) + .send({sid: sid}) + .end(verify(sid)); + } + + function verify(sid) { + return function (err) { + if(err) return done(err); + Session.findById(sid, function (err, session) { + assert(!session, 'session should not exist after logging out'); + done(err); + }); + } + } + }); + }); + + describe('user.hasPassword(plain, fn)', function(){ + it('Determine if the password matches the stored password.', function(done) { + var u = new User({username: 'foo', password: 'bar'}); + + u.hasPassword('bar', function (err, isMatch) { + assert(isMatch, 'password doesnt match'); + done(); + }); + }); + }); + + describe('Verification', function(){ + + describe('user.verify(options, fn)', function(){ + it('Verify a user\'s email address', function(done) { + User.afterRemote('create', function(ctx, user, next) { + assert(user, 'afterRemote should include result'); + + var options = { + type: 'email', + to: user.email, + from: 'noreply@myapp.org', + redirect: '/', + protocol: ctx.req.protocol, + host: ctx.req.get('host') + }; + + user.verify(options, function (err, result) { + assert(result.email); + assert(result.email.message); + assert(result.token); + + + var lines = result.email.message.split('\n'); + assert(lines[4].indexOf('To: bar@bat.com') === 0); + done(); + }); + }); + + request(app) + .post('/users') + .expect('Content-Type', /json/) + .expect(200) + .send({data: {email: 'bar@bat.com', password: 'bar'}}) + .end(function(err, res){ + if(err) return done(err); + }); + }); + }); + + describe('User.confirm(options, fn)', function(){ + it('Confirm a user verification', function(done) { + User.afterRemote('create', function(ctx, user, next) { + assert(user, 'afterRemote should include result'); + + var options = { + type: 'email', + to: user.email, + from: 'noreply@myapp.org', + redirect: 'http://foo.com/bar', + protocol: ctx.req.protocol, + host: ctx.req.get('host') + }; + + user.verify(options, function (err, result) { + if(err) return done(err); + + request(app) + .get('/users/confirm?uid=' + result.uid + '&token=' + encodeURIComponent(result.token) + '&redirect=' + encodeURIComponent(options.redirect)) + .expect(302) + .expect('location', options.redirect) + .end(function(err, res){ + if(err) return done(err); + done(); + }); + }); + }); + + request(app) + .post('/users') + .expect('Content-Type', /json/) + .expect(302) + .send({data: {email: 'bar@bat.com', password: 'bar'}}) + .end(function(err, res){ + if(err) return done(err); + }); + }); + }); + }); }); \ No newline at end of file