Add basic email verification

This commit is contained in:
Ritchie Martori 2013-07-02 22:37:31 -07:00 committed by Ritchie
parent 8387a68b85
commit fc0777de08
8 changed files with 493 additions and 73 deletions

View File

@ -866,7 +866,7 @@ Setup an authentication strategy.
#### Login a User #### 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) { User.login({username: 'foo', password: 'bar'}, function(err, session) {
console.log(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" "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 #### Logout a User
User.logout({username: 'foo'}, function(err) { User.logout({username: 'foo'}, function(err) {
console.log(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 #### 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 User.requireEmailVerfication = true;
var User = asteroid.User.extend( User.afterRemote('create', function(ctx, user, next) {
'user', var options = {
{ type: 'email',
email: { to: user.email,
type: 'EmailAddress', from: 'noreply@myapp.com',
username: true subject: 'Thanks for Registering at FooBar',
}, text: 'Please verify your email address!'
password: { template: 'verify.ejs',
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: '/' redirect: '/'
} };
}
}, user.verify(options, next);
{ });
// 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) 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 Reset Password Email
Send an email to the user's supplied email address containing a link to reset their password. Send an email to the user's supplied email address containing a link to reset their password.
User.sendResetPasswordEmail(email, function(err) { User.reset(email, function(err) {
// email sent console.log('email sent');
}); });
#### Remote Password Reset #### 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 ### Email Model

View File

@ -4,6 +4,7 @@
var express = require('express') var express = require('express')
, fs = require('fs') , fs = require('fs')
, ejs = require('ejs')
, EventEmitter = require('events').EventEmitter , EventEmitter = require('events').EventEmitter
, path = require('path') , path = require('path')
, proto = require('./application') , proto = require('./application')
@ -212,7 +213,7 @@ asteroid.createModel = function (name, properties, options) {
if(this.app) { if(this.app) {
var remotes = this.app.remotes(); var remotes = this.app.remotes();
remotes.before(self.pluralModelName + '.' + name, function (ctx, next) { remotes.before(self.pluralModelName + '.' + name, function (ctx, next) {
fn(ctx, ctx.instance, next); fn(ctx, ctx.result, next);
}); });
} else { } else {
var args = arguments; var args = arguments;
@ -228,7 +229,7 @@ asteroid.createModel = function (name, properties, options) {
if(this.app) { if(this.app) {
var remotes = this.app.remotes(); var remotes = this.app.remotes();
remotes.after(self.pluralModelName + '.' + name, function (ctx, next) { remotes.after(self.pluralModelName + '.' + name, function (ctx, next) {
fn(ctx, ctx.instance, next); fn(ctx, ctx.result, next);
}); });
} else { } else {
var args = arguments; var args = arguments;
@ -257,10 +258,27 @@ asteroid.remoteMethod = function (fn, options) {
fn.http = fn.http || {verb: 'get'}; 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.Model = asteroid.createModel('model');
asteroid.Email = require('./models/email');
asteroid.User = require('./models/user'); asteroid.User = require('./models/user');
asteroid.Session = require('./models/session'); asteroid.Session = require('./models/session');

109
lib/models/email.js Normal file
View File

@ -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 ✔ <foo@blurdybloop.com>", // sender address
* to: "bar@blurdybloop.com, baz@blurdybloop.com", // list of receivers
* subject: "Hello ✔", // Subject line
* text: "Hello world ✔", // plaintext body
* html: "<b>Hello world ✔</b>" // 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);
}

View File

@ -4,6 +4,8 @@
var Model = require('../asteroid').Model var Model = require('../asteroid').Model
, asteroid = require('../asteroid') , asteroid = require('../asteroid')
, path = require('path')
, crypto = require('crypto')
, passport = require('passport') , passport = require('passport')
, LocalStrategy = require('passport-local').Strategy; , LocalStrategy = require('passport-local').Strategy;
@ -13,7 +15,7 @@ var Model = require('../asteroid').Model
var properties = { var properties = {
id: {type: String, required: true}, id: {type: String, required: true},
realm: {type: String}, realm: {type: String, },
username: {type: String, required: true}, 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) 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 macKey: {type: String}, // HMAC to calculate the hash code
email: String, email: String,
emailVerified: Boolean, emailVerified: Boolean,
verificationToken: String,
credentials: [ credentials: [
'UserCredential' // User credentials, private or public, such as private/public keys, Kerberos tickets, oAuth tokens, facebook, google, github ids '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. * Compare the given `password` with the users hashed password.
* *
@ -103,19 +132,114 @@ User.prototype.hasPassword = function (plain, fn) {
fn(null, this.password === plain); 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. * Override the extend method to setup any extended user models.
*/ */
User.extend = function () { User.setup = function () {
var EUser = Model.extend.apply(User, arguments); var UserModel = this;
setup(EUser);
return EUser;
}
function setup(UserModel) {
asteroid.remoteMethod( asteroid.remoteMethod(
UserModel.login, 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; return UserModel;
} }
setup(User); /*!
* Setup the base user.
*/
User.setup();

View File

@ -13,7 +13,9 @@
"inflection": "~1.2.5", "inflection": "~1.2.5",
"bcrypt": "~0.7.6", "bcrypt": "~0.7.6",
"passport": "~0.1.17", "passport": "~0.1.17",
"passport-local": "~0.1.6" "passport-local": "~0.1.6",
"nodemailer": "~0.4.4",
"ejs": "~0.8.4"
}, },
"devDependencies": { "devDependencies": {
"mocha": "latest", "mocha": "latest",

9
templates/verify.ejs Normal file
View File

@ -0,0 +1,9 @@
<h1>Thank You</h1>
<p>
Thanks for registering. Please follow the link below to complete your registration.
</p>
<p>
<a href="<%= verifyHref %>"><%= verifyHref %></a>
</p>

View File

@ -288,7 +288,7 @@ describe('Model', function() {
it('Run a function before a remote method is called by a client.', function(done) { it('Run a function before a remote method is called by a client.', function(done) {
var hookCalled = false; var hookCalled = false;
User.beforeRemote('*.save', function(ctx, user, next) { User.beforeRemote('create', function(ctx, user, next) {
hookCalled = true; hookCalled = true;
next(); next();
}); });
@ -312,12 +312,12 @@ describe('Model', function() {
var beforeCalled = false; var beforeCalled = false;
var afterCalled = false; var afterCalled = false;
User.beforeRemote('*.save', function(ctx, user, next) { User.beforeRemote('create', function(ctx, user, next) {
assert(!afterCalled); assert(!afterCalled);
beforeCalled = true; beforeCalled = true;
next(); next();
}); });
User.afterRemote('*.save', function(ctx, user, next) { User.afterRemote('create', function(ctx, user, next) {
assert(beforeCalled); assert(beforeCalled);
afterCalled = true; afterCalled = true;
next(); next();
@ -349,7 +349,7 @@ describe('Model', function() {
it("The express ServerRequest object", function(done) { it("The express ServerRequest object", function(done) {
var hookCalled = false; var hookCalled = false;
User.beforeRemote('*.save', function(ctx, user, next) { User.beforeRemote('create', function(ctx, user, next) {
hookCalled = true; hookCalled = true;
assert(ctx.req); assert(ctx.req);
assert(ctx.req.url); assert(ctx.req.url);
@ -378,7 +378,7 @@ describe('Model', function() {
it("The express ServerResponse object", function(done) { it("The express ServerResponse object", function(done) {
var hookCalled = false; var hookCalled = false;
User.beforeRemote('*.save', function(ctx, user, next) { User.beforeRemote('create', function(ctx, user, next) {
hookCalled = true; hookCalled = true;
assert(ctx.req); assert(ctx.req);
assert(ctx.req.url); assert(ctx.req.url);
@ -466,7 +466,7 @@ describe('Model', function() {
describe('Model.extend()', function(){ describe('Model.extend()', function(){
it('Create a new model by extending an existing model.', 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 email: String
}); });
@ -486,7 +486,6 @@ describe('Model', function() {
assert.equal(MyUser.prototype.bar, User.prototype.bar); assert.equal(MyUser.prototype.bar, User.prototype.bar);
assert.equal(MyUser.foo, User.foo); assert.equal(MyUser.foo, User.foo);
debugger;
var user = new MyUser({ var user = new MyUser({
email: 'foo@bar.com', email: 'foo@bar.com',
a: 'foo', a: 'foo',

View File

@ -1,14 +1,17 @@
var User = asteroid.User; var User = asteroid.User;
var Session = asteroid.Session;
var passport = require('passport'); 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(){ describe('User', function(){
beforeEach(function (done) { 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.cookieParser());
app.use(asteroid.auth()); app.use(asteroid.auth());
app.use(asteroid.rest()); app.use(asteroid.rest());
@ -17,7 +20,11 @@ describe('User', function(){
asteroid.User.create({email: 'foo@bar.com', password: 'bar'}, done); 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) { it('Login a user by providing credentials.', function(done) {
request(app) request(app)
.post('/users/login') .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);
});
});
});
});
}); });