Merge pull request #62 from strongloop/token

Token
This commit is contained in:
Ritchie Martori 2013-11-14 20:14:41 -08:00
commit 51a38fc0f6
14 changed files with 417 additions and 138 deletions

View File

@ -211,6 +211,46 @@ 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);
}
```

View File

@ -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": "<session id from user login>"
"sid": "<accessToken id from user login>"
}
```
@ -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

View File

@ -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.

View File

@ -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.
*/

View File

@ -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');

31
lib/middleware/token.js Normal file
View File

@ -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();
}
});
}
}

166
lib/models/access-token.js Normal file
View File

@ -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;
}

View File

@ -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');

View File

@ -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();
}
});
}

View File

@ -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,}))$/;

View File

@ -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",

94
test/access-token.test.js Normal file
View File

@ -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;
}

View File

@ -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);
});
}