From 77a137eca6515511312da2b8a09ee3fa4a88292f Mon Sep 17 00:00:00 2001 From: Ritchie Date: Wed, 13 Nov 2013 11:49:08 -0800 Subject: [PATCH 1/5] Rename Session => AccessToken --- lib/application.js | 8 ++++ lib/loopback.js | 2 +- lib/middleware/token.js | 28 ++++++++++++ lib/models/{session.js => access-token.js} | 16 +++---- lib/models/index.js | 2 +- lib/models/user.js | 32 +++++++------- package.json | 3 +- test/access-token.test.js | 35 +++++++++++++++ test/user.test.js | 50 +++++++++++----------- 9 files changed, 124 insertions(+), 52 deletions(-) create mode 100644 lib/middleware/token.js rename lib/models/{session.js => access-token.js} (57%) create mode 100644 test/access-token.test.js 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..c642c84a --- /dev/null +++ b/lib/middleware/token.js @@ -0,0 +1,28 @@ +/** + * Module dependencies. + */ + +var loopback = require('../loopback'); +var RemoteObjects = require('strong-remoting'); + +/** + * Export the middleware. + */ + +module.exports = token; + +/** + * + */ + +function token(app, options) { + options = options || {}; + var tokenModelName = options.tokenModelName || 'Token'; + var TokenModel = app.getModel(tokenModelName); + var tokenHeaderName = options.tokenHeaderName || 'X-Access-Token'; + + return function (req, res, next) { + next(); + } +} + diff --git a/lib/models/session.js b/lib/models/access-token.js similarity index 57% rename from lib/models/session.js rename to lib/models/access-token.js index 9099db1a..df8a95ab 100644 --- a/lib/models/session.js +++ b/lib/models/access-token.js @@ -7,7 +7,7 @@ var Model = require('../loopback').Model , crypto = require('crypto'); /** - * Default Session properties. + * Default AccessToken properties. */ var properties = { @@ -20,16 +20,16 @@ var properties = { * Extends from the built in `loopback.Model` type. */ -var Session = module.exports = Model.extend('Session', properties); +var AccessToken = module.exports = Model.extend('access-token', properties); /** - * Create a cryptographically random session id. + * Create a cryptographically random access token id. * * @param {Function} callback */ -Session.createSessionId = function (fn) { - crypto.randomBytes(this.settings.sessionIdLength || 64, function(err, buf) { +AccessToken.createAccessTokenId = function (fn) { + crypto.randomBytes(this.settings.accessTokenIdLength || 64, function(err, buf) { if(err) { fn(err); } else { @@ -39,13 +39,13 @@ Session.createSessionId = function (fn) { } /*! - * Hook to create session id. + * Hook to create accessToken id. */ -Session.beforeCreate = function (next, data) { +AccessToken.beforeCreate = function (next, data) { data = data || {}; - Session.createSessionId(function (err, id) { + AccessToken.createAccessTokenId(function (err, id) { if(err) { next(err); } else { 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/user.js b/lib/models/user.js index 581262be..dc4685a4 100644 --- a/lib/models/user.js +++ b/lib/models/user.js @@ -50,8 +50,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 +79,7 @@ User.login = function (credentials, fn) { if(err) { fn(defaultError); } else if(isMatch) { - createSession(user, fn); + createAccessToken(user, fn); } else { fn(defaultError); } @@ -89,41 +89,41 @@ User.login = function (credentials, fn) { } }); - function createSession(user, fn) { - var Session = UserCtor.session; + function createAccessToken(user, fn) { + var AccessToken = UserCtor.accessToken; - Session.create({uid: user.id}, function (err, session) { + AccessToken.create({uid: user.id}, function (err, accessToken) { if(err) { fn(err); } else { - fn(null, session) + fn(null, accessToken) } }); } } /** - * 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; + var AccessToken = UserCtor.settings.accessToken || loopback.AccessToken; - Session.findById(sid, function (err, session) { + AccessToken.findById(sid, 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')); } }); } @@ -266,7 +266,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 +305,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..d8978bb0 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "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" }, "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..f65a30e1 --- /dev/null +++ b/test/access-token.test.js @@ -0,0 +1,35 @@ +var loopback = require('../'); +var Token = loopback.AccessToken.extend('MyToken'); + +// attach Token to testing memory ds +Token.attachTo(loopback.memory()); + +describe('loopback.token(app, options)', function() { + beforeEach(createTestingToken); + + it('should populate req.token from the query string', function (done) { + var app = loopback(); + var options = {}; + var testToken = this.token; + app.use(loopback.token(app, options)); + app.get('/', function (req, res) { + assert(req.token === testToken); + res.send('ok'); + done(); + }); + + request(app) + .get('/?access_token=' + this.token.id) + .expect(200) + .end(done); + }); +}); + +function createTestingToken(done) { + var test = this; + Token.create({}, function (err, token) { + if(err) return done(err); + test.token = token; + done(); + }); +} \ No newline at end of file diff --git a/test/user.test.js b/test/user.test.js index e664c73a..a70c0047 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'); @@ -15,7 +15,7 @@ describe('User', function(){ transports: [{type: 'STUB'}] }); User.attachTo(userMemory); - User.session.attachTo(userMemory); + User.accessToken.attachTo(userMemory); User.email.attachTo(mailDataSource); // allow many User.afterRemote's to be called @@ -30,7 +30,7 @@ describe('User', function(){ afterEach(function (done) { User.destroyAll(function (err) { - User.session.destroyAll(done); + User.accessToken.destroyAll(done); }); }); @@ -84,8 +84,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 +100,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.uid); + assert(accessToken.id); + assert.equal((new Buffer(accessToken.id, 'base64')).length, 64); done(); }); @@ -117,11 +117,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.uid); + assert(accessToken.id); + assert.equal((new Buffer(accessToken.id, 'base64')).length, 64); done(); }); @@ -129,9 +129,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 +139,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 +162,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.uid); + assert(accessToken.id); - fn(null, session.id); + fn(null, accessToken.id); }); } @@ -186,8 +186,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); }); } From 64d8ff986bf86561a05a0039b392a29e8de5383e Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Thu, 14 Nov 2013 13:01:47 -0800 Subject: [PATCH 2/5] Add loopback.token() middleware --- lib/middleware/token.js | 20 ++++++----- lib/models/access-token.js | 68 ++++++++++++++++++++++++++++++++++++-- package.json | 3 +- test/access-token.test.js | 11 ++++-- test/user.test.js | 4 +-- 5 files changed, 89 insertions(+), 17 deletions(-) diff --git a/lib/middleware/token.js b/lib/middleware/token.js index c642c84a..6b9a76e4 100644 --- a/lib/middleware/token.js +++ b/lib/middleware/token.js @@ -11,18 +11,22 @@ var RemoteObjects = require('strong-remoting'); module.exports = token; -/** - * - */ - function token(app, options) { options = options || {}; - var tokenModelName = options.tokenModelName || 'Token'; - var TokenModel = app.getModel(tokenModelName); - var tokenHeaderName = options.tokenHeaderName || 'X-Access-Token'; + var tokenModelName = options.tokenModelName || 'AccessToken'; + var TokenModel = options.model; + assert(TokenModel, 'loopback.token() middleware requires a AccessToken model'); return function (req, res, next) { - 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 index df8a95ab..65211d66 100644 --- a/lib/models/access-token.js +++ b/lib/models/access-token.js @@ -4,7 +4,8 @@ var Model = require('../loopback').Model , loopback = require('../loopback') - , crypto = require('crypto'); + , crypto = require('crypto') + , uid = require('uid2'); /** * Default AccessToken properties. @@ -20,7 +21,7 @@ var properties = { * Extends from the built in `loopback.Model` type. */ -var AccessToken = module.exports = Model.extend('access-token', properties); +var AccessToken = module.exports = Model.extend('AccessToken', properties); /** * Create a cryptographically random access token id. @@ -29,7 +30,7 @@ var AccessToken = module.exports = Model.extend('access-token', properties); */ AccessToken.createAccessTokenId = function (fn) { - crypto.randomBytes(this.settings.accessTokenIdLength || 64, function(err, buf) { + uid(this.settings.accessTokenIdLength || 64, function(err, buf) { if(err) { fn(err); } else { @@ -54,3 +55,64 @@ AccessToken.beforeCreate = function (next, data) { } }); } + +/** + * 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, cb); + } else { + process.nextTick(function() { + cb(); + }); + } +} + +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(length = headers.length; i < length; i++) { + id = req.header(params[i]); + + if(typeof id === 'string') { + return id; + } + } + + for(length = headers.length; i < length; i++) { + id = req.signedCookies(cookies[i]); + + if(typeof id === 'string') { + return id; + } + } + + return null; +} diff --git a/package.json b/package.json index d8978bb0..7883e8de 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "ejs": "~0.8.4", "bcryptjs": "~0.7.10", "underscore.string": "~2.3.3", - "underscore": "~1.5.2" + "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 index f65a30e1..468d6c58 100644 --- a/test/access-token.test.js +++ b/test/access-token.test.js @@ -10,12 +10,17 @@ describe('loopback.token(app, options)', function() { it('should populate req.token from the query string', function (done) { var app = loopback(); var options = {}; + options.model = Token; var testToken = this.token; app.use(loopback.token(app, options)); app.get('/', function (req, res) { - assert(req.token === testToken); + try { + assert(req.accessToken, 'req should have accessToken'); + assert(req.accessToken.id === testToken.id); + } catch(e) { + return done(e); + } res.send('ok'); - done(); }); request(app) @@ -32,4 +37,4 @@ function createTestingToken(done) { test.token = token; done(); }); -} \ No newline at end of file +} diff --git a/test/user.test.js b/test/user.test.js index a70c0047..24a7f7b9 100644 --- a/test/user.test.js +++ b/test/user.test.js @@ -103,7 +103,7 @@ describe('User', function(){ User.login({email: 'foo@bar.com', password: 'bar'}, function (err, accessToken) { assert(accessToken.uid); assert(accessToken.id); - assert.equal((new Buffer(accessToken.id, 'base64')).length, 64); + assert.equal(accessToken.id.length, 64); done(); }); @@ -121,7 +121,7 @@ describe('User', function(){ assert(accessToken.uid); assert(accessToken.id); - assert.equal((new Buffer(accessToken.id, 'base64')).length, 64); + assert.equal(accessToken.id.length, 64); done(); }); From 1bb95607b9e71a94935189af904a11025d61ff6b Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Thu, 14 Nov 2013 15:27:36 -0800 Subject: [PATCH 3/5] Update session / token documentation --- docs/api.md | 48 +++++++++++++++++++++++--- docs/bundled-models.md | 36 ++++++++++---------- docs/concepts.md | 2 +- lib/middleware/token.js | 3 +- lib/models/access-token.js | 15 +++++---- test/access-token.test.js | 69 ++++++++++++++++++++++++++++---------- 6 files changed, 123 insertions(+), 50 deletions(-) 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/middleware/token.js b/lib/middleware/token.js index 6b9a76e4..585dc812 100644 --- a/lib/middleware/token.js +++ b/lib/middleware/token.js @@ -11,9 +11,8 @@ var RemoteObjects = require('strong-remoting'); module.exports = token; -function token(app, options) { +function token(options) { options = options || {}; - var tokenModelName = options.tokenModelName || 'AccessToken'; var TokenModel = options.model; assert(TokenModel, 'loopback.token() middleware requires a AccessToken model'); diff --git a/lib/models/access-token.js b/lib/models/access-token.js index 65211d66..9c3f5e8f 100644 --- a/lib/models/access-token.js +++ b/lib/models/access-token.js @@ -5,7 +5,8 @@ var Model = require('../loopback').Model , loopback = require('../loopback') , crypto = require('crypto') - , uid = require('uid2'); + , uid = require('uid2') + , DEFAULT_TOKEN_LEN = 64; /** * Default AccessToken properties. @@ -30,11 +31,11 @@ var AccessToken = module.exports = Model.extend('AccessToken', properties); */ AccessToken.createAccessTokenId = function (fn) { - uid(this.settings.accessTokenIdLength || 64, function(err, buf) { + uid(this.settings.accessTokenIdLength || DEFAULT_TOKEN_LEN, function(err, guid) { if(err) { fn(err); } else { - fn(null, buf.toString('base64')); + fn(null, guid); } }); } @@ -98,16 +99,16 @@ function tokenIdForRequest(req, options) { } } - for(length = headers.length; i < length; i++) { - id = req.header(params[i]); + for(i = 0, length = headers.length; i < length; i++) { + id = req.header(headers[i]); if(typeof id === 'string') { return id; } } - for(length = headers.length; i < length; i++) { - id = req.signedCookies(cookies[i]); + for(i = 0, length = headers.length; i < length; i++) { + id = req.signedCookies[cookies[i]]; if(typeof id === 'string') { return id; diff --git a/test/access-token.test.js b/test/access-token.test.js index 468d6c58..f76829ef 100644 --- a/test/access-token.test.js +++ b/test/access-token.test.js @@ -4,30 +4,36 @@ var Token = loopback.AccessToken.extend('MyToken'); // attach Token to testing memory ds Token.attachTo(loopback.memory()); -describe('loopback.token(app, options)', function() { +describe('loopback.token(options)', function() { beforeEach(createTestingToken); - + it('should populate req.token from the query string', function (done) { - var app = loopback(); - var options = {}; - options.model = Token; - var testToken = this.token; - app.use(loopback.token(app, options)); - 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'); - }); - - request(app) + 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); + }); + }); }); function createTestingToken(done) { @@ -38,3 +44,30 @@ function createTestingToken(done) { 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; +} From efce5039f6254e5c6d8885ad4e6b783b65026ec8 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Thu, 14 Nov 2013 16:47:24 -0800 Subject: [PATCH 4/5] Added AccessToken created property --- lib/models/access-token.js | 4 +++- lib/models/role.js | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/models/access-token.js b/lib/models/access-token.js index 9c3f5e8f..b8b39c4b 100644 --- a/lib/models/access-token.js +++ b/lib/models/access-token.js @@ -15,7 +15,8 @@ var Model = require('../loopback').Model var properties = { id: {type: String, generated: true, id: 1}, uid: {type: String}, - ttl: {type: Number, ttl: true} + ttl: {type: Number, ttl: true}, // time to live in seconds + created: {type: Date} }; /** @@ -52,6 +53,7 @@ AccessToken.beforeCreate = function (next, data) { next(err); } else { data.id = id; + next(); } }); 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 +} From 1de2a40e88fc2b54b7deeb9322e2eb0ce6ad00f4 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Thu, 14 Nov 2013 18:34:51 -0800 Subject: [PATCH 5/5] Update AccessToken and User relationship - Add created default - Default TTLs for user login access tokens - Break out User / AccessToken relationship --- lib/models/access-token.js | 53 +++++++++++++++++++++++++++++++++++--- lib/models/user.js | 33 ++++++++++-------------- test/access-token.test.js | 21 +++++++++++++++ test/user.test.js | 11 ++++---- 4 files changed, 89 insertions(+), 29 deletions(-) diff --git a/lib/models/access-token.js b/lib/models/access-token.js index b8b39c4b..ec2f0187 100644 --- a/lib/models/access-token.js +++ b/lib/models/access-token.js @@ -4,8 +4,10 @@ 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; /** @@ -14,9 +16,10 @@ var Model = require('../loopback').Model var properties = { id: {type: String, generated: true, id: 1}, - uid: {type: String}, - ttl: {type: Number, ttl: true}, // time to live in seconds - created: {type: Date} + ttl: {type: Number, ttl: true, default: DEFAULT_TTL}, // time to live in seconds + created: {type: Date, default: function() { + return new Date(); + }} }; /** @@ -71,7 +74,21 @@ AccessToken.findForRequest = function(req, options, cb) { var id = tokenIdForRequest(req, options); if(id) { - this.findById(id, cb); + 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(); @@ -79,6 +96,34 @@ AccessToken.findForRequest = function(req, options, 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 || []; diff --git a/lib/models/user.js b/lib/models/user.js index dc4685a4..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. @@ -79,7 +82,9 @@ User.login = function (credentials, fn) { if(err) { fn(defaultError); } else if(isMatch) { - createAccessToken(user, fn); + user.accessTokens.create({ + ttl: Math.min(credentials.ttl || User.settings.ttl, User.settings.maxTTL) + }, fn); } else { fn(defaultError); } @@ -88,18 +93,6 @@ User.login = function (credentials, fn) { fn(defaultError); } }); - - function createAccessToken(user, fn) { - var AccessToken = UserCtor.accessToken; - - AccessToken.create({uid: user.id}, function (err, accessToken) { - if(err) { - fn(err); - } else { - fn(null, accessToken) - } - }); - } } /** @@ -112,12 +105,8 @@ User.login = function (credentials, fn) { * @param {String} accessTokenID */ -User.logout = function (sid, fn) { - var UserCtor = this; - - var AccessToken = UserCtor.settings.accessToken || loopback.AccessToken; - - AccessToken.findById(sid, function (err, accessToken) { +User.logout = function (tokenId, fn) { + this.relations.accessTokens.modelTo.findById(tokenId, function (err, accessToken) { if(err) { fn(err); } else if(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); diff --git a/test/access-token.test.js b/test/access-token.test.js index f76829ef..d74ef153 100644 --- a/test/access-token.test.js +++ b/test/access-token.test.js @@ -36,6 +36,27 @@ describe('loopback.token(options)', function() { }); }); +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) { diff --git a/test/user.test.js b/test/user.test.js index 24a7f7b9..74ddab1a 100644 --- a/test/user.test.js +++ b/test/user.test.js @@ -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.accessToken.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 @@ -101,7 +102,7 @@ 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, accessToken) { - assert(accessToken.uid); + assert(accessToken.userId); assert(accessToken.id); assert.equal(accessToken.id.length, 64); @@ -119,7 +120,7 @@ describe('User', function(){ if(err) return done(err); var accessToken = res.body; - assert(accessToken.uid); + assert(accessToken.userId); assert(accessToken.id); assert.equal(accessToken.id.length, 64); @@ -164,7 +165,7 @@ describe('User', function(){ if(err) return done(err); var accessToken = res.body; - assert(accessToken.uid); + assert(accessToken.userId); assert(accessToken.id); fn(null, accessToken.id);