Merge branch 'release/2.6.0' into production

This commit is contained in:
Miroslav Bajtoš 2014-10-23 13:46:05 +02:00
commit 29bfcce9fc
57 changed files with 3168 additions and 2832 deletions

View File

@ -5,9 +5,10 @@
"indent": 2,
"undef": true,
"quotmark": "single",
"maxlen": 80,
"maxlen": 150,
"trailing": true,
"newcap": true,
"nonew": true,
"undef": false
"laxcomma": true,
"laxbreak": true
}

View File

@ -30,8 +30,11 @@ module.exports = function(grunt) {
gruntfile: {
src: 'Gruntfile.js'
},
lib_test: {
src: ['lib/**/*.js', 'test/**/*.js']
lib: {
src: ['lib/**/*.js']
},
test: {
src: ['test/**/*.js']
}
},
watch: {
@ -39,9 +42,13 @@ module.exports = function(grunt) {
files: '<%= jshint.gruntfile.src %>',
tasks: ['jshint:gruntfile']
},
lib_test: {
files: '<%= jshint.lib_test.src %>',
tasks: ['jshint:lib_test']
lib: {
files: ['<%= jshint.lib.src %>'],
tasks: ['jshint:lib']
},
test: {
files: ['<%= jshint.test.src %>'],
tasks: ['jshint:test']
}
},
browserify: {
@ -104,7 +111,7 @@ module.exports = function(grunt) {
// list of files to exclude
exclude: [
],
// test results reporter to use

View File

@ -0,0 +1,203 @@
/*!
* Module Dependencies.
*/
var loopback = require('../../lib/loopback')
, assert = require('assert')
, uid = require('uid2')
, DEFAULT_TOKEN_LEN = 64;
/**
* Token based authentication and access control.
*
* **Default ACLs**
*
* - DENY EVERYONE `*`
* - ALLOW EVERYONE create
*
* @property {String} id Generated token ID
* @property {Number} ttl Time to live in seconds, 2 weeks by default.
* @property {Date} created When the token was created
*
* @class AccessToken
* @inherits {PersistedModel}
*/
module.exports = function(AccessToken) {
// Workaround for https://github.com/strongloop/loopback/issues/292
AccessToken.definition.rawProperties.created.default =
AccessToken.definition.properties.created.default = function() {
return new Date();
};
/**
* Anonymous Token
*
* ```js
* assert(AccessToken.ANONYMOUS.id === '$anonymous');
* ```
*/
AccessToken.ANONYMOUS = new AccessToken({id: '$anonymous'});
/**
* Create a cryptographically random access token id.
*
* @callback {Function} callback
* @param {Error} err
* @param {String} token
*/
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
* @callback {Function} callback
* @param {Error} err
* @param {AccessToken} token
*/
AccessToken.findForRequest = function(req, options, cb) {
var id = tokenIdForRequest(req, options);
if (id) {
this.findById(id, function(err, token) {
if (err) {
cb(err);
} else if (token) {
token.validate(function(err, isValid) {
if (err) {
cb(err);
} else if (isValid) {
cb(null, token);
} else {
var e = new Error('Invalid Access Token');
e.status = e.statusCode = 401;
cb(e);
}
});
} else {
cb();
}
});
} else {
process.nextTick(function() {
cb();
});
}
}
/**
* Validate the token.
*
* @callback {Function} callback
* @param {Error} err
* @param {Boolean} isValid
*/
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 = params.concat(['access_token']);
headers = headers.concat(['X-Access-Token', 'authorization']);
cookies = cookies.concat(['access_token', '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') {
// Add support for oAuth 2.0 bearer token
// http://tools.ietf.org/html/rfc6750
if (id.indexOf('Bearer ') === 0) {
id = id.substring(7);
// Decode from base64
var buf = new Buffer(id, 'base64');
id = buf.toString('utf8');
}
return id;
}
}
if (req.signedCookies) {
for (i = 0, length = cookies.length; i < length; i++) {
id = req.signedCookies[cookies[i]];
if (typeof id === 'string') {
return id;
}
}
}
return null;
}
};

View File

@ -0,0 +1,38 @@
{
"name": "AccessToken",
"properties": {
"id": {
"type": "string",
"id": true
},
"ttl": {
"type": "number",
"ttl": true,
"default": 1209600,
"description": "time to live in seconds (2 weeks by default)"
},
"created": {
"type": "Date"
}
},
"relations": {
"user": {
"type": "belongsTo",
"model": "User",
"foreignKey": "userId"
}
},
"acls": [
{
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "DENY"
},
{
"principalType": "ROLE",
"principalId": "$everyone",
"property": "create",
"permission": "ALLOW"
}
]
}

462
common/models/acl.js Normal file
View File

@ -0,0 +1,462 @@
/*!
Schema ACL options
Object level permissions, for example, an album owned by a user
Factors to be authorized against:
* model name: Album
* model instance properties: userId of the album, friends, shared
* methods
* app and/or user ids/roles
** loggedIn
** roles
** userId
** appId
** none
** everyone
** relations: owner/friend/granted
Class level permissions, for example, Album
* model name: Album
* methods
URL/Route level permissions
* url pattern
* application id
* ip addresses
* http headers
Map to oAuth 2.0 scopes
*/
var loopback = require('../../lib/loopback');
var async = require('async');
var assert = require('assert');
var debug = require('debug')('loopback:security:acl');
var ctx = require('../../lib/access-context');
var AccessContext = ctx.AccessContext;
var Principal = ctx.Principal;
var AccessRequest = ctx.AccessRequest;
var Role = loopback.Role;
assert(Role, 'Role model must be defined before ACL model');
/**
* A Model for access control meta data.
*
* System grants permissions to principals (users/applications, can be grouped
* into roles).
*
* Protected resource: the model data and operations
* (model/property/method/relation/)
*
* For a given principal, such as client application and/or user, is it allowed
* to access (read/write/execute)
* the protected resource?
*
* @header ACL
* @property {String} model Name of the model.
* @property {String} property Name of the property, method, scope, or relation.
* @property {String} accessType Type of access being granted: one of READ, WRITE, or EXECUTE.
* @property {String} permission Type of permission granted. One of:
* - ALARM: Generate an alarm, in a system-dependent way, the access specified in the permissions component of the ACL entry.
* - ALLOW: Explicitly grants access to the resource.
* - AUDIT: Log, in a system-dependent way, the access specified in the permissions component of the ACL entry.
* - DENY: Explicitly denies access to the resource.
* @property {String} principalType Type of the principal; one of: Application, Use, Role.
* @property {String} principalId ID of the principal - such as appId, userId or roleId
*
* @class ACL
* @inherits PersistedModel
*/
module.exports = function(ACL) {
ACL.ALL = AccessContext.ALL;
ACL.DEFAULT = AccessContext.DEFAULT; // Not specified
ACL.ALLOW = AccessContext.ALLOW; // Allow
ACL.ALARM = AccessContext.ALARM; // Warn - send an alarm
ACL.AUDIT = AccessContext.AUDIT; // Audit - record the access
ACL.DENY = AccessContext.DENY; // Deny
ACL.READ = AccessContext.READ; // Read operation
ACL.WRITE = AccessContext.WRITE; // Write operation
ACL.EXECUTE = AccessContext.EXECUTE; // Execute operation
ACL.USER = Principal.USER;
ACL.APP = ACL.APPLICATION = Principal.APPLICATION;
ACL.ROLE = Principal.ROLE;
ACL.SCOPE = Principal.SCOPE;
/**
* Calculate the matching score for the given rule and request
* @param {ACL} rule The ACL entry
* @param {AccessRequest} req The request
* @returns {Number}
*/
ACL.getMatchingScore = function getMatchingScore(rule, req) {
var props = ['model', 'property', 'accessType'];
var score = 0;
for (var i = 0; i < props.length; i++) {
// Shift the score by 4 for each of the properties as the weight
score = score * 4;
var val1 = rule[props[i]] || ACL.ALL;
var val2 = req[props[i]] || ACL.ALL;
var isMatchingMethodName = props[i] === 'property' && req.methodNames.indexOf(val1) !== -1;
if (val1 === val2 || isMatchingMethodName) {
// Exact match
score += 3;
} else if (val1 === ACL.ALL) {
// Wildcard match
score += 2;
} else if (val2 === ACL.ALL) {
// Doesn't match at all
score += 1;
} else {
return -1;
}
}
// Weigh against the principal type into 4 levels
// - user level (explicitly allow/deny a given user)
// - app level (explicitly allow/deny a given app)
// - role level (role based authorization)
// - other
// user > app > role > ...
score = score * 4;
switch (rule.principalType) {
case ACL.USER:
score += 4;
break;
case ACL.APP:
score += 3;
break;
case ACL.ROLE:
score += 2;
break;
default:
score += 1;
}
// Weigh against the roles
// everyone < authenticated/unauthenticated < related < owner < ...
score = score * 8;
if (rule.principalType === ACL.ROLE) {
switch (rule.principalId) {
case Role.OWNER:
score += 4;
break;
case Role.RELATED:
score += 3;
break;
case Role.AUTHENTICATED:
case Role.UNAUTHENTICATED:
score += 2;
break;
case Role.EVERYONE:
score += 1;
break;
default:
score += 5;
}
}
score = score * 4;
score += AccessContext.permissionOrder[rule.permission || ACL.ALLOW] - 1;
return score;
};
/**
* Get matching score for the given `AccessRequest`.
* @param {AccessRequest} req The request
* @returns {Number} score
*/
ACL.prototype.score = function(req) {
return this.constructor.getMatchingScore(this, req);
}
/*!
* Resolve permission from the ACLs
* @param {Object[]) acls The list of ACLs
* @param {Object} req The request
* @returns {AccessRequest} result The effective ACL
*/
ACL.resolvePermission = function resolvePermission(acls, req) {
if (!(req instanceof AccessRequest)) {
req = new AccessRequest(req);
}
// Sort by the matching score in descending order
acls = acls.sort(function(rule1, rule2) {
return ACL.getMatchingScore(rule2, req) - ACL.getMatchingScore(rule1, req);
});
var permission = ACL.DEFAULT;
var score = 0;
for (var i = 0; i < acls.length; i++) {
score = ACL.getMatchingScore(acls[i], req);
if (score < 0) {
// the highest scored ACL did not match
break;
}
if (!req.isWildcard()) {
// We should stop from the first match for non-wildcard
permission = acls[i].permission;
break;
} else {
if (req.exactlyMatches(acls[i])) {
permission = acls[i].permission;
break;
}
// For wildcard match, find the strongest permission
if (AccessContext.permissionOrder[acls[i].permission]
> AccessContext.permissionOrder[permission]) {
permission = acls[i].permission;
}
}
}
if (debug.enabled) {
debug('The following ACLs were searched: ');
acls.forEach(function(acl) {
acl.debug();
debug('with score:', acl.score(req));
});
}
var res = new AccessRequest(req.model, req.property, req.accessType,
permission || ACL.DEFAULT);
return res;
};
/*!
* Get the static ACLs from the model definition
* @param {String} model The model name
* @param {String} property The property/method/relation name
*
* @return {Object[]} An array of ACLs
*/
ACL.getStaticACLs = function getStaticACLs(model, property) {
var modelClass = loopback.findModel(model);
var staticACLs = [];
if (modelClass && modelClass.settings.acls) {
modelClass.settings.acls.forEach(function(acl) {
if (!acl.property || acl.property === ACL.ALL
|| property === acl.property) {
staticACLs.push(new ACL({
model: model,
property: acl.property || ACL.ALL,
principalType: acl.principalType,
principalId: acl.principalId, // TODO: Should it be a name?
accessType: acl.accessType || ACL.ALL,
permission: acl.permission
}));
}
});
}
var prop = modelClass &&
(modelClass.definition.properties[property] // regular property
|| (modelClass._scopeMeta && modelClass._scopeMeta[property]) // relation/scope
|| modelClass[property] // static method
|| modelClass.prototype[property]); // prototype method
if (prop && prop.acls) {
prop.acls.forEach(function(acl) {
staticACLs.push(new ACL({
model: modelClass.modelName,
property: property,
principalType: acl.principalType,
principalId: acl.principalId,
accessType: acl.accessType,
permission: acl.permission
}));
});
}
return staticACLs;
};
/**
* Check if the given principal is allowed to access the model/property
* @param {String} principalType The principal type.
* @param {String} principalId The principal ID.
* @param {String} model The model name.
* @param {String} property The property/method/relation name.
* @param {String} accessType The access type.
* @callback {Function} callback Callback function.
* @param {String|Error} err The error object
* @param {AccessRequest} result The access permission
*/
ACL.checkPermission = function checkPermission(principalType, principalId,
model, property, accessType,
callback) {
if (principalId !== null && principalId !== undefined && (typeof principalId !== 'string')) {
principalId = principalId.toString();
}
property = property || ACL.ALL;
var propertyQuery = (property === ACL.ALL) ? undefined : {inq: [property, ACL.ALL]};
accessType = accessType || ACL.ALL;
var accessTypeQuery = (accessType === ACL.ALL) ? undefined : {inq: [accessType, ACL.ALL]};
var req = new AccessRequest(model, property, accessType);
var acls = this.getStaticACLs(model, property);
var resolved = this.resolvePermission(acls, req);
if (resolved && resolved.permission === ACL.DENY) {
debug('Permission denied by statically resolved permission');
debug(' Resolved Permission: %j', resolved);
process.nextTick(function() {
callback && callback(null, resolved);
});
return;
}
var self = this;
this.find({where: {principalType: principalType, principalId: principalId,
model: model, property: propertyQuery, accessType: accessTypeQuery}},
function(err, dynACLs) {
if (err) {
callback && callback(err);
return;
}
acls = acls.concat(dynACLs);
resolved = self.resolvePermission(acls, req);
if (resolved && resolved.permission === ACL.DEFAULT) {
var modelClass = loopback.findModel(model);
resolved.permission = (modelClass && modelClass.settings.defaultPermission) || ACL.ALLOW;
}
callback && callback(null, resolved);
});
};
ACL.prototype.debug = function() {
if (debug.enabled) {
debug('---ACL---');
debug('model %s', this.model);
debug('property %s', this.property);
debug('principalType %s', this.principalType);
debug('principalId %s', this.principalId);
debug('accessType %s', this.accessType);
debug('permission %s', this.permission);
}
}
/**
* Check if the request has the permission to access.
* @options {Object} context See below.
* @property {Object[]} principals An array of principals.
* @property {String|Model} model The model name or model class.
* @property {*} id The model instance ID.
* @property {String} property The property/method/relation name.
* @property {String} accessType The access type: READE, WRITE, or EXECUTE.
* @param {Function} callback Callback function
*/
ACL.checkAccessForContext = function(context, callback) {
if (!(context instanceof AccessContext)) {
context = new AccessContext(context);
}
var model = context.model;
var property = context.property;
var accessType = context.accessType;
var modelName = context.modelName;
var methodNames = context.methodNames;
var propertyQuery = (property === ACL.ALL) ? undefined : {inq: methodNames.concat([ACL.ALL])};
var accessTypeQuery = (accessType === ACL.ALL) ? undefined : {inq: [accessType, ACL.ALL]};
var req = new AccessRequest(modelName, property, accessType, ACL.DEFAULT, methodNames);
var effectiveACLs = [];
var staticACLs = this.getStaticACLs(model.modelName, property);
var self = this;
var roleModel = loopback.getModelByType(Role);
this.find({where: {model: model.modelName, property: propertyQuery,
accessType: accessTypeQuery}}, function(err, acls) {
if (err) {
callback && callback(err);
return;
}
var inRoleTasks = [];
acls = acls.concat(staticACLs);
acls.forEach(function(acl) {
// Check exact matches
for (var i = 0; i < context.principals.length; i++) {
var p = context.principals[i];
if (p.type === acl.principalType
&& String(p.id) === String(acl.principalId)) {
effectiveACLs.push(acl);
return;
}
}
// Check role matches
if (acl.principalType === ACL.ROLE) {
inRoleTasks.push(function(done) {
roleModel.isInRole(acl.principalId, context,
function(err, inRole) {
if (!err && inRole) {
effectiveACLs.push(acl);
}
done(err, acl);
});
});
}
});
async.parallel(inRoleTasks, function(err, results) {
if (err) {
callback && callback(err, null);
return;
}
var resolved = self.resolvePermission(effectiveACLs, req);
if (resolved && resolved.permission === ACL.DEFAULT) {
resolved.permission = (model && model.settings.defaultPermission) || ACL.ALLOW;
}
debug('---Resolved---');
resolved.debug();
callback && callback(null, resolved);
});
});
};
/**
* Check if the given access token can invoke the method
* @param {AccessToken} token The access token
* @param {String} model The model name
* @param {*} modelId The model id
* @param {String} method The method name
* @callback {Function} callback Callback function
* @param {String|Error} err The error object
* @param {Boolean} allowed is the request allowed
*/
ACL.checkAccessForToken = function(token, model, modelId, method, callback) {
assert(token, 'Access token is required');
var context = new AccessContext({
accessToken: token,
model: model,
property: method,
method: method,
modelId: modelId
});
this.checkAccessForContext(context, function(err, access) {
if (err) {
callback && callback(err);
return;
}
callback && callback(null, access.permission !== ACL.DENY);
});
};
}

17
common/models/acl.json Normal file
View File

@ -0,0 +1,17 @@
{
"name": "ACL",
"properties": {
"model": {
"type": "string",
"description": "The name of the model"
},
"property": {
"type": "string",
"description": "The name of the property, method, scope, or relation"
},
"accessType": "string",
"permission": "string",
"principalType": "string",
"principalId": "string"
}
}

View File

@ -0,0 +1,186 @@
var assert = require('assert');
/*!
* Application management functions
*/
var crypto = require('crypto');
function generateKey(hmacKey, algorithm, encoding) {
hmacKey = hmacKey || 'loopback';
algorithm = algorithm || 'sha1';
encoding = encoding || 'hex';
var hmac = crypto.createHmac(algorithm, hmacKey);
var buf = crypto.randomBytes(32);
hmac.update(buf);
var key = hmac.digest(encoding);
return key;
}
/**
* Manage client applications and organize their users.
*
* @property {String} id Generated ID.
* @property {String} name Name; required.
* @property {String} description Text description
* @property {String} icon String Icon image URL.
* @property {String} owner User ID of the developer who registers the application.
* @property {String} email E-mail address
* @property {Boolean} emailVerified Whether the e-mail is verified.
* @property {String} url OAuth 2.0 application URL.
* @property {String}[] callbackUrls The OAuth 2.0 code/token callback URL.
* @property {String} status Status of the application; Either `production`, `sandbox` (default), or `disabled`.
* @property {Date} created Date Application object was created. Default: current date.
* @property {Date} modified Date Application object was modified. Default: current date.
*
* @property {Object} pushSettings.apns APNS configuration, see the options
* below and also
* https://github.com/argon/node-apn/blob/master/doc/apn.markdown
* @property {Boolean} pushSettings.apns.production Whether to use production Apple Push Notification Service (APNS) servers to send push notifications.
* If true, uses `gateway.push.apple.com:2195` and `feedback.push.apple.com:2196`.
* If false, uses `gateway.sandbox.push.apple.com:2195` and `feedback.sandbox.push.apple.com:2196`
* @property {String} pushSettings.apns.certData The certificate data loaded from the cert.pem file (APNS).
* @property {String} pushSettings.apns.keyData The key data loaded from the key.pem file (APNS).
* @property {String} pushSettings.apns.pushOptions.gateway (APNS).
* @property {Number} pushSettings.apns.pushOptions.port (APNS).
* @property {String} pushSettings.apns.feedbackOptions.gateway (APNS).
* @property {Number} pushSettings.apns.feedbackOptions.port (APNS).
* @property {Boolean} pushSettings.apns.feedbackOptions.batchFeedback (APNS).
* @property {Number} pushSettings.apns.feedbackOptions.interval (APNS).
* @property {String} pushSettings.gcm.serverApiKey: Google Cloud Messaging API key.
*
* @property {Boolean} authenticationEnabled
* @property {Boolean} anonymousAllowed
* @property {Array} authenticationSchemes List of authentication schemes
* (see below).
* @property {String} authenticationSchemes.scheme Scheme name.
* Supported values: `local`, `facebook`, `google`,
* `twitter`, `linkedin`, `github`.
* @property {Object} authenticationSchemes.credential
* Scheme-specific credentials.
*
* @class Application
* @inherits {PersistedModel}
*/
module.exports = function(Application) {
// Workaround for https://github.com/strongloop/loopback/issues/292
Application.definition.rawProperties.created.default =
Application.definition.properties.created.default = function() {
return new Date();
};
// Workaround for https://github.com/strongloop/loopback/issues/292
Application.definition.rawProperties.modified.default =
Application.definition.properties.modified.default = function() {
return new Date();
};
/*!
* A hook to generate keys before creation
* @param next
*/
Application.beforeCreate = function(next) {
var app = this;
app.created = app.modified = new Date();
app.id = generateKey('id', 'md5');
app.clientKey = generateKey('client');
app.javaScriptKey = generateKey('javaScript');
app.restApiKey = generateKey('restApi');
app.windowsKey = generateKey('windows');
app.masterKey = generateKey('master');
next();
};
/**
* Register a new application
* @param {String} owner Owner's user ID.
* @param {String} name Name of the application
* @param {Object} options Other options
* @param {Function} callback Callback function
*/
Application.register = function(owner, name, options, cb) {
assert(owner, 'owner is required');
assert(name, 'name is required');
if (typeof options === 'function' && !cb) {
cb = options;
options = {};
}
var props = {owner: owner, name: name};
for (var p in options) {
if (!(p in props)) {
props[p] = options[p];
}
}
this.create(props, cb);
};
/**
* Reset keys for the application instance
* @callback {Function} callback
* @param {Error} err
*/
Application.prototype.resetKeys = function(cb) {
this.clientKey = generateKey('client');
this.javaScriptKey = generateKey('javaScript');
this.restApiKey = generateKey('restApi');
this.windowsKey = generateKey('windows');
this.masterKey = generateKey('master');
this.modified = new Date();
this.save(cb);
};
/**
* Reset keys for a given application by the appId
* @param {Any} appId
* @callback {Function} callback
* @param {Error} err
*/
Application.resetKeys = function(appId, cb) {
this.findById(appId, function(err, app) {
if (err) {
cb && cb(err, app);
return;
}
app.resetKeys(cb);
});
};
/**
* Authenticate the application id and key.
*
* @param {Any} appId
* @param {String} key
* @callback {Function} callback
* @param {Error} err
* @param {String} matched The matching key; one of:
* - clientKey
* - javaScriptKey
* - restApiKey
* - windowsKey
* - masterKey
*
*/
Application.authenticate = function(appId, key, cb) {
this.findById(appId, function(err, app) {
if (err || !app) {
cb && cb(err, null);
return;
}
var result = null;
var keyNames = ['clientKey', 'javaScriptKey', 'restApiKey', 'windowsKey', 'masterKey'];
for (var i = 0; i < keyNames.length; i++) {
if (app[keyNames[i]] === key) {
result = {
application: app,
keyType: keyNames[i]
};
break;
}
}
cb && cb(null, result);
});
};
};

View File

@ -0,0 +1,121 @@
{
"name": "Application",
"properties": {
"id": {
"type": "string",
"id": true
},
"name": {
"type": "string",
"required": true
},
"description": "string",
"icon": {
"type": "string",
"description": "The icon image url"
},
"owner": {
"type": "string",
"description": "The user id of the developer who registers the application"
},
"collaborators": {
"type": ["string"],
"description": "A list of users ids who have permissions to work on this app"
},
"email": "string",
"emailVerified": "boolean",
"url": {
"type": "string",
"description": "The application URL for OAuth 2.0"
},
"callbackUrls": {
"type": ["string"],
"description": "OAuth 2.0 code/token callback URLs"
},
"permissions": {
"type": ["string"],
"description": "A list of permissions required by the application"
},
"clientKey": "string",
"javaScriptKey": "string",
"restApiKey": "string",
"windowsKey": "string",
"masterKey": "string",
"pushSettings": {
"apns": {
"production": {
"type": "boolean",
"description": [
"Production or development mode. It denotes what default APNS",
"servers to be used to send notifications.",
"See API documentation for more details."
]
},
"certData": {
"type": "string",
"description": "The certificate data loaded from the cert.pem file"
},
"keyData": {
"type": "string",
"description": "The key data loaded from the key.pem file"
},
"pushOptions": {
"type": {
"gateway": "string",
"port": "number"
}
},
"feedbackOptions": {
"type": {
"gateway": "string",
"port": "number",
"batchFeedback": "boolean",
"interval": "number"
}
}
},
"gcm": {
"serverApiKey": "string"
}
},
"authenticationEnabled": {
"type": "boolean",
"default": true
},
"anonymousAllowed": {
"type": "boolean",
"default": true
},
"authenticationSchemes": [
{
"scheme": {
"type": "string",
"description": "See the API docs for the list of supported values."
},
"credential": {
"type": "object",
"description": "Scheme-specific credentials"
}
}
],
"status": {
"type": "string",
"default": "sandbox",
"description": "Status of the application, production/sandbox/disabled"
},
"created": "date",
"modified": "date"
}
}

628
common/models/change.js Normal file
View File

@ -0,0 +1,628 @@
/*!
* Module Dependencies.
*/
var PersistedModel = require('../../lib/loopback').PersistedModel
, loopback = require('../../lib/loopback')
, crypto = require('crypto')
, CJSON = {stringify: require('canonical-json')}
, async = require('async')
, assert = require('assert')
, debug = require('debug')('loopback:change');
/**
* Change list entry.
*
* @property {String} id Hash of the modelName and id
* @property {String} rev The current model revision
* @property {String} prev The previous model revision
* @property {Number} checkpoint The current checkpoint at time of the change
* @property {String} modelName Model name
* @property {String} modelId Model ID
*
* @class Change
* @inherits {PersistedModel}
*/
module.exports = function(Change) {
/*!
* Constants
*/
Change.UPDATE = 'update';
Change.CREATE = 'create';
Change.DELETE = 'delete';
Change.UNKNOWN = 'unknown';
/*!
* Conflict Class
*/
Change.Conflict = Conflict;
/*!
* Setup the extended model.
*/
Change.setup = function() {
PersistedModel.setup.call(this);
var Change = this;
Change.getter.id = function() {
var hasModel = this.modelName && this.modelId;
if (!hasModel) return null;
return Change.idForModel(this.modelName, this.modelId);
}
}
Change.setup();
/**
* Track the recent change of the given modelIds.
*
* @param {String} modelName
* @param {Array} modelIds
* @callback {Function} callback
* @param {Error} err
* @param {Array} changes Changes that were tracked
*/
Change.rectifyModelChanges = function(modelName, modelIds, callback) {
var tasks = [];
var Change = this;
modelIds.forEach(function(id) {
tasks.push(function(cb) {
Change.findOrCreateChange(modelName, id, function(err, change) {
if (err) return Change.handleError(err, cb);
change.rectify(cb);
});
});
});
async.parallel(tasks, callback);
}
/**
* Get an identifier for a given model.
*
* @param {String} modelName
* @param {String} modelId
* @return {String}
*/
Change.idForModel = function(modelName, modelId) {
return this.hash([modelName, modelId].join('-'));
}
/**
* Find or create a change for the given model.
*
* @param {String} modelName
* @param {String} modelId
* @callback {Function} callback
* @param {Error} err
* @param {Change} change
* @end
*/
Change.findOrCreateChange = function(modelName, modelId, callback) {
assert(loopback.findModel(modelName), modelName + ' does not exist');
var id = this.idForModel(modelName, modelId);
var Change = this;
this.findById(id, function(err, change) {
if (err) return callback(err);
if (change) {
callback(null, change);
} else {
var ch = new Change({
id: id,
modelName: modelName,
modelId: modelId
});
ch.debug('creating change');
ch.save(callback);
}
});
}
/**
* Update (or create) the change with the current revision.
*
* @callback {Function} callback
* @param {Error} err
* @param {Change} change
*/
Change.prototype.rectify = function(cb) {
var change = this;
var tasks = [
updateRevision,
updateCheckpoint
];
var currentRev = this.rev;
change.debug('rectify change');
cb = cb || function(err) {
if (err) throw new Error(err);
}
async.parallel(tasks, function(err) {
if (err) return cb(err);
if (change.prev === Change.UNKNOWN) {
// this occurs when a record of a change doesn't exist
// and its current revision is null (not found)
change.remove(cb);
} else {
change.save(cb);
}
});
function updateRevision(cb) {
// get the current revision
change.currentRevision(function(err, rev) {
if (err) return Change.handleError(err, cb);
if (rev) {
// avoid setting rev and prev to the same value
if (currentRev !== rev) {
change.rev = rev;
change.prev = currentRev;
} else {
change.debug('rev and prev are equal (not updating rev)');
}
} else {
change.rev = null;
if (currentRev) {
change.prev = currentRev;
} else if (!change.prev) {
change.debug('ERROR - could not determing prev');
change.prev = Change.UNKNOWN;
}
}
change.debug('updated revision (was ' + currentRev + ')');
cb();
});
}
function updateCheckpoint(cb) {
change.constructor.getCheckpointModel().current(function(err, checkpoint) {
if (err) return Change.handleError(err);
change.checkpoint = checkpoint;
cb();
});
}
}
/**
* Get a change's current revision based on current data.
* @callback {Function} callback
* @param {Error} err
* @param {String} rev The current revision
*/
Change.prototype.currentRevision = function(cb) {
var model = this.getModelCtor();
var id = this.getModelId();
model.findById(id, function(err, inst) {
if (err) return Change.handleError(err, cb);
if (inst) {
cb(null, Change.revisionForInst(inst));
} else {
cb(null, null);
}
});
}
/**
* Create a hash of the given `string` with the `options.hashAlgorithm`.
* **Default: `sha1`**
*
* @param {String} str The string to be hashed
* @return {String} The hashed string
*/
Change.hash = function(str) {
return crypto
.createHash(Change.settings.hashAlgorithm || 'sha1')
.update(str)
.digest('hex');
}
/**
* Get the revision string for the given object
* @param {Object} inst The data to get the revision string for
* @return {String} The revision string
*/
Change.revisionForInst = function(inst) {
return this.hash(CJSON.stringify(inst));
}
/**
* Get a change's type. Returns one of:
*
* - `Change.UPDATE`
* - `Change.CREATE`
* - `Change.DELETE`
* - `Change.UNKNOWN`
*
* @return {String} the type of change
*/
Change.prototype.type = function() {
if (this.rev && this.prev) {
return Change.UPDATE;
}
if (this.rev && !this.prev) {
return Change.CREATE;
}
if (!this.rev && this.prev) {
return Change.DELETE;
}
return Change.UNKNOWN;
}
/**
* Compare two changes.
* @param {Change} change
* @return {Boolean}
*/
Change.prototype.equals = function(change) {
if (!change) return false;
var thisRev = this.rev || null;
var thatRev = change.rev || null;
return thisRev === thatRev;
}
/**
* Does this change conflict with the given change.
* @param {Change} change
* @return {Boolean}
*/
Change.prototype.conflictsWith = function(change) {
if (!change) return false;
if (this.equals(change)) return false;
if (Change.bothDeleted(this, change)) return false;
if (this.isBasedOn(change)) return false;
return true;
}
/**
* Are both changes deletes?
* @param {Change} a
* @param {Change} b
* @return {Boolean}
*/
Change.bothDeleted = function(a, b) {
return a.type() === Change.DELETE
&& b.type() === Change.DELETE;
}
/**
* Determine if the change is based on the given change.
* @param {Change} change
* @return {Boolean}
*/
Change.prototype.isBasedOn = function(change) {
return this.prev === change.rev;
}
/**
* Determine the differences for a given model since a given checkpoint.
*
* The callback will contain an error or `result`.
*
* **result**
*
* ```js
* {
* deltas: Array,
* conflicts: Array
* }
* ```
*
* **deltas**
*
* An array of changes that differ from `remoteChanges`.
*
* **conflicts**
*
* An array of changes that conflict with `remoteChanges`.
*
* @param {String} modelName
* @param {Number} since Compare changes after this checkpoint
* @param {Change[]} remoteChanges A set of changes to compare
* @callback {Function} callback
* @param {Error} err
* @param {Object} result See above.
*/
Change.diff = function(modelName, since, remoteChanges, callback) {
var remoteChangeIndex = {};
var modelIds = [];
remoteChanges.forEach(function(ch) {
modelIds.push(ch.modelId);
remoteChangeIndex[ch.modelId] = new Change(ch);
});
// normalize `since`
since = Number(since) || 0;
this.find({
where: {
modelName: modelName,
modelId: {inq: modelIds},
checkpoint: {gte: since}
}
}, function(err, localChanges) {
if (err) return callback(err);
var deltas = [];
var conflicts = [];
var localModelIds = [];
localChanges.forEach(function(localChange) {
localChange = new Change(localChange);
localModelIds.push(localChange.modelId);
var remoteChange = remoteChangeIndex[localChange.modelId];
if (remoteChange && !localChange.equals(remoteChange)) {
if (remoteChange.conflictsWith(localChange)) {
remoteChange.debug('remote conflict');
localChange.debug('local conflict');
conflicts.push(localChange);
} else {
remoteChange.debug('remote delta');
deltas.push(remoteChange);
}
}
});
modelIds.forEach(function(id) {
if (localModelIds.indexOf(id) === -1) {
deltas.push(remoteChangeIndex[id]);
}
});
callback(null, {
deltas: deltas,
conflicts: conflicts
});
});
}
/**
* Correct all change list entries.
* @param {Function} callback
*/
Change.rectifyAll = function(cb) {
debug('rectify all');
var Change = this;
// this should be optimized
this.find(function(err, changes) {
if (err) return cb(err);
changes.forEach(function(change) {
change = new Change(change);
change.rectify();
});
});
}
/**
* Get the checkpoint model.
* @return {Checkpoint}
*/
Change.getCheckpointModel = function() {
var checkpointModel = this.Checkpoint;
if (checkpointModel) return checkpointModel;
this.checkpoint = checkpointModel = loopback.Checkpoint.extend('checkpoint');
assert(this.dataSource, 'Cannot getCheckpointModel(): ' + this.modelName
+ ' is not attached to a dataSource');
checkpointModel.attachTo(this.dataSource);
return checkpointModel;
}
Change.handleError = function(err) {
if (!this.settings.ignoreErrors) {
throw err;
}
}
Change.prototype.debug = function() {
if (debug.enabled) {
var args = Array.prototype.slice.call(arguments);
debug.apply(this, args);
debug('\tid', this.id);
debug('\trev', this.rev);
debug('\tprev', this.prev);
debug('\tmodelName', this.modelName);
debug('\tmodelId', this.modelId);
debug('\ttype', this.type());
}
}
/**
* Get the `Model` class for `change.modelName`.
* @return {Model}
*/
Change.prototype.getModelCtor = function() {
return this.constructor.settings.trackModel;
}
Change.prototype.getModelId = function() {
// TODO(ritch) get rid of the need to create an instance
var Model = this.getModelCtor();
var id = this.modelId;
var m = new Model();
m.setId(id);
return m.getId();
}
Change.prototype.getModel = function(callback) {
var Model = this.constructor.settings.trackModel;
var id = this.getModelId();
Model.findById(id, callback);
}
/**
* When two changes conflict a conflict is created.
*
* **Note**: call `conflict.fetch()` to get the `target` and `source` models.
*
* @param {*} modelId
* @param {PersistedModel} SourceModel
* @param {PersistedModel} TargetModel
* @property {ModelClass} source The source model instance
* @property {ModelClass} target The target model instance
* @class Change.Conflict
*/
function Conflict(modelId, SourceModel, TargetModel) {
this.SourceModel = SourceModel;
this.TargetModel = TargetModel;
this.SourceChange = SourceModel.getChangeModel();
this.TargetChange = TargetModel.getChangeModel();
this.modelId = modelId;
}
/**
* Fetch the conflicting models.
*
* @callback {Function} callback
* @param {Error} err
* @param {PersistedModel} source
* @param {PersistedModel} target
*/
Conflict.prototype.models = function(cb) {
var conflict = this;
var SourceModel = this.SourceModel;
var TargetModel = this.TargetModel;
var source;
var target;
async.parallel([
getSourceModel,
getTargetModel
], done);
function getSourceModel(cb) {
SourceModel.findById(conflict.modelId, function(err, model) {
if (err) return cb(err);
source = model;
cb();
});
}
function getTargetModel(cb) {
TargetModel.findById(conflict.modelId, function(err, model) {
if (err) return cb(err);
target = model;
cb();
});
}
function done(err) {
if (err) return cb(err);
cb(null, source, target);
}
}
/**
* Get the conflicting changes.
*
* @callback {Function} callback
* @param {Error} err
* @param {Change} sourceChange
* @param {Change} targetChange
*/
Conflict.prototype.changes = function(cb) {
var conflict = this;
var sourceChange;
var targetChange;
async.parallel([
getSourceChange,
getTargetChange
], done);
function getSourceChange(cb) {
conflict.SourceChange.findOne({where: {
modelId: conflict.modelId
}}, function(err, change) {
if (err) return cb(err);
sourceChange = change;
cb();
});
}
function getTargetChange(cb) {
conflict.TargetChange.findOne({where: {
modelId: conflict.modelId
}}, function(err, change) {
if (err) return cb(err);
targetChange = change;
cb();
});
}
function done(err) {
if (err) return cb(err);
cb(null, sourceChange, targetChange);
}
}
/**
* Resolve the conflict.
*
* @callback {Function} callback
* @param {Error} err
*/
Conflict.prototype.resolve = function(cb) {
var conflict = this;
conflict.changes(function(err, sourceChange, targetChange) {
if (err) return cb(err);
sourceChange.prev = targetChange.rev;
sourceChange.save(cb);
});
}
/**
* Determine the conflict type.
*
* Possible results are
*
* - `Change.UPDATE`: Source and target models were updated.
* - `Change.DELETE`: Source and or target model was deleted.
* - `Change.UNKNOWN`: the conflict type is uknown or due to an error.
*
* @callback {Function} callback
* @param {Error} err
* @param {String} type The conflict type.
*/
Conflict.prototype.type = function(cb) {
var conflict = this;
this.changes(function(err, sourceChange, targetChange) {
if (err) return cb(err);
var sourceChangeType = sourceChange.type();
var targetChangeType = targetChange.type();
if (sourceChangeType === Change.UPDATE && targetChangeType === Change.UPDATE) {
return cb(null, Change.UPDATE);
}
if (sourceChangeType === Change.DELETE || targetChangeType === Change.DELETE) {
return cb(null, Change.DELETE);
}
return cb(null, Change.UNKNOWN);
});
}
};

25
common/models/change.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "Change",
"trackChanges": false,
"properties": {
"id": {
"type": "string",
"id": true
},
"rev": {
"type": "string"
},
"prev": {
"type": "string"
},
"checkpoint": {
"type": "number"
},
"modelName": {
"type": "string"
},
"modelId": {
"type": "string"
}
}
}

View File

@ -0,0 +1,63 @@
/**
* Module Dependencies.
*/
var assert = require('assert');
/**
* Checkpoint list entry.
*
* @property id {Number} the sequencial identifier of a checkpoint
* @property time {Number} the time when the checkpoint was created
* @property sourceId {String} the source identifier
*
* @class Checkpoint
* @inherits {PersistedModel}
*/
module.exports = function(Checkpoint) {
// Workaround for https://github.com/strongloop/loopback/issues/292
Checkpoint.definition.rawProperties.time.default =
Checkpoint.definition.properties.time.default = function() {
return new Date();
};
/**
* Get the current checkpoint id
* @callback {Function} callback
* @param {Error} err
* @param {Number} checkpointId The current checkpoint id
*/
Checkpoint.current = function(cb) {
var Checkpoint = this;
this.find({
limit: 1,
order: 'seq DESC'
}, function(err, checkpoints) {
if (err) return cb(err);
var checkpoint = checkpoints[0];
if (checkpoint) {
cb(null, checkpoint.seq);
} else {
Checkpoint.create({seq: 0}, function(err, checkpoint) {
if (err) return cb(err);
cb(null, checkpoint.seq);
});
}
});
}
Checkpoint.beforeSave = function(next, model) {
if (!model.getId() && model.seq === undefined) {
model.constructor.current(function(err, seq) {
if (err) return next(err);
model.seq = seq + 1;
next();
});
} else {
next();
}
}
};

View File

@ -0,0 +1,14 @@
{
"name": "Checkpoint",
"properties": {
"seq": {
"type": "number"
},
"time": {
"type": "date"
},
"sourceId": {
"type": "string"
}
}
}

51
common/models/email.js Normal file
View File

@ -0,0 +1,51 @@
/**
* Email model. Extends LoopBack base [Model](#model-new-model).
* @property {String} to Email addressee. Required.
* @property {String} from Email sender address. Required.
* @property {String} subject Email subject string. Required.
* @property {String} text Text body of email.
* @property {String} html HTML body of email.
*
* @class Email
* @inherits {Model}
*/
module.exports = function(Email) {
/**
* Send an email with the given `options`.
*
* Example Options:
*
* ```js
* {
* 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.
*
* @options {Object} options See below
* @prop {String} from Senders's email address
* @prop {String} to List of one or more recipient email addresses (comma-delimited)
* @prop {String} subject Subject line
* @prop {String} text Body text
* @prop {String} html Body HTML (optional)
* @param {Function} callback Called after the e-mail is sent or the sending failed
*/
Email.send = function() {
throw new Error('You must connect the Email Model to a Mail connector');
};
/**
* A shortcut for Email.send(this).
*/
Email.prototype.send = function() {
throw new Error('You must connect the Email Model to a Mail connector');
};
};

11
common/models/email.json Normal file
View File

@ -0,0 +1,11 @@
{
"name": "Email",
"base": "Model",
"properties": {
"to": {"type": "String", "required": true},
"from": {"type": "String", "required": true},
"subject": {"type": "String", "required": true},
"text": {"type": "String"},
"html": {"type": "String"}
}
}

View File

@ -0,0 +1,73 @@
var loopback = require('../../lib/loopback');
/**
* The `RoleMapping` model extends from the built in `loopback.Model` type.
*
* @property {String} id Generated ID.
* @property {String} name Name of the role.
* @property {String} Description Text description.
*
* @class RoleMapping
* @inherits {PersistedModel}
*/
module.exports = function(RoleMapping) {
// Principal types
RoleMapping.USER = 'USER';
RoleMapping.APP = RoleMapping.APPLICATION = 'APP';
RoleMapping.ROLE = 'ROLE';
/**
* Get the application principal
* @callback {Function} callback
* @param {Error} err
* @param {Application} application
*/
RoleMapping.prototype.application = function (callback) {
if (this.principalType === RoleMapping.APPLICATION) {
var applicationModel = this.constructor.Application
|| loopback.getModelByType(loopback.Application);
applicationModel.findById(this.principalId, callback);
} else {
process.nextTick(function () {
callback && callback(null, null);
});
}
};
/**
* Get the user principal
* @callback {Function} callback
* @param {Error} err
* @param {User} user
*/
RoleMapping.prototype.user = function (callback) {
if (this.principalType === RoleMapping.USER) {
var userModel = this.constructor.User
|| loopback.getModelByType(loopback.User);
userModel.findById(this.principalId, callback);
} else {
process.nextTick(function () {
callback && callback(null, null);
});
}
};
/**
* Get the child role principal
* @callback {Function} callback
* @param {Error} err
* @param {User} childUser
*/
RoleMapping.prototype.childRole = function (callback) {
if (this.principalType === RoleMapping.ROLE) {
var roleModel = this.constructor.Role ||
loopback.getModelByType(loopback.Role);
roleModel.findById(this.principalId, callback);
} else {
process.nextTick(function () {
callback && callback(null, null);
});
}
};
};

View File

@ -0,0 +1,23 @@
{
"name": "RoleMapping",
"description": "Map principals to roles",
"properties": {
"id": {
"type": "string",
"id": true,
"generated": true
},
"principalType": {
"type": "string",
"description": "The principal type, such as user, application, or role"
},
"principalId": "string"
},
"relations": {
"role": {
"type": "belongsTo",
"model": "Role",
"foreignKey": "roleId"
}
}
}

387
common/models/role.js Normal file
View File

@ -0,0 +1,387 @@
var loopback = require('../../lib/loopback');
var debug = require('debug')('loopback:security:role');
var assert = require('assert');
var async = require('async');
var AccessContext = require('../../lib/access-context').AccessContext;
var RoleMapping = loopback.RoleMapping;
assert(RoleMapping, 'RoleMapping model must be defined before Role model');
/**
* The Role Model
* @class Role
*/
module.exports = function(Role) {
// Workaround for https://github.com/strongloop/loopback/issues/292
Role.definition.rawProperties.created.default =
Role.definition.properties.created.default = function() {
return new Date();
};
// Workaround for https://github.com/strongloop/loopback/issues/292
Role.definition.rawProperties.modified.default =
Role.definition.properties.modified.default = function() {
return new Date();
};
// Set up the connection to users/applications/roles once the model
Role.once('dataSourceAttached', function() {
var roleMappingModel = this.RoleMapping || loopback.getModelByType(RoleMapping);
Role.prototype.users = function(callback) {
roleMappingModel.find({where: {roleId: this.id,
principalType: RoleMapping.USER}}, function(err, mappings) {
if (err) {
callback && callback(err);
return;
}
return mappings.map(function(m) {
return m.principalId;
});
});
};
Role.prototype.applications = function(callback) {
roleMappingModel.find({where: {roleId: this.id,
principalType: RoleMapping.APPLICATION}}, function(err, mappings) {
if (err) {
callback && callback(err);
return;
}
return mappings.map(function(m) {
return m.principalId;
});
});
};
Role.prototype.roles = function(callback) {
roleMappingModel.find({where: {roleId: this.id,
principalType: RoleMapping.ROLE}}, function(err, mappings) {
if (err) {
callback && callback(err);
return;
}
return mappings.map(function(m) {
return m.principalId;
});
});
};
});
// Special roles
Role.OWNER = '$owner'; // owner of the object
Role.RELATED = "$related"; // any User with a relationship to the object
Role.AUTHENTICATED = "$authenticated"; // authenticated user
Role.UNAUTHENTICATED = "$unauthenticated"; // authenticated user
Role.EVERYONE = "$everyone"; // everyone
/**
* Add custom handler for roles.
* @param {String} role Name of role.
* @param {Function} resolver Function that determines if a principal is in the specified role.
* Signature must be `function(role, context, callback)`
*/
Role.registerResolver = function(role, resolver) {
if (!Role.resolvers) {
Role.resolvers = {};
}
Role.resolvers[role] = resolver;
};
Role.registerResolver(Role.OWNER, function(role, context, callback) {
if (!context || !context.model || !context.modelId) {
process.nextTick(function() {
callback && callback(null, false);
});
return;
}
var modelClass = context.model;
var modelId = context.modelId;
var userId = context.getUserId();
Role.isOwner(modelClass, modelId, userId, callback);
});
function isUserClass(modelClass) {
return modelClass === loopback.User ||
modelClass.prototype instanceof loopback.User;
}
/*!
* Check if two user ids matches
* @param {*} id1
* @param {*} id2
* @returns {boolean}
*/
function matches(id1, id2) {
if (id1 === undefined || id1 === null || id1 === ''
|| id2 === undefined || id2 === null || id2 === '') {
return false;
}
// The id can be a MongoDB ObjectID
return id1 === id2 || id1.toString() === id2.toString();
}
/**
* Check if a given user ID is the owner the model instance.
* @param {Function} modelClass The model class
* @param {*} modelId The model ID
* @param {*} userId The user ID
* @param {Function} callback Callback function
*/
Role.isOwner = function isOwner(modelClass, modelId, userId, callback) {
assert(modelClass, 'Model class is required');
debug('isOwner(): %s %s userId: %s', modelClass && modelClass.modelName, modelId, userId);
// No userId is present
if (!userId) {
process.nextTick(function() {
callback(null, false);
});
return;
}
// Is the modelClass User or a subclass of User?
if (isUserClass(modelClass)) {
process.nextTick(function() {
callback(null, matches(modelId, userId));
});
return;
}
modelClass.findById(modelId, function(err, inst) {
if (err || !inst) {
debug('Model not found for id %j', modelId);
callback && callback(err, false);
return;
}
debug('Model found: %j', inst);
var ownerId = inst.userId || inst.owner;
if (ownerId) {
callback && callback(null, matches(ownerId, userId));
return;
} else {
// Try to follow belongsTo
for (var r in modelClass.relations) {
var rel = modelClass.relations[r];
if (rel.type === 'belongsTo' && isUserClass(rel.modelTo)) {
debug('Checking relation %s to %s: %j', r, rel.modelTo.modelName, rel);
inst[r](function(err, user) {
if (!err && user) {
debug('User found: %j', user.id);
callback && callback(null, matches(user.id, userId));
} else {
callback && callback(err, false);
}
});
return;
}
}
debug('No matching belongsTo relation found for model %j and user: %j', modelId, userId);
callback && callback(null, false);
}
});
};
Role.registerResolver(Role.AUTHENTICATED, function(role, context, callback) {
if (!context) {
process.nextTick(function() {
callback && callback(null, false);
});
return;
}
Role.isAuthenticated(context, callback);
});
/**
* Check if the user id is authenticated
* @param {Object} context The security context
* @callback {Function} callback
* @param {Error} err
* @param {Boolean} isAuthenticated
*/
Role.isAuthenticated = function isAuthenticated(context, callback) {
process.nextTick(function() {
callback && callback(null, context.isAuthenticated());
});
};
Role.registerResolver(Role.UNAUTHENTICATED, function(role, context, callback) {
process.nextTick(function() {
callback && callback(null, !context || !context.isAuthenticated());
});
});
Role.registerResolver(Role.EVERYONE, function(role, context, callback) {
process.nextTick(function() {
callback && callback(null, true); // Always true
});
});
/**
* Check if a given principal is in the role
*
* @param {String} role The role name
* @param {Object} context The context object
* @callback {Function} callback
* @param {Error} err
* @param {Boolean} isInRole
*/
Role.isInRole = function(role, context, callback) {
if (!(context instanceof AccessContext)) {
context = new AccessContext(context);
}
debug('isInRole(): %s', role);
context.debug();
var resolver = Role.resolvers[role];
if (resolver) {
debug('Custom resolver found for role %s', role);
resolver(role, context, callback);
return;
}
if (context.principals.length === 0) {
debug('isInRole() returns: false');
process.nextTick(function() {
callback && callback(null, false);
});
return;
}
var inRole = context.principals.some(function(p) {
var principalType = p.type || undefined;
var principalId = p.id || undefined;
// Check if it's the same role
return principalType === RoleMapping.ROLE && principalId === role;
});
if (inRole) {
debug('isInRole() returns: %j', inRole);
process.nextTick(function() {
callback && callback(null, true);
});
return;
}
var roleMappingModel = this.RoleMapping || loopback.getModelByType(RoleMapping);
this.findOne({where: {name: role}}, function(err, result) {
if (err) {
callback && callback(err);
return;
}
if (!result) {
callback && callback(null, false);
return;
}
debug('Role found: %j', result);
// Iterate through the list of principals
async.some(context.principals, function(p, done) {
var principalType = p.type || undefined;
var principalId = p.id || undefined;
var roleId = result.id.toString();
if (principalId !== null && principalId !== undefined && (typeof principalId !== 'string')) {
principalId = principalId.toString();
}
if (principalType && principalId) {
roleMappingModel.findOne({where: {roleId: roleId,
principalType: principalType, principalId: principalId}},
function(err, result) {
debug('Role mapping found: %j', result);
done(!err && result); // The only arg is the result
});
} else {
process.nextTick(function() {
done(false);
});
}
}, function(inRole) {
debug('isInRole() returns: %j', inRole);
callback && callback(null, inRole);
});
});
};
/**
* List roles for a given principal
* @param {Object} context The security context
* @param {Function} callback
*
* @callback {Function} callback
* @param err
* @param {String[]} An array of role ids
*/
Role.getRoles = function(context, callback) {
if (!(context instanceof AccessContext)) {
context = new AccessContext(context);
}
var roles = [];
var addRole = function(role) {
if (role && roles.indexOf(role) === -1) {
roles.push(role);
}
};
var self = this;
// Check against the smart roles
var inRoleTasks = [];
Object.keys(Role.resolvers).forEach(function(role) {
inRoleTasks.push(function(done) {
self.isInRole(role, context, function(err, inRole) {
if (debug.enabled) {
debug('In role %j: %j', role, inRole);
}
if (!err && inRole) {
addRole(role);
done();
} else {
done(err, null);
}
});
});
});
var roleMappingModel = this.RoleMapping || loopback.getModelByType(RoleMapping);
context.principals.forEach(function(p) {
// Check against the role mappings
var principalType = p.type || undefined;
var principalId = p.id || undefined;
// Add the role itself
if (principalType === RoleMapping.ROLE && principalId) {
addRole(principalId);
}
if (principalType && principalId) {
// Please find() treat undefined matches all values
inRoleTasks.push(function(done) {
roleMappingModel.find({where: {principalType: principalType,
principalId: principalId}}, function(err, mappings) {
debug('Role mappings found: %s %j', err, mappings);
if (err) {
done && done(err);
return;
}
mappings.forEach(function(m) {
addRole(m.roleId);
});
done && done();
});
});
}
});
async.parallel(inRoleTasks, function(err, results) {
debug('getRoles() returns: %j %j', err, roles);
callback && callback(err, roles);
});
};
};

26
common/models/role.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "Role",
"properties": {
"id": {
"type": "string",
"id": true,
"generated": true
},
"name": {
"type": "string",
"required": true
},
"description": "string",
"created": "date",
"modified": "date"
},
"relations": {
"principals": {
"type": "hasMany",
"model": "RoleMapping",
"foreignKey": "roleId"
}
}
}

40
common/models/scope.js Normal file
View File

@ -0,0 +1,40 @@
var assert = require('assert');
var loopback = require('../../lib/loopback');
/**
* Resource owner grants/delegates permissions to client applications
*
* For a protected resource, does the client application have the authorization
* from the resource owner (user or system)?
*
* Scope has many resource access entries
*
* @class Scope
*/
module.exports = function(Scope) {
/**
* Check if the given scope is allowed to access the model/property
* @param {String} scope The scope name
* @param {String} model The model name
* @param {String} property The property/method/relation name
* @param {String} accessType The access type
* @callback {Function} callback
* @param {String|Error} err The error object
* @param {AccessRequest} result The access permission
*/
Scope.checkPermission = function (scope, model, property, accessType, callback) {
var ACL = loopback.ACL;
assert(ACL,
'ACL model must be defined before Scope.checkPermission is called');
this.findOne({where: {name: scope}}, function (err, scope) {
if (err) {
callback && callback(err);
} else {
var aclModel = loopback.getModelByType(ACL);
aclModel.checkPermission(ACL.SCOPE, scope.id, model, property, accessType, callback);
}
});
};
};

14
common/models/scope.json Normal file
View File

@ -0,0 +1,14 @@
{
"name": "Scope",
"description": [
"Schema for Scope which represents the permissions that are granted",
"to client applications by the resource owner"
],
"properties": {
"name": {
"type": "string",
"required": true
},
"description": "string"
}
}

View File

@ -2,109 +2,21 @@
* Module Dependencies.
*/
var PersistedModel = require('../loopback').PersistedModel
, loopback = require('../loopback')
var loopback = require('../../lib/loopback')
, path = require('path')
, SALT_WORK_FACTOR = 10
, crypto = require('crypto')
, bcrypt = require('bcryptjs')
, BaseAccessToken = require('./access-token')
, DEFAULT_TTL = 1209600 // 2 weeks in seconds
, DEFAULT_RESET_PW_TTL = 15 * 60 // 15 mins in seconds
, DEFAULT_MAX_TTL = 31556926 // 1 year in seconds
, Role = require('./role').Role
, ACL = require('./acl').ACL
, assert = require('assert');
var debug = require('debug')('loopback:user');
/*!
* Default User properties.
*/
var properties = {
realm: {type: String},
username: {type: String},
password: {type: String, required: true},
credentials: Object, // deprecated, to be removed in 2.x
challenges: Object, // deprecated, to be removed in 2.x
email: {type: String, required: true},
emailVerified: Boolean,
verificationToken: String,
status: String,
created: Date,
lastUpdated: Date
};
var options = {
hidden: ['password'],
acls: [
{
principalType: ACL.ROLE,
principalId: Role.EVERYONE,
permission: ACL.DENY
},
{
principalType: ACL.ROLE,
principalId: Role.EVERYONE,
permission: ACL.ALLOW,
property: 'create'
},
{
principalType: ACL.ROLE,
principalId: Role.OWNER,
permission: ACL.ALLOW,
property: 'deleteById'
},
{
principalType: ACL.ROLE,
principalId: Role.EVERYONE,
permission: ACL.ALLOW,
property: "login"
},
{
principalType: ACL.ROLE,
principalId: Role.EVERYONE,
permission: ACL.ALLOW,
property: "logout"
},
{
principalType: ACL.ROLE,
principalId: Role.OWNER,
permission: ACL.ALLOW,
property: "findById"
},
{
principalType: ACL.ROLE,
principalId: Role.OWNER,
permission: ACL.ALLOW,
property: "updateAttributes"
},
{
principalType: ACL.ROLE,
principalId: Role.EVERYONE,
permission: ACL.ALLOW,
property: "confirm"
},
{
principalType: ACL.ROLE,
principalId: Role.EVERYONE,
permission: ACL.ALLOW,
property: "resetPassword",
accessType: ACL.EXECUTE
}
],
relations: {
accessTokens: {
type: 'hasMany',
model: 'AccessToken',
foreignKey: 'userId'
}
}
};
/**
* Extends from the built in `loopback.Model` type.
* Built-in User model.
* Extends LoopBack [PersistedModel](#persistedmodel-new-persistedmodel).
*
* Default `User` ACLs.
*
@ -122,11 +34,11 @@ var options = {
* @property {Boolean} emailVerified Set when a user's email has been verified via `confirm()`
* @property {String} verificationToken Set when `verify()` is called
*
* @class
* @inherits {Model}
* @class User
* @inherits {PersistedModel}
*/
var User = module.exports = PersistedModel.extend('User', properties, options);
module.exports = function(User) {
/**
* Create access token for the logged in user. This method can be overridden to
@ -150,8 +62,8 @@ User.prototype.createAccessToken = function(ttl, cb) {
*
* ```js
* User.login({username: 'foo', password: 'bar'}, function (err, token) {
* console.log(token.id);
* });
* console.log(token.id);
* });
* ```
*
* @param {Object} credentials
@ -160,7 +72,7 @@ User.prototype.createAccessToken = function(ttl, cb) {
* @param {AccessToken} token
*/
User.login = function (credentials, include, fn) {
User.login = function(credentials, include, fn) {
var self = this;
if (typeof include === 'function') {
fn = include;
@ -169,19 +81,18 @@ User.login = function (credentials, include, fn) {
include = (include || '');
if (Array.isArray(include)) {
include = include.map(function ( val ) {
include = include.map(function(val) {
return val.toLowerCase();
});
}else {
} else {
include = include.toLowerCase();
}
var query = {};
if(credentials.email) {
if (credentials.email) {
query.email = credentials.email;
} else if(credentials.username) {
} else if (credentials.username) {
query.username = credentials.username;
} else {
var err = new Error('username or email is required');
@ -193,10 +104,10 @@ User.login = function (credentials, include, fn) {
var defaultError = new Error('login failed');
defaultError.statusCode = 401;
if(err) {
if (err) {
debug('An error is reported from User.findOne: %j', err);
fn(defaultError);
} else if(user) {
} else if (user) {
if (self.settings.emailVerificationRequired) {
if (!user.emailVerified) {
// Fail to log in if email verification is not done yet
@ -207,10 +118,10 @@ User.login = function (credentials, include, fn) {
}
}
user.hasPassword(credentials.password, function(err, isMatch) {
if(err) {
if (err) {
debug('An error is reported from User.hasPassword: %j', err);
fn(defaultError);
} else if(isMatch) {
} else if (isMatch) {
user.createAccessToken(credentials.ttl, function(err, token) {
if (err) return fn(err);
if (Array.isArray(include) ? include.indexOf('user') !== -1 : include === 'user') {
@ -241,8 +152,8 @@ User.login = function (credentials, include, fn) {
*
* ```js
* User.logout('asd0a9f8dsj9s0s3223mk', function (err) {
* console.log(err || 'Logged out');
* });
* console.log(err || 'Logged out');
* });
* ```
*
* @param {String} accessTokenID
@ -250,11 +161,11 @@ User.login = function (credentials, include, fn) {
* @param {Error} err
*/
User.logout = function (tokenId, fn) {
this.relations.accessTokens.modelTo.findById(tokenId, function (err, accessToken) {
if(err) {
User.logout = function(tokenId, fn) {
this.relations.accessTokens.modelTo.findById(tokenId, function(err, accessToken) {
if (err) {
fn(err);
} else if(accessToken) {
} else if (accessToken) {
accessToken.destroy(fn);
} else {
fn(new Error('could not find accessToken'));
@ -269,10 +180,10 @@ User.logout = function (tokenId, fn) {
* @returns {Boolean}
*/
User.prototype.hasPassword = function (plain, fn) {
if(this.password && plain) {
User.prototype.hasPassword = function(plain, fn) {
if (this.password && plain) {
bcrypt.compare(plain, this.password, function(err, isMatch) {
if(err) return fn(err);
if (err) return fn(err);
fn(null, isMatch);
});
} else {
@ -285,19 +196,29 @@ User.prototype.hasPassword = function (plain, fn) {
*
* ```js
* var options = {
* type: 'email',
* to: user.email,
* template: 'verify.ejs',
* redirect: '/'
* };
* type: 'email',
* to: user.email,
* template: 'verify.ejs',
* redirect: '/'
* };
*
* user.verify(options, next);
* ```
*
* @param {Object} options
* @options {Object} options
* @property {String} type Must be 'email'.
* @property {String} to Email address to which verification email is sent.
* @property {String} from Sender email addresss, for example
* `'noreply@myapp.com'`.
* @property {String} subject Subject line text.
* @property {String} text Text of email.
* @property {String} template Name of template that displays verification
* page, for example, `'verify.ejs'.
* @property {String} redirect Page to which user will be redirected after
* they verify their email, for example `'/'` for root URI.
*/
User.prototype.verify = function (options, fn) {
User.prototype.verify = function(options, fn) {
var user = this;
var userModel = this.constructor;
assert(typeof options === 'object', 'options required when calling user.verify()');
@ -314,32 +235,32 @@ User.prototype.verify = function (options, fn) {
var app = userModel.app;
options.host = options.host || (app && app.get('host')) || 'localhost';
options.port = options.port || (app && app.get('port')) || 3000;
options.restApiRoot = options.restApiRoot || (app && app.get('restApiRoot')) || '/api';
options.restApiRoot = options.restApiRoot || (app && app.get('restApiRoot')) || '/api';
options.verifyHref = options.verifyHref ||
options.protocol
+ '://'
+ options.host
+ ':'
+ options.port
+ options.restApiRoot
+ userModel.http.path
+ userModel.confirm.http.path
+ '?uid='
+ options.user.id
+ '&redirect='
+ options.redirect;
options.protocol
+ '://'
+ options.host
+ ':'
+ options.port
+ options.restApiRoot
+ userModel.http.path
+ userModel.confirm.http.path
+ '?uid='
+ options.user.id
+ '&redirect='
+ options.redirect;
// Email model
var Email = options.mailer || this.constructor.email || loopback.getModelByType(loopback.Email);
crypto.randomBytes(64, function(err, buf) {
if(err) {
if (err) {
fn(err);
} else {
user.verificationToken = buf.toString('hex');
user.save(function (err) {
if(err) {
user.save(function(err) {
if (err) {
fn(err);
} else {
sendEmail(user);
@ -363,8 +284,8 @@ User.prototype.verify = function (options, fn) {
subject: options.subject || 'Thanks for Registering',
text: options.text,
html: template(options)
}, function (err, email) {
if(err) {
}, function(err, email) {
if (err) {
fn(err);
} else {
fn(null, {email: email, token: user.verificationToken, uid: user.id});
@ -383,16 +304,16 @@ User.prototype.verify = function (options, fn) {
* @callback {Function} callback
* @param {Error} err
*/
User.confirm = function (uid, token, redirect, fn) {
this.findById(uid, function (err, user) {
if(err) {
User.confirm = function(uid, token, redirect, fn) {
this.findById(uid, function(err, user) {
if (err) {
fn(err);
} else {
if(user && user.verificationToken === token) {
if (user && user.verificationToken === token) {
user.verificationToken = undefined;
user.emailVerified = true;
user.save(function (err) {
if(err) {
user.save(function(err) {
if (err) {
fn(err);
} else {
fn();
@ -427,15 +348,15 @@ User.resetPassword = function(options, cb) {
var ttl = UserModel.settings.resetPasswordTokenTTL || DEFAULT_RESET_PW_TTL;
options = options || {};
if(typeof options.email === 'string') {
if (typeof options.email === 'string') {
UserModel.findOne({ where: {email: options.email} }, function(err, user) {
if(err) {
if (err) {
cb(err);
} else if(user) {
} else if (user) {
// create a short lived access token for temp login to change password
// TODO(ritch) - eventually this should only allow password change
user.accessTokens.create({ttl: ttl}, function(err, accessToken) {
if(err) {
if (err) {
cb(err);
} else {
cb();
@ -462,16 +383,16 @@ User.resetPassword = function(options, cb) {
* Setup an extended user model.
*/
User.setup = function () {
User.setup = function() {
// We need to call the base class's setup method
PersistedModel.setup.call(this);
User.base.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) {
UserModel.setter.password = function(plain) {
var salt = bcrypt.genSaltSync(this.constructor.settings.saltWorkFactor || SALT_WORK_FACTOR);
this.$password = bcrypt.hashSync(plain, salt);
}
@ -491,16 +412,14 @@ User.setup = function () {
description: 'Login a user with username/email and password',
accepts: [
{arg: 'credentials', type: 'object', required: true, http: {source: 'body'}},
{arg: 'include', type: 'string', http: {source: 'query' }, description:
'Related objects to include in the response. ' +
'See the description of return value for more details.'}
{arg: 'include', type: 'string', http: {source: 'query' }, description: 'Related objects to include in the response. ' +
'See the description of return value for more details.'}
],
returns: {
arg: 'accessToken', type: 'object', root: true, description:
'The response body contains properties of the AccessToken created on login.\n' +
'Depending on the value of `include` parameter, the body may contain ' +
'additional properties:\n\n' +
' - `user` - `{User}` - Data of the currently logged in user. (`include=user`)\n\n'
arg: 'accessToken', type: 'object', root: true, description: 'The response body contains properties of the AccessToken created on login.\n' +
'Depending on the value of `include` parameter, the body may contain ' +
'additional properties:\n\n' +
' - `user` - `{User}` - Data of the currently logged in user. (`include=user`)\n\n'
},
http: {verb: 'post'}
}
@ -517,9 +436,8 @@ User.setup = function () {
var tokenID = accessToken && accessToken.id;
return tokenID;
}, description:
'Do not supply this argument, it is automatically extracted ' +
'from request headers.'
}, description: 'Do not supply this argument, it is automatically extracted ' +
'from request headers.'
}
],
http: {verb: 'all'}
@ -550,26 +468,29 @@ User.setup = function () {
}
);
UserModel.on('attached', function () {
UserModel.afterRemote('confirm', function (ctx, inst, next) {
if(ctx.req) {
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'));
next(new Error('transport unsupported'));
}
});
});
// default models
UserModel.email = require('./email');
UserModel.accessToken = require('./access-token');
assert(loopback.Email, 'Email model must be defined before User model');
UserModel.email = loopback.Email;
assert(loopback.AccessToken, 'AccessToken model must be defined before User model');
UserModel.accessToken = loopback.AccessToken;
// email validation regex
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,}))$/;
UserModel.validatesUniquenessOf('email', {message: 'Email already exists'});
UserModel.validatesFormatOf('email', {with: re, message: 'Must provide a valid email'});
UserModel.validatesUniquenessOf('username', {message: 'User already exists'});
UserModel.validatesUniquenessOf('username', {message: 'User already exists'});
return UserModel;
}
@ -579,3 +500,5 @@ User.setup = function () {
*/
User.setup();
};

96
common/models/user.json Normal file
View File

@ -0,0 +1,96 @@
{
"name": "User",
"properties": {
"realm": {
"type": "string"
},
"username": {
"type": "string"
},
"password": {
"type": "string",
"required": true
},
"credentials": {
"type": "object",
"deprecated": true
},
"challenges": {
"type": "object",
"deprecated": true
},
"email": {
"type": "string",
"required": true
},
"emailVerified": "boolean",
"verificationToken": "string",
"status": "string",
"created": "date",
"lastUpdated": "date"
},
"hidden": ["password"],
"acls": [
{
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "DENY"
},
{
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW",
"property": "create"
},
{
"principalType": "ROLE",
"principalId": "$owner",
"permission": "ALLOW",
"property": "deleteById"
},
{
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW",
"property": "login"
},
{
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW",
"property": "logout"
},
{
"principalType": "ROLE",
"principalId": "$owner",
"permission": "ALLOW",
"property": "findById"
},
{
"principalType": "ROLE",
"principalId": "$owner",
"permission": "ALLOW",
"property": "updateAttributes"
},
{
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW",
"property": "confirm"
},
{
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW",
"property": "resetPassword",
"accessType": "EXECUTE"
}
],
"relations": {
"accessTokens": {
"type": "hasMany",
"model": "AccessToken",
"foreignKey": "userId"
}
}
}

View File

@ -3,24 +3,25 @@
"content": [
"lib/application.js",
"lib/loopback.js",
"lib/runtime.js",
"lib/registry.js",
{ "title": "Base models", "depth": 2 },
"lib/models/model.js",
"lib/models/persisted-model.js",
{ "title": "Middleware", "depth": 2 },
{ "title": "Base models", "depth": 2 },
"lib/model.js",
"lib/persisted-model.js",
{ "title": "Middleware", "depth": 2 },
"lib/middleware/rest.js",
"lib/middleware/status.js",
"lib/middleware/token.js",
"lib/middleware/token.js",
"lib/middleware/urlNotFound.js",
{ "title": "Built-in models", "depth": 2 },
"lib/models/access-token.js",
"lib/models/acl.js",
"lib/models/application.js",
"lib/models/email.js",
"lib/models/role.js",
"lib/models/user.js",
"lib/models/change.js"
{ "title": "Built-in models", "depth": 2 },
"common/models/access-token.js",
"common/models/acl.js",
"common/models/scope.js",
"common/models/application.js",
"common/models/email.js",
"common/models/role-mapping.js",
"common/models/role.js",
"common/models/user.js",
"common/models/change.js"
],
"assets": "/docs/assets"
}

View File

@ -19,4 +19,4 @@ loopback.Remote = require('loopback-connector-remote');
*/
loopback.GeoPoint = require('loopback-datasource-juggler/lib/geo').GeoPoint;
loopback.ValidationError = datasourceJuggler.ValidationError;
loopback.ValidationError = loopback.Model.ValidationError;

View File

@ -1,5 +1,5 @@
var loopback = require('../loopback');
var AccessToken = require('./access-token');
var assert = require('assert');
var loopback = require('./loopback');
var debug = require('debug')('loopback:security:access-context');
/**
@ -39,17 +39,19 @@ function AccessContext(context) {
this.sharedMethod = context.sharedMethod;
this.sharedClass = this.sharedMethod && this.sharedMethod.sharedClass;
if(this.sharedMethod) {
this.methodNames = this.sharedMethod.aliases.concat([this.sharedMethod.name]);
this.methodNames = this.sharedMethod.aliases.concat([this.sharedMethod.name]);
} else {
this.methodNames = [];
}
if(this.sharedMethod) {
this.accessType = this.model._getAccessTypeForMethod(this.sharedMethod);
this.accessType = this.model._getAccessTypeForMethod(this.sharedMethod);
}
this.accessType = context.accessType || AccessContext.ALL;
this.accessToken = context.accessToken || AccessToken.ANONYMOUS;
assert(loopback.AccessToken,
'AccessToken model must be defined before AccessContext model');
this.accessToken = context.accessToken || loopback.AccessToken.ANONYMOUS;
var principalType = context.principalType || Principal.USER;
var principalId = context.principalId || undefined;
@ -155,9 +157,9 @@ AccessContext.prototype.debug = function() {
if(debug.enabled) {
debug('---AccessContext---');
if(this.principals && this.principals.length) {
debug('principals:')
debug('principals:');
this.principals.forEach(function(principal) {
debug('principal: %j', principal)
debug('principal: %j', principal);
});
} else {
debug('principals: %j', this.principals);
@ -168,14 +170,14 @@ AccessContext.prototype.debug = function() {
debug('method %s', this.method);
debug('accessType %s', this.accessType);
if(this.accessToken) {
debug('accessToken:')
debug('accessToken:');
debug(' id %j', this.accessToken.id);
debug(' ttl %j', this.accessToken.ttl);
}
debug('getUserId() %s', this.getUserId());
debug('isAuthenticated() %s', this.isAuthenticated());
}
}
};
/**
* This class represents the abstract notion of a principal, which can be used
@ -271,17 +273,17 @@ AccessRequest.prototype.exactlyMatches = function(acl) {
}
return false;
}
};
/**
* Is the request for access allowed?
*
*
* @returns {Boolean}
*/
AccessRequest.prototype.isAllowed = function() {
return this.permission !== require('./acl').ACL.DENY;
}
return this.permission !== loopback.ACL.DENY;
};
AccessRequest.prototype.debug = function() {
if(debug.enabled) {
@ -293,7 +295,7 @@ AccessRequest.prototype.debug = function() {
debug(' isWildcard() %s', this.isWildcard());
debug(' isAllowed() %s', this.isAllowed());
}
}
};
module.exports.AccessContext = AccessContext;
module.exports.Principal = Principal;

View File

@ -14,23 +14,22 @@ var DataSource = require('loopback-datasource-juggler').DataSource
/**
* The `App` object represents a Loopback application.
*
*
* The App object extends [Express](http://expressjs.com/api.html#express) and
* supports
* [Express / Connect middleware](http://expressjs.com/api.html#middleware). See
* [Express documentation](http://expressjs.com/api.html) for details.
*
* supports Express middleware. See
* [Express documentation](http://expressjs.com/) for details.
*
* ```js
* var loopback = require('loopback');
* var app = loopback();
*
*
* app.get('/', function(req, res){
* res.send('hello world');
* });
*
*
* app.listen(3000);
* ```
*
*
* @class LoopBackApplication
* @header var app = loopback()
*/
@ -60,10 +59,10 @@ app.remotes = function () {
if(this.get) {
options = this.get('remoting');
}
return (this._remotes = RemoteObjects.create(options));
}
}
};
/*!
* Remove a route by reference.
@ -77,36 +76,28 @@ app.disuse = function (route) {
}
}
}
}
};
/**
* Attach a model to the app. The `Model` will be available on the
* `app.models` object.
*
* ```js
* // Attach an existing model
* Example - Attach an existing model:
```js
* var User = loopback.User;
* app.model(User);
*
* // Attach an existing model, alter some aspects of the model
*```
* Example - Attach an existing model, alter some aspects of the model:
* ```js
* var User = loopback.User;
* app.model(User, { dataSource: 'db' });
*```
*
* // LoopBack 1.x way: create and attach a new model (deprecated)
* var Widget = app.model('Widget', {
* dataSource: 'db',
* properties: {
* name: 'string'
* }
* });
* ```
*
* @param {Object|String} Model The model to attach.
* @options {Object} config The model's configuration.
* @property {String|DataSource} dataSource The `DataSource` to which to
* attach the model.
* @property {Boolean} [public] whether the model should be exposed via REST API
* @property {Object} [relations] relations to add/update
* @property {String|DataSource} dataSource The `DataSource` to which to attach the model.
* @property {Boolean} [public] Whether the model should be exposed via REST API.
* @property {Object} [relations] Relations to add/update.
* @end
* @returns {ModelConstructor} the model class
*/
@ -180,7 +171,7 @@ app.model = function (Model, config) {
* });
* ```
*
* **2. Use `app.model` to access a model by name.
* 2. Use `app.model` to access a model by name.
* `app.model` has properties for all defined models.
*
* The following example illustrates accessing the `Product` and `CustomerReceipt` models
@ -216,14 +207,13 @@ app.model = function (Model, config) {
app.models = function () {
return this._models || (this._models = []);
}
};
/**
* Define a DataSource.
*
* @param {String} name The data source name
* @param {Object} config The data source config
* @param {DataSource} The registered data source
*/
app.dataSource = function (name, config) {
var ds = dataSourcesFromConfig(config, this.connectors);
@ -231,7 +221,7 @@ app.dataSource = function (name, config) {
this.dataSources[classify(name)] =
this.dataSources[camelize(name)] = ds;
return ds;
}
};
/**
* Register a connector.
@ -264,30 +254,30 @@ app.remoteObjects = function () {
this.remotes().classes().forEach(function(sharedClass) {
result[sharedClass.name] = sharedClass.ctor;
});
return result;
}
};
/*!
* Get a handler of the specified type from the handler cache.
* @triggers `mounted` events on shared class constructors (models)
* @triggers `mounted` events on shared class constructors (models)
*/
app.handler = function (type) {
app.handler = function (type, options) {
var handlers = this._handlers || (this._handlers = {});
if(handlers[type]) {
return handlers[type];
}
var remotes = this.remotes();
var handler = this._handlers[type] = remotes.handler(type);
var handler = this._handlers[type] = remotes.handler(type, options);
remotes.classes().forEach(function(sharedClass) {
sharedClass.ctor.emit('mounted', app, sharedClass, remotes);
});
return handler;
}
};
/**
* An object to store dataSource instances.
@ -352,7 +342,7 @@ app.enableAuth = function() {
app.boot = function(options) {
throw new Error(
'`app.boot` was removed, use the new module loopback-boot instead');
}
};
function classify(str) {
return stringUtils.classify(str);
@ -365,7 +355,7 @@ function camelize(str) {
function dataSourcesFromConfig(config, connectorRegistry) {
var connectorPath;
assert(typeof config === 'object',
assert(typeof config === 'object',
'cannont create data source without config object');
if(typeof config.connector === 'string') {
@ -416,15 +406,15 @@ function clearHandlerCache(app) {
* When there are no parameters or there is only one callback parameter,
* the server will listen on `app.get('host')` and `app.get('port')`.
*
* For example, to listen on host/port configured in app config:
* ```js
* // listen on host/port configured in app config
* app.listen();
* ```
*
* Otherwise all arguments are forwarded to `http.Server.listen`.
*
* For example, to listen on the specified port and all hosts, and ignore app config.
* ```js
* // listen on the specified port and all hosts, ignore app config
* app.listen(80);
* ```
*
@ -433,7 +423,7 @@ function clearHandlerCache(app) {
* This way the port param contains always the real port number, even when
* listen was called with port number 0.
*
* @param {Function} cb If specified, the callback is added as a listener
* @param {Function} [cb] If specified, the callback is added as a listener
* for the server's "listening" event.
* @returns {http.Server} A node `http.Server` with this application configured
* as the request handler.
@ -445,17 +435,31 @@ app.listen = function(cb) {
server.on('listening', function() {
self.set('port', this.address().port);
var listeningOnAll = false;
var host = self.get('host');
if (!host) {
listeningOnAll = true;
host = this.address().address;
self.set('host', host);
} else if (host === '0.0.0.0' || host === '::') {
listeningOnAll = true;
}
if (!self.get('url')) {
// A better default host would be `0.0.0.0`,
// but such URL is not supported by Windows
var host = self.get('host') || '127.0.0.1';
if (process.platform === 'win32' && listeningOnAll) {
// Windows browsers don't support `0.0.0.0` host in the URL
// We are replacing it with localhost to build a URL
// that can be copied and pasted into the browser.
host = 'localhost';
}
var url = 'http://' + host + ':' + self.get('port') + '/';
self.set('url', url);
}
});
var useAppConfig =
arguments.length == 0 ||
arguments.length === 0 ||
(arguments.length == 1 && typeof arguments[0] == 'function');
if (useAppConfig) {
@ -465,4 +469,4 @@ app.listen = function(cb) {
}
return server;
}
};

68
lib/builtin-models.js Normal file
View File

@ -0,0 +1,68 @@
module.exports = function(loopback) {
// NOTE(bajtos) we must use static require() due to browserify limitations
loopback.Email = createModel(
require('../common/models/email.json'),
require('../common/models/email.js'));
loopback.Application = createModel(
require('../common/models/application.json'),
require('../common/models/application.js'));
loopback.AccessToken = createModel(
require('../common/models/access-token.json'),
require('../common/models/access-token.js'));
loopback.RoleMapping = createModel(
require('../common/models/role-mapping.json'),
require('../common/models/role-mapping.js'));
loopback.Role = createModel(
require('../common/models/role.json'),
require('../common/models/role.js'));
loopback.ACL = createModel(
require('../common/models/acl.json'),
require('../common/models/acl.js'));
loopback.Scope = createModel(
require('../common/models/scope.json'),
require('../common/models/scope.js'));
loopback.User = createModel(
require('../common/models/user.json'),
require('../common/models/user.js'));
loopback.Change = createModel(
require('../common/models/change.json'),
require('../common/models/change.js'));
loopback.Checkpoint = createModel(
require('../common/models/checkpoint.json'),
require('../common/models/checkpoint.js'));
/*!
* Automatically attach these models to dataSources
*/
var dataSourceTypes = {
DB: 'db',
MAIL: 'mail'
};
loopback.Email.autoAttach = dataSourceTypes.MAIL;
loopback.PersistedModel.autoAttach = dataSourceTypes.DB;
loopback.User.autoAttach = dataSourceTypes.DB;
loopback.AccessToken.autoAttach = dataSourceTypes.DB;
loopback.Role.autoAttach = dataSourceTypes.DB;
loopback.RoleMapping.autoAttach = dataSourceTypes.DB;
loopback.ACL.autoAttach = dataSourceTypes.DB;
loopback.Scope.autoAttach = dataSourceTypes.DB;
loopback.Application.autoAttach = dataSourceTypes.DB;
function createModel(definitionJson, customizeFn) {
var Model = loopback.createModel(definitionJson);
customizeFn(Model);
return Model;
}
};

View File

@ -7,13 +7,13 @@ module.exports = Connector;
/**
* Module dependencies.
*/
var EventEmitter = require('events').EventEmitter
, debug = require('debug')('connector')
, util = require('util')
, inherits = util.inherits
, assert = require('assert');
/**
* Create a new `Connector` with the given `options`.
*
@ -24,7 +24,7 @@ var EventEmitter = require('events').EventEmitter
function Connector(options) {
EventEmitter.apply(this, arguments);
this.options = options;
debug('created with options', options);
}
@ -43,12 +43,12 @@ Connector._createJDBAdapter = function (jdbModule) {
jdbModule.initialize(fauxSchema, function () {
// connected
});
}
};
/*!
* Add default crud operations from a JugglingDB adapter.
*/
Connector.prototype._addCrudOperationsFromJDBAdapter = function (connector) {
}
};

View File

@ -5,7 +5,7 @@
var mailer = require('nodemailer')
, assert = require('assert')
, debug = require('debug')('loopback:connector:mail')
, loopback = require('../loopback')
, loopback = require('../loopback');
/**
* Export the MailConnector class.
@ -44,7 +44,7 @@ function MailConnector(settings) {
MailConnector.initialize = function(dataSource, callback) {
dataSource.connector = new MailConnector(dataSource.settings);
callback();
}
};
MailConnector.prototype.DataAccessObject = Mailer;
@ -86,7 +86,7 @@ MailConnector.prototype.setupTransport = function(setting) {
connector.transportsIndex[setting.type] = transport;
connector.transports.push(transport);
}
};
function Mailer() {
@ -101,7 +101,7 @@ function Mailer() {
MailConnector.prototype.transportForName = function(name) {
return this.transportsIndex[name];
}
};
/**
* Get the default transport.
@ -111,7 +111,7 @@ MailConnector.prototype.transportForName = function(name) {
MailConnector.prototype.defaultTransport = function() {
return this.transports[0] || this.stubTransport;
}
};
/**
* Send an email with the given `options`.
@ -166,7 +166,7 @@ Mailer.send = function (options, fn) {
fn(null, options);
});
}
}
};
/**
* Send an email instance using `modelInstance.send()`.
@ -174,7 +174,7 @@ Mailer.send = function (options, fn) {
Mailer.prototype.send = function (fn) {
this.constructor.send(this, fn);
}
};
/**
* Access the node mailer object.

View File

@ -7,14 +7,14 @@ module.exports = Memory;
/**
* Module dependencies.
*/
var Connector = require('./base-connector')
, debug = require('debug')('memory')
, util = require('util')
, inherits = util.inherits
, assert = require('assert')
, JdbMemory = require('loopback-datasource-juggler/lib/connectors/memory');
/**
* Create a new `Memory` connector with the given `options`.
*

View File

@ -20,20 +20,20 @@ function createMiddlewareNotInstalled(memberName, moduleName) {
}
var middlewareModules = {
"compress": "compression",
"timeout": "connect-timeout",
"cookieParser": "cookie-parser",
"cookieSession": "cookie-session",
"csrf": "csurf",
"errorHandler": "errorhandler",
"session": "express-session",
"methodOverride": "method-override",
"logger": "morgan",
"responseTime": "response-time",
"favicon": "serve-favicon",
"directory": "serve-index",
// "static": "serve-static",
"vhost": "vhost"
'compress': 'compression',
'timeout': 'connect-timeout',
'cookieParser': 'cookie-parser',
'cookieSession': 'cookie-session',
'csrf': 'csurf',
'errorHandler': 'errorhandler',
'session': 'express-session',
'methodOverride': 'method-override',
'logger': 'morgan',
'responseTime': 'response-time',
'favicon': 'serve-favicon',
'directory': 'serve-index',
// 'static': 'serve-static',
'vhost': 'vhost'
};
middlewares.bodyParser = safeRequire('body-parser');

View File

@ -11,28 +11,32 @@ var express = require('express')
, assert = require('assert');
/**
* Main entry for LoopBack core module. It provides static properties and
* LoopBack core module. It provides static properties and
* methods to create models and data sources. The module itself is a function
* that creates loopback `app`. For example,
* that creates loopback `app`. For example:
*
* ```js
* var loopback = require('loopback');
* var app = loopback();
* ```
*
* @property {String} version Version of LoopBack framework. Static read-only property.
* @property {String} mime
* @property {Boolean} isBrowser True if running in a browser environment; false otherwise. Static read-only property.
* @property {Boolean} isServer True if running in a server environment; false otherwise. Static read-only property.
* @class loopback
* @header loopback
*/
var loopback = exports = module.exports = createApplication;
/**
/*!
* Framework version.
*/
loopback.version = require('../package.json').version;
/**
/*!
* Expose mime.
*/
@ -165,31 +169,4 @@ loopback.template = function (file) {
* Built in models / services
*/
loopback.Email = require('./models/email');
loopback.User = require('./models/user');
loopback.Application = require('./models/application');
loopback.AccessToken = require('./models/access-token');
loopback.Role = require('./models/role').Role;
loopback.RoleMapping = require('./models/role').RoleMapping;
loopback.ACL = require('./models/acl').ACL;
loopback.Scope = require('./models/acl').Scope;
loopback.Change = require('./models/change');
/*!
* Automatically attach these models to dataSources
*/
var dataSourceTypes = {
DB: 'db',
MAIL: 'mail'
};
loopback.Email.autoAttach = dataSourceTypes.MAIL;
loopback.PersistedModel.autoAttach = dataSourceTypes.DB;
loopback.User.autoAttach = dataSourceTypes.DB;
loopback.AccessToken.autoAttach = dataSourceTypes.DB;
loopback.Role.autoAttach = dataSourceTypes.DB;
loopback.RoleMapping.autoAttach = dataSourceTypes.DB;
loopback.ACL.autoAttach = dataSourceTypes.DB;
loopback.Scope.autoAttach = dataSourceTypes.DB;
loopback.Application.autoAttach = dataSourceTypes.DB;
require('./builtin-models')(loopback);

View File

@ -14,7 +14,7 @@ module.exports = status;
* "uptime": 9.394
* }
* ```
*
*
* @header loopback.status()
*/
function status() {
@ -25,6 +25,6 @@ function status() {
started: started,
uptime: (Date.now() - Number(started)) / 1000
});
}
};
}

View File

@ -11,22 +11,22 @@ var assert = require('assert');
module.exports = token;
/**
/**
* Check for an access token in cookies, headers, and query string parameters.
* This function always checks for the following:
*
*
* - `access_token` (params only)
* - `X-Access-Token` (headers only)
* - `authorization` (headers and cookies)
*
* It checks for these values in cookies, headers, and query string parameters _in addition_ to the items
* specified in the options parameter.
*
*
* **NOTE:** This function only checks for [signed cookies](http://expressjs.com/api.html#req.signedCookies).
*
*
* 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'],
@ -47,13 +47,13 @@ function token(options) {
options = options || {};
var TokenModel = options.model || loopback.AccessToken;
assert(TokenModel, 'loopback.token() middleware requires a AccessToken model');
return function (req, res, next) {
if (req.accessToken !== undefined) return next();
TokenModel.findForRequest(req, options, function(err, token) {
req.accessToken = token || null;
next(err);
});
}
};
}

View File

@ -15,5 +15,5 @@ function urlNotFound() {
var error = new Error('Cannot ' + req.method + ' ' + req.url);
error.status = 404;
next(error);
}
};
}

View File

@ -1,7 +1,7 @@
/*!
* Module Dependencies.
*/
var registry = require('../registry');
var registry = require('./registry');
var assert = require('assert');
var RemoteObjects = require('strong-remoting');
var SharedClass = require('strong-remoting').SharedClass;
@ -9,7 +9,7 @@ var extend = require('util')._extend;
var stringUtils = require('underscore.string');
/**
* The base class for **all models**.
* The base class for **all models**.
*
* **Inheriting from `Model`**
*
@ -18,7 +18,7 @@ var stringUtils = require('underscore.string');
* var options = {...};
* var MyModel = loopback.Model.extend('MyModel', properties, options);
* ```
*
*
* **Options**
*
* - `trackChanges` - If true, changes to the model will be tracked. **Required
@ -27,7 +27,7 @@ var stringUtils = require('underscore.string');
* **Events**
*
* #### Event: `changed`
*
*
* Emitted after a model has been successfully created, saved, or updated.
* Argument: `inst`, model instance, object
*
@ -37,10 +37,10 @@ var stringUtils = require('underscore.string');
* // => model with id 1 has been changed
* });
* ```
*
*
* #### Event: `deleted`
*
* Emitted after an individual model has been deleted.
*
* Emitted after an individual model has been deleted.
* Argument: `id`, model ID (number).
*
* ```js
@ -51,7 +51,7 @@ var stringUtils = require('underscore.string');
* ```
*
* #### Event: `deletedAll`
*
*
* Emitted after an individual model has been deleted.
* Argument: `where` (optional), where filter, JSON object.
*
@ -65,31 +65,31 @@ var stringUtils = require('underscore.string');
* }
* });
* ```
*
*
* #### Event: `attached`
*
*
* Emitted after a `Model` has been attached to an `app`.
*
*
* #### Event: `dataSourceAttached`
*
*
* Emitted after a `Model` has been attached to a `DataSource`.
*
*
* #### Event: set
*
*
* Emitted when model property is set.
* Argument: `inst`, model instance, object
*
*
* ```js
* MyModel.on('set', function(inst) {
* console.log('model with id %s has been changed', inst.id);
* // => model with id 1 has been changed
* });
* ```
*
* @class
*
* @param {Object} data
* @property {String} modelName The name of the model
* @property {DataSource} dataSource
* @property {String} modelName The name of the model. Static property.
* @property {DataSource} dataSource Data source to which the model is connected, if any. Static property.
* @class
*/
var Model = module.exports = registry.modelBuilder.define('Model');
@ -103,11 +103,14 @@ Model.setup = function () {
var options = this.settings;
var typeName = this.modelName;
var remotingOptions = {};
extend(remotingOptions, options.remoting || {});
// create a sharedClass
var sharedClass = ModelCtor.sharedClass = new SharedClass(
ModelCtor.modelName,
ModelCtor,
options.remoting
remotingOptions
);
// setup a remoting type converter for this model
@ -125,7 +128,7 @@ Model.setup = function () {
id = null;
} else if (typeof id === 'function') {
fn = id;
if(typeof data !== 'object') {
id = data;
data = null;
@ -133,7 +136,7 @@ Model.setup = function () {
id = null;
}
}
if(id && data) {
var model = new ModelCtor(data);
model.id = id;
@ -149,14 +152,14 @@ Model.setup = function () {
} else {
err = new Error('could not find a model with id ' + id);
err.statusCode = 404;
fn(err);
}
});
} else {
fn(new Error('must specify an id or data'));
}
}
};
var idDesc = ModelCtor.modelName + ' id';
ModelCtor.sharedCtor.accepts = [
@ -168,7 +171,7 @@ Model.setup = function () {
ModelCtor.sharedCtor.http = [
{path: '/:id'}
];
ModelCtor.sharedCtor.returns = {root: true};
// before remote hook
@ -187,7 +190,7 @@ Model.setup = function () {
});
}
};
// after remote hook
ModelCtor.afterRemote = function (name, fn) {
var self = this;
@ -250,25 +253,25 @@ Model._ACL = function getACL(ACL) {
if(_aclModel) {
return _aclModel;
}
var aclModel = require('./acl').ACL;
var aclModel = registry.getModel('ACL');
_aclModel = registry.getModelByType(aclModel);
return _aclModel;
};
/**
* Check if the given access token can invoke the method
* Check if the given access token can invoke the specified method.
*
* @param {AccessToken} token The access token
* @param {AccessToken} token The access token.
* @param {*} modelId The model ID.
* @param {SharedMethod} sharedMethod The method in question
* @param {Object} ctx The remote invocation context
* @callback {Function} callback The callback function
* @param {String|Error} err The error object
* @param {SharedMethod} sharedMethod The method in question.
* @param {Object} ctx The remote invocation context.
* @callback {Function} callback The callback function.
* @param {String|Error} err The error object.
* @param {Boolean} allowed True if the request is allowed; false otherwise.
*/
Model.checkAccess = function(token, modelId, sharedMethod, ctx, callback) {
var ANONYMOUS = require('./access-token').ANONYMOUS;
var ANONYMOUS = registry.getModel('AccessToken').ANONYMOUS;
token = token || ANONYMOUS;
var aclModel = Model._ACL();
@ -277,7 +280,7 @@ Model.checkAccess = function(token, modelId, sharedMethod, ctx, callback) {
callback = ctx;
ctx = {};
}
aclModel.checkAccessForContext({
accessToken: token,
model: this,
@ -305,7 +308,7 @@ Model._getAccessTypeForMethod = function(method) {
method = {name: method};
}
assert(
typeof method === 'object',
typeof method === 'object',
'method is a required argument and must be a RemoteMethod object'
);
@ -337,7 +340,7 @@ Model._getAccessTypeForMethod = function(method) {
default:
return ACL.EXECUTE;
}
}
};
/**
* Get the `Application` the Model is attached to.
@ -358,17 +361,18 @@ Model.getApp = function(callback) {
callback(null, Model.app);
});
}
}
};
/**
* Enable remote invocation for the method with the given name.
* See [Remote methods and hooks](http://docs.strongloop.com/display/LB/Remote+methods+and+hooks for more information.
* See [Defining remote methods](http://docs.strongloop.com/display/LB/Defining+remote+methods) for more information.
*
* Static method example:
* ```js
* Model.myMethod();
* Model.remoteMethod('myMethod');
* ```
*
*
* @param {String} name The name of the method.
* @param {Object} options The remoting options.
*/
@ -378,7 +382,7 @@ Model.remoteMethod = function(name, options) {
options.isStatic = true;
}
this.sharedClass.defineMethod(name, options);
}
};
/**
* Disable remote invocation for the method with the given name.
@ -391,7 +395,7 @@ Model.remoteMethod = function(name, options) {
Model.disableRemoteMethod = function(name, isStatic) {
this.sharedClass.disableMethod(name, isStatic || false);
}
};
Model.belongsToRemoting = function(relationName, relation, define) {
var modelName = relation.modelTo && relation.modelTo.modelName;
@ -405,7 +409,7 @@ Model.belongsToRemoting = function(relationName, relation, define) {
description: 'Fetches belongsTo relation ' + relationName,
returns: {arg: relationName, type: modelName, root: true}
}, fn);
}
};
Model.hasOneRemoting = function(relationName, relation, define) {
var fn = this.prototype[relationName];
@ -417,22 +421,22 @@ Model.hasOneRemoting = function(relationName, relation, define) {
description: 'Fetches hasOne relation ' + relationName,
returns: {arg: relationName, type: relation.modelTo.modelName, root: true}
}, fn);
}
};
Model.hasManyRemoting = function (relationName, relation, define) {
var pathName = (relation.options.http && relation.options.http.path) || relationName;
var toModelName = relation.modelTo.modelName;
function convertNullToNotFoundError(ctx, cb) {
if (ctx.result !== null) return cb();
var fk = ctx.getArgByName('fk');
var msg = 'Unknown "' + toModelName + '" id "' + fk + '".';
var error = new Error(msg);
error.statusCode = error.status = 404;
cb(error);
}
var findByIdFunc = this.prototype['__findById__' + relationName];
define('__findById__' + relationName, {
isStatic: false,
@ -564,9 +568,9 @@ Model.scopeRemoting = function(scopeName, scope, define) {
http: {verb: 'get', path: '/' + pathName + '/count'},
accepts: {arg: 'where', type: 'object', description: 'Criteria to match model instances'},
description: 'Counts ' + scopeName + ' of ' + this.modelName + '.',
returns: {arg: 'count', type: 'number'}
returns: {arg: 'count', type: 'number'}
});
};
Model.nestRemoting = function(relationName, options, cb) {
@ -575,7 +579,7 @@ Model.nestRemoting = function(relationName, options, cb) {
options = {};
}
options = options || {};
var regExp = /^__([^_]+)__([^_]+)$/;
var relation = this.relations[relationName];
if (relation && relation.modelTo && relation.modelTo.sharedClass) {
@ -583,17 +587,17 @@ Model.nestRemoting = function(relationName, options, cb) {
var sharedClass = this.sharedClass;
var sharedToClass = relation.modelTo.sharedClass;
var toModelName = relation.modelTo.modelName;
var pathName = options.pathName || relation.options.path || relationName;
var paramName = options.paramName || 'nk';
var http = [].concat(sharedToClass.http || [])[0];
if (relation.multiple) {
var httpPath = pathName + '/:' + paramName;
var acceptArgs = [
{
arg: paramName, type: 'any', http: { source: 'path' },
{
arg: paramName, type: 'any', http: { source: 'path' },
description: 'Foreign key for ' + relation.name,
required: true
}
@ -602,7 +606,7 @@ Model.nestRemoting = function(relationName, options, cb) {
var httpPath = pathName;
var acceptArgs = [];
}
// A method should return the method name to use, if it is to be
// included as a nested method - a falsy return value will skip.
var filter = cb || options.filterMethod || function(method, relation) {
@ -611,31 +615,31 @@ Model.nestRemoting = function(relationName, options, cb) {
return '__' + matches[1] + '__' + relation.name + '__' + matches[2];
}
};
sharedToClass.methods().forEach(function(method) {
var methodName;
if (!method.isStatic && (methodName = filter(method, relation))) {
var prefix = relation.multiple ? '__findById__' : '__get__';
var getterName = options.getterName || (prefix + relationName);
var getterFn = relation.modelFrom.prototype[getterName];
if (typeof getterFn !== 'function') {
throw new Error('Invalid remote method: `' + getterName + '`');
}
var nestedFn = relation.modelTo.prototype[method.name];
if (typeof nestedFn !== 'function') {
throw new Error('Invalid remote method: `' + method.name + '`');
}
var opts = {};
opts.accepts = acceptArgs.concat(method.accepts || []);
opts.returns = [].concat(method.returns || []);
opts.description = method.description;
opts.rest = extend({}, method.rest || {});
opts.rest.delegateTo = method.name;
opts.http = [];
var routes = [].concat(method.http || []);
routes.forEach(function(route) {
@ -645,7 +649,7 @@ Model.nestRemoting = function(relationName, options, cb) {
opts.http.push(copy);
}
});
if (relation.multiple) {
sharedClass.defineMethod(methodName, opts, function(fkId) {
var args = Array.prototype.slice.call(arguments, 1);
@ -677,17 +681,17 @@ Model.nestRemoting = function(relationName, options, cb) {
}
}
});
if (options.hooks === false) return; // don't inherit before/after hooks
self.once('mounted', function(app, sc, remotes) {
var listenerTree = extend({}, remotes.listenerTree || {});
listenerTree.before = listenerTree.before || {};
listenerTree.after = listenerTree.after || {};
var beforeListeners = remotes.listenerTree.before[toModelName] || {};
var afterListeners = remotes.listenerTree.after[toModelName] || {};
sharedClass.methods().forEach(function(method) {
var delegateTo = method.rest && method.rest.delegateTo;
if (delegateTo) {
@ -707,12 +711,14 @@ Model.nestRemoting = function(relationName, options, cb) {
}
});
});
} else {
throw new Error('Relation `' + relationName + '` does not exist for model `' + this.modelName + '`');
}
};
Model.ValidationError = require('loopback-datasource-juggler').ValidationError;
// setup the initial model
Model.setup();

View File

@ -1,234 +0,0 @@
/*!
* Module Dependencies.
*/
var loopback = require('../loopback')
, assert = require('assert')
, crypto = require('crypto')
, uid = require('uid2')
, DEFAULT_TTL = 1209600 // 2 weeks in seconds
, DEFAULT_TOKEN_LEN = 64
, Role = require('./role').Role
, ACL = require('./acl').ACL;
/*!
* Default AccessToken properties.
*/
var properties = {
id: {type: String, id: true},
ttl: {type: Number, ttl: true, default: DEFAULT_TTL}, // time to live in seconds
created: {type: Date, default: function() {
return new Date();
}}
};
/**
* Token based authentication and access control.
*
* **Default ACLs**
*
* - DENY EVERYONE `*`
* - ALLOW EVERYONE create
*
* @property {String} id Generated token ID
* @property {Number} ttl Time to live in seconds
* @property {Date} created When the token was created
*
* @class
* @inherits {PersistedModel}
*/
var AccessToken = module.exports =
loopback.PersistedModel.extend('AccessToken', properties, {
acls: [
{
principalType: ACL.ROLE,
principalId: Role.EVERYONE,
permission: 'DENY'
},
{
principalType: ACL.ROLE,
principalId: Role.EVERYONE,
property: 'create',
permission: 'ALLOW'
}
],
relations: {
user: {
type: 'belongsTo',
model: 'User',
foreignKey: 'userId'
}
}
});
/**
* Anonymous Token
*
* ```js
* assert(AccessToken.ANONYMOUS.id === '$anonymous');
* ```
*/
AccessToken.ANONYMOUS = new AccessToken({id: '$anonymous'});
/**
* Create a cryptographically random access token id.
*
* @callback {Function} callback
* @param {Error} err
* @param {String} token
*/
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
* @callback {Function} callback
* @param {Error} err
* @param {AccessToken} token
*/
AccessToken.findForRequest = function(req, options, cb) {
var id = tokenIdForRequest(req, options);
if(id) {
this.findById(id, function(err, token) {
if(err) {
cb(err);
} else if(token) {
token.validate(function(err, isValid) {
if(err) {
cb(err);
} else if(isValid) {
cb(null, token);
} else {
var e = new Error('Invalid Access Token');
e.status = e.statusCode = 401;
cb(e);
}
});
} else {
cb();
}
});
} else {
process.nextTick(function() {
cb();
});
}
}
/**
* Validate the token.
*
* @callback {Function} callback
* @param {Error} err
* @param {Boolean} isValid
*/
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 = params.concat(['access_token']);
headers = headers.concat(['X-Access-Token', 'authorization']);
cookies = cookies.concat(['access_token', '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') {
// Add support for oAuth 2.0 bearer token
// http://tools.ietf.org/html/rfc6750
if (id.indexOf('Bearer ') === 0) {
id = id.substring(7);
// Decode from base64
var buf = new Buffer(id, 'base64');
id = buf.toString('utf8');
}
return id;
}
}
if(req.signedCookies) {
for(i = 0, length = cookies.length; i < length; i++) {
id = req.signedCookies[cookies[i]];
if(typeof id === 'string') {
return id;
}
}
}
return null;
}

View File

@ -1,511 +0,0 @@
/*!
Schema ACL options
Object level permissions, for example, an album owned by a user
Factors to be authorized against:
* model name: Album
* model instance properties: userId of the album, friends, shared
* methods
* app and/or user ids/roles
** loggedIn
** roles
** userId
** appId
** none
** everyone
** relations: owner/friend/granted
Class level permissions, for example, Album
* model name: Album
* methods
URL/Route level permissions
* url pattern
* application id
* ip addresses
* http headers
Map to oAuth 2.0 scopes
*/
var loopback = require('../loopback');
var async = require('async');
var assert = require('assert');
var debug = require('debug')('loopback:security:acl');
var ctx = require('./access-context');
var AccessContext = ctx.AccessContext;
var Principal = ctx.Principal;
var AccessRequest = ctx.AccessRequest;
var role = require('./role');
var Role = role.Role;
/**
* System grants permissions to principals (users/applications, can be grouped
* into roles).
*
* Protected resource: the model data and operations
* (model/property/method/relation/)
*
* For a given principal, such as client application and/or user, is it allowed
* to access (read/write/execute)
* the protected resource?
*/
var ACLSchema = {
model: String, // The name of the model
property: String, // The name of the property, method, scope, or relation
accessType: String,
permission: String,
principalType: String,
principalId: String
};
/**
* A Model for access control meta data.
*
* @header ACL
* @property {String} model Name of the model.
* @property {String} property Name of the property, method, scope, or relation.
* @property {String} accessType Type of access being granted: one of READ, WRITE, or EXECUTE.
* @property {String} permission Type of permission granted. One of:
* - ALARM: Generate an alarm, in a system-dependent way, the access specified in the permissions component of the ACL entry.
* - ALLOW: Explicitly grants access to the resource.
* - AUDIT: Log, in a system-dependent way, the access specified in the permissions component of the ACL entry.
* - DENY: Explicitly denies access to the resource.
* @property {String} principalType Type of the principal; one of: Application, Use, Role.
* @property {String} principalId ID of the principal - such as appId, userId or roleId
* @class
* @inherits Model
*/
var ACL = loopback.PersistedModel.extend('ACL', ACLSchema);
ACL.ALL = AccessContext.ALL;
ACL.DEFAULT = AccessContext.DEFAULT; // Not specified
ACL.ALLOW = AccessContext.ALLOW; // Allow
ACL.ALARM = AccessContext.ALARM; // Warn - send an alarm
ACL.AUDIT = AccessContext.AUDIT; // Audit - record the access
ACL.DENY = AccessContext.DENY; // Deny
ACL.READ = AccessContext.READ; // Read operation
ACL.WRITE = AccessContext.WRITE; // Write operation
ACL.EXECUTE = AccessContext.EXECUTE; // Execute operation
ACL.USER = Principal.USER;
ACL.APP = ACL.APPLICATION = Principal.APPLICATION;
ACL.ROLE = Principal.ROLE;
ACL.SCOPE = Principal.SCOPE;
/**
* Calculate the matching score for the given rule and request
* @param {ACL} rule The ACL entry
* @param {AccessRequest} req The request
* @returns {Number}
*/
ACL.getMatchingScore = function getMatchingScore(rule, req) {
var props = ['model', 'property', 'accessType'];
var score = 0;
for (var i = 0; i < props.length; i++) {
// Shift the score by 4 for each of the properties as the weight
score = score * 4;
var val1 = rule[props[i]] || ACL.ALL;
var val2 = req[props[i]] || ACL.ALL;
var isMatchingMethodName = props[i] === 'property' && req.methodNames.indexOf(val1) !== -1;
if (val1 === val2 || isMatchingMethodName) {
// Exact match
score += 3;
} else if (val1 === ACL.ALL) {
// Wildcard match
score += 2;
} else if (val2 === ACL.ALL) {
// Doesn't match at all
score += 1;
} else {
return -1;
}
}
// Weigh against the principal type into 4 levels
// - user level (explicitly allow/deny a given user)
// - app level (explicitly allow/deny a given app)
// - role level (role based authorization)
// - other
// user > app > role > ...
score = score * 4;
switch(rule.principalType) {
case ACL.USER:
score += 4;
break;
case ACL.APP:
score += 3;
break;
case ACL.ROLE:
score += 2;
break;
default:
score +=1;
}
// Weigh against the roles
// everyone < authenticated/unauthenticated < related < owner < ...
score = score * 8;
if(rule.principalType === ACL.ROLE) {
switch(rule.principalId) {
case Role.OWNER:
score += 4;
break;
case Role.RELATED:
score += 3;
break;
case Role.AUTHENTICATED:
case Role.UNAUTHENTICATED:
score += 2;
break;
case Role.EVERYONE:
score += 1;
break;
default:
score += 5;
}
}
score = score * 4;
score += AccessContext.permissionOrder[rule.permission || ACL.ALLOW] - 1;
return score;
};
/**
* Get matching score for the given `AccessRequest`.
* @param {AccessRequest} req The request
* @returns {Number} score
*/
ACL.prototype.score = function(req) {
return this.constructor.getMatchingScore(this, req);
}
/*!
* Resolve permission from the ACLs
* @param {Object[]) acls The list of ACLs
* @param {Object} req The request
* @returns {AccessRequest} result The effective ACL
*/
ACL.resolvePermission = function resolvePermission(acls, req) {
if(!(req instanceof AccessRequest)) {
req = new AccessRequest(req);
}
// Sort by the matching score in descending order
acls = acls.sort(function (rule1, rule2) {
return ACL.getMatchingScore(rule2, req) - ACL.getMatchingScore(rule1, req);
});
var permission = ACL.DEFAULT;
var score = 0;
for (var i = 0; i < acls.length; i++) {
score = ACL.getMatchingScore(acls[i], req);
if (score < 0) {
// the highest scored ACL did not match
break;
}
if (!req.isWildcard()) {
// We should stop from the first match for non-wildcard
permission = acls[i].permission;
break;
} else {
if(req.exactlyMatches(acls[i])) {
permission = acls[i].permission;
break;
}
// For wildcard match, find the strongest permission
if(AccessContext.permissionOrder[acls[i].permission]
> AccessContext.permissionOrder[permission]) {
permission = acls[i].permission;
}
}
}
if(debug.enabled) {
debug('The following ACLs were searched: ');
acls.forEach(function(acl) {
acl.debug();
debug('with score:', acl.score(req));
});
}
var res = new AccessRequest(req.model, req.property, req.accessType,
permission || ACL.DEFAULT);
return res;
};
/*!
* Get the static ACLs from the model definition
* @param {String} model The model name
* @param {String} property The property/method/relation name
*
* @return {Object[]} An array of ACLs
*/
ACL.getStaticACLs = function getStaticACLs(model, property) {
var modelClass = loopback.findModel(model);
var staticACLs = [];
if (modelClass && modelClass.settings.acls) {
modelClass.settings.acls.forEach(function (acl) {
staticACLs.push(new ACL({
model: model,
property: acl.property || ACL.ALL,
principalType: acl.principalType,
principalId: acl.principalId, // TODO: Should it be a name?
accessType: acl.accessType || ACL.ALL,
permission: acl.permission
}));
});
}
var prop = modelClass &&
(modelClass.definition.properties[property] // regular property
|| (modelClass._scopeMeta && modelClass._scopeMeta[property]) // relation/scope
|| modelClass[property] // static method
|| modelClass.prototype[property]); // prototype method
if (prop && prop.acls) {
prop.acls.forEach(function (acl) {
staticACLs.push(new ACL({
model: modelClass.modelName,
property: property,
principalType: acl.principalType,
principalId: acl.principalId,
accessType: acl.accessType,
permission: acl.permission
}));
});
}
return staticACLs;
};
/**
* Check if the given principal is allowed to access the model/property
* @param {String} principalType The principal type.
* @param {String} principalId The principal ID.
* @param {String} model The model name.
* @param {String} property The property/method/relation name.
* @param {String} accessType The access type.
* @callback {Function} callback Callback function.
* @param {String|Error} err The error object
* @param {AccessRequest} result The access permission
*/
ACL.checkPermission = function checkPermission(principalType, principalId,
model, property, accessType,
callback) {
if(principalId !== null && principalId !== undefined && (typeof principalId !== 'string') ) {
principalId = principalId.toString();
}
property = property || ACL.ALL;
var propertyQuery = (property === ACL.ALL) ? undefined : {inq: [property, ACL.ALL]};
accessType = accessType || ACL.ALL;
var accessTypeQuery = (accessType === ACL.ALL) ? undefined : {inq: [accessType, ACL.ALL]};
var req = new AccessRequest(model, property, accessType);
var acls = this.getStaticACLs(model, property);
var resolved = this.resolvePermission(acls, req);
if(resolved && resolved.permission === ACL.DENY) {
debug('Permission denied by statically resolved permission');
debug(' Resolved Permission: %j', resolved);
process.nextTick(function() {
callback && callback(null, resolved);
});
return;
}
var self = this;
this.find({where: {principalType: principalType, principalId: principalId,
model: model, property: propertyQuery, accessType: accessTypeQuery}},
function (err, dynACLs) {
if (err) {
callback && callback(err);
return;
}
acls = acls.concat(dynACLs);
resolved = self.resolvePermission(acls, req);
if(resolved && resolved.permission === ACL.DEFAULT) {
var modelClass = loopback.findModel(model);
resolved.permission = (modelClass && modelClass.settings.defaultPermission) || ACL.ALLOW;
}
callback && callback(null, resolved);
});
};
ACL.prototype.debug = function() {
if(debug.enabled) {
debug('---ACL---');
debug('model %s', this.model);
debug('property %s', this.property);
debug('principalType %s', this.principalType);
debug('principalId %s', this.principalId);
debug('accessType %s', this.accessType);
debug('permission %s', this.permission);
}
}
/**
* Check if the request has the permission to access.
* @options {Object} context See below.
* @property {Object[]} principals An array of principals.
* @property {String|Model} model The model name or model class.
* @property {*} id The model instance ID.
* @property {String} property The property/method/relation name.
* @property {String} accessType The access type: READE, WRITE, or EXECUTE.
* @param {Function} callback Callback function
*/
ACL.checkAccessForContext = function (context, callback) {
if(!(context instanceof AccessContext)) {
context = new AccessContext(context);
}
var model = context.model;
var property = context.property;
var accessType = context.accessType;
var modelName = context.modelName;
var methodNames = context.methodNames;
var propertyQuery = (property === ACL.ALL) ? undefined : {inq: methodNames.concat([ACL.ALL])};
var accessTypeQuery = (accessType === ACL.ALL) ? undefined : {inq: [accessType, ACL.ALL]};
var req = new AccessRequest(modelName, property, accessType, ACL.DEFAULT, methodNames);
var effectiveACLs = [];
var staticACLs = this.getStaticACLs(model.modelName, property);
var self = this;
var roleModel = loopback.getModelByType(Role);
this.find({where: {model: model.modelName, property: propertyQuery,
accessType: accessTypeQuery}}, function (err, acls) {
if (err) {
callback && callback(err);
return;
}
var inRoleTasks = [];
acls = acls.concat(staticACLs);
acls.forEach(function (acl) {
// Check exact matches
for (var i = 0; i < context.principals.length; i++) {
var p = context.principals[i];
if (p.type === acl.principalType
&& String(p.id) === String(acl.principalId)) {
effectiveACLs.push(acl);
return;
}
}
// Check role matches
if (acl.principalType === ACL.ROLE) {
inRoleTasks.push(function (done) {
roleModel.isInRole(acl.principalId, context,
function (err, inRole) {
if (!err && inRole) {
effectiveACLs.push(acl);
}
done(err, acl);
});
});
}
});
async.parallel(inRoleTasks, function (err, results) {
if(err) {
callback && callback(err, null);
return;
}
var resolved = self.resolvePermission(effectiveACLs, req);
if(resolved && resolved.permission === ACL.DEFAULT) {
resolved.permission = (model && model.settings.defaultPermission) || ACL.ALLOW;
}
debug('---Resolved---');
resolved.debug();
callback && callback(null, resolved);
});
});
};
/**
* Check if the given access token can invoke the method
* @param {AccessToken} token The access token
* @param {String} model The model name
* @param {*} modelId The model id
* @param {String} method The method name
* @callback {Function} callback Callback function
* @param {String|Error} err The error object
* @param {Boolean} allowed is the request allowed
*/
ACL.checkAccessForToken = function (token, model, modelId, method, callback) {
assert(token, 'Access token is required');
var context = new AccessContext({
accessToken: token,
model: model,
property: method,
method: method,
modelId: modelId
});
this.checkAccessForContext(context, function (err, access) {
if (err) {
callback && callback(err);
return;
}
callback && callback(null, access.permission !== ACL.DENY);
});
};
/*!
* Schema for Scope which represents the permissions that are granted to client
* applications by the resource owner
*/
var ScopeSchema = {
name: {type: String, required: true},
description: String
};
/**
* Resource owner grants/delegates permissions to client applications
*
* For a protected resource, does the client application have the authorization
* from the resource owner (user or system)?
*
* Scope has many resource access entries
* @class
*/
var Scope = loopback.createModel('Scope', ScopeSchema);
/**
* Check if the given scope is allowed to access the model/property
* @param {String} scope The scope name
* @param {String} model The model name
* @param {String} property The property/method/relation name
* @param {String} accessType The access type
* @callback {Function} callback
* @param {String|Error} err The error object
* @param {AccessRequest} result The access permission
*/
Scope.checkPermission = function (scope, model, property, accessType, callback) {
this.findOne({where: {name: scope}}, function (err, scope) {
if (err) {
callback && callback(err);
} else {
var aclModel = loopback.getModelByType(ACL);
aclModel.checkPermission(ACL.SCOPE, scope.id, model, property, accessType, callback);
}
});
};
module.exports.ACL = ACL;
module.exports.Scope = Scope;

View File

@ -1,254 +0,0 @@
var loopback = require('../loopback');
var assert = require('assert');
// Authentication schemes
var AuthenticationSchemeSchema = {
scheme: String, // local, facebook, google, twitter, linkedin, github
credential: Object // Scheme-specific credentials
};
// See https://github.com/argon/node-apn/blob/master/doc/apn.markdown
var APNSSettingSchema = {
/**
* production or development mode. It denotes what default APNS servers to be
* used to send notifications
* - true (production mode)
* - push: gateway.push.apple.com:2195
* - feedback: feedback.push.apple.com:2196
* - false (development mode, the default)
* - push: gateway.sandbox.push.apple.com:2195
* - feedback: feedback.sandbox.push.apple.com:2196
*/
production: Boolean,
certData: String, // The certificate data loaded from the cert.pem file
keyData: String, // The key data loaded from the key.pem file
pushOptions: {type: {
gateway: String,
port: Number
}},
feedbackOptions: {type: {
gateway: String,
port: Number,
batchFeedback: Boolean,
interval: Number
}}
};
var GcmSettingsSchema = {
serverApiKey: String
};
// Push notification settings
var PushNotificationSettingSchema = {
apns: APNSSettingSchema,
gcm: GcmSettingsSchema
};
/*!
* Data model for Application
*/
var ApplicationSchema = {
id: {type: String, id: true},
// Basic information
name: {type: String, required: true}, // The name
description: String, // The description
icon: String, // The icon image url
owner: String, // The user id of the developer who registers the application
collaborators: [String], // A list of users ids who have permissions to work on this app
// EMail
email: String, // e-mail address
emailVerified: Boolean, // Is the e-mail verified
// oAuth 2.0 settings
url: String, // The application url
callbackUrls: [String], // oAuth 2.0 code/token callback url
permissions: [String], // A list of permissions required by the application
// Keys
clientKey: String,
javaScriptKey: String,
restApiKey: String,
windowsKey: String,
masterKey: String,
// Push notification
pushSettings: PushNotificationSettingSchema,
// User Authentication
authenticationEnabled: {type: Boolean, default: true},
anonymousAllowed: {type: Boolean, default: true},
authenticationSchemes: [AuthenticationSchemeSchema],
status: {type: String, default: 'sandbox'}, // Status of the application, production/sandbox/disabled
// Timestamps
created: {type: Date, default: Date},
modified: {type: Date, default: Date}
};
/*!
* Application management functions
*/
var crypto = require('crypto');
function generateKey(hmacKey, algorithm, encoding) {
hmacKey = hmacKey || 'loopback';
algorithm = algorithm || 'sha1';
encoding = encoding || 'hex';
var hmac = crypto.createHmac(algorithm, hmacKey);
var buf = crypto.randomBytes(32);
hmac.update(buf);
var key = hmac.digest(encoding);
return key;
}
/**
* Manage client applications and organize their users.
*
* @property {String} id Generated ID.
* @property {String} name Name; required.
* @property {String} description Text description
* @property {String} icon String Icon image URL.
* @property {String} owner User ID of the developer who registers the application.
* @property {String} email E-mail address
* @property {Boolean} emailVerified Whether the e-mail is verified.
* @property {String} url OAuth 2.0 application URL.
* @property {String}[] callbackUrls The OAuth 2.0 code/token callback URL.
* @property {String} status Status of the application; Either `production`, `sandbox` (default), or `disabled`.
* @property {Date} created Date Application object was created. Default: current date.
* @property {Date} modified Date Application object was modified. Default: current date.
*
* @property {Boolean} pushSettings.apns.production Whether to use production Apple Push Notification Service (APNS) servers to send push notifications.
* If true, uses `gateway.push.apple.com:2195` and `feedback.push.apple.com:2196`.
* If false, uses `gateway.sandbox.push.apple.com:2195` and `feedback.sandbox.push.apple.com:2196`
* @property {String} pushSettings.apns.certData The certificate data loaded from the cert.pem file (APNS).
* @property {String} pushSettings.apns.keyData The key data loaded from the key.pem file (APNS).
* @property {String} pushSettings.apns.pushOptions.gateway (APNS).
* @property {Number} pushSettings.apns.pushOptions.port (APNS).
* @property {String} pushSettings.apns.feedbackOptions.gateway (APNS).
* @property {Number} pushSettings.apns.feedbackOptions.port (APNS).
* @property {Boolean} pushSettings.apns.feedbackOptions.batchFeedback (APNS).
* @property {Number} pushSettings.apns.feedbackOptions.interval (APNS).
* @property {String} pushSettings.gcm.serverApiKey: Google Cloud Messaging API key.
*
* @class
* @inherits {Model}
*/
var Application = loopback.PersistedModel.extend('Application', ApplicationSchema);
/*!
* A hook to generate keys before creation
* @param next
*/
Application.beforeCreate = function (next) {
var app = this;
app.created = app.modified = new Date();
app.id = generateKey('id', 'md5');
app.clientKey = generateKey('client');
app.javaScriptKey = generateKey('javaScript');
app.restApiKey = generateKey('restApi');
app.windowsKey = generateKey('windows');
app.masterKey = generateKey('master');
next();
};
/**
* Register a new application
* @param {String} owner Owner's user ID.
* @param {String} name Name of the application
* @param {Object} options Other options
* @param {Function} callback Callback function
*/
Application.register = function (owner, name, options, cb) {
assert(owner, 'owner is required');
assert(name, 'name is required');
if (typeof options === 'function' && !cb) {
cb = options;
options = {};
}
var props = {owner: owner, name: name};
for (var p in options) {
if (!(p in props)) {
props[p] = options[p];
}
}
this.create(props, cb);
};
/**
* Reset keys for the application instance
* @callback {Function} callback
* @param {Error} err
*/
Application.prototype.resetKeys = function (cb) {
this.clientKey = generateKey('client');
this.javaScriptKey = generateKey('javaScript');
this.restApiKey = generateKey('restApi');
this.windowsKey = generateKey('windows');
this.masterKey = generateKey('master');
this.modified = new Date();
this.save(cb);
};
/**
* Reset keys for a given application by the appId
* @param {Any} appId
* @callback {Function} callback
* @param {Error} err
*/
Application.resetKeys = function (appId, cb) {
this.findById(appId, function (err, app) {
if (err) {
cb && cb(err, app);
return;
}
app.resetKeys(cb);
});
};
/**
* Authenticate the application id and key.
*
* `matched` parameter is one of:
* - clientKey
* - javaScriptKey
* - restApiKey
* - windowsKey
* - masterKey
*
* @param {Any} appId
* @param {String} key
* @callback {Function} callback
* @param {Error} err
* @param {String} matched The matching key
*/
Application.authenticate = function (appId, key, cb) {
this.findById(appId, function (err, app) {
if (err || !app) {
cb && cb(err, null);
return;
}
var result = null;
var keyNames = ['clientKey', 'javaScriptKey', 'restApiKey', 'windowsKey', 'masterKey'];
for (var i = 0; i < keyNames.length; i++) {
if (app[keyNames[i]] === key) {
result = {
application: app,
keyType: keyNames[i]
};
break;
}
}
cb && cb(null, result);
});
};
module.exports = Application;

View File

@ -1,646 +0,0 @@
/*!
* Module Dependencies.
*/
var PersistedModel = require('./persisted-model')
, loopback = require('../loopback')
, crypto = require('crypto')
, CJSON = {stringify: require('canonical-json')}
, async = require('async')
, assert = require('assert')
, debug = require('debug')('loopback:change');
/*!
* Properties
*/
var properties = {
id: {type: String, id: true},
rev: {type: String},
prev: {type: String},
checkpoint: {type: Number},
modelName: {type: String},
modelId: {type: String}
};
/*!
* Options
*/
var options = {
trackChanges: false
};
/**
* Change list entry.
*
* @property {String} id Hash of the modelName and id
* @property {String} rev The current model revision
* @property {String} prev The previous model revision
* @property {Number} checkpoint The current checkpoint at time of the change
* @property {String} modelName Model name
* @property {String} modelId Model ID
*
* @class
* @inherits {Model}
*/
var Change = module.exports = PersistedModel.extend('Change', properties, options);
/*!
* Constants
*/
Change.UPDATE = 'update';
Change.CREATE = 'create';
Change.DELETE = 'delete';
Change.UNKNOWN = 'unknown';
/*!
* Conflict Class
*/
Change.Conflict = Conflict;
/*!
* Setup the extended model.
*/
Change.setup = function() {
PersistedModel.setup.call(this);
var Change = this;
Change.getter.id = function() {
var hasModel = this.modelName && this.modelId;
if(!hasModel) return null;
return Change.idForModel(this.modelName, this.modelId);
}
}
Change.setup();
/**
* Track the recent change of the given modelIds.
*
* @param {String} modelName
* @param {Array} modelIds
* @callback {Function} callback
* @param {Error} err
* @param {Array} changes Changes that were tracked
*/
Change.rectifyModelChanges = function(modelName, modelIds, callback) {
var tasks = [];
var Change = this;
modelIds.forEach(function(id) {
tasks.push(function(cb) {
Change.findOrCreateChange(modelName, id, function(err, change) {
if(err) return Change.handleError(err, cb);
change.rectify(cb);
});
});
});
async.parallel(tasks, callback);
}
/**
* Get an identifier for a given model.
*
* @param {String} modelName
* @param {String} modelId
* @return {String}
*/
Change.idForModel = function(modelName, modelId) {
return this.hash([modelName, modelId].join('-'));
}
/**
* Find or create a change for the given model.
*
* @param {String} modelName
* @param {String} modelId
* @callback {Function} callback
* @param {Error} err
* @param {Change} change
* @end
*/
Change.findOrCreateChange = function(modelName, modelId, callback) {
assert(loopback.findModel(modelName), modelName + ' does not exist');
var id = this.idForModel(modelName, modelId);
var Change = this;
this.findById(id, function(err, change) {
if(err) return callback(err);
if(change) {
callback(null, change);
} else {
var ch = new Change({
id: id,
modelName: modelName,
modelId: modelId
});
ch.debug('creating change');
ch.save(callback);
}
});
}
/**
* Update (or create) the change with the current revision.
*
* @callback {Function} callback
* @param {Error} err
* @param {Change} change
*/
Change.prototype.rectify = function(cb) {
var change = this;
var tasks = [
updateRevision,
updateCheckpoint
];
var currentRev = this.rev;
change.debug('rectify change');
cb = cb || function(err) {
if(err) throw new Error(err);
}
async.parallel(tasks, function(err) {
if(err) return cb(err);
if(change.prev === Change.UNKNOWN) {
// this occurs when a record of a change doesn't exist
// and its current revision is null (not found)
change.remove(cb);
} else {
change.save(cb);
}
});
function updateRevision(cb) {
// get the current revision
change.currentRevision(function(err, rev) {
if(err) return Change.handleError(err, cb);
if(rev) {
// avoid setting rev and prev to the same value
if(currentRev !== rev) {
change.rev = rev;
change.prev = currentRev;
} else {
change.debug('rev and prev are equal (not updating rev)');
}
} else {
change.rev = null;
if(currentRev) {
change.prev = currentRev;
} else if(!change.prev) {
change.debug('ERROR - could not determing prev');
change.prev = Change.UNKNOWN;
}
}
change.debug('updated revision (was ' + currentRev + ')');
cb();
});
}
function updateCheckpoint(cb) {
change.constructor.getCheckpointModel().current(function(err, checkpoint) {
if(err) return Change.handleError(err);
change.checkpoint = checkpoint;
cb();
});
}
}
/**
* Get a change's current revision based on current data.
* @callback {Function} callback
* @param {Error} err
* @param {String} rev The current revision
*/
Change.prototype.currentRevision = function(cb) {
var model = this.getModelCtor();
var id = this.getModelId();
model.findById(id, function(err, inst) {
if(err) return Change.handleError(err, cb);
if(inst) {
cb(null, Change.revisionForInst(inst));
} else {
cb(null, null);
}
});
}
/**
* Create a hash of the given `string` with the `options.hashAlgorithm`.
* **Default: `sha1`**
*
* @param {String} str The string to be hashed
* @return {String} The hashed string
*/
Change.hash = function(str) {
return crypto
.createHash(Change.settings.hashAlgorithm || 'sha1')
.update(str)
.digest('hex');
}
/**
* Get the revision string for the given object
* @param {Object} inst The data to get the revision string for
* @return {String} The revision string
*/
Change.revisionForInst = function(inst) {
return this.hash(CJSON.stringify(inst));
}
/**
* Get a change's type. Returns one of:
*
* - `Change.UPDATE`
* - `Change.CREATE`
* - `Change.DELETE`
* - `Change.UNKNOWN`
*
* @return {String} the type of change
*/
Change.prototype.type = function() {
if(this.rev && this.prev) {
return Change.UPDATE;
}
if(this.rev && !this.prev) {
return Change.CREATE;
}
if(!this.rev && this.prev) {
return Change.DELETE;
}
return Change.UNKNOWN;
}
/**
* Compare two changes.
* @param {Change} change
* @return {Boolean}
*/
Change.prototype.equals = function(change) {
if(!change) return false;
var thisRev = this.rev || null;
var thatRev = change.rev || null;
return thisRev === thatRev;
}
/**
* Does this change conflict with the given change.
* @param {Change} change
* @return {Boolean}
*/
Change.prototype.conflictsWith = function(change) {
if(!change) return false;
if(this.equals(change)) return false;
if(Change.bothDeleted(this, change)) return false;
if(this.isBasedOn(change)) return false;
return true;
}
/**
* Are both changes deletes?
* @param {Change} a
* @param {Change} b
* @return {Boolean}
*/
Change.bothDeleted = function(a, b) {
return a.type() === Change.DELETE
&& b.type() === Change.DELETE;
}
/**
* Determine if the change is based on the given change.
* @param {Change} change
* @return {Boolean}
*/
Change.prototype.isBasedOn = function(change) {
return this.prev === change.rev;
}
/**
* Determine the differences for a given model since a given checkpoint.
*
* The callback will contain an error or `result`.
*
* **result**
*
* ```js
* {
* deltas: Array,
* conflicts: Array
* }
* ```
*
* **deltas**
*
* An array of changes that differ from `remoteChanges`.
*
* **conflicts**
*
* An array of changes that conflict with `remoteChanges`.
*
* @param {String} modelName
* @param {Number} since Compare changes after this checkpoint
* @param {Change[]} remoteChanges A set of changes to compare
* @callback {Function} callback
* @param {Error} err
* @param {Object} result See above.
*/
Change.diff = function(modelName, since, remoteChanges, callback) {
var remoteChangeIndex = {};
var modelIds = [];
remoteChanges.forEach(function(ch) {
modelIds.push(ch.modelId);
remoteChangeIndex[ch.modelId] = new Change(ch);
});
// normalize `since`
since = Number(since) || 0;
this.find({
where: {
modelName: modelName,
modelId: {inq: modelIds},
checkpoint: {gte: since}
}
}, function(err, localChanges) {
if(err) return callback(err);
var deltas = [];
var conflicts = [];
var localModelIds = [];
localChanges.forEach(function(localChange) {
localChange = new Change(localChange);
localModelIds.push(localChange.modelId);
var remoteChange = remoteChangeIndex[localChange.modelId];
if(remoteChange && !localChange.equals(remoteChange)) {
if(remoteChange.conflictsWith(localChange)) {
remoteChange.debug('remote conflict');
localChange.debug('local conflict');
conflicts.push(localChange);
} else {
remoteChange.debug('remote delta');
deltas.push(remoteChange);
}
}
});
modelIds.forEach(function(id) {
if(localModelIds.indexOf(id) === -1) {
deltas.push(remoteChangeIndex[id]);
}
});
callback(null, {
deltas: deltas,
conflicts: conflicts
});
});
}
/**
* Correct all change list entries.
* @param {Function} callback
*/
Change.rectifyAll = function(cb) {
debug('rectify all');
var Change = this;
// this should be optimized
this.find(function(err, changes) {
if(err) return cb(err);
changes.forEach(function(change) {
change = new Change(change);
change.rectify();
});
});
}
/**
* Get the checkpoint model.
* @return {Checkpoint}
*/
Change.getCheckpointModel = function() {
var checkpointModel = this.Checkpoint;
if(checkpointModel) return checkpointModel;
this.checkpoint = checkpointModel = require('./checkpoint').extend('checkpoint');
assert(this.dataSource, 'Cannot getCheckpointModel(): ' + this.modelName
+ ' is not attached to a dataSource');
checkpointModel.attachTo(this.dataSource);
return checkpointModel;
}
Change.handleError = function(err) {
if(!this.settings.ignoreErrors) {
throw err;
}
}
Change.prototype.debug = function() {
if(debug.enabled) {
var args = Array.prototype.slice.call(arguments);
debug.apply(this, args);
debug('\tid', this.id);
debug('\trev', this.rev);
debug('\tprev', this.prev);
debug('\tmodelName', this.modelName);
debug('\tmodelId', this.modelId);
debug('\ttype', this.type());
}
}
/**
* Get the `Model` class for `change.modelName`.
* @return {Model}
*/
Change.prototype.getModelCtor = function() {
return this.constructor.settings.trackModel;
}
Change.prototype.getModelId = function() {
// TODO(ritch) get rid of the need to create an instance
var Model = this.getModelCtor();
var id = this.modelId;
var m = new Model();
m.setId(id);
return m.getId();
}
Change.prototype.getModel = function(callback) {
var Model = this.constructor.settings.trackModel;
var id = this.getModelId();
Model.findById(id, callback);
}
/**
* When two changes conflict a conflict is created.
*
* **Note: call `conflict.fetch()` to get the `target` and `source` models.
*
* @param {*} modelId
* @param {PersistedModel} SourceModel
* @param {PersistedModel} TargetModel
* @property {ModelClass} source The source model instance
* @property {ModelClass} target The target model instance
*/
function Conflict(modelId, SourceModel, TargetModel) {
this.SourceModel = SourceModel;
this.TargetModel = TargetModel;
this.SourceChange = SourceModel.getChangeModel();
this.TargetChange = TargetModel.getChangeModel();
this.modelId = modelId;
}
/**
* Fetch the conflicting models.
*
* @callback {Function} callback
* @param {Error} err
* @param {PersistedModel} source
* @param {PersistedModel} target
*/
Conflict.prototype.models = function(cb) {
var conflict = this;
var SourceModel = this.SourceModel;
var TargetModel = this.TargetModel;
var source;
var target;
async.parallel([
getSourceModel,
getTargetModel
], done);
function getSourceModel(cb) {
SourceModel.findById(conflict.modelId, function(err, model) {
if(err) return cb(err);
source = model;
cb();
});
}
function getTargetModel(cb) {
TargetModel.findById(conflict.modelId, function(err, model) {
if(err) return cb(err);
target = model;
cb();
});
}
function done(err) {
if(err) return cb(err);
cb(null, source, target);
}
}
/**
* Get the conflicting changes.
*
* @callback {Function} callback
* @param {Error} err
* @param {Change} sourceChange
* @param {Change} targetChange
*/
Conflict.prototype.changes = function(cb) {
var conflict = this;
var sourceChange;
var targetChange;
async.parallel([
getSourceChange,
getTargetChange
], done);
function getSourceChange(cb) {
conflict.SourceChange.findOne({where: {
modelId: conflict.modelId
}}, function(err, change) {
if(err) return cb(err);
sourceChange = change;
cb();
});
}
function getTargetChange(cb) {
conflict.TargetChange.findOne({where: {
modelId: conflict.modelId
}}, function(err, change) {
if(err) return cb(err);
targetChange = change;
cb();
});
}
function done(err) {
if(err) return cb(err);
cb(null, sourceChange, targetChange);
}
}
/**
* Resolve the conflict.
*
* @callback {Function} callback
* @param {Error} err
*/
Conflict.prototype.resolve = function(cb) {
var conflict = this;
conflict.changes(function(err, sourceChange, targetChange) {
if(err) return cb(err);
sourceChange.prev = targetChange.rev;
sourceChange.save(cb);
});
}
/**
* Determine the conflict type.
*
* Possible results are
*
* - `Change.UPDATE`: Source and target models were updated.
* - `Change.DELETE`: Source and or target model was deleted.
* - `Change.UNKNOWN`: the conflict type is uknown or due to an error.
*
* @callback {Function} callback
* @param {Error} err
* @param {String} type The conflict type.
*/
Conflict.prototype.type = function(cb) {
var conflict = this;
this.changes(function(err, sourceChange, targetChange) {
if(err) return cb(err);
var sourceChangeType = sourceChange.type();
var targetChangeType = targetChange.type();
if(sourceChangeType === Change.UPDATE && targetChangeType === Change.UPDATE) {
return cb(null, Change.UPDATE);
}
if(sourceChangeType === Change.DELETE || targetChangeType === Change.DELETE) {
return cb(null, Change.DELETE);
}
return cb(null, Change.UNKNOWN);
});
}

View File

@ -1,77 +0,0 @@
/**
* Module Dependencies.
*/
var PersistedModel = require('../loopback').PersistedModel
, loopback = require('../loopback')
, assert = require('assert');
/**
* Properties
*/
var properties = {
seq: {type: Number},
time: {type: Date, default: Date},
sourceId: {type: String}
};
/**
* Options
*/
var options = {
};
/**
* Checkpoint list entry.
*
* @property id {Number} the sequencial identifier of a checkpoint
* @property time {Number} the time when the checkpoint was created
* @property sourceId {String} the source identifier
*
* @class
* @inherits {PersistedModel}
*/
var Checkpoint = module.exports = PersistedModel.extend('Checkpoint', properties, options);
/**
* Get the current checkpoint id
* @callback {Function} callback
* @param {Error} err
* @param {Number} checkpointId The current checkpoint id
*/
Checkpoint.current = function(cb) {
var Checkpoint = this;
this.find({
limit: 1,
order: 'seq DESC'
}, function(err, checkpoints) {
if(err) return cb(err);
var checkpoint = checkpoints[0];
if(checkpoint) {
cb(null, checkpoint.seq);
} else {
Checkpoint.create({seq: 0}, function(err, checkpoint) {
if(err) return cb(err);
cb(null, checkpoint.seq);
});
}
});
}
Checkpoint.beforeSave = function(next, model) {
if(!model.getId() && model.seq === undefined) {
model.constructor.current(function(err, seq) {
if(err) return next(err);
model.seq = seq + 1;
next();
});
} else {
next();
}
}

View File

@ -1,57 +0,0 @@
/*!
* Module Dependencies.
*/
var Model = require('../loopback').Model
, loopback = require('../loopback');
var properties = {
to: {type: String, required: true},
from: {type: String, required: true},
subject: {type: String, required: true},
text: {type: String},
html: {type: String}
};
/**
* @property {String} to Email addressee. Required.
* @property {String} from Email sender address. Required.
* @property {String} subject Email subject string. Required.
* @property {String} text Text body of email.
* @property {String} html HTML body of email.
*
* @class
* @inherits {Model}
*/
var Email = module.exports = Model.extend('Email', properties);
/**
* Send an email with the given `options`.
*
* Example Options:
*
* ```js
* {
* 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.
*
* @options {Object} options See below
* @prop {String} from Senders's email address
* @prop {String} to List of one or more recipient email addresses (comma-delimited)
* @prop {String} subject Subject line
* @prop {String} text Body text
* @prop {String} html Body HTML (optional)
* @param {Function} callback Called after the e-mail is sent or the sending failed
*/
Email.prototype.send = function() {
throw new Error('You must connect the Email Model to a Mail connector');
}

View File

@ -1,12 +0,0 @@
exports.Model = require('./model');
exports.Email = require('./email');
exports.User = require('./user');
exports.AccessToken = require('./access-token');
exports.Application = require('./application');
exports.ACL = require('./acl');
exports.Role = require('./role');

View File

@ -1,488 +0,0 @@
var loopback = require('../loopback');
var debug = require('debug')('loopback:security:role');
var assert = require('assert');
var async = require('async');
var AccessContext = require('./access-context').AccessContext;
// Role model
var RoleSchema = {
id: {type: String, id: true, generated: true}, // Id
name: {type: String, required: true}, // The name of a role
description: String, // Description
// Timestamps
created: {type: Date, default: Date},
modified: {type: Date, default: Date}
};
/*!
* Map principals to roles
*/
var RoleMappingSchema = {
id: {type: String, id: true, generated: true}, // Id
// roleId: String, // The role id, to be injected by the belongsTo relation
principalType: String, // The principal type, such as user, application, or role
principalId: String // The principal id
};
/**
* The `RoleMapping` model extends from the built in `loopback.Model` type.
*
* @class
* @property {String} id Generated ID.
* @property {String} name Name of the role.
* @property {String} Description Text description.
* @inherits {Model}
*/
var RoleMapping = loopback.createModel('RoleMapping', RoleMappingSchema, {
relations: {
role: {
type: 'belongsTo',
model: 'Role',
foreignKey: 'roleId'
}
}
});
// Principal types
RoleMapping.USER = 'USER';
RoleMapping.APP = RoleMapping.APPLICATION = 'APP';
RoleMapping.ROLE = 'ROLE';
/**
* Get the application principal
* @callback {Function} callback
* @param {Error} err
* @param {Application} application
*/
RoleMapping.prototype.application = function (callback) {
if (this.principalType === RoleMapping.APPLICATION) {
var applicationModel = this.constructor.Application
|| loopback.getModelByType(loopback.Application);
applicationModel.findById(this.principalId, callback);
} else {
process.nextTick(function () {
callback && callback(null, null);
});
}
};
/**
* Get the user principal
* @callback {Function} callback
* @param {Error} err
* @param {User} user
*/
RoleMapping.prototype.user = function (callback) {
if (this.principalType === RoleMapping.USER) {
var userModel = this.constructor.User
|| loopback.getModelByType(loopback.User);
userModel.findById(this.principalId, callback);
} else {
process.nextTick(function () {
callback && callback(null, null);
});
}
};
/**
* Get the child role principal
* @callback {Function} callback
* @param {Error} err
* @param {User} childUser
*/
RoleMapping.prototype.childRole = function (callback) {
if (this.principalType === RoleMapping.ROLE) {
var roleModel = this.constructor.Role || loopback.getModelByType(Role);
roleModel.findById(this.principalId, callback);
} else {
process.nextTick(function () {
callback && callback(null, null);
});
}
};
/**
* The Role Model
* @class
*/
var Role = loopback.createModel('Role', RoleSchema, {
relations: {
principals: {
type: 'hasMany',
model: 'RoleMapping',
foreignKey: 'roleId'
}
}
});
// Set up the connection to users/applications/roles once the model
Role.once('dataSourceAttached', function () {
var roleMappingModel = this.RoleMapping || loopback.getModelByType(RoleMapping);
Role.prototype.users = function (callback) {
roleMappingModel.find({where: {roleId: this.id,
principalType: RoleMapping.USER}}, function (err, mappings) {
if (err) {
callback && callback(err);
return;
}
return mappings.map(function (m) {
return m.principalId;
});
});
};
Role.prototype.applications = function (callback) {
roleMappingModel.find({where: {roleId: this.id,
principalType: RoleMapping.APPLICATION}}, function (err, mappings) {
if (err) {
callback && callback(err);
return;
}
return mappings.map(function (m) {
return m.principalId;
});
});
};
Role.prototype.roles = function (callback) {
roleMappingModel.find({where: {roleId: this.id,
principalType: RoleMapping.ROLE}}, function (err, mappings) {
if (err) {
callback && callback(err);
return;
}
return mappings.map(function (m) {
return m.principalId;
});
});
};
});
// Special roles
Role.OWNER = '$owner'; // owner of the object
Role.RELATED = "$related"; // any User with a relationship to the object
Role.AUTHENTICATED = "$authenticated"; // authenticated user
Role.UNAUTHENTICATED = "$unauthenticated"; // authenticated user
Role.EVERYONE = "$everyone"; // everyone
/**
* Add custom handler for roles
* @param role
* @param resolver The resolver function decides if a principal is in the role
* dynamically
*
* function(role, context, callback)
*/
Role.registerResolver = function(role, resolver) {
if(!Role.resolvers) {
Role.resolvers = {};
}
Role.resolvers[role] = resolver;
};
Role.registerResolver(Role.OWNER, function(role, context, callback) {
if(!context || !context.model || !context.modelId) {
process.nextTick(function() {
callback && callback(null, false);
});
return;
}
var modelClass = context.model;
var modelId = context.modelId;
var userId = context.getUserId();
Role.isOwner(modelClass, modelId, userId, callback);
});
function isUserClass(modelClass) {
return modelClass === loopback.User ||
modelClass.prototype instanceof loopback.User;
}
/*!
* Check if two user ids matches
* @param {*} id1
* @param {*} id2
* @returns {boolean}
*/
function matches(id1, id2) {
if (id1 === undefined || id1 === null || id1 ===''
|| id2 === undefined || id2 === null || id2 === '') {
return false;
}
// The id can be a MongoDB ObjectID
return id1 === id2 || id1.toString() === id2.toString();
}
/**
* Check if a given userId is the owner the model instance
* @param {Function} modelClass The model class
* @param {*} modelId The model id
* @param {*) userId The user id
* @param {Function} callback
*/
Role.isOwner = function isOwner(modelClass, modelId, userId, callback) {
assert(modelClass, 'Model class is required');
debug('isOwner(): %s %s userId: %s', modelClass && modelClass.modelName, modelId, userId);
// No userId is present
if(!userId) {
process.nextTick(function() {
callback(null, false);
});
return;
}
// Is the modelClass User or a subclass of User?
if(isUserClass(modelClass)) {
process.nextTick(function() {
callback(null, matches(modelId, userId));
});
return;
}
modelClass.findById(modelId, function(err, inst) {
if(err || !inst) {
debug('Model not found for id %j', modelId);
callback && callback(err, false);
return;
}
debug('Model found: %j', inst);
var ownerId = inst.userId || inst.owner;
if(ownerId) {
callback && callback(null, matches(ownerId, userId));
return;
} else {
// Try to follow belongsTo
for(var r in modelClass.relations) {
var rel = modelClass.relations[r];
if(rel.type === 'belongsTo' && isUserClass(rel.modelTo)) {
debug('Checking relation %s to %s: %j', r, rel.modelTo.modelName, rel);
inst[r](function(err, user) {
if(!err && user) {
debug('User found: %j', user.id);
callback && callback(null, matches(user.id, userId));
} else {
callback && callback(err, false);
}
});
return;
}
}
debug('No matching belongsTo relation found for model %j and user: %j', modelId, userId);
callback && callback(null, false);
}
});
};
Role.registerResolver(Role.AUTHENTICATED, function(role, context, callback) {
if(!context) {
process.nextTick(function() {
callback && callback(null, false);
});
return;
}
Role.isAuthenticated(context, callback);
});
/**
* Check if the user id is authenticated
* @param {Object} context The security context
* @callback {Function} callback
* @param {Error} err
* @param {Boolean} isAuthenticated
*/
Role.isAuthenticated = function isAuthenticated(context, callback) {
process.nextTick(function() {
callback && callback(null, context.isAuthenticated());
});
};
Role.registerResolver(Role.UNAUTHENTICATED, function(role, context, callback) {
process.nextTick(function() {
callback && callback(null, !context || !context.isAuthenticated());
});
});
Role.registerResolver(Role.EVERYONE, function (role, context, callback) {
process.nextTick(function () {
callback && callback(null, true); // Always true
});
});
/**
* Check if a given principal is in the role
*
* @param {String} role The role name
* @param {Object} context The context object
* @callback {Function} callback
* @param {Error} err
* @param {Boolean} isInRole
*/
Role.isInRole = function (role, context, callback) {
if (!(context instanceof AccessContext)) {
context = new AccessContext(context);
}
debug('isInRole(): %s', role);
context.debug();
var resolver = Role.resolvers[role];
if (resolver) {
debug('Custom resolver found for role %s', role);
resolver(role, context, callback);
return;
}
if (context.principals.length === 0) {
debug('isInRole() returns: false');
process.nextTick(function () {
callback && callback(null, false);
});
return;
}
var inRole = context.principals.some(function (p) {
var principalType = p.type || undefined;
var principalId = p.id || undefined;
// Check if it's the same role
return principalType === RoleMapping.ROLE && principalId === role;
});
if (inRole) {
debug('isInRole() returns: %j', inRole);
process.nextTick(function () {
callback && callback(null, true);
});
return;
}
var roleMappingModel = this.RoleMapping || loopback.getModelByType(RoleMapping);
this.findOne({where: {name: role}}, function (err, result) {
if (err) {
callback && callback(err);
return;
}
if (!result) {
callback && callback(null, false);
return;
}
debug('Role found: %j', result);
// Iterate through the list of principals
async.some(context.principals, function (p, done) {
var principalType = p.type || undefined;
var principalId = p.id || undefined;
var roleId = result.id.toString();
if(principalId !== null && principalId !== undefined && (typeof principalId !== 'string') ) {
principalId = principalId.toString();
}
if (principalType && principalId) {
roleMappingModel.findOne({where: {roleId: roleId,
principalType: principalType, principalId: principalId}},
function (err, result) {
debug('Role mapping found: %j', result);
done(!err && result); // The only arg is the result
});
} else {
process.nextTick(function () {
done(false);
});
}
}, function (inRole) {
debug('isInRole() returns: %j', inRole);
callback && callback(null, inRole);
});
});
};
/**
* List roles for a given principal
* @param {Object} context The security context
* @param {Function} callback
*
* @callback {Function} callback
* @param err
* @param {String[]} An array of role ids
*/
Role.getRoles = function (context, callback) {
if(!(context instanceof AccessContext)) {
context = new AccessContext(context);
}
var roles = [];
var addRole = function (role) {
if (role && roles.indexOf(role) === -1) {
roles.push(role);
}
};
var self = this;
// Check against the smart roles
var inRoleTasks = [];
Object.keys(Role.resolvers).forEach(function (role) {
inRoleTasks.push(function (done) {
self.isInRole(role, context, function (err, inRole) {
if(debug.enabled) {
debug('In role %j: %j', role, inRole);
}
if (!err && inRole) {
addRole(role);
done();
} else {
done(err, null);
}
});
});
});
var roleMappingModel = this.RoleMapping || loopback.getModelByType(RoleMapping);
context.principals.forEach(function (p) {
// Check against the role mappings
var principalType = p.type || undefined;
var principalId = p.id || undefined;
// Add the role itself
if (principalType === RoleMapping.ROLE && principalId) {
addRole(principalId);
}
if (principalType && principalId) {
// Please find() treat undefined matches all values
inRoleTasks.push(function (done) {
roleMappingModel.find({where: {principalType: principalType,
principalId: principalId}}, function (err, mappings) {
debug('Role mappings found: %s %j', err, mappings);
if (err) {
done && done(err);
return;
}
mappings.forEach(function (m) {
addRole(m.roleId);
});
done && done();
});
});
}
});
async.parallel(inRoleTasks, function (err, results) {
debug('getRoles() returns: %j %j', err, roles);
callback && callback(err, roles);
});
};
module.exports = {
Role: Role,
RoleMapping: RoleMapping
};

View File

@ -3,7 +3,8 @@
*/
var Model = require('./model');
var runtime = require('../runtime');
var registry = require('./registry');
var runtime = require('./runtime');
var assert = require('assert');
var async = require('async');
@ -21,8 +22,6 @@ var async = require('async');
* ```
*
* @class PersistedModel
* @param {Object} data
* @param {Number} data.id The default id property
*/
var PersistedModel = module.exports = Model.extend('PersistedModel');
@ -44,9 +43,9 @@ PersistedModel.setup = function setupPersistedModel() {
PersistedModel.enableChangeTracking();
});
}
PersistedModel.setupRemoting();
}
};
/*!
* Throw an error telling the user that the method is not available and why.
@ -80,12 +79,9 @@ function convertNullToNotFoundError(ctx, cb) {
/**
* Create new instance of Model class, saved in database
*
* @param data [optional]
* @param callback(err, obj)
* callback called with arguments:
*
* - err (null or Error)
* - instance (null or Model)
* @param {Object} data Optional data object.
* @param {Function} cb Callback function with `cb(err, obj)` signature,
* where `err` is error object and `obj` is null or Model instance.
*/
PersistedModel.create = function (data, callback) {
@ -103,12 +99,12 @@ PersistedModel.upsert = PersistedModel.updateOrCreate = function upsert(data, ca
};
/**
* Find one record, same as `find`, limited by 1 and return object, not collection,
* if not found, create using data provided as second argument
* Find one record, same as `find`, but limited to one object. Returns an object, not collection.
* If not found, create the object using data provided as second argument.
*
* @param {Object} query - search conditions: {where: {test: 'me'}}.
* @param {Object} data - object to create.
* @param {Function} cb - callback called with (err, instance)
* @param {Object} query Search conditions: {where: {test: 'me'}}.
* @param {Object} data Object to create.
* @param {Function} cb Callback called with `cb(err, instance)` signature.
*/
PersistedModel.findOrCreate = function findOrCreate(query, data, callback) {
@ -118,10 +114,10 @@ PersistedModel.findOrCreate = function findOrCreate(query, data, callback) {
PersistedModel.findOrCreate._delegate = true;
/**
* Check whether a model instance exists in database
* Check whether a model instance exists in database.
*
* @param {id} id - identifier of object (primary key value)
* @param {Function} cb - callback called with (err, exists: Bool)
* @param {id} id Identifier of object (primary key value)
* @param {Function} cb Callback function called with (err, exists: Bool)
*/
PersistedModel.exists = function exists(id, cb) {
@ -129,10 +125,10 @@ PersistedModel.exists = function exists(id, cb) {
};
/**
* Find object by id
* Find object by ID.
*
* @param {*} id - primary key value
* @param {Function} cb - callback called with (err, instance)
* @param {Function} cb Callback function called with `(err, instances)`. Required.
*/
PersistedModel.findById = function find(id, cb) {
@ -140,21 +136,28 @@ PersistedModel.findById = function find(id, cb) {
};
/**
* Find all instances of Model, matched by query
* make sure you have marked as `index: true` fields for filter or sort
* Find all model instances that match `filter` specification.
* See [Querying models](http://docs.strongloop.com/display/LB/Querying+models).
*
* @param {Object} params (optional)
* @options {Object} [filter] Optional Filter JSON object; see below.
* @property {String|Object|Array} fields Identify fields to include in return result.
* <br/>See [Fields filter](http://docs.strongloop.com/display/LB/Fields+filter).
* @property {String|Object|Array} include See PersistedModel.include documentation.
* <br/>See [Include filter](http://docs.strongloop.com/display/LB/Include+filter).
* @property {Number} limit Maximum number of instances to return.
* <br/>See [Limit filter](http://docs.strongloop.com/display/LB/Limit+filter).
* @property {String} order Sort order: either "ASC" for ascending or "DESC" for descending.
* <br/>See [Order filter](http://docs.strongloop.com/display/LB/Order+filter).
* @property {Number} skip Number of results to skip.
* <br/>See [Skip filter](http://docs.strongloop.com/display/LB/Skip+filter).
* @property {Object} where Where clause, like
* ```
* { key: val, key2: {gt: 'val2'}, ...}
* ```
* <br/>See [Where filter](http://docs.strongloop.com/display/LB/Where+filter).
*
* - where: Object `{ key: val, key2: {gt: 'val2'}}`
* - include: String, Object or Array. See PersistedModel.include documentation.
* - order: String
* - limit: Number
* - skip: Number
*
* @param {Function} callback (required) called with arguments:
*
* - err (null or Error)
* - Array of instances
* @param {Function} Callback function called with `(err, returned-instances)`.
* @returns {Object} Array of model instances that match the filter; or Error.
*/
PersistedModel.find = function find(params, cb) {
@ -162,10 +165,29 @@ PersistedModel.find = function find(params, cb) {
};
/**
* Find one record, same as `all`, limited by 1 and return object, not collection
* Find one model instance that matches `filter` specification.
* Same as `find`, but limited to one result;
* Returns object, not collection.
*
* @param {Object} params - search conditions: {where: {test: 'me'}}
* @param {Function} cb - callback called with (err, instance)
* @options {Object} [filter] Optional Filter JSON object; see below.
* @property {String|Object|Array} fields Identify fields to include in return result.
* <br/>See [Fields filter](http://docs.strongloop.com/display/LB/Fields+filter).
* @property {String|Object|Array} include See PersistedModel.include documentation.
* <br/>See [Include filter](http://docs.strongloop.com/display/LB/Include+filter).
* @property {Number} limit Maximum number of instances to return.
* <br/>See [Limit filter](http://docs.strongloop.com/display/LB/Limit+filter).
* @property {String} order Sort order: either "ASC" for ascending or "DESC" for descending.
* <br/>See [Order filter](http://docs.strongloop.com/display/LB/Order+filter).
* @property {Number} skip Number of results to skip.
* <br/>See [Skip filter](http://docs.strongloop.com/display/LB/Skip+filter).
* @property {Object} where Where clause, like
* ```
* { key: val, key2: {gt: 'val2'}, ...}
* ```
* <br/>See [Where filter](http://docs.strongloop.com/display/LB/Where+filter).
*
* @param {Function} Callback function called with `(err, returned-instance)`. Required.
* @returns {Object} First model instance that matches the filter; or Error.
*/
PersistedModel.findOne = function findOne(params, cb) {
@ -173,17 +195,31 @@ PersistedModel.findOne = function findOne(params, cb) {
};
/**
* Destroy all matching records
* @param {Object} [where] An object that defines the criteria
* @param {Function} [cb] - callback called with (err)
* Destroy all model instances that match the optional `filter` specification.
*
* @options {Object} [where] Optional where filter JSON object; see below.
* @property {Object} where Where clause, like
* ```
* { key: val, key2: {gt: 'val2'}, ...}
* ```
*
* @param {Function} [cb] - callback called with `(err)`.
*/
PersistedModel.remove =
PersistedModel.deleteAll =
PersistedModel.destroyAll = function destroyAll(where, cb) {
throwNotAttached(this.modelName, 'destroyAll');
};
/**
* Alias for `destroyAll`
*/
PersistedModel.remove = PersistedModel.destroyAll;
/**
* Alias for `destroyAll`
*/
PersistedModel.deleteAll = PersistedModel.destroyAll;
/**
* Update multiple instances that match the where clause
*
@ -195,32 +231,50 @@ PersistedModel.destroyAll = function destroyAll(where, cb) {
* });
* ```
*
* @param {Object} [where] Search conditions (optional)
* @options {Object} [where] Optional where filter JSON object; see below.
* @property {Object} where Where clause, like
* ```
* { key: val, key2: {gt: 'val2'}, ...}
* ```
* @param {Object} data Changes to be made
* @param {Function} cb Callback, called with (err, count)
* @param {Function} cb Callback function called with (err, count).
*/
PersistedModel.update =
PersistedModel.updateAll = function updateAll(where, data, cb) {
throwNotAttached(this.modelName, 'updateAll');
};
PersistedModel.updateAll = function updateAll(where, data, cb) {
throwNotAttached(this.modelName, 'updateAll');
};
/**
* Destroy a record by id
* @param {*} id The id value
* @param {Function} cb - callback called with (err)
* Alias for updateAll.
*/
PersistedModel.update = PersistedModel.updateAll;
PersistedModel.removeById =
PersistedModel.deleteById =
/**
* Destroy model instance with the specified ID.
* @param {*} id The ID value of model instance to delete.
* @param {Function} cb Callback function called with (err).
*/
PersistedModel.destroyById = function deleteById(id, cb) {
throwNotAttached(this.modelName, 'deleteById');
};
/**
* Return count of matched records
*
* @param {Object} where - search conditions (optional)
* @param {Function} cb - callback, called with (err, count)
* Alias for destroyById.
*/
PersistedModel.removeById = PersistedModel.destroyById;
/**
* Alias for destroyById.
*/
PersistedModel.deleteById = PersistedModel.destroyById;
/**
* Return the number of records that match the optional filter.
* @options {Object} [filter] Optional where filter JSON object; see below.
* @property {Object} where Where clause, like
* ```
* { key: val, key2: {gt: 'val2'}, ...}
* ```
* @param {Function} cb Callback function called with (err, count).
*/
PersistedModel.count = function (where, cb) {
@ -228,10 +282,12 @@ PersistedModel.count = function (where, cb) {
};
/**
* Save instance. When instance haven't id, create method called instead.
* Triggers: validate, save, update | create
* @param options {validate: true, throws: false} [optional]
* @param callback(err, obj)
* Save model instance. If the instance doesn't have an ID, then the [create](#persistedmodelcreatedata-cb) method is called instead.
* Triggers: validate, save, update, or create.
* @options {Object} [options] See below.
* @property {Boolean} validate
* @property {Boolean} throws
* @param {Function} [callback] Callback function called with (err, obj).
*/
PersistedModel.prototype.save = function (options, callback) {
@ -270,7 +326,7 @@ PersistedModel.prototype.save = function (options, callback) {
if (valid) {
save();
} else {
var err = new ValidationError(inst);
var err = new Model.ValidationError(inst);
// throws option is dangerous for async usage
if (options.throws) {
throw err;
@ -282,7 +338,7 @@ PersistedModel.prototype.save = function (options, callback) {
// then save
function save() {
inst.trigger('save', function (saveDone) {
inst.trigger('update', function (updateDone) {
inst.trigger('update', function (updateDone) {
Model.upsert(inst, function(err) {
inst._initProperties(data);
updateDone.call(inst, function () {
@ -298,7 +354,7 @@ PersistedModel.prototype.save = function (options, callback) {
/**
* Determine if the data model is new.
* @returns {Boolean}
* @returns {Boolean} Returns true if the data model is new; false otherwise.
*/
PersistedModel.prototype.isNewRecord = function () {
@ -306,27 +362,36 @@ PersistedModel.prototype.isNewRecord = function () {
};
/**
* Delete object from persistence
*
* @triggers `destroy` hook (async) before and after destroying object
* Deletes the model from persistence.
* Triggers `destroy` hook (async) before and after destroying object.
* @param {Function} callback Callback function.
*/
PersistedModel.prototype.remove =
PersistedModel.prototype.delete =
PersistedModel.prototype.destroy = function (cb) {
throwNotAttached(this.constructor.modelName, 'destroy');
};
/**
* Alias for destroy.
* @header PersistedModel.remove
*/
PersistedModel.prototype.remove = PersistedModel.prototype.destroy;
/**
* Alias for destroy.
* @header PersistedModel.delete
*/
PersistedModel.prototype.delete = PersistedModel.prototype.destroy;
PersistedModel.prototype.destroy._delegate = true;
/**
* Update single attribute
* Update a single attribute.
* Equivalent to `updateAttributes({name: 'value'}, cb)`
*
* equals to `updateAttributes({name: value}, cb)
*
* @param {String} name - name of property
* @param {Mixed} value - value of property
* @param {Function} callback - callback called with (err, instance)
* @param {String} name Name of property
* @param {Mixed} value Value of property
* @param {Function} callback Callback function called with (err, instance).
*/
PersistedModel.prototype.updateAttribute = function updateAttribute(name, value, callback) {
@ -334,13 +399,11 @@ PersistedModel.prototype.updateAttribute = function updateAttribute(name, value,
};
/**
* Update set of attributes
* Update set of attributes. Performs validation before updating.
*
* this method performs validation before updating
*
* @trigger `validation`, `save` and `update` hooks
* @param {Object} data - data to update
* @param {Function} callback - callback called with (err, instance)
* Trigger: `validation`, `save` and `update` hooks
* @param {Object} data Dta to update.
* @param {Function} callback Callback function called with (err, instance).
*/
PersistedModel.prototype.updateAttributes = function updateAttributes(data, cb) {
@ -348,10 +411,8 @@ PersistedModel.prototype.updateAttributes = function updateAttributes(data, cb)
};
/**
* Reload object from persistence
*
* @requires `id` member of `object` to be able to call `find`
* @param {Function} callback - called with (err, instance) arguments
* Reload object from persistence. Requires `id` member of `object` to be able to call `find`.
* @param {Function} callback Callback function called with (err, instance) arguments.
*/
PersistedModel.prototype.reload = function reload(callback) {
@ -360,17 +421,16 @@ PersistedModel.prototype.reload = function reload(callback) {
/**
* Set the correct `id` property for the `PersistedModel`. If a `Connector` defines
* a `setId` method it will be used. Otherwise the default lookup is used. You
* should override this method to handle complex ids.
* a `setId` method it will be used. Otherwise the default lookup is used.
* Override this method to handle complex IDs.
*
* @param {*} val The `id` value. Will be converted to the type the id property
* specifies.
* @param {*} val The `id` value. Will be converted to the type that the `id` property specifies.
*/
PersistedModel.prototype.setId = function(val) {
var ds = this.getDataSource();
this[this.getIdName()] = val;
}
};
/**
* Get the `id` value for the `PersistedModel`.
@ -382,7 +442,7 @@ PersistedModel.prototype.getId = function() {
var data = this.toObject();
if(!data) return;
return data[this.getIdName()];
}
};
/**
* Get the id property name of the constructor.
@ -392,7 +452,7 @@ PersistedModel.prototype.getId = function() {
PersistedModel.prototype.getIdName = function() {
return this.constructor.getIdName();
}
};
/**
* Get the id property name of the constructor.
@ -409,7 +469,7 @@ PersistedModel.getIdName = function() {
} else {
return 'id';
}
}
};
PersistedModel.setupRemoting = function() {
var PersistedModel = this;
@ -537,7 +597,7 @@ PersistedModel.setupRemoting = function() {
{arg: 'since', type: 'number', description: 'Find deltas since this checkpoint'},
{arg: 'remoteChanges', type: 'array', description: 'an array of change objects',
http: {source: 'body'}}
],
],
returns: {arg: 'result', type: 'object', root: true},
http: {verb: 'post', path: '/diff'}
});
@ -552,7 +612,7 @@ PersistedModel.setupRemoting = function() {
returns: {arg: 'changes', type: 'array', root: true},
http: {verb: 'get', path: '/changes'}
});
setRemoting(PersistedModel, 'checkpoint', {
description: 'Create a checkpoint.',
returns: {arg: 'checkpoint', type: 'object', root: true},
@ -589,22 +649,22 @@ PersistedModel.setupRemoting = function() {
http: {verb: 'post', path: '/:id/rectify-change'}
});
}
}
};
/**
* Get a set of deltas and conflicts since the given checkpoint.
*
* See `Change.diff()` for details.
*
*
* @param {Number} since Find deltas since this checkpoint
* @param {Array} remoteChanges An array of change objects
* @param {Function} callback
* @param {Function} callback
*/
PersistedModel.diff = function(since, remoteChanges, callback) {
var Change = this.getChangeModel();
Change.diff(this.modelName, since, remoteChanges, callback);
}
};
/**
* Get the changes to a model since a given checkpoint. Provide a filter object
@ -644,13 +704,13 @@ PersistedModel.changes = function(since, filter, callback) {
checkpoint: {gt: since},
modelName: this.modelName
}, function(err, changes) {
if(err) return cb(err);
if(err) return callback(err);
var ids = changes.map(function(change) {
return change.getModelId();
});
filter.where[idName] = {inq: ids};
model.find(filter, function(err, models) {
if(err) return cb(err);
if(err) return callback(err);
var modelIds = models.map(function(m) {
return m[idName].toString();
});
@ -660,11 +720,11 @@ PersistedModel.changes = function(since, filter, callback) {
}));
});
});
}
};
/**
* Create a checkpoint.
*
*
* @param {Function} callback
*/
@ -676,11 +736,11 @@ PersistedModel.checkpoint = function(cb) {
sourceId: sourceId
}, cb);
});
}
};
/**
* Get the current checkpoint id.
*
*
* @callback {Function} callback
* @param {Error} err
* @param {Number} currentCheckpointId
@ -690,7 +750,7 @@ PersistedModel.checkpoint = function(cb) {
PersistedModel.currentCheckpoint = function(cb) {
var Checkpoint = this.getChangeModel().getCheckpointModel();
Checkpoint.current(cb);
}
};
/**
* Replicate changes since the given checkpoint to the given target model.
@ -733,7 +793,7 @@ PersistedModel.replicate = function(since, targetModel, options, callback) {
callback = callback || function defaultReplicationCallback(err) {
if(err) throw err;
}
};
var tasks = [
getSourceChanges,
@ -788,13 +848,13 @@ PersistedModel.replicate = function(since, targetModel, options, callback) {
callback && callback(null, conflicts);
}
}
};
/**
* Create an update list (for `Model.bulkUpdate()`) from a delta list
* (result of `Change.diff()`).
*
* @param {Array} deltas
*
* @param {Array} deltas
* @param {Function} callback
*/
@ -839,13 +899,13 @@ PersistedModel.createUpdates = function(deltas, cb) {
if(err) return cb(err);
cb(null, updates);
});
}
};
/**
* Apply an update list.
*
* **Note: this is not atomic**
*
*
* @param {Array} updates An updates list (usually from Model.createUpdates())
* @param {Function} callback
*/
@ -877,11 +937,11 @@ PersistedModel.bulkUpdate = function(updates, callback) {
});
async.parallel(tasks, callback);
}
};
/**
* Get the `Change` model.
*
*
* @throws {Error} Throws an error if the change model is not correctly setup.
* @return {Change}
*/
@ -893,11 +953,11 @@ PersistedModel.getChangeModel = function() {
assert(isSetup, 'Cannot get a setup Change model');
return changeModel;
}
};
/**
* Get the source identifier for this model / dataSource.
*
*
* @callback {Function} callback
* @param {Error} err
* @param {String} sourceId
@ -909,12 +969,12 @@ PersistedModel.getSourceId = function(cb) {
this.once('dataSourceAttached', this.getSourceId.bind(this, cb));
}
assert(
dataSource.connector.name,
dataSource.connector.name,
'Model.getSourceId: cannot get id without dataSource.connector.name'
);
var id = [dataSource.connector.name, this.modelName].join('-');
cb(null, id);
}
};
/**
* Enable the tracking of changes made to the model. Usually for replication.
@ -933,11 +993,11 @@ PersistedModel.enableChangeTracking = function() {
Model.afterSave = function afterSave(next) {
Model.rectifyChange(this.getId(), next);
}
};
Model.afterDestroy = function afterDestroy(next) {
Model.rectifyChange(this.getId(), next);
}
};
Model.on('deletedAll', cleanup);
@ -957,21 +1017,24 @@ PersistedModel.enableChangeTracking = function() {
});
}
}
}
};
PersistedModel._defineChangeModel = function() {
var BaseChangeModel = require('./change');
var BaseChangeModel = registry.getModel('Change');
assert(BaseChangeModel,
'Change model must be defined before enabling change replication');
return this.Change = BaseChangeModel.extend(this.modelName + '-change',
{},
{
trackModel: this
}
);
}
};
PersistedModel.rectifyAllChanges = function(callback) {
this.getChangeModel().rectifyAll(callback);
}
};
/**
* Handle a change error. Override this method in a subclassing model to customize
@ -985,7 +1048,7 @@ PersistedModel.handleChangeError = function(err) {
console.error(Model.modelName + ' Change Tracking Error:');
console.error(err);
}
}
};
/**
* Tell loopback that a change to the model with the given id has occurred.
@ -998,6 +1061,6 @@ PersistedModel.handleChangeError = function(err) {
PersistedModel.rectifyChange = function(id, callback) {
var Change = this.getChangeModel();
Change.rectifyModelChanges(this.modelName, [id], callback);
}
};
PersistedModel.setup();

View File

@ -283,7 +283,7 @@ registry.createDataSource = function (name, options) {
* @param {String} [name] The name of the data source.
* If not provided, the `'default'` is used.
*
* @header loopback.memory()
* @header loopback.memory([name])
*/
registry.memory = function (name) {
@ -303,9 +303,9 @@ registry.memory = function (name) {
/**
* Set the default `dataSource` for a given `type`.
* @param {String} type The datasource type
* @param {String} type The datasource type.
* @param {Object|DataSource} dataSource The data source settings or instance
* @returns {DataSource} The data source instance
* @returns {DataSource} The data source instance.
*
* @header loopback.setDefaultDataSourceForType(type, dataSource)
*/
@ -323,9 +323,9 @@ registry.setDefaultDataSourceForType = function(type, dataSource) {
/**
* Get the default `dataSource` for a given `type`.
* @param {String} type The datasource type
* @returns {DataSource} The data source instance
* @header loopback.getDefaultDataSourceForType()
* @param {String} type The datasource type.
* @returns {DataSource} The data source instance.
* @header loopback.getDefaultDataSourceForType(type)
*/
registry.getDefaultDataSourceForType = function(type) {
@ -371,8 +371,8 @@ registry.DataSource = DataSource;
* @private
*/
registry.Model = require('./models/model');
registry.PersistedModel = require('./models/persisted-model');
registry.Model = require('./model');
registry.PersistedModel = require('./persisted-model');
// temporary alias to simplify migration of code based on <=2.0.0-beta3
Object.defineProperty(registry, 'DataModel', {

View File

@ -1,5 +1,6 @@
{
"name": "loopback",
"version": "2.6.0",
"description": "LoopBack: Open Source Framework for Node.js",
"homepage": "http://loopback.io",
"keywords": [
@ -27,7 +28,6 @@
"mobile",
"mBaaS"
],
"version": "2.4.1",
"scripts": {
"test": "grunt mocha-and-karma"
},

View File

@ -109,8 +109,6 @@ describe('AccessToken', function () {
});
describe('app.enableAuth()', function() {
this.timeout(0);
beforeEach(createTestingToken);
it('prevents remote call with 401 status on denied ACL', function (done) {

View File

@ -1,11 +1,9 @@
var assert = require('assert');
var loopback = require('../index');
var acl = require('../lib/models/acl');
var Scope = acl.Scope;
var ACL = acl.ACL;
var role = require('../lib/models/role');
var Role = role.Role;
var RoleMapping = role.RoleMapping;
var Scope = loopback.Scope;
var ACL = loopback.ACL;
var Role = loopback.Role;
var RoleMapping = loopback.RoleMapping;
var User = loopback.User;
var testModel;
@ -229,6 +227,33 @@ describe('security ACLs', function () {
});
it("should filter static ACLs by model/property", function() {
var Model1 = ds.createModel('Model1', {
name: {
type: String,
acls: [
{principalType: ACL.USER, principalId: 'u001',
accessType: ACL.WRITE, permission: ACL.DENY},
{principalType: ACL.USER, principalId: 'u001',
accessType: ACL.ALL, permission: ACL.ALLOW}
]
}
}, {
acls: [
{principalType: ACL.USER, principalId: 'u001', property: 'name',
accessType: ACL.ALL, permission: ACL.ALLOW},
{principalType: ACL.USER, principalId: 'u002', property: 'findOne',
accessType: ACL.ALL, permission: ACL.ALLOW}
]
});
var staticACLs = ACL.getStaticACLs('Model1', 'name');
assert(staticACLs.length === 3);
staticACLs = ACL.getStaticACLs('Model1', 'findOne');
assert(staticACLs.length === 1);
});
it("should check access against LDL, ACL, and Role", function () {
// var log = console.log;
var log = function() {};

View File

@ -213,8 +213,9 @@ describe('app', function() {
app.set('host', undefined);
app.listen(function() {
expect(app.get('url'), 'url')
.to.equal('http://127.0.0.1:' + app.get('port') + '/');
var host = process.platform === 'win32' ? 'localhost' : app.get('host');
var expectedUrl = 'http://' + host + ':' + app.get('port') + '/';
expect(app.get('url'), 'url').to.equal(expectedUrl);
done();
});
});
@ -350,4 +351,26 @@ describe('app', function() {
var app = loopback();
expect(app.loopback).to.equal(loopback);
});
describe('normalizeHttpPath option', function() {
var app, db;
beforeEach(function() {
app = loopback();
db = loopback.createDataSource({ connector: loopback.Memory });
});
it.onServer('normalizes the http path', function(done) {
var UserAccount = PersistedModel.extend(
'UserAccount',
{ name: String },
{
remoting: { normalizeHttpPath: true }
});
app.model(UserAccount);
UserAccount.attachTo(db);
app.use(loopback.rest());
request(app).get('/user-accounts').expect(200, done);
});
});
});

View File

@ -2,7 +2,7 @@ var async = require('async');
var loopback = require('../');
// create a unique Checkpoint model
var Checkpoint = require('../lib/models/checkpoint').extend('TestCheckpoint');
var Checkpoint = loopback.Checkpoint.extend('TestCheckpoint');
Checkpoint.attachTo(loopback.memory());
describe('Checkpoint', function() {

View File

@ -16,7 +16,9 @@ module.exports = function(config) {
files: [
'node_modules/es5-shim/es5-shim.js',
'test/support.js',
'test/loopback.test.js',
'test/model.test.js',
'test/model.application.test.js',
'test/geo-point.test.js',
'test/app.test.js'
],

View File

@ -241,4 +241,28 @@ describe('loopback', function() {
expect(owner._targetClass).to.equal('User');
});
});
describe('loopback object', function() {
it('exports all built-in models', function() {
var expectedModelNames = [
'Email',
'User',
'Application',
'AccessToken',
'Role',
'RoleMapping',
'ACL',
'Scope',
'Change',
'Checkpoint'
];
expect(Object.keys(loopback)).to.include.members(expectedModelNames);
expectedModelNames.forEach(function(name) {
expect(loopback[name], name).to.be.a('function');
expect(loopback[name].modelName, name + '.modelName').to.eql(name);
});
});
});
});

View File

@ -12,7 +12,7 @@ describe('RemoteConnector', function() {
remoteApp.use(loopback.rest());
remoteApp.listen(0, function() {
test.dataSource = loopback.createDataSource({
host: remoteApp.get('host'),
host: 'localhost',
port: remoteApp.get('port'),
connector: loopback.Remote
});
@ -38,7 +38,7 @@ describe('RemoteConnector', function() {
remoteApp.listen(0, function() {
test.remote = loopback.createDataSource({
host: remoteApp.get('host'),
host: 'localhost',
port: remoteApp.get('port'),
connector: loopback.Remote
});
@ -63,6 +63,7 @@ describe('RemoteConnector', function() {
var m = new RemoteModel({foo: 'bar'});
m.save(function(err, inst) {
if (err) return done(err);
assert(inst instanceof RemoteModel);
assert(calledServerCreate);
done();

View File

@ -1,10 +1,9 @@
var assert = require('assert');
var loopback = require('../index');
var role = require('../lib/models/role');
var Role = role.Role;
var RoleMapping = role.RoleMapping;
var Role = loopback.Role;
var RoleMapping = loopback.RoleMapping;
var User = loopback.User;
var ACL = require('../lib/models/acl');
var ACL = loopback.ACL;
function checkResult(err, result) {
// console.log(err, result);

View File

@ -28,6 +28,7 @@ describe('User', function(){
});
beforeEach(function (done) {
app.enableAuth();
app.use(loopback.token());
app.use(loopback.rest());
app.model(User);
@ -600,4 +601,16 @@ describe('User', function(){
});
});
});
describe('ctor', function() {
it('exports default Email model', function() {
expect(User.email, 'User.email').to.be.a('function');
expect(User.email.modelName, 'modelName').to.eql('email');
});
it('exports default AccessToken model', function() {
expect(User.accessToken, 'User.accessToken').to.be.a('function');
expect(User.accessToken.modelName, 'modelName').to.eql('AccessToken');
});
});
});