diff --git a/docs/api.md b/docs/api.md index 413f821a..71a6e8b3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -210,7 +210,47 @@ app.use(loopback.rest()); View generated REST documentation by visiting: [http://localhost:3000/_docs](http://localhost:3000/_docs). + +### Middleware + +LoopBack comes bundled with several `connect` / `express` style middleware. + +#### loopback.token(options) +**Options** + + - `cookies` - An `Array` of cookie names + - `headers` - An `Array` of header names + - `params` - An `Array` of param names + +Each array is used to add additional keys to find an `accessToken` for a `request`. + +The following example illustrates how to check for an `accessToken` in a custom cookie, query string parameter +and header called `foo-auth`. + +```js +app.use(loopback.token({ + cookies: ['foo-auth'], + headers: ['foo-auth', 'X-Foo-Auth'], + cookies: ['foo-auth', 'foo_auth'] +})); +``` + +**Defaults** + +By default the following names will be checked. These names are appended to any optional names. They will always +be checked, but any names specified will be checked first. + +```js + params.push('access_token'); + headers.push('X-Access-Token'); + headers.push('authorization'); + cookies.push('access_token'); + cookies.push('authorization'); +``` + +> **NOTE:** The `loopback.token()` middleware will only check for [signed cookies](http://expressjs.com/api.html#req.signedCookies). + ### Model A Loopback `Model` is a vanilla JavaScript class constructor with an attached set of properties and options. A `Model` instance is created by passing a data object containing properties to the `Model` constructor. A `Model` constructor will clean the object passed to it and only set the values matching the properties you define. @@ -565,8 +605,8 @@ User.login = function (username, password, fn) { } else if(!user) { fn(failErr); } else if(user.password === passwordHash) { - MySessionModel.create({userId: user.id}, function (err, session) { - fn(null, session.id); + MyAccessTokenModel.create({userId: user.id}, function (err, accessToken) { + fn(null, accessToken.id); }); } else { fn(failErr); @@ -585,7 +625,7 @@ loopback.remoteMethod( {arg: 'username', type: 'string', required: true}, {arg: 'password', type: 'string', required: true} ], - returns: {arg: 'sessionId', type: 'any'}, + returns: {arg: 'accessTokenId', type: 'any'}, http: {path: '/sign-in'} } ); @@ -637,7 +677,7 @@ Define an instance method. ```js User.prototype.logout = function (fn) { - MySessionModel.destroyAll({userId: this.id}, fn); + MyAccessTokenModel.destroyAll({userId: this.id}, fn); } ``` diff --git a/docs/bundled-models.md b/docs/bundled-models.md index 5ce1ddba..48d12d1f 100644 --- a/docs/bundled-models.md +++ b/docs/bundled-models.md @@ -25,16 +25,16 @@ var User = loopback.User.extend('user'); // attach to the memory connector User.attachTo(memory); -// also attach the session model to a data source -User.session.attachTo(memory); +// also attach the accessToken model to a data source +User.accessToken.attachTo(memory); // expose over the app's api app.model(User); ``` -**Note:** By default the `loopback.User` model uses the `loopback.Session` model to persist sessions. You can change this by setting the `session` property. +**Note:** By default the `loopback.User` model uses the `loopback.AccessToken` model to persist access tokens. You can change this by setting the `accessToken` property. -**Note:** You must attach both the `User` and `User.session` model's to a data source! +**Note:** You must attach both the `User` and `User.accessToken` model's to a data source! #### User Creation @@ -49,13 +49,13 @@ User.create({email: 'foo@bar.com', password: 'bar'}, function(err, user) { #### Login a User -Create a session for a user using the local auth strategy. +Create an `accessToken` for a user using the local auth strategy. **Node.js** ```js -User.login({username: 'foo', password: 'bar'}, function(err, session) { - console.log(session); +User.login({username: 'foo', password: 'bar'}, function(err, accessToken) { + console.log(accessToken); }); ``` @@ -88,8 +88,8 @@ POST ```js // login a user and logout -User.login({"email": "foo@bar.com", "password": "bar"}, function(err, session) { - User.logout(session.id, function(err) { +User.login({"email": "foo@bar.com", "password": "bar"}, function(err, accessToken) { + User.logout(accessToken.id, function(err) { // user logged out }); }); @@ -106,7 +106,7 @@ User.findOne({email: 'foo@bar.com'}, function(err, user) { POST /users/logout ... { - "sid": "" + "sid": "" } ``` @@ -175,23 +175,23 @@ User.confirmReset(token, function(err) { }); ``` -### Session Model +### AccessToken Model -Identify users by creating sessions when they connect to your loopback app. By default the `loopback.User` model uses the `loopback.Session` model to persist sessions. You can change this by setting the `session` property. +Identify users by creating accessTokens when they connect to your loopback app. By default the `loopback.User` model uses the `loopback.AccessToken` model to persist accessTokens. You can change this by setting the `accessToken` property. ```js -// define a custom session model -var MySession = loopback.Session.extend('my-session'); +// define a custom accessToken model +var MyAccessToken = loopback.AccessToken.extend('MyAccessToken'); // define a custom User model var User = loopback.User.extend('user'); -// use the custom session model -User.session = MySession; +// use the custom accessToken model +User.accessToken = MyAccessToken; -// attach both Session and User to a data source +// attach both AccessToken and User to a data source User.attachTo(loopback.memory()); -MySession.attachTo(loopback.memory()); +MyAccessToken.attachTo(loopback.memory()); ``` ### Email Model diff --git a/docs/concepts.md b/docs/concepts.md index 762636c3..b5ad6e08 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -10,7 +10,7 @@ A LoopBack model consists of: Apps use the model API to display information to the user or trigger actions on the models to interact with backend systems. LoopBack supports both "dynamic" schema-less models and "static", schema-driven models. -_Dynamic models_ require only a name. The format of the data are specified completely and flexibly by the client application. Well-suited for data that originates on the client, dynamic models enable you to persist data both between sessions and between devices without involving a schema. +_Dynamic models_ require only a name. The format of the data are specified completely and flexibly by the client application. Well-suited for data that originates on the client, dynamic models enable you to persist data both between accessTokens and between devices without involving a schema. _Static models_ require more code up front, with the format of the data specified completely in JSON. Well-suited to both existing data and large, intricate datasets, static models provide structure and consistency to their data, preventing bugs that can result from unexpected data in the database. diff --git a/lib/application.js b/lib/application.js index a82332df..590b83ab 100644 --- a/lib/application.js +++ b/lib/application.js @@ -79,6 +79,14 @@ app.model = function (Model, config) { return Model; } +/** + * Get a Model by name. + */ + +app.getModel = function (modelName) { + this.models +}; + /** * Get all exposed models. */ diff --git a/lib/loopback.js b/lib/loopback.js index 418abe98..423a3f99 100644 --- a/lib/loopback.js +++ b/lib/loopback.js @@ -172,5 +172,5 @@ loopback.memory = function (name) { loopback.Model = require('./models/model'); loopback.Email = require('./models/email'); loopback.User = require('./models/user'); -loopback.Session = require('./models/session'); loopback.Application = require('./models/application'); +loopback.AccessToken = require('./models/access-token'); diff --git a/lib/middleware/token.js b/lib/middleware/token.js new file mode 100644 index 00000000..585dc812 --- /dev/null +++ b/lib/middleware/token.js @@ -0,0 +1,31 @@ +/** + * Module dependencies. + */ + +var loopback = require('../loopback'); +var RemoteObjects = require('strong-remoting'); + +/** + * Export the middleware. + */ + +module.exports = token; + +function token(options) { + options = options || {}; + var TokenModel = options.model; + assert(TokenModel, 'loopback.token() middleware requires a AccessToken model'); + + return function (req, res, next) { + TokenModel.findForRequest(req, options, function(err, token) { + if(err) return next(err); + if(token) { + req.accessToken = token; + next(); + } else { + return next(); + } + }); + } +} + diff --git a/lib/models/access-token.js b/lib/models/access-token.js new file mode 100644 index 00000000..ec2f0187 --- /dev/null +++ b/lib/models/access-token.js @@ -0,0 +1,166 @@ +/** + * Module Dependencies. + */ + +var Model = require('../loopback').Model + , loopback = require('../loopback') + , assert = require('assert') + , crypto = require('crypto') + , uid = require('uid2') + , DEFAULT_TTL = 1209600 // 2 weeks in seconds + , DEFAULT_TOKEN_LEN = 64; + +/** + * Default AccessToken properties. + */ + +var properties = { + id: {type: String, generated: true, id: 1}, + ttl: {type: Number, ttl: true, default: DEFAULT_TTL}, // time to live in seconds + created: {type: Date, default: function() { + return new Date(); + }} +}; + +/** + * Extends from the built in `loopback.Model` type. + */ + +var AccessToken = module.exports = Model.extend('AccessToken', properties); + +/** + * Create a cryptographically random access token id. + * + * @param {Function} callback + */ + +AccessToken.createAccessTokenId = function (fn) { + uid(this.settings.accessTokenIdLength || DEFAULT_TOKEN_LEN, function(err, guid) { + if(err) { + fn(err); + } else { + fn(null, guid); + } + }); +} + +/*! + * Hook to create accessToken id. + */ + +AccessToken.beforeCreate = function (next, data) { + data = data || {}; + + AccessToken.createAccessTokenId(function (err, id) { + if(err) { + next(err); + } else { + data.id = id; + + next(); + } + }); +} + +/** + * Find a token for the given `ServerRequest`. + * + * @param {ServerRequest} req + * @param {Object} [options] Options for finding the token + * @param {Function} callback Calls back with a token if one exists otherwise null or an error. + */ + +AccessToken.findForRequest = function(req, options, cb) { + var id = tokenIdForRequest(req, options); + + if(id) { + this.findById(id, function(err, token) { + if(err) { + cb(err); + } else { + token.validate(function(err, isValid) { + if(err) { + cb(err); + } else if(isValid) { + cb(null, token); + } else { + cb(new Error('Invalid Access Token')); + } + }); + } + }); + } else { + process.nextTick(function() { + cb(); + }); + } +} + +AccessToken.prototype.validate = function(cb) { + try { + assert( + this.created && typeof this.created.getTime === 'function', + 'token.created must be a valid Date' + ); + assert(this.ttl !== 0, 'token.ttl must be not be 0'); + assert(this.ttl, 'token.ttl must exist'); + assert(this.ttl >= -1, 'token.ttl must be >= -1'); + + var now = Date.now(); + var created = this.created.getTime(); + var elapsedSeconds = (now - created) / 1000; + var secondsToLive = this.ttl; + var isValid = elapsedSeconds < secondsToLive; + + if(isValid) { + cb(null, isValid); + } else { + this.destroy(function(err) { + cb(err, isValid); + }); + } + } catch(e) { + cb(e); + } +} + +function tokenIdForRequest(req, options) { + var params = options.params || []; + var headers = options.headers || []; + var cookies = options.cookies || []; + var i = 0; + var length; + var id; + + params.push('access_token'); + headers.push('X-Access-Token'); + headers.push('authorization'); + cookies.push('access_token'); + cookies.push('authorization'); + + for(length = params.length; i < length; i++) { + id = req.param(params[i]); + + if(typeof id === 'string') { + return id; + } + } + + for(i = 0, length = headers.length; i < length; i++) { + id = req.header(headers[i]); + + if(typeof id === 'string') { + return id; + } + } + + for(i = 0, length = headers.length; i < length; i++) { + id = req.signedCookies[cookies[i]]; + + if(typeof id === 'string') { + return id; + } + } + + return null; +} diff --git a/lib/models/index.js b/lib/models/index.js index 1ab7d7f4..295acccd 100644 --- a/lib/models/index.js +++ b/lib/models/index.js @@ -1,7 +1,7 @@ exports.Model = require('./model'); exports.Email = require('./email'); exports.User = require('./user'); -exports.Session = require('./session'); +exports.AccessToken = require('./access-token'); exports.Application = require('./application'); exports.ACL = require('./acl'); diff --git a/lib/models/role.js b/lib/models/role.js index a8af2645..9a18e418 100644 --- a/lib/models/role.js +++ b/lib/models/role.js @@ -15,4 +15,4 @@ module.exports = function(dataSource) { dataSource = dataSource || new require('loopback-datasource-juggler').ModelBuilder(); var Role = dataSource.define('Role', RoleSchema); return Role; -} \ No newline at end of file +} diff --git a/lib/models/session.js b/lib/models/session.js deleted file mode 100644 index 9099db1a..00000000 --- a/lib/models/session.js +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Module Dependencies. - */ - -var Model = require('../loopback').Model - , loopback = require('../loopback') - , crypto = require('crypto'); - -/** - * Default Session properties. - */ - -var properties = { - id: {type: String, generated: true, id: 1}, - uid: {type: String}, - ttl: {type: Number, ttl: true} -}; - -/** - * Extends from the built in `loopback.Model` type. - */ - -var Session = module.exports = Model.extend('Session', properties); - -/** - * Create a cryptographically random session id. - * - * @param {Function} callback - */ - -Session.createSessionId = function (fn) { - crypto.randomBytes(this.settings.sessionIdLength || 64, function(err, buf) { - if(err) { - fn(err); - } else { - fn(null, buf.toString('base64')); - } - }); -} - -/*! - * Hook to create session id. - */ - -Session.beforeCreate = function (next, data) { - data = data || {}; - - Session.createSessionId(function (err, id) { - if(err) { - next(err); - } else { - data.id = id; - next(); - } - }); -} diff --git a/lib/models/user.js b/lib/models/user.js index 581262be..ace3ef5a 100644 --- a/lib/models/user.js +++ b/lib/models/user.js @@ -9,7 +9,10 @@ var Model = require('../loopback').Model , crypto = require('crypto') , bcrypt = require('bcryptjs') , passport = require('passport') - , LocalStrategy = require('passport-local').Strategy; + , LocalStrategy = require('passport-local').Strategy + , BaseAccessToken = require('./access-token') + , DEFAULT_TTL = 1209600 // 2 weeks in seconds + , DEFAULT_MAX_TTL = 31556926; // 1 year in seconds /** * Default User properties. @@ -50,8 +53,8 @@ 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); + * User.login({username: 'foo', password: 'bar'}, function (err, token) { + * console.log(token.id); * }); * * @param {Object} credentials @@ -79,7 +82,9 @@ User.login = function (credentials, fn) { if(err) { fn(defaultError); } else if(isMatch) { - createSession(user, fn); + user.accessTokens.create({ + ttl: Math.min(credentials.ttl || User.settings.ttl, User.settings.maxTTL) + }, fn); } else { fn(defaultError); } @@ -88,42 +93,26 @@ User.login = function (credentials, fn) { fn(defaultError); } }); - - function createSession(user, fn) { - var Session = UserCtor.session; - - Session.create({uid: user.id}, function (err, session) { - if(err) { - fn(err); - } else { - fn(null, session) - } - }); - } } /** - * Logout a user with the given session id. + * Logout a user with the given accessToken id. * * User.logout('asd0a9f8dsj9s0s3223mk', function (err) { * console.log(err || 'Logged out'); * }); * - * @param {String} sessionID + * @param {String} accessTokenID */ -User.logout = function (sid, fn) { - var UserCtor = this; - - var Session = UserCtor.settings.session || loopback.Session; - - Session.findById(sid, function (err, session) { +User.logout = function (tokenId, fn) { + this.relations.accessTokens.modelTo.findById(tokenId, function (err, accessToken) { if(err) { fn(err); - } else if(session) { - session.destroy(fn); + } else if(accessToken) { + accessToken.destroy(fn); } else { - fn(new Error('could not find session')); + fn(new Error('could not find accessToken')); } }); } @@ -255,6 +244,10 @@ User.setup = function () { Model.setup.call(this); var UserModel = this; + // max ttl + this.settings.maxTTL = this.settings.maxTTL || DEFAULT_MAX_TTL; + this.settings.ttl = DEFAULT_TTL; + UserModel.setter.password = function (plain) { var salt = bcrypt.genSaltSync(this.constructor.settings.saltWorkFactor || SALT_WORK_FACTOR); this.$password = bcrypt.hashSync(plain, salt); @@ -266,7 +259,7 @@ User.setup = function () { accepts: [ {arg: 'credentials', type: 'object', required: true, http: {source: 'body'}} ], - returns: {arg: 'session', type: 'object', root: true}, + returns: {arg: 'accessToken', type: 'object', root: true}, http: {verb: 'post'} } ); @@ -305,7 +298,7 @@ User.setup = function () { // default models UserModel.email = require('./email'); - UserModel.session = require('./session'); + UserModel.accessToken = require('./access-token'); UserModel.validatesUniquenessOf('email', {message: 'Email already exists'}); var re = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; diff --git a/package.json b/package.json index d03d0bda..7883e8de 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,9 @@ "nodemailer": "~0.4.4", "ejs": "~0.8.4", "bcryptjs": "~0.7.10", - "underscore.string": "~2.3.3" + "underscore.string": "~2.3.3", + "underscore": "~1.5.2", + "uid2": "0.0.3" }, "devDependencies": { "blanket": "~1.1.5", diff --git a/test/access-token.test.js b/test/access-token.test.js new file mode 100644 index 00000000..d74ef153 --- /dev/null +++ b/test/access-token.test.js @@ -0,0 +1,94 @@ +var loopback = require('../'); +var Token = loopback.AccessToken.extend('MyToken'); + +// attach Token to testing memory ds +Token.attachTo(loopback.memory()); + +describe('loopback.token(options)', function() { + beforeEach(createTestingToken); + + it('should populate req.token from the query string', function (done) { + createTestAppAndRequest(this.token, done) + .get('/?access_token=' + this.token.id) + .expect(200) + .end(done); + }); + + it('should populate req.token from a header', function (done) { + createTestAppAndRequest(this.token, done) + .get('/') + .set('authorization', this.token.id) + .expect(200) + .end(done); + }); + + it('should populate req.token from a secure cookie', function (done) { + var app = createTestApp(this.token, done); + + request(app) + .get('/token') + .end(function(err, res) { + request(app) + .get('/') + .set('Cookie', res.header['set-cookie']) + .end(done); + }); + }); +}); + +describe('AccessToken', function () { + beforeEach(createTestingToken); + + it('should auto-generate id', function () { + assert(this.token.id); + assert.equal(this.token.id.length, 64); + }); + + it('should auto-generate created date', function () { + assert(this.token.created); + assert(Object.prototype.toString.call(this.token.created), '[object Date]'); + }); + + it('should be validateable', function (done) { + this.token.validate(function(err, isValid) { + assert(isValid); + done(); + }); + }); +}); + +function createTestingToken(done) { + var test = this; + Token.create({}, function (err, token) { + if(err) return done(err); + test.token = token; + done(); + }); +} + +function createTestAppAndRequest(testToken, done) { + var app = createTestApp(testToken, done); + return request(app); +} + +function createTestApp(testToken, done) { + var app = loopback(); + + app.use(loopback.cookieParser('secret')); + app.use(loopback.token({model: Token})); + app.get('/token', function(req, res) { + res.cookie('authorization', testToken.id, {signed: true}); + res.end(); + }); + app.get('/', function (req, res) { + try { + assert(req.accessToken, 'req should have accessToken'); + assert(req.accessToken.id === testToken.id); + } catch(e) { + return done(e); + } + res.send('ok'); + }); + + return app; +} diff --git a/test/user.test.js b/test/user.test.js index e664c73a..74ddab1a 100644 --- a/test/user.test.js +++ b/test/user.test.js @@ -1,5 +1,5 @@ var User = loopback.User.extend('user'); -var Session = loopback.Session; +var AccessToken = loopback.AccessToken; var passport = require('passport'); var MailConnector = require('../lib/connectors/mail'); @@ -7,7 +7,6 @@ var userMemory = loopback.createDataSource({ connector: loopback.Memory }); - describe('User', function(){ var mailDataSource = loopback.createDataSource({ @@ -15,7 +14,9 @@ describe('User', function(){ transports: [{type: 'STUB'}] }); User.attachTo(userMemory); - User.session.attachTo(userMemory); + AccessToken.attachTo(userMemory); + // TODO(ritch) - this should be a default relationship + User.hasMany(AccessToken, {as: 'accessTokens', foreignKey: 'userId'}); User.email.attachTo(mailDataSource); // allow many User.afterRemote's to be called @@ -30,7 +31,7 @@ describe('User', function(){ afterEach(function (done) { User.destroyAll(function (err) { - User.session.destroyAll(done); + User.accessToken.destroyAll(done); }); }); @@ -84,8 +85,8 @@ describe('User', function(){ it('Requires a password to login with basic auth', function(done) { User.create({email: 'b@c.com'}, function (err) { - User.login({email: 'b@c.com'}, function (err, session) { - assert(!session, 'should not create a session without a valid password'); + User.login({email: 'b@c.com'}, function (err, accessToken) { + assert(!accessToken, 'should not create a accessToken without a valid password'); assert(err, 'should not login without a password'); done(); }); @@ -100,10 +101,10 @@ describe('User', function(){ describe('User.login', function() { it('Login a user by providing credentials', function(done) { - User.login({email: 'foo@bar.com', password: 'bar'}, function (err, session) { - assert(session.uid); - assert(session.id); - assert.equal((new Buffer(session.id, 'base64')).length, 64); + User.login({email: 'foo@bar.com', password: 'bar'}, function (err, accessToken) { + assert(accessToken.userId); + assert(accessToken.id); + assert.equal(accessToken.id.length, 64); done(); }); @@ -117,11 +118,11 @@ describe('User', function(){ .send({email: 'foo@bar.com', password: 'bar'}) .end(function(err, res){ if(err) return done(err); - var session = res.body; + var accessToken = res.body; - assert(session.uid); - assert(session.id); - assert.equal((new Buffer(session.id, 'base64')).length, 64); + assert(accessToken.userId); + assert(accessToken.id); + assert.equal(accessToken.id.length, 64); done(); }); @@ -129,9 +130,9 @@ describe('User', function(){ it('Login should only allow correct credentials', function(done) { User.create({email: 'foo22@bar.com', password: 'bar'}, function(user, err) { - User.login({email: 'foo44@bar.com', password: 'bar'}, function(err, session) { + User.login({email: 'foo44@bar.com', password: 'bar'}, function(err, accessToken) { assert(err); - assert(!session); + assert(!accessToken); done(); }); }); @@ -139,19 +140,19 @@ describe('User', function(){ }); describe('User.logout', function() { - it('Logout a user by providing the current session id (using node)', function(done) { + it('Logout a user by providing the current accessToken id (using node)', function(done) { login(logout); function login(fn) { User.login({email: 'foo@bar.com', password: 'bar'}, fn); } - function logout(err, session) { - User.logout(session.id, verify(session.id, done)); + function logout(err, accessToken) { + User.logout(accessToken.id, verify(accessToken.id, done)); } }); - it('Logout a user by providing the current session id (over rest)', function(done) { + it('Logout a user by providing the current accessToken id (over rest)', function(done) { login(logout); function login(fn) { @@ -162,12 +163,12 @@ describe('User', function(){ .send({email: 'foo@bar.com', password: 'bar'}) .end(function(err, res){ if(err) return done(err); - var session = res.body; + var accessToken = res.body; - assert(session.uid); - assert(session.id); + assert(accessToken.userId); + assert(accessToken.id); - fn(null, session.id); + fn(null, accessToken.id); }); } @@ -186,8 +187,8 @@ describe('User', function(){ return function (err) { if(err) return done(err); - Session.findById(sid, function (err, session) { - assert(!session, 'session should not exist after logging out'); + AccessToken.findById(sid, function (err, accessToken) { + assert(!accessToken, 'accessToken should not exist after logging out'); done(err); }); }