Merge pull request #635 from strongloop/feature/builtin-models-defined-via-json

Define built-in models via JSON
This commit is contained in:
Miroslav Bajtoš 2014-10-14 09:25:55 +02:00
commit dcedfa03a1
29 changed files with 2270 additions and 2072 deletions

View File

@ -4,24 +4,8 @@
var loopback = require('../../lib/loopback') var loopback = require('../../lib/loopback')
, assert = require('assert') , assert = require('assert')
, crypto = require('crypto')
, uid = require('uid2') , uid = require('uid2')
, DEFAULT_TTL = 1209600 // 2 weeks in seconds , DEFAULT_TOKEN_LEN = 64;
, 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. * Token based authentication and access control.
@ -32,38 +16,22 @@ var properties = {
* - ALLOW EVERYONE create * - ALLOW EVERYONE create
* *
* @property {String} id Generated token ID * @property {String} id Generated token ID
* @property {Number} ttl Time to live in seconds * @property {Number} ttl Time to live in seconds, 2 weeks by default.
* @property {Date} created When the token was created * @property {Date} created When the token was created
* *
* @class * @class AccessToken
* @inherits {PersistedModel} * @inherits {PersistedModel}
*/ */
var AccessToken = module.exports = module.exports = function(AccessToken) {
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'
}
}
});
/** // 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 * Anonymous Token
* *
* ```js * ```js
@ -71,9 +39,9 @@ var AccessToken = module.exports =
* ``` * ```
*/ */
AccessToken.ANONYMOUS = new AccessToken({id: '$anonymous'}); AccessToken.ANONYMOUS = new AccessToken({id: '$anonymous'});
/** /**
* Create a cryptographically random access token id. * Create a cryptographically random access token id.
* *
* @callback {Function} callback * @callback {Function} callback
@ -81,25 +49,25 @@ AccessToken.ANONYMOUS = new AccessToken({id: '$anonymous'});
* @param {String} token * @param {String} token
*/ */
AccessToken.createAccessTokenId = function (fn) { AccessToken.createAccessTokenId = function(fn) {
uid(this.settings.accessTokenIdLength || DEFAULT_TOKEN_LEN, function(err, guid) { uid(this.settings.accessTokenIdLength || DEFAULT_TOKEN_LEN, function(err, guid) {
if(err) { if (err) {
fn(err); fn(err);
} else { } else {
fn(null, guid); fn(null, guid);
} }
}); });
} }
/*! /*!
* Hook to create accessToken id. * Hook to create accessToken id.
*/ */
AccessToken.beforeCreate = function (next, data) { AccessToken.beforeCreate = function(next, data) {
data = data || {}; data = data || {};
AccessToken.createAccessTokenId(function (err, id) { AccessToken.createAccessTokenId(function(err, id) {
if(err) { if (err) {
next(err); next(err);
} else { } else {
data.id = id; data.id = id;
@ -107,9 +75,9 @@ AccessToken.beforeCreate = function (next, data) {
next(); next();
} }
}); });
} }
/** /**
* Find a token for the given `ServerRequest`. * Find a token for the given `ServerRequest`.
* *
* @param {ServerRequest} req * @param {ServerRequest} req
@ -119,18 +87,18 @@ AccessToken.beforeCreate = function (next, data) {
* @param {AccessToken} token * @param {AccessToken} token
*/ */
AccessToken.findForRequest = function(req, options, cb) { AccessToken.findForRequest = function(req, options, cb) {
var id = tokenIdForRequest(req, options); var id = tokenIdForRequest(req, options);
if(id) { if (id) {
this.findById(id, function(err, token) { this.findById(id, function(err, token) {
if(err) { if (err) {
cb(err); cb(err);
} else if(token) { } else if (token) {
token.validate(function(err, isValid) { token.validate(function(err, isValid) {
if(err) { if (err) {
cb(err); cb(err);
} else if(isValid) { } else if (isValid) {
cb(null, token); cb(null, token);
} else { } else {
var e = new Error('Invalid Access Token'); var e = new Error('Invalid Access Token');
@ -147,9 +115,9 @@ AccessToken.findForRequest = function(req, options, cb) {
cb(); cb();
}); });
} }
} }
/** /**
* Validate the token. * Validate the token.
* *
* @callback {Function} callback * @callback {Function} callback
@ -157,7 +125,7 @@ AccessToken.findForRequest = function(req, options, cb) {
* @param {Boolean} isValid * @param {Boolean} isValid
*/ */
AccessToken.prototype.validate = function(cb) { AccessToken.prototype.validate = function(cb) {
try { try {
assert( assert(
this.created && typeof this.created.getTime === 'function', this.created && typeof this.created.getTime === 'function',
@ -173,19 +141,19 @@ AccessToken.prototype.validate = function(cb) {
var secondsToLive = this.ttl; var secondsToLive = this.ttl;
var isValid = elapsedSeconds < secondsToLive; var isValid = elapsedSeconds < secondsToLive;
if(isValid) { if (isValid) {
cb(null, isValid); cb(null, isValid);
} else { } else {
this.destroy(function(err) { this.destroy(function(err) {
cb(err, isValid); cb(err, isValid);
}); });
} }
} catch(e) { } catch (e) {
cb(e); cb(e);
} }
} }
function tokenIdForRequest(req, options) { function tokenIdForRequest(req, options) {
var params = options.params || []; var params = options.params || [];
var headers = options.headers || []; var headers = options.headers || [];
var cookies = options.cookies || []; var cookies = options.cookies || [];
@ -197,18 +165,18 @@ function tokenIdForRequest(req, options) {
headers = headers.concat(['X-Access-Token', 'authorization']); headers = headers.concat(['X-Access-Token', 'authorization']);
cookies = cookies.concat(['access_token', 'authorization']); cookies = cookies.concat(['access_token', 'authorization']);
for(length = params.length; i < length; i++) { for (length = params.length; i < length; i++) {
id = req.param(params[i]); id = req.param(params[i]);
if(typeof id === 'string') { if (typeof id === 'string') {
return id; return id;
} }
} }
for(i = 0, length = headers.length; i < length; i++) { for (i = 0, length = headers.length; i < length; i++) {
id = req.header(headers[i]); id = req.header(headers[i]);
if(typeof id === 'string') { if (typeof id === 'string') {
// Add support for oAuth 2.0 bearer token // Add support for oAuth 2.0 bearer token
// http://tools.ietf.org/html/rfc6750 // http://tools.ietf.org/html/rfc6750
if (id.indexOf('Bearer ') === 0) { if (id.indexOf('Bearer ') === 0) {
@ -221,14 +189,15 @@ function tokenIdForRequest(req, options) {
} }
} }
if(req.signedCookies) { if (req.signedCookies) {
for(i = 0, length = cookies.length; i < length; i++) { for (i = 0, length = cookies.length; i < length; i++) {
id = req.signedCookies[cookies[i]]; id = req.signedCookies[cookies[i]];
if(typeof id === 'string') { if (typeof id === 'string') {
return id; return id;
} }
} }
} }
return null; 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"
}
]
}

View File

@ -41,10 +41,12 @@ var AccessContext = ctx.AccessContext;
var Principal = ctx.Principal; var Principal = ctx.Principal;
var AccessRequest = ctx.AccessRequest; var AccessRequest = ctx.AccessRequest;
var role = require('./role'); var Role = loopback.Role;
var Role = role.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 * System grants permissions to principals (users/applications, can be grouped
* into roles). * into roles).
* *
@ -54,18 +56,6 @@ var Role = role.Role;
* For a given principal, such as client application and/or user, is it allowed * For a given principal, such as client application and/or user, is it allowed
* to access (read/write/execute) * to access (read/write/execute)
* the protected resource? * 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 * @header ACL
* @property {String} model Name of the model. * @property {String} model Name of the model.
@ -78,36 +68,37 @@ var ACLSchema = {
* - DENY: Explicitly denies access to the resource. * - DENY: Explicitly denies access to the resource.
* @property {String} principalType Type of the principal; one of: Application, Use, Role. * @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 * @property {String} principalId ID of the principal - such as appId, userId or roleId
* @class *
* @inherits Model * @class ACL
* @inherits PersistedModel
*/ */
var ACL = loopback.PersistedModel.extend('ACL', ACLSchema); module.exports = function(ACL) {
ACL.ALL = AccessContext.ALL; ACL.ALL = AccessContext.ALL;
ACL.DEFAULT = AccessContext.DEFAULT; // Not specified ACL.DEFAULT = AccessContext.DEFAULT; // Not specified
ACL.ALLOW = AccessContext.ALLOW; // Allow ACL.ALLOW = AccessContext.ALLOW; // Allow
ACL.ALARM = AccessContext.ALARM; // Warn - send an alarm ACL.ALARM = AccessContext.ALARM; // Warn - send an alarm
ACL.AUDIT = AccessContext.AUDIT; // Audit - record the access ACL.AUDIT = AccessContext.AUDIT; // Audit - record the access
ACL.DENY = AccessContext.DENY; // Deny ACL.DENY = AccessContext.DENY; // Deny
ACL.READ = AccessContext.READ; // Read operation ACL.READ = AccessContext.READ; // Read operation
ACL.WRITE = AccessContext.WRITE; // Write operation ACL.WRITE = AccessContext.WRITE; // Write operation
ACL.EXECUTE = AccessContext.EXECUTE; // Execute operation ACL.EXECUTE = AccessContext.EXECUTE; // Execute operation
ACL.USER = Principal.USER; ACL.USER = Principal.USER;
ACL.APP = ACL.APPLICATION = Principal.APPLICATION; ACL.APP = ACL.APPLICATION = Principal.APPLICATION;
ACL.ROLE = Principal.ROLE; ACL.ROLE = Principal.ROLE;
ACL.SCOPE = Principal.SCOPE; ACL.SCOPE = Principal.SCOPE;
/** /**
* Calculate the matching score for the given rule and request * Calculate the matching score for the given rule and request
* @param {ACL} rule The ACL entry * @param {ACL} rule The ACL entry
* @param {AccessRequest} req The request * @param {AccessRequest} req The request
* @returns {Number} * @returns {Number}
*/ */
ACL.getMatchingScore = function getMatchingScore(rule, req) { ACL.getMatchingScore = function getMatchingScore(rule, req) {
var props = ['model', 'property', 'accessType']; var props = ['model', 'property', 'accessType'];
var score = 0; var score = 0;
@ -139,7 +130,7 @@ ACL.getMatchingScore = function getMatchingScore(rule, req) {
// - other // - other
// user > app > role > ... // user > app > role > ...
score = score * 4; score = score * 4;
switch(rule.principalType) { switch (rule.principalType) {
case ACL.USER: case ACL.USER:
score += 4; score += 4;
break; break;
@ -150,14 +141,14 @@ ACL.getMatchingScore = function getMatchingScore(rule, req) {
score += 2; score += 2;
break; break;
default: default:
score +=1; score += 1;
} }
// Weigh against the roles // Weigh against the roles
// everyone < authenticated/unauthenticated < related < owner < ... // everyone < authenticated/unauthenticated < related < owner < ...
score = score * 8; score = score * 8;
if(rule.principalType === ACL.ROLE) { if (rule.principalType === ACL.ROLE) {
switch(rule.principalId) { switch (rule.principalId) {
case Role.OWNER: case Role.OWNER:
score += 4; score += 4;
break; break;
@ -178,30 +169,30 @@ ACL.getMatchingScore = function getMatchingScore(rule, req) {
score = score * 4; score = score * 4;
score += AccessContext.permissionOrder[rule.permission || ACL.ALLOW] - 1; score += AccessContext.permissionOrder[rule.permission || ACL.ALLOW] - 1;
return score; return score;
}; };
/** /**
* Get matching score for the given `AccessRequest`. * Get matching score for the given `AccessRequest`.
* @param {AccessRequest} req The request * @param {AccessRequest} req The request
* @returns {Number} score * @returns {Number} score
*/ */
ACL.prototype.score = function(req) { ACL.prototype.score = function(req) {
return this.constructor.getMatchingScore(this, req); return this.constructor.getMatchingScore(this, req);
} }
/*! /*!
* Resolve permission from the ACLs * Resolve permission from the ACLs
* @param {Object[]) acls The list of ACLs * @param {Object[]) acls The list of ACLs
* @param {Object} req The request * @param {Object} req The request
* @returns {AccessRequest} result The effective ACL * @returns {AccessRequest} result The effective ACL
*/ */
ACL.resolvePermission = function resolvePermission(acls, req) { ACL.resolvePermission = function resolvePermission(acls, req) {
if(!(req instanceof AccessRequest)) { if (!(req instanceof AccessRequest)) {
req = new AccessRequest(req); req = new AccessRequest(req);
} }
// Sort by the matching score in descending order // Sort by the matching score in descending order
acls = acls.sort(function (rule1, rule2) { acls = acls.sort(function(rule1, rule2) {
return ACL.getMatchingScore(rule2, req) - ACL.getMatchingScore(rule1, req); return ACL.getMatchingScore(rule2, req) - ACL.getMatchingScore(rule1, req);
}); });
var permission = ACL.DEFAULT; var permission = ACL.DEFAULT;
@ -218,19 +209,19 @@ ACL.resolvePermission = function resolvePermission(acls, req) {
permission = acls[i].permission; permission = acls[i].permission;
break; break;
} else { } else {
if(req.exactlyMatches(acls[i])) { if (req.exactlyMatches(acls[i])) {
permission = acls[i].permission; permission = acls[i].permission;
break; break;
} }
// For wildcard match, find the strongest permission // For wildcard match, find the strongest permission
if(AccessContext.permissionOrder[acls[i].permission] if (AccessContext.permissionOrder[acls[i].permission]
> AccessContext.permissionOrder[permission]) { > AccessContext.permissionOrder[permission]) {
permission = acls[i].permission; permission = acls[i].permission;
} }
} }
} }
if(debug.enabled) { if (debug.enabled) {
debug('The following ACLs were searched: '); debug('The following ACLs were searched: ');
acls.forEach(function(acl) { acls.forEach(function(acl) {
acl.debug(); acl.debug();
@ -241,20 +232,20 @@ ACL.resolvePermission = function resolvePermission(acls, req) {
var res = new AccessRequest(req.model, req.property, req.accessType, var res = new AccessRequest(req.model, req.property, req.accessType,
permission || ACL.DEFAULT); permission || ACL.DEFAULT);
return res; return res;
}; };
/*! /*!
* Get the static ACLs from the model definition * Get the static ACLs from the model definition
* @param {String} model The model name * @param {String} model The model name
* @param {String} property The property/method/relation name * @param {String} property The property/method/relation name
* *
* @return {Object[]} An array of ACLs * @return {Object[]} An array of ACLs
*/ */
ACL.getStaticACLs = function getStaticACLs(model, property) { ACL.getStaticACLs = function getStaticACLs(model, property) {
var modelClass = loopback.findModel(model); var modelClass = loopback.findModel(model);
var staticACLs = []; var staticACLs = [];
if (modelClass && modelClass.settings.acls) { if (modelClass && modelClass.settings.acls) {
modelClass.settings.acls.forEach(function (acl) { modelClass.settings.acls.forEach(function(acl) {
if (!acl.property || acl.property === ACL.ALL if (!acl.property || acl.property === ACL.ALL
|| property === acl.property) { || property === acl.property) {
staticACLs.push(new ACL({ staticACLs.push(new ACL({
@ -274,7 +265,7 @@ ACL.getStaticACLs = function getStaticACLs(model, property) {
|| modelClass[property] // static method || modelClass[property] // static method
|| modelClass.prototype[property]); // prototype method || modelClass.prototype[property]); // prototype method
if (prop && prop.acls) { if (prop && prop.acls) {
prop.acls.forEach(function (acl) { prop.acls.forEach(function(acl) {
staticACLs.push(new ACL({ staticACLs.push(new ACL({
model: modelClass.modelName, model: modelClass.modelName,
property: property, property: property,
@ -286,9 +277,9 @@ ACL.getStaticACLs = function getStaticACLs(model, property) {
}); });
} }
return staticACLs; return staticACLs;
}; };
/** /**
* Check if the given principal is allowed to access the model/property * Check if the given principal is allowed to access the model/property
* @param {String} principalType The principal type. * @param {String} principalType The principal type.
* @param {String} principalId The principal ID. * @param {String} principalId The principal ID.
@ -299,10 +290,10 @@ ACL.getStaticACLs = function getStaticACLs(model, property) {
* @param {String|Error} err The error object * @param {String|Error} err The error object
* @param {AccessRequest} result The access permission * @param {AccessRequest} result The access permission
*/ */
ACL.checkPermission = function checkPermission(principalType, principalId, ACL.checkPermission = function checkPermission(principalType, principalId,
model, property, accessType, model, property, accessType,
callback) { callback) {
if(principalId !== null && principalId !== undefined && (typeof principalId !== 'string') ) { if (principalId !== null && principalId !== undefined && (typeof principalId !== 'string')) {
principalId = principalId.toString(); principalId = principalId.toString();
} }
property = property || ACL.ALL; property = property || ACL.ALL;
@ -316,7 +307,7 @@ ACL.checkPermission = function checkPermission(principalType, principalId,
var resolved = this.resolvePermission(acls, req); var resolved = this.resolvePermission(acls, req);
if(resolved && resolved.permission === ACL.DENY) { if (resolved && resolved.permission === ACL.DENY) {
debug('Permission denied by statically resolved permission'); debug('Permission denied by statically resolved permission');
debug(' Resolved Permission: %j', resolved); debug(' Resolved Permission: %j', resolved);
process.nextTick(function() { process.nextTick(function() {
@ -328,23 +319,23 @@ ACL.checkPermission = function checkPermission(principalType, principalId,
var self = this; var self = this;
this.find({where: {principalType: principalType, principalId: principalId, this.find({where: {principalType: principalType, principalId: principalId,
model: model, property: propertyQuery, accessType: accessTypeQuery}}, model: model, property: propertyQuery, accessType: accessTypeQuery}},
function (err, dynACLs) { function(err, dynACLs) {
if (err) { if (err) {
callback && callback(err); callback && callback(err);
return; return;
} }
acls = acls.concat(dynACLs); acls = acls.concat(dynACLs);
resolved = self.resolvePermission(acls, req); resolved = self.resolvePermission(acls, req);
if(resolved && resolved.permission === ACL.DEFAULT) { if (resolved && resolved.permission === ACL.DEFAULT) {
var modelClass = loopback.findModel(model); var modelClass = loopback.findModel(model);
resolved.permission = (modelClass && modelClass.settings.defaultPermission) || ACL.ALLOW; resolved.permission = (modelClass && modelClass.settings.defaultPermission) || ACL.ALLOW;
} }
callback && callback(null, resolved); callback && callback(null, resolved);
}); });
}; };
ACL.prototype.debug = function() { ACL.prototype.debug = function() {
if(debug.enabled) { if (debug.enabled) {
debug('---ACL---'); debug('---ACL---');
debug('model %s', this.model); debug('model %s', this.model);
debug('property %s', this.property); debug('property %s', this.property);
@ -353,9 +344,9 @@ ACL.prototype.debug = function() {
debug('accessType %s', this.accessType); debug('accessType %s', this.accessType);
debug('permission %s', this.permission); debug('permission %s', this.permission);
} }
} }
/** /**
* Check if the request has the permission to access. * Check if the request has the permission to access.
* @options {Object} context See below. * @options {Object} context See below.
* @property {Object[]} principals An array of principals. * @property {Object[]} principals An array of principals.
@ -366,8 +357,8 @@ ACL.prototype.debug = function() {
* @param {Function} callback Callback function * @param {Function} callback Callback function
*/ */
ACL.checkAccessForContext = function (context, callback) { ACL.checkAccessForContext = function(context, callback) {
if(!(context instanceof AccessContext)) { if (!(context instanceof AccessContext)) {
context = new AccessContext(context); context = new AccessContext(context);
} }
@ -388,7 +379,7 @@ ACL.checkAccessForContext = function (context, callback) {
var self = this; var self = this;
var roleModel = loopback.getModelByType(Role); var roleModel = loopback.getModelByType(Role);
this.find({where: {model: model.modelName, property: propertyQuery, this.find({where: {model: model.modelName, property: propertyQuery,
accessType: accessTypeQuery}}, function (err, acls) { accessType: accessTypeQuery}}, function(err, acls) {
if (err) { if (err) {
callback && callback(err); callback && callback(err);
return; return;
@ -397,7 +388,7 @@ ACL.checkAccessForContext = function (context, callback) {
acls = acls.concat(staticACLs); acls = acls.concat(staticACLs);
acls.forEach(function (acl) { acls.forEach(function(acl) {
// Check exact matches // Check exact matches
for (var i = 0; i < context.principals.length; i++) { for (var i = 0; i < context.principals.length; i++) {
var p = context.principals[i]; var p = context.principals[i];
@ -410,9 +401,9 @@ ACL.checkAccessForContext = function (context, callback) {
// Check role matches // Check role matches
if (acl.principalType === ACL.ROLE) { if (acl.principalType === ACL.ROLE) {
inRoleTasks.push(function (done) { inRoleTasks.push(function(done) {
roleModel.isInRole(acl.principalId, context, roleModel.isInRole(acl.principalId, context,
function (err, inRole) { function(err, inRole) {
if (!err && inRole) { if (!err && inRole) {
effectiveACLs.push(acl); effectiveACLs.push(acl);
} }
@ -422,13 +413,13 @@ ACL.checkAccessForContext = function (context, callback) {
} }
}); });
async.parallel(inRoleTasks, function (err, results) { async.parallel(inRoleTasks, function(err, results) {
if(err) { if (err) {
callback && callback(err, null); callback && callback(err, null);
return; return;
} }
var resolved = self.resolvePermission(effectiveACLs, req); var resolved = self.resolvePermission(effectiveACLs, req);
if(resolved && resolved.permission === ACL.DEFAULT) { if (resolved && resolved.permission === ACL.DEFAULT) {
resolved.permission = (model && model.settings.defaultPermission) || ACL.ALLOW; resolved.permission = (model && model.settings.defaultPermission) || ACL.ALLOW;
} }
debug('---Resolved---'); debug('---Resolved---');
@ -436,9 +427,9 @@ ACL.checkAccessForContext = function (context, callback) {
callback && callback(null, resolved); callback && callback(null, resolved);
}); });
}); });
}; };
/** /**
* Check if the given access token can invoke the method * Check if the given access token can invoke the method
* @param {AccessToken} token The access token * @param {AccessToken} token The access token
* @param {String} model The model name * @param {String} model The model name
@ -448,7 +439,7 @@ ACL.checkAccessForContext = function (context, callback) {
* @param {String|Error} err The error object * @param {String|Error} err The error object
* @param {Boolean} allowed is the request allowed * @param {Boolean} allowed is the request allowed
*/ */
ACL.checkAccessForToken = function (token, model, modelId, method, callback) { ACL.checkAccessForToken = function(token, model, modelId, method, callback) {
assert(token, 'Access token is required'); assert(token, 'Access token is required');
var context = new AccessContext({ var context = new AccessContext({
@ -459,56 +450,13 @@ ACL.checkAccessForToken = function (token, model, modelId, method, callback) {
modelId: modelId modelId: modelId
}); });
this.checkAccessForContext(context, function (err, access) { this.checkAccessForContext(context, function(err, access) {
if (err) { if (err) {
callback && callback(err); callback && callback(err);
return; return;
} }
callback && callback(null, access.permission !== ACL.DENY); 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;

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

@ -1,95 +1,5 @@
var loopback = require('../../lib/loopback');
var assert = require('assert'); 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 * Application management functions
*/ */
@ -123,6 +33,9 @@ function generateKey(hmacKey, algorithm, encoding) {
* @property {Date} created Date Application object was created. Default: current date. * @property {Date} created Date Application object was created. Default: current date.
* @property {Date} modified Date Application object was modified. 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. * @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 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` * If false, uses `gateway.sandbox.push.apple.com:2195` and `feedback.sandbox.push.apple.com:2196`
@ -136,17 +49,39 @@ function generateKey(hmacKey, algorithm, encoding) {
* @property {Number} pushSettings.apns.feedbackOptions.interval (APNS). * @property {Number} pushSettings.apns.feedbackOptions.interval (APNS).
* @property {String} pushSettings.gcm.serverApiKey: Google Cloud Messaging API key. * @property {String} pushSettings.gcm.serverApiKey: Google Cloud Messaging API key.
* *
* @class * @property {Boolean} authenticationEnabled
* @inherits {Model} * @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}
*/ */
var Application = loopback.PersistedModel.extend('Application', ApplicationSchema); 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 * A hook to generate keys before creation
* @param next * @param next
*/ */
Application.beforeCreate = function (next) { Application.beforeCreate = function(next) {
var app = this; var app = this;
app.created = app.modified = new Date(); app.created = app.modified = new Date();
app.id = generateKey('id', 'md5'); app.id = generateKey('id', 'md5');
@ -156,16 +91,16 @@ Application.beforeCreate = function (next) {
app.windowsKey = generateKey('windows'); app.windowsKey = generateKey('windows');
app.masterKey = generateKey('master'); app.masterKey = generateKey('master');
next(); next();
}; };
/** /**
* Register a new application * Register a new application
* @param {String} owner Owner's user ID. * @param {String} owner Owner's user ID.
* @param {String} name Name of the application * @param {String} name Name of the application
* @param {Object} options Other options * @param {Object} options Other options
* @param {Function} callback Callback function * @param {Function} callback Callback function
*/ */
Application.register = function (owner, name, options, cb) { Application.register = function(owner, name, options, cb) {
assert(owner, 'owner is required'); assert(owner, 'owner is required');
assert(name, 'name is required'); assert(name, 'name is required');
@ -180,14 +115,14 @@ Application.register = function (owner, name, options, cb) {
} }
} }
this.create(props, cb); this.create(props, cb);
}; };
/** /**
* Reset keys for the application instance * Reset keys for the application instance
* @callback {Function} callback * @callback {Function} callback
* @param {Error} err * @param {Error} err
*/ */
Application.prototype.resetKeys = function (cb) { Application.prototype.resetKeys = function(cb) {
this.clientKey = generateKey('client'); this.clientKey = generateKey('client');
this.javaScriptKey = generateKey('javaScript'); this.javaScriptKey = generateKey('javaScript');
this.restApiKey = generateKey('restApi'); this.restApiKey = generateKey('restApi');
@ -195,25 +130,25 @@ Application.prototype.resetKeys = function (cb) {
this.masterKey = generateKey('master'); this.masterKey = generateKey('master');
this.modified = new Date(); this.modified = new Date();
this.save(cb); this.save(cb);
}; };
/** /**
* Reset keys for a given application by the appId * Reset keys for a given application by the appId
* @param {Any} appId * @param {Any} appId
* @callback {Function} callback * @callback {Function} callback
* @param {Error} err * @param {Error} err
*/ */
Application.resetKeys = function (appId, cb) { Application.resetKeys = function(appId, cb) {
this.findById(appId, function (err, app) { this.findById(appId, function(err, app) {
if (err) { if (err) {
cb && cb(err, app); cb && cb(err, app);
return; return;
} }
app.resetKeys(cb); app.resetKeys(cb);
}); });
}; };
/** /**
* Authenticate the application id and key. * Authenticate the application id and key.
* *
* `matched` parameter is one of: * `matched` parameter is one of:
@ -229,8 +164,8 @@ Application.resetKeys = function (appId, cb) {
* @param {Error} err * @param {Error} err
* @param {String} matched The matching key * @param {String} matched The matching key
*/ */
Application.authenticate = function (appId, key, cb) { Application.authenticate = function(appId, key, cb) {
this.findById(appId, function (err, app) { this.findById(appId, function(err, app) {
if (err || !app) { if (err || !app) {
cb && cb(err, null); cb && cb(err, null);
return; return;
@ -248,7 +183,5 @@ Application.authenticate = function (appId, key, cb) {
} }
cb && cb(null, result); cb && cb(null, result);
}); });
};
}; };
module.exports = Application;

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"
}
}

View File

@ -10,26 +10,6 @@ var PersistedModel = require('../../lib/loopback').PersistedModel
, assert = require('assert') , assert = require('assert')
, debug = require('debug')('loopback:change'); , 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. * Change list entry.
@ -41,45 +21,45 @@ var options = {
* @property {String} modelName Model name * @property {String} modelName Model name
* @property {String} modelId Model ID * @property {String} modelId Model ID
* *
* @class * @class Change
* @inherits {Model} * @inherits {PersistedModel}
*/ */
var Change = module.exports = PersistedModel.extend('Change', properties, options); module.exports = function(Change) {
/*! /*!
* Constants * Constants
*/ */
Change.UPDATE = 'update'; Change.UPDATE = 'update';
Change.CREATE = 'create'; Change.CREATE = 'create';
Change.DELETE = 'delete'; Change.DELETE = 'delete';
Change.UNKNOWN = 'unknown'; Change.UNKNOWN = 'unknown';
/*! /*!
* Conflict Class * Conflict Class
*/ */
Change.Conflict = Conflict; Change.Conflict = Conflict;
/*! /*!
* Setup the extended model. * Setup the extended model.
*/ */
Change.setup = function() { Change.setup = function() {
PersistedModel.setup.call(this); PersistedModel.setup.call(this);
var Change = this; var Change = this;
Change.getter.id = function() { Change.getter.id = function() {
var hasModel = this.modelName && this.modelId; var hasModel = this.modelName && this.modelId;
if(!hasModel) return null; if (!hasModel) return null;
return Change.idForModel(this.modelName, this.modelId); return Change.idForModel(this.modelName, this.modelId);
} }
} }
Change.setup(); Change.setup();
/** /**
* Track the recent change of the given modelIds. * Track the recent change of the given modelIds.
* *
* @param {String} modelName * @param {String} modelName
@ -89,22 +69,22 @@ Change.setup();
* @param {Array} changes Changes that were tracked * @param {Array} changes Changes that were tracked
*/ */
Change.rectifyModelChanges = function(modelName, modelIds, callback) { Change.rectifyModelChanges = function(modelName, modelIds, callback) {
var tasks = []; var tasks = [];
var Change = this; var Change = this;
modelIds.forEach(function(id) { modelIds.forEach(function(id) {
tasks.push(function(cb) { tasks.push(function(cb) {
Change.findOrCreateChange(modelName, id, function(err, change) { Change.findOrCreateChange(modelName, id, function(err, change) {
if(err) return Change.handleError(err, cb); if (err) return Change.handleError(err, cb);
change.rectify(cb); change.rectify(cb);
}); });
}); });
}); });
async.parallel(tasks, callback); async.parallel(tasks, callback);
} }
/** /**
* Get an identifier for a given model. * Get an identifier for a given model.
* *
* @param {String} modelName * @param {String} modelName
@ -112,11 +92,11 @@ Change.rectifyModelChanges = function(modelName, modelIds, callback) {
* @return {String} * @return {String}
*/ */
Change.idForModel = function(modelName, modelId) { Change.idForModel = function(modelName, modelId) {
return this.hash([modelName, modelId].join('-')); return this.hash([modelName, modelId].join('-'));
} }
/** /**
* Find or create a change for the given model. * Find or create a change for the given model.
* *
* @param {String} modelName * @param {String} modelName
@ -127,14 +107,14 @@ Change.idForModel = function(modelName, modelId) {
* @end * @end
*/ */
Change.findOrCreateChange = function(modelName, modelId, callback) { Change.findOrCreateChange = function(modelName, modelId, callback) {
assert(loopback.findModel(modelName), modelName + ' does not exist'); assert(loopback.findModel(modelName), modelName + ' does not exist');
var id = this.idForModel(modelName, modelId); var id = this.idForModel(modelName, modelId);
var Change = this; var Change = this;
this.findById(id, function(err, change) { this.findById(id, function(err, change) {
if(err) return callback(err); if (err) return callback(err);
if(change) { if (change) {
callback(null, change); callback(null, change);
} else { } else {
var ch = new Change({ var ch = new Change({
@ -146,9 +126,9 @@ Change.findOrCreateChange = function(modelName, modelId, callback) {
ch.save(callback); ch.save(callback);
} }
}); });
} }
/** /**
* Update (or create) the change with the current revision. * Update (or create) the change with the current revision.
* *
* @callback {Function} callback * @callback {Function} callback
@ -156,7 +136,7 @@ Change.findOrCreateChange = function(modelName, modelId, callback) {
* @param {Change} change * @param {Change} change
*/ */
Change.prototype.rectify = function(cb) { Change.prototype.rectify = function(cb) {
var change = this; var change = this;
var tasks = [ var tasks = [
updateRevision, updateRevision,
@ -167,12 +147,12 @@ Change.prototype.rectify = function(cb) {
change.debug('rectify change'); change.debug('rectify change');
cb = cb || function(err) { cb = cb || function(err) {
if(err) throw new Error(err); if (err) throw new Error(err);
} }
async.parallel(tasks, function(err) { async.parallel(tasks, function(err) {
if(err) return cb(err); if (err) return cb(err);
if(change.prev === Change.UNKNOWN) { if (change.prev === Change.UNKNOWN) {
// this occurs when a record of a change doesn't exist // this occurs when a record of a change doesn't exist
// and its current revision is null (not found) // and its current revision is null (not found)
change.remove(cb); change.remove(cb);
@ -184,10 +164,10 @@ Change.prototype.rectify = function(cb) {
function updateRevision(cb) { function updateRevision(cb) {
// get the current revision // get the current revision
change.currentRevision(function(err, rev) { change.currentRevision(function(err, rev) {
if(err) return Change.handleError(err, cb); if (err) return Change.handleError(err, cb);
if(rev) { if (rev) {
// avoid setting rev and prev to the same value // avoid setting rev and prev to the same value
if(currentRev !== rev) { if (currentRev !== rev) {
change.rev = rev; change.rev = rev;
change.prev = currentRev; change.prev = currentRev;
} else { } else {
@ -195,9 +175,9 @@ Change.prototype.rectify = function(cb) {
} }
} else { } else {
change.rev = null; change.rev = null;
if(currentRev) { if (currentRev) {
change.prev = currentRev; change.prev = currentRev;
} else if(!change.prev) { } else if (!change.prev) {
change.debug('ERROR - could not determing prev'); change.debug('ERROR - could not determing prev');
change.prev = Change.UNKNOWN; change.prev = Change.UNKNOWN;
} }
@ -209,34 +189,34 @@ Change.prototype.rectify = function(cb) {
function updateCheckpoint(cb) { function updateCheckpoint(cb) {
change.constructor.getCheckpointModel().current(function(err, checkpoint) { change.constructor.getCheckpointModel().current(function(err, checkpoint) {
if(err) return Change.handleError(err); if (err) return Change.handleError(err);
change.checkpoint = checkpoint; change.checkpoint = checkpoint;
cb(); cb();
}); });
} }
} }
/** /**
* Get a change's current revision based on current data. * Get a change's current revision based on current data.
* @callback {Function} callback * @callback {Function} callback
* @param {Error} err * @param {Error} err
* @param {String} rev The current revision * @param {String} rev The current revision
*/ */
Change.prototype.currentRevision = function(cb) { Change.prototype.currentRevision = function(cb) {
var model = this.getModelCtor(); var model = this.getModelCtor();
var id = this.getModelId(); var id = this.getModelId();
model.findById(id, function(err, inst) { model.findById(id, function(err, inst) {
if(err) return Change.handleError(err, cb); if (err) return Change.handleError(err, cb);
if(inst) { if (inst) {
cb(null, Change.revisionForInst(inst)); cb(null, Change.revisionForInst(inst));
} else { } else {
cb(null, null); cb(null, null);
} }
}); });
} }
/** /**
* Create a hash of the given `string` with the `options.hashAlgorithm`. * Create a hash of the given `string` with the `options.hashAlgorithm`.
* **Default: `sha1`** * **Default: `sha1`**
* *
@ -244,24 +224,24 @@ Change.prototype.currentRevision = function(cb) {
* @return {String} The hashed string * @return {String} The hashed string
*/ */
Change.hash = function(str) { Change.hash = function(str) {
return crypto return crypto
.createHash(Change.settings.hashAlgorithm || 'sha1') .createHash(Change.settings.hashAlgorithm || 'sha1')
.update(str) .update(str)
.digest('hex'); .digest('hex');
} }
/** /**
* Get the revision string for the given object * Get the revision string for the given object
* @param {Object} inst The data to get the revision string for * @param {Object} inst The data to get the revision string for
* @return {String} The revision string * @return {String} The revision string
*/ */
Change.revisionForInst = function(inst) { Change.revisionForInst = function(inst) {
return this.hash(CJSON.stringify(inst)); return this.hash(CJSON.stringify(inst));
} }
/** /**
* Get a change's type. Returns one of: * Get a change's type. Returns one of:
* *
* - `Change.UPDATE` * - `Change.UPDATE`
@ -272,69 +252,69 @@ Change.revisionForInst = function(inst) {
* @return {String} the type of change * @return {String} the type of change
*/ */
Change.prototype.type = function() { Change.prototype.type = function() {
if(this.rev && this.prev) { if (this.rev && this.prev) {
return Change.UPDATE; return Change.UPDATE;
} }
if(this.rev && !this.prev) { if (this.rev && !this.prev) {
return Change.CREATE; return Change.CREATE;
} }
if(!this.rev && this.prev) { if (!this.rev && this.prev) {
return Change.DELETE; return Change.DELETE;
} }
return Change.UNKNOWN; return Change.UNKNOWN;
} }
/** /**
* Compare two changes. * Compare two changes.
* @param {Change} change * @param {Change} change
* @return {Boolean} * @return {Boolean}
*/ */
Change.prototype.equals = function(change) { Change.prototype.equals = function(change) {
if(!change) return false; if (!change) return false;
var thisRev = this.rev || null; var thisRev = this.rev || null;
var thatRev = change.rev || null; var thatRev = change.rev || null;
return thisRev === thatRev; return thisRev === thatRev;
} }
/** /**
* Does this change conflict with the given change. * Does this change conflict with the given change.
* @param {Change} change * @param {Change} change
* @return {Boolean} * @return {Boolean}
*/ */
Change.prototype.conflictsWith = function(change) { Change.prototype.conflictsWith = function(change) {
if(!change) return false; if (!change) return false;
if(this.equals(change)) return false; if (this.equals(change)) return false;
if(Change.bothDeleted(this, change)) return false; if (Change.bothDeleted(this, change)) return false;
if(this.isBasedOn(change)) return false; if (this.isBasedOn(change)) return false;
return true; return true;
} }
/** /**
* Are both changes deletes? * Are both changes deletes?
* @param {Change} a * @param {Change} a
* @param {Change} b * @param {Change} b
* @return {Boolean} * @return {Boolean}
*/ */
Change.bothDeleted = function(a, b) { Change.bothDeleted = function(a, b) {
return a.type() === Change.DELETE return a.type() === Change.DELETE
&& b.type() === Change.DELETE; && b.type() === Change.DELETE;
} }
/** /**
* Determine if the change is based on the given change. * Determine if the change is based on the given change.
* @param {Change} change * @param {Change} change
* @return {Boolean} * @return {Boolean}
*/ */
Change.prototype.isBasedOn = function(change) { Change.prototype.isBasedOn = function(change) {
return this.prev === change.rev; return this.prev === change.rev;
} }
/** /**
* Determine the differences for a given model since a given checkpoint. * Determine the differences for a given model since a given checkpoint.
* *
* The callback will contain an error or `result`. * The callback will contain an error or `result`.
@ -364,7 +344,7 @@ Change.prototype.isBasedOn = function(change) {
* @param {Object} result See above. * @param {Object} result See above.
*/ */
Change.diff = function(modelName, since, remoteChanges, callback) { Change.diff = function(modelName, since, remoteChanges, callback) {
var remoteChangeIndex = {}; var remoteChangeIndex = {};
var modelIds = []; var modelIds = [];
remoteChanges.forEach(function(ch) { remoteChanges.forEach(function(ch) {
@ -381,7 +361,7 @@ Change.diff = function(modelName, since, remoteChanges, callback) {
checkpoint: {gte: since} checkpoint: {gte: since}
} }
}, function(err, localChanges) { }, function(err, localChanges) {
if(err) return callback(err); if (err) return callback(err);
var deltas = []; var deltas = [];
var conflicts = []; var conflicts = [];
var localModelIds = []; var localModelIds = [];
@ -390,8 +370,8 @@ Change.diff = function(modelName, since, remoteChanges, callback) {
localChange = new Change(localChange); localChange = new Change(localChange);
localModelIds.push(localChange.modelId); localModelIds.push(localChange.modelId);
var remoteChange = remoteChangeIndex[localChange.modelId]; var remoteChange = remoteChangeIndex[localChange.modelId];
if(remoteChange && !localChange.equals(remoteChange)) { if (remoteChange && !localChange.equals(remoteChange)) {
if(remoteChange.conflictsWith(localChange)) { if (remoteChange.conflictsWith(localChange)) {
remoteChange.debug('remote conflict'); remoteChange.debug('remote conflict');
localChange.debug('local conflict'); localChange.debug('local conflict');
conflicts.push(localChange); conflicts.push(localChange);
@ -403,7 +383,7 @@ Change.diff = function(modelName, since, remoteChanges, callback) {
}); });
modelIds.forEach(function(id) { modelIds.forEach(function(id) {
if(localModelIds.indexOf(id) === -1) { if (localModelIds.indexOf(id) === -1) {
deltas.push(remoteChangeIndex[id]); deltas.push(remoteChangeIndex[id]);
} }
}); });
@ -413,49 +393,49 @@ Change.diff = function(modelName, since, remoteChanges, callback) {
conflicts: conflicts conflicts: conflicts
}); });
}); });
} }
/** /**
* Correct all change list entries. * Correct all change list entries.
* @param {Function} callback * @param {Function} callback
*/ */
Change.rectifyAll = function(cb) { Change.rectifyAll = function(cb) {
debug('rectify all'); debug('rectify all');
var Change = this; var Change = this;
// this should be optimized // this should be optimized
this.find(function(err, changes) { this.find(function(err, changes) {
if(err) return cb(err); if (err) return cb(err);
changes.forEach(function(change) { changes.forEach(function(change) {
change = new Change(change); change = new Change(change);
change.rectify(); change.rectify();
}); });
}); });
} }
/** /**
* Get the checkpoint model. * Get the checkpoint model.
* @return {Checkpoint} * @return {Checkpoint}
*/ */
Change.getCheckpointModel = function() { Change.getCheckpointModel = function() {
var checkpointModel = this.Checkpoint; var checkpointModel = this.Checkpoint;
if(checkpointModel) return checkpointModel; if (checkpointModel) return checkpointModel;
this.checkpoint = checkpointModel = require('./checkpoint').extend('checkpoint'); this.checkpoint = checkpointModel = loopback.Checkpoint.extend('checkpoint');
assert(this.dataSource, 'Cannot getCheckpointModel(): ' + this.modelName assert(this.dataSource, 'Cannot getCheckpointModel(): ' + this.modelName
+ ' is not attached to a dataSource'); + ' is not attached to a dataSource');
checkpointModel.attachTo(this.dataSource); checkpointModel.attachTo(this.dataSource);
return checkpointModel; return checkpointModel;
} }
Change.handleError = function(err) { Change.handleError = function(err) {
if(!this.settings.ignoreErrors) { if (!this.settings.ignoreErrors) {
throw err; throw err;
} }
} }
Change.prototype.debug = function() { Change.prototype.debug = function() {
if(debug.enabled) { if (debug.enabled) {
var args = Array.prototype.slice.call(arguments); var args = Array.prototype.slice.call(arguments);
debug.apply(this, args); debug.apply(this, args);
debug('\tid', this.id); debug('\tid', this.id);
@ -465,33 +445,33 @@ Change.prototype.debug = function() {
debug('\tmodelId', this.modelId); debug('\tmodelId', this.modelId);
debug('\ttype', this.type()); debug('\ttype', this.type());
} }
} }
/** /**
* Get the `Model` class for `change.modelName`. * Get the `Model` class for `change.modelName`.
* @return {Model} * @return {Model}
*/ */
Change.prototype.getModelCtor = function() { Change.prototype.getModelCtor = function() {
return this.constructor.settings.trackModel; return this.constructor.settings.trackModel;
} }
Change.prototype.getModelId = function() { Change.prototype.getModelId = function() {
// TODO(ritch) get rid of the need to create an instance // TODO(ritch) get rid of the need to create an instance
var Model = this.getModelCtor(); var Model = this.getModelCtor();
var id = this.modelId; var id = this.modelId;
var m = new Model(); var m = new Model();
m.setId(id); m.setId(id);
return m.getId(); return m.getId();
} }
Change.prototype.getModel = function(callback) { Change.prototype.getModel = function(callback) {
var Model = this.constructor.settings.trackModel; var Model = this.constructor.settings.trackModel;
var id = this.getModelId(); var id = this.getModelId();
Model.findById(id, callback); Model.findById(id, callback);
} }
/** /**
* When two changes conflict a conflict is created. * When two changes conflict a conflict is created.
* *
* **Note: call `conflict.fetch()` to get the `target` and `source` models. * **Note: call `conflict.fetch()` to get the `target` and `source` models.
@ -501,17 +481,18 @@ Change.prototype.getModel = function(callback) {
* @param {PersistedModel} TargetModel * @param {PersistedModel} TargetModel
* @property {ModelClass} source The source model instance * @property {ModelClass} source The source model instance
* @property {ModelClass} target The target model instance * @property {ModelClass} target The target model instance
* @class Change.Conflict
*/ */
function Conflict(modelId, SourceModel, TargetModel) { function Conflict(modelId, SourceModel, TargetModel) {
this.SourceModel = SourceModel; this.SourceModel = SourceModel;
this.TargetModel = TargetModel; this.TargetModel = TargetModel;
this.SourceChange = SourceModel.getChangeModel(); this.SourceChange = SourceModel.getChangeModel();
this.TargetChange = TargetModel.getChangeModel(); this.TargetChange = TargetModel.getChangeModel();
this.modelId = modelId; this.modelId = modelId;
} }
/** /**
* Fetch the conflicting models. * Fetch the conflicting models.
* *
* @callback {Function} callback * @callback {Function} callback
@ -520,7 +501,7 @@ function Conflict(modelId, SourceModel, TargetModel) {
* @param {PersistedModel} target * @param {PersistedModel} target
*/ */
Conflict.prototype.models = function(cb) { Conflict.prototype.models = function(cb) {
var conflict = this; var conflict = this;
var SourceModel = this.SourceModel; var SourceModel = this.SourceModel;
var TargetModel = this.TargetModel; var TargetModel = this.TargetModel;
@ -534,7 +515,7 @@ Conflict.prototype.models = function(cb) {
function getSourceModel(cb) { function getSourceModel(cb) {
SourceModel.findById(conflict.modelId, function(err, model) { SourceModel.findById(conflict.modelId, function(err, model) {
if(err) return cb(err); if (err) return cb(err);
source = model; source = model;
cb(); cb();
}); });
@ -542,19 +523,19 @@ Conflict.prototype.models = function(cb) {
function getTargetModel(cb) { function getTargetModel(cb) {
TargetModel.findById(conflict.modelId, function(err, model) { TargetModel.findById(conflict.modelId, function(err, model) {
if(err) return cb(err); if (err) return cb(err);
target = model; target = model;
cb(); cb();
}); });
} }
function done(err) { function done(err) {
if(err) return cb(err); if (err) return cb(err);
cb(null, source, target); cb(null, source, target);
} }
} }
/** /**
* Get the conflicting changes. * Get the conflicting changes.
* *
* @callback {Function} callback * @callback {Function} callback
@ -563,7 +544,7 @@ Conflict.prototype.models = function(cb) {
* @param {Change} targetChange * @param {Change} targetChange
*/ */
Conflict.prototype.changes = function(cb) { Conflict.prototype.changes = function(cb) {
var conflict = this; var conflict = this;
var sourceChange; var sourceChange;
var targetChange; var targetChange;
@ -577,7 +558,7 @@ Conflict.prototype.changes = function(cb) {
conflict.SourceChange.findOne({where: { conflict.SourceChange.findOne({where: {
modelId: conflict.modelId modelId: conflict.modelId
}}, function(err, change) { }}, function(err, change) {
if(err) return cb(err); if (err) return cb(err);
sourceChange = change; sourceChange = change;
cb(); cb();
}); });
@ -587,35 +568,35 @@ Conflict.prototype.changes = function(cb) {
conflict.TargetChange.findOne({where: { conflict.TargetChange.findOne({where: {
modelId: conflict.modelId modelId: conflict.modelId
}}, function(err, change) { }}, function(err, change) {
if(err) return cb(err); if (err) return cb(err);
targetChange = change; targetChange = change;
cb(); cb();
}); });
} }
function done(err) { function done(err) {
if(err) return cb(err); if (err) return cb(err);
cb(null, sourceChange, targetChange); cb(null, sourceChange, targetChange);
} }
} }
/** /**
* Resolve the conflict. * Resolve the conflict.
* *
* @callback {Function} callback * @callback {Function} callback
* @param {Error} err * @param {Error} err
*/ */
Conflict.prototype.resolve = function(cb) { Conflict.prototype.resolve = function(cb) {
var conflict = this; var conflict = this;
conflict.changes(function(err, sourceChange, targetChange) { conflict.changes(function(err, sourceChange, targetChange) {
if(err) return cb(err); if (err) return cb(err);
sourceChange.prev = targetChange.rev; sourceChange.prev = targetChange.rev;
sourceChange.save(cb); sourceChange.save(cb);
}); });
} }
/** /**
* Determine the conflict type. * Determine the conflict type.
* *
* Possible results are * Possible results are
@ -629,18 +610,19 @@ Conflict.prototype.resolve = function(cb) {
* @param {String} type The conflict type. * @param {String} type The conflict type.
*/ */
Conflict.prototype.type = function(cb) { Conflict.prototype.type = function(cb) {
var conflict = this; var conflict = this;
this.changes(function(err, sourceChange, targetChange) { this.changes(function(err, sourceChange, targetChange) {
if(err) return cb(err); if (err) return cb(err);
var sourceChangeType = sourceChange.type(); var sourceChangeType = sourceChange.type();
var targetChangeType = targetChange.type(); var targetChangeType = targetChange.type();
if(sourceChangeType === Change.UPDATE && targetChangeType === Change.UPDATE) { if (sourceChangeType === Change.UPDATE && targetChangeType === Change.UPDATE) {
return cb(null, Change.UPDATE); return cb(null, Change.UPDATE);
} }
if(sourceChangeType === Change.DELETE || targetChangeType === Change.DELETE) { if (sourceChangeType === Change.DELETE || targetChangeType === Change.DELETE) {
return cb(null, Change.DELETE); return cb(null, Change.DELETE);
} }
return cb(null, Change.UNKNOWN); 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

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

View File

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

View File

@ -1,18 +1,3 @@
/*!
* Module Dependencies.
*/
var Model = require('../../lib/loopback').Model
, loopback = require('../../lib/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} to Email addressee. Required.
* @property {String} from Email sender address. Required. * @property {String} from Email sender address. Required.
@ -20,13 +5,13 @@ var properties = {
* @property {String} text Text body of email. * @property {String} text Text body of email.
* @property {String} html HTML body of email. * @property {String} html HTML body of email.
* *
* @class * @class Email
* @inherits {Model} * @inherits {Model}
*/ */
var Email = module.exports = Model.extend('Email', properties); module.exports = function(Email) {
/** /**
* Send an email with the given `options`. * Send an email with the given `options`.
* *
* Example Options: * Example Options:
@ -52,6 +37,14 @@ var Email = module.exports = Model.extend('Email', properties);
* @param {Function} callback Called after the e-mail is sent or the sending failed * @param {Function} callback Called after the e-mail is sent or the sending failed
*/ */
Email.prototype.send = function() { Email.send = function() {
throw new Error('You must connect the Email Model to a Mail connector'); 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,70 @@
/**
* 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(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"
}
}
}

View File

@ -5,171 +5,79 @@ var async = require('async');
var AccessContext = require('../../lib/access-context').AccessContext; var AccessContext = require('../../lib/access-context').AccessContext;
// Role model var RoleMapping = loopback.RoleMapping;
var RoleSchema = { assert(RoleMapping, 'RoleMapping model must be defined before Role model');
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 * The Role Model
* @class * @class Role
*/ */
var Role = loopback.createModel('Role', RoleSchema, { module.exports = function(Role) {
relations: {
principals: {
type: 'hasMany',
model: 'RoleMapping',
foreignKey: 'roleId'
}
}
});
// Set up the connection to users/applications/roles once the model // Workaround for https://github.com/strongloop/loopback/issues/292
Role.once('dataSourceAttached', function () { 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); var roleMappingModel = this.RoleMapping || loopback.getModelByType(RoleMapping);
Role.prototype.users = function (callback) { Role.prototype.users = function(callback) {
roleMappingModel.find({where: {roleId: this.id, roleMappingModel.find({where: {roleId: this.id,
principalType: RoleMapping.USER}}, function (err, mappings) { principalType: RoleMapping.USER}}, function(err, mappings) {
if (err) { if (err) {
callback && callback(err); callback && callback(err);
return; return;
} }
return mappings.map(function (m) { return mappings.map(function(m) {
return m.principalId; return m.principalId;
}); });
}); });
}; };
Role.prototype.applications = function (callback) { Role.prototype.applications = function(callback) {
roleMappingModel.find({where: {roleId: this.id, roleMappingModel.find({where: {roleId: this.id,
principalType: RoleMapping.APPLICATION}}, function (err, mappings) { principalType: RoleMapping.APPLICATION}}, function(err, mappings) {
if (err) { if (err) {
callback && callback(err); callback && callback(err);
return; return;
} }
return mappings.map(function (m) { return mappings.map(function(m) {
return m.principalId; return m.principalId;
}); });
}); });
}; };
Role.prototype.roles = function (callback) { Role.prototype.roles = function(callback) {
roleMappingModel.find({where: {roleId: this.id, roleMappingModel.find({where: {roleId: this.id,
principalType: RoleMapping.ROLE}}, function (err, mappings) { principalType: RoleMapping.ROLE}}, function(err, mappings) {
if (err) { if (err) {
callback && callback(err); callback && callback(err);
return; return;
} }
return mappings.map(function (m) { return mappings.map(function(m) {
return m.principalId; return m.principalId;
}); });
}); });
}; };
}); });
// Special roles // Special roles
Role.OWNER = '$owner'; // owner of the object Role.OWNER = '$owner'; // owner of the object
Role.RELATED = "$related"; // any User with a relationship to the object Role.RELATED = "$related"; // any User with a relationship to the object
Role.AUTHENTICATED = "$authenticated"; // authenticated user Role.AUTHENTICATED = "$authenticated"; // authenticated user
Role.UNAUTHENTICATED = "$unauthenticated"; // authenticated user Role.UNAUTHENTICATED = "$unauthenticated"; // authenticated user
Role.EVERYONE = "$everyone"; // everyone Role.EVERYONE = "$everyone"; // everyone
/** /**
* Add custom handler for roles * Add custom handler for roles
* @param role * @param role
* @param resolver The resolver function decides if a principal is in the role * @param resolver The resolver function decides if a principal is in the role
@ -177,15 +85,15 @@ Role.EVERYONE = "$everyone"; // everyone
* *
* function(role, context, callback) * function(role, context, callback)
*/ */
Role.registerResolver = function(role, resolver) { Role.registerResolver = function(role, resolver) {
if(!Role.resolvers) { if (!Role.resolvers) {
Role.resolvers = {}; Role.resolvers = {};
} }
Role.resolvers[role] = resolver; Role.resolvers[role] = resolver;
}; };
Role.registerResolver(Role.OWNER, function(role, context, callback) { Role.registerResolver(Role.OWNER, function(role, context, callback) {
if(!context || !context.model || !context.modelId) { if (!context || !context.model || !context.modelId) {
process.nextTick(function() { process.nextTick(function() {
callback && callback(null, false); callback && callback(null, false);
}); });
@ -195,40 +103,40 @@ Role.registerResolver(Role.OWNER, function(role, context, callback) {
var modelId = context.modelId; var modelId = context.modelId;
var userId = context.getUserId(); var userId = context.getUserId();
Role.isOwner(modelClass, modelId, userId, callback); Role.isOwner(modelClass, modelId, userId, callback);
}); });
function isUserClass(modelClass) { function isUserClass(modelClass) {
return modelClass === loopback.User || return modelClass === loopback.User ||
modelClass.prototype instanceof loopback.User; modelClass.prototype instanceof loopback.User;
} }
/*! /*!
* Check if two user ids matches * Check if two user ids matches
* @param {*} id1 * @param {*} id1
* @param {*} id2 * @param {*} id2
* @returns {boolean} * @returns {boolean}
*/ */
function matches(id1, id2) { function matches(id1, id2) {
if (id1 === undefined || id1 === null || id1 ==='' if (id1 === undefined || id1 === null || id1 === ''
|| id2 === undefined || id2 === null || id2 === '') { || id2 === undefined || id2 === null || id2 === '') {
return false; return false;
} }
// The id can be a MongoDB ObjectID // The id can be a MongoDB ObjectID
return id1 === id2 || id1.toString() === id2.toString(); return id1 === id2 || id1.toString() === id2.toString();
} }
/** /**
* Check if a given userId is the owner the model instance * Check if a given userId is the owner the model instance
* @param {Function} modelClass The model class * @param {Function} modelClass The model class
* @param {*} modelId The model id * @param {*} modelId The model id
* @param {*) userId The user id * @param {*) userId The user id
* @param {Function} callback * @param {Function} callback
*/ */
Role.isOwner = function isOwner(modelClass, modelId, userId, callback) { Role.isOwner = function isOwner(modelClass, modelId, userId, callback) {
assert(modelClass, 'Model class is required'); assert(modelClass, 'Model class is required');
debug('isOwner(): %s %s userId: %s', modelClass && modelClass.modelName, modelId, userId); debug('isOwner(): %s %s userId: %s', modelClass && modelClass.modelName, modelId, userId);
// No userId is present // No userId is present
if(!userId) { if (!userId) {
process.nextTick(function() { process.nextTick(function() {
callback(null, false); callback(null, false);
}); });
@ -236,7 +144,7 @@ Role.isOwner = function isOwner(modelClass, modelId, userId, callback) {
} }
// Is the modelClass User or a subclass of User? // Is the modelClass User or a subclass of User?
if(isUserClass(modelClass)) { if (isUserClass(modelClass)) {
process.nextTick(function() { process.nextTick(function() {
callback(null, matches(modelId, userId)); callback(null, matches(modelId, userId));
}); });
@ -244,24 +152,24 @@ Role.isOwner = function isOwner(modelClass, modelId, userId, callback) {
} }
modelClass.findById(modelId, function(err, inst) { modelClass.findById(modelId, function(err, inst) {
if(err || !inst) { if (err || !inst) {
debug('Model not found for id %j', modelId); debug('Model not found for id %j', modelId);
callback && callback(err, false); callback && callback(err, false);
return; return;
} }
debug('Model found: %j', inst); debug('Model found: %j', inst);
var ownerId = inst.userId || inst.owner; var ownerId = inst.userId || inst.owner;
if(ownerId) { if (ownerId) {
callback && callback(null, matches(ownerId, userId)); callback && callback(null, matches(ownerId, userId));
return; return;
} else { } else {
// Try to follow belongsTo // Try to follow belongsTo
for(var r in modelClass.relations) { for (var r in modelClass.relations) {
var rel = modelClass.relations[r]; var rel = modelClass.relations[r];
if(rel.type === 'belongsTo' && isUserClass(rel.modelTo)) { if (rel.type === 'belongsTo' && isUserClass(rel.modelTo)) {
debug('Checking relation %s to %s: %j', r, rel.modelTo.modelName, rel); debug('Checking relation %s to %s: %j', r, rel.modelTo.modelName, rel);
inst[r](function(err, user) { inst[r](function(err, user) {
if(!err && user) { if (!err && user) {
debug('User found: %j', user.id); debug('User found: %j', user.id);
callback && callback(null, matches(user.id, userId)); callback && callback(null, matches(user.id, userId));
} else { } else {
@ -275,44 +183,44 @@ Role.isOwner = function isOwner(modelClass, modelId, userId, callback) {
callback && callback(null, false); callback && callback(null, false);
} }
}); });
}; };
Role.registerResolver(Role.AUTHENTICATED, function(role, context, callback) { Role.registerResolver(Role.AUTHENTICATED, function(role, context, callback) {
if(!context) { if (!context) {
process.nextTick(function() { process.nextTick(function() {
callback && callback(null, false); callback && callback(null, false);
}); });
return; return;
} }
Role.isAuthenticated(context, callback); Role.isAuthenticated(context, callback);
}); });
/** /**
* Check if the user id is authenticated * Check if the user id is authenticated
* @param {Object} context The security context * @param {Object} context The security context
* @callback {Function} callback * @callback {Function} callback
* @param {Error} err * @param {Error} err
* @param {Boolean} isAuthenticated * @param {Boolean} isAuthenticated
*/ */
Role.isAuthenticated = function isAuthenticated(context, callback) { Role.isAuthenticated = function isAuthenticated(context, callback) {
process.nextTick(function() { process.nextTick(function() {
callback && callback(null, context.isAuthenticated()); callback && callback(null, context.isAuthenticated());
}); });
}; };
Role.registerResolver(Role.UNAUTHENTICATED, function(role, context, callback) { Role.registerResolver(Role.UNAUTHENTICATED, function(role, context, callback) {
process.nextTick(function() { process.nextTick(function() {
callback && callback(null, !context || !context.isAuthenticated()); callback && callback(null, !context || !context.isAuthenticated());
}); });
}); });
Role.registerResolver(Role.EVERYONE, function (role, context, callback) { Role.registerResolver(Role.EVERYONE, function(role, context, callback) {
process.nextTick(function () { process.nextTick(function() {
callback && callback(null, true); // Always true callback && callback(null, true); // Always true
}); });
}); });
/** /**
* Check if a given principal is in the role * Check if a given principal is in the role
* *
* @param {String} role The role name * @param {String} role The role name
@ -321,7 +229,7 @@ Role.registerResolver(Role.EVERYONE, function (role, context, callback) {
* @param {Error} err * @param {Error} err
* @param {Boolean} isInRole * @param {Boolean} isInRole
*/ */
Role.isInRole = function (role, context, callback) { Role.isInRole = function(role, context, callback) {
if (!(context instanceof AccessContext)) { if (!(context instanceof AccessContext)) {
context = new AccessContext(context); context = new AccessContext(context);
} }
@ -338,13 +246,13 @@ Role.isInRole = function (role, context, callback) {
if (context.principals.length === 0) { if (context.principals.length === 0) {
debug('isInRole() returns: false'); debug('isInRole() returns: false');
process.nextTick(function () { process.nextTick(function() {
callback && callback(null, false); callback && callback(null, false);
}); });
return; return;
} }
var inRole = context.principals.some(function (p) { var inRole = context.principals.some(function(p) {
var principalType = p.type || undefined; var principalType = p.type || undefined;
var principalId = p.id || undefined; var principalId = p.id || undefined;
@ -355,14 +263,14 @@ Role.isInRole = function (role, context, callback) {
if (inRole) { if (inRole) {
debug('isInRole() returns: %j', inRole); debug('isInRole() returns: %j', inRole);
process.nextTick(function () { process.nextTick(function() {
callback && callback(null, true); callback && callback(null, true);
}); });
return; return;
} }
var roleMappingModel = this.RoleMapping || loopback.getModelByType(RoleMapping); var roleMappingModel = this.RoleMapping || loopback.getModelByType(RoleMapping);
this.findOne({where: {name: role}}, function (err, result) { this.findOne({where: {name: role}}, function(err, result) {
if (err) { if (err) {
callback && callback(err); callback && callback(err);
return; return;
@ -374,36 +282,36 @@ Role.isInRole = function (role, context, callback) {
debug('Role found: %j', result); debug('Role found: %j', result);
// Iterate through the list of principals // Iterate through the list of principals
async.some(context.principals, function (p, done) { async.some(context.principals, function(p, done) {
var principalType = p.type || undefined; var principalType = p.type || undefined;
var principalId = p.id || undefined; var principalId = p.id || undefined;
var roleId = result.id.toString(); var roleId = result.id.toString();
if(principalId !== null && principalId !== undefined && (typeof principalId !== 'string') ) { if (principalId !== null && principalId !== undefined && (typeof principalId !== 'string')) {
principalId = principalId.toString(); principalId = principalId.toString();
} }
if (principalType && principalId) { if (principalType && principalId) {
roleMappingModel.findOne({where: {roleId: roleId, roleMappingModel.findOne({where: {roleId: roleId,
principalType: principalType, principalId: principalId}}, principalType: principalType, principalId: principalId}},
function (err, result) { function(err, result) {
debug('Role mapping found: %j', result); debug('Role mapping found: %j', result);
done(!err && result); // The only arg is the result done(!err && result); // The only arg is the result
}); });
} else { } else {
process.nextTick(function () { process.nextTick(function() {
done(false); done(false);
}); });
} }
}, function (inRole) { }, function(inRole) {
debug('isInRole() returns: %j', inRole); debug('isInRole() returns: %j', inRole);
callback && callback(null, inRole); callback && callback(null, inRole);
}); });
}); });
}; };
/** /**
* List roles for a given principal * List roles for a given principal
* @param {Object} context The security context * @param {Object} context The security context
* @param {Function} callback * @param {Function} callback
@ -412,13 +320,13 @@ Role.isInRole = function (role, context, callback) {
* @param err * @param err
* @param {String[]} An array of role ids * @param {String[]} An array of role ids
*/ */
Role.getRoles = function (context, callback) { Role.getRoles = function(context, callback) {
if(!(context instanceof AccessContext)) { if (!(context instanceof AccessContext)) {
context = new AccessContext(context); context = new AccessContext(context);
} }
var roles = []; var roles = [];
var addRole = function (role) { var addRole = function(role) {
if (role && roles.indexOf(role) === -1) { if (role && roles.indexOf(role) === -1) {
roles.push(role); roles.push(role);
} }
@ -427,10 +335,10 @@ Role.getRoles = function (context, callback) {
var self = this; var self = this;
// Check against the smart roles // Check against the smart roles
var inRoleTasks = []; var inRoleTasks = [];
Object.keys(Role.resolvers).forEach(function (role) { Object.keys(Role.resolvers).forEach(function(role) {
inRoleTasks.push(function (done) { inRoleTasks.push(function(done) {
self.isInRole(role, context, function (err, inRole) { self.isInRole(role, context, function(err, inRole) {
if(debug.enabled) { if (debug.enabled) {
debug('In role %j: %j', role, inRole); debug('In role %j: %j', role, inRole);
} }
if (!err && inRole) { if (!err && inRole) {
@ -444,7 +352,7 @@ Role.getRoles = function (context, callback) {
}); });
var roleMappingModel = this.RoleMapping || loopback.getModelByType(RoleMapping); var roleMappingModel = this.RoleMapping || loopback.getModelByType(RoleMapping);
context.principals.forEach(function (p) { context.principals.forEach(function(p) {
// Check against the role mappings // Check against the role mappings
var principalType = p.type || undefined; var principalType = p.type || undefined;
var principalId = p.id || undefined; var principalId = p.id || undefined;
@ -456,15 +364,15 @@ Role.getRoles = function (context, callback) {
if (principalType && principalId) { if (principalType && principalId) {
// Please find() treat undefined matches all values // Please find() treat undefined matches all values
inRoleTasks.push(function (done) { inRoleTasks.push(function(done) {
roleMappingModel.find({where: {principalType: principalType, roleMappingModel.find({where: {principalType: principalType,
principalId: principalId}}, function (err, mappings) { principalId: principalId}}, function(err, mappings) {
debug('Role mappings found: %s %j', err, mappings); debug('Role mappings found: %s %j', err, mappings);
if (err) { if (err) {
done && done(err); done && done(err);
return; return;
} }
mappings.forEach(function (m) { mappings.forEach(function(m) {
addRole(m.roleId); addRole(m.roleId);
}); });
done && done(); done && done();
@ -473,16 +381,9 @@ Role.getRoles = function (context, callback) {
} }
}); });
async.parallel(inRoleTasks, function (err, results) { async.parallel(inRoleTasks, function(err, results) {
debug('getRoles() returns: %j %j', err, roles); debug('getRoles() returns: %j %j', err, roles);
callback && callback(err, roles); callback && callback(err, roles);
}); });
};
}; };
module.exports = {
Role: Role,
RoleMapping: RoleMapping
};

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"
}
}
}

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

@ -0,0 +1,39 @@
var assert = require('assert');
/**
* 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,107 +2,18 @@
* Module Dependencies. * Module Dependencies.
*/ */
var PersistedModel = require('../../lib/loopback').PersistedModel var loopback = require('../../lib/loopback')
, loopback = require('../../lib/loopback')
, path = require('path') , path = require('path')
, SALT_WORK_FACTOR = 10 , SALT_WORK_FACTOR = 10
, crypto = require('crypto') , crypto = require('crypto')
, bcrypt = require('bcryptjs') , bcrypt = require('bcryptjs')
, BaseAccessToken = require('./access-token')
, DEFAULT_TTL = 1209600 // 2 weeks in seconds , DEFAULT_TTL = 1209600 // 2 weeks in seconds
, DEFAULT_RESET_PW_TTL = 15 * 60 // 15 mins in seconds , DEFAULT_RESET_PW_TTL = 15 * 60 // 15 mins in seconds
, DEFAULT_MAX_TTL = 31556926 // 1 year in seconds , DEFAULT_MAX_TTL = 31556926 // 1 year in seconds
, Role = require('./role').Role
, ACL = require('./acl').ACL
, assert = require('assert'); , assert = require('assert');
var debug = require('debug')('loopback:user'); 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. * Extends from the built in `loopback.Model` type.
* *
@ -122,11 +33,11 @@ var options = {
* @property {Boolean} emailVerified Set when a user's email has been verified via `confirm()` * @property {Boolean} emailVerified Set when a user's email has been verified via `confirm()`
* @property {String} verificationToken Set when `verify()` is called * @property {String} verificationToken Set when `verify()` is called
* *
* @class * @class User
* @inherits {Model} * @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 * Create access token for the logged in user. This method can be overridden to
@ -150,8 +61,8 @@ User.prototype.createAccessToken = function(ttl, cb) {
* *
* ```js * ```js
* User.login({username: 'foo', password: 'bar'}, function (err, token) { * User.login({username: 'foo', password: 'bar'}, function (err, token) {
* console.log(token.id); * console.log(token.id);
* }); * });
* ``` * ```
* *
* @param {Object} credentials * @param {Object} credentials
@ -160,7 +71,7 @@ User.prototype.createAccessToken = function(ttl, cb) {
* @param {AccessToken} token * @param {AccessToken} token
*/ */
User.login = function (credentials, include, fn) { User.login = function(credentials, include, fn) {
var self = this; var self = this;
if (typeof include === 'function') { if (typeof include === 'function') {
fn = include; fn = include;
@ -169,19 +80,18 @@ User.login = function (credentials, include, fn) {
include = (include || ''); include = (include || '');
if (Array.isArray(include)) { if (Array.isArray(include)) {
include = include.map(function ( val ) { include = include.map(function(val) {
return val.toLowerCase(); return val.toLowerCase();
}); });
}else { } else {
include = include.toLowerCase(); include = include.toLowerCase();
} }
var query = {}; var query = {};
if(credentials.email) { if (credentials.email) {
query.email = credentials.email; query.email = credentials.email;
} else if(credentials.username) { } else if (credentials.username) {
query.username = credentials.username; query.username = credentials.username;
} else { } else {
var err = new Error('username or email is required'); var err = new Error('username or email is required');
@ -193,10 +103,10 @@ User.login = function (credentials, include, fn) {
var defaultError = new Error('login failed'); var defaultError = new Error('login failed');
defaultError.statusCode = 401; defaultError.statusCode = 401;
if(err) { if (err) {
debug('An error is reported from User.findOne: %j', err); debug('An error is reported from User.findOne: %j', err);
fn(defaultError); fn(defaultError);
} else if(user) { } else if (user) {
if (self.settings.emailVerificationRequired) { if (self.settings.emailVerificationRequired) {
if (!user.emailVerified) { if (!user.emailVerified) {
// Fail to log in if email verification is not done yet // Fail to log in if email verification is not done yet
@ -207,10 +117,10 @@ User.login = function (credentials, include, fn) {
} }
} }
user.hasPassword(credentials.password, function(err, isMatch) { user.hasPassword(credentials.password, function(err, isMatch) {
if(err) { if (err) {
debug('An error is reported from User.hasPassword: %j', err); debug('An error is reported from User.hasPassword: %j', err);
fn(defaultError); fn(defaultError);
} else if(isMatch) { } else if (isMatch) {
user.createAccessToken(credentials.ttl, function(err, token) { user.createAccessToken(credentials.ttl, function(err, token) {
if (err) return fn(err); if (err) return fn(err);
if (Array.isArray(include) ? include.indexOf('user') !== -1 : include === 'user') { if (Array.isArray(include) ? include.indexOf('user') !== -1 : include === 'user') {
@ -241,8 +151,8 @@ User.login = function (credentials, include, fn) {
* *
* ```js * ```js
* User.logout('asd0a9f8dsj9s0s3223mk', function (err) { * User.logout('asd0a9f8dsj9s0s3223mk', function (err) {
* console.log(err || 'Logged out'); * console.log(err || 'Logged out');
* }); * });
* ``` * ```
* *
* @param {String} accessTokenID * @param {String} accessTokenID
@ -250,11 +160,11 @@ User.login = function (credentials, include, fn) {
* @param {Error} err * @param {Error} err
*/ */
User.logout = function (tokenId, fn) { User.logout = function(tokenId, fn) {
this.relations.accessTokens.modelTo.findById(tokenId, function (err, accessToken) { this.relations.accessTokens.modelTo.findById(tokenId, function(err, accessToken) {
if(err) { if (err) {
fn(err); fn(err);
} else if(accessToken) { } else if (accessToken) {
accessToken.destroy(fn); accessToken.destroy(fn);
} else { } else {
fn(new Error('could not find accessToken')); fn(new Error('could not find accessToken'));
@ -269,10 +179,10 @@ User.logout = function (tokenId, fn) {
* @returns {Boolean} * @returns {Boolean}
*/ */
User.prototype.hasPassword = function (plain, fn) { User.prototype.hasPassword = function(plain, fn) {
if(this.password && plain) { if (this.password && plain) {
bcrypt.compare(plain, this.password, function(err, isMatch) { bcrypt.compare(plain, this.password, function(err, isMatch) {
if(err) return fn(err); if (err) return fn(err);
fn(null, isMatch); fn(null, isMatch);
}); });
} else { } else {
@ -285,11 +195,11 @@ User.prototype.hasPassword = function (plain, fn) {
* *
* ```js * ```js
* var options = { * var options = {
* type: 'email', * type: 'email',
* to: user.email, * to: user.email,
* template: 'verify.ejs', * template: 'verify.ejs',
* redirect: '/' * redirect: '/'
* }; * };
* *
* user.verify(options, next); * user.verify(options, next);
* ``` * ```
@ -297,7 +207,7 @@ User.prototype.hasPassword = function (plain, fn) {
* @param {Object} options * @param {Object} options
*/ */
User.prototype.verify = function (options, fn) { User.prototype.verify = function(options, fn) {
var user = this; var user = this;
var userModel = this.constructor; var userModel = this.constructor;
assert(typeof options === 'object', 'options required when calling user.verify()'); assert(typeof options === 'object', 'options required when calling user.verify()');
@ -334,12 +244,12 @@ User.prototype.verify = function (options, fn) {
var Email = options.mailer || this.constructor.email || loopback.getModelByType(loopback.Email); var Email = options.mailer || this.constructor.email || loopback.getModelByType(loopback.Email);
crypto.randomBytes(64, function(err, buf) { crypto.randomBytes(64, function(err, buf) {
if(err) { if (err) {
fn(err); fn(err);
} else { } else {
user.verificationToken = buf.toString('hex'); user.verificationToken = buf.toString('hex');
user.save(function (err) { user.save(function(err) {
if(err) { if (err) {
fn(err); fn(err);
} else { } else {
sendEmail(user); sendEmail(user);
@ -363,8 +273,8 @@ User.prototype.verify = function (options, fn) {
subject: options.subject || 'Thanks for Registering', subject: options.subject || 'Thanks for Registering',
text: options.text, text: options.text,
html: template(options) html: template(options)
}, function (err, email) { }, function(err, email) {
if(err) { if (err) {
fn(err); fn(err);
} else { } else {
fn(null, {email: email, token: user.verificationToken, uid: user.id}); fn(null, {email: email, token: user.verificationToken, uid: user.id});
@ -383,16 +293,16 @@ User.prototype.verify = function (options, fn) {
* @callback {Function} callback * @callback {Function} callback
* @param {Error} err * @param {Error} err
*/ */
User.confirm = function (uid, token, redirect, fn) { User.confirm = function(uid, token, redirect, fn) {
this.findById(uid, function (err, user) { this.findById(uid, function(err, user) {
if(err) { if (err) {
fn(err); fn(err);
} else { } else {
if(user && user.verificationToken === token) { if (user && user.verificationToken === token) {
user.verificationToken = undefined; user.verificationToken = undefined;
user.emailVerified = true; user.emailVerified = true;
user.save(function (err) { user.save(function(err) {
if(err) { if (err) {
fn(err); fn(err);
} else { } else {
fn(); fn();
@ -427,15 +337,15 @@ User.resetPassword = function(options, cb) {
var ttl = UserModel.settings.resetPasswordTokenTTL || DEFAULT_RESET_PW_TTL; var ttl = UserModel.settings.resetPasswordTokenTTL || DEFAULT_RESET_PW_TTL;
options = options || {}; options = options || {};
if(typeof options.email === 'string') { if (typeof options.email === 'string') {
UserModel.findOne({ where: {email: options.email} }, function(err, user) { UserModel.findOne({ where: {email: options.email} }, function(err, user) {
if(err) { if (err) {
cb(err); cb(err);
} else if(user) { } else if (user) {
// create a short lived access token for temp login to change password // create a short lived access token for temp login to change password
// TODO(ritch) - eventually this should only allow password change // TODO(ritch) - eventually this should only allow password change
user.accessTokens.create({ttl: ttl}, function(err, accessToken) { user.accessTokens.create({ttl: ttl}, function(err, accessToken) {
if(err) { if (err) {
cb(err); cb(err);
} else { } else {
cb(); cb();
@ -462,16 +372,16 @@ User.resetPassword = function(options, cb) {
* Setup an extended user model. * Setup an extended user model.
*/ */
User.setup = function () { User.setup = function() {
// We need to call the base class's setup method // We need to call the base class's setup method
PersistedModel.setup.call(this); User.base.setup.call(this);
var UserModel = this; var UserModel = this;
// max ttl // max ttl
this.settings.maxTTL = this.settings.maxTTL || DEFAULT_MAX_TTL; this.settings.maxTTL = this.settings.maxTTL || DEFAULT_MAX_TTL;
this.settings.ttl = DEFAULT_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); var salt = bcrypt.genSaltSync(this.constructor.settings.saltWorkFactor || SALT_WORK_FACTOR);
this.$password = bcrypt.hashSync(plain, salt); this.$password = bcrypt.hashSync(plain, salt);
} }
@ -491,13 +401,11 @@ User.setup = function () {
description: 'Login a user with username/email and password', description: 'Login a user with username/email and password',
accepts: [ accepts: [
{arg: 'credentials', type: 'object', required: true, http: {source: 'body'}}, {arg: 'credentials', type: 'object', required: true, http: {source: 'body'}},
{arg: 'include', type: 'string', http: {source: 'query' }, description: {arg: 'include', type: 'string', http: {source: 'query' }, description: 'Related objects to include in the response. ' +
'Related objects to include in the response. ' +
'See the description of return value for more details.'} 'See the description of return value for more details.'}
], ],
returns: { returns: {
arg: 'accessToken', type: 'object', root: true, description: arg: 'accessToken', type: 'object', root: true, description: 'The response body contains properties of the AccessToken created on login.\n' +
'The response body contains properties of the AccessToken created on login.\n' +
'Depending on the value of `include` parameter, the body may contain ' + 'Depending on the value of `include` parameter, the body may contain ' +
'additional properties:\n\n' + 'additional properties:\n\n' +
' - `user` - `{User}` - Data of the currently logged in user. (`include=user`)\n\n' ' - `user` - `{User}` - Data of the currently logged in user. (`include=user`)\n\n'
@ -517,8 +425,7 @@ User.setup = function () {
var tokenID = accessToken && accessToken.id; var tokenID = accessToken && accessToken.id;
return tokenID; return tokenID;
}, description: }, description: 'Do not supply this argument, it is automatically extracted ' +
'Do not supply this argument, it is automatically extracted ' +
'from request headers.' 'from request headers.'
} }
], ],
@ -550,9 +457,9 @@ User.setup = function () {
} }
); );
UserModel.on('attached', function () { UserModel.on('attached', function() {
UserModel.afterRemote('confirm', function (ctx, inst, next) { UserModel.afterRemote('confirm', function(ctx, inst, next) {
if(ctx.req) { if (ctx.req) {
ctx.res.redirect(ctx.req.param('redirect')); ctx.res.redirect(ctx.req.param('redirect'));
} else { } else {
fn(new Error('transport unsupported')); fn(new Error('transport unsupported'));
@ -561,8 +468,11 @@ User.setup = function () {
}); });
// default models // default models
UserModel.email = require('./email'); assert(loopback.Email, 'Email model must be defined before User model');
UserModel.accessToken = require('./access-token'); UserModel.email = loopback.Email;
assert(loopback.AccessToken, 'AccessToken model must be defined before User model');
UserModel.accessToken = loopback.AccessToken;
// email validation regex // 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,}))$/; 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,}))$/;
@ -579,3 +489,5 @@ User.setup = function () {
*/ */
User.setup(); 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": "ACL.ALLOW",
"property": "confirm"
},
{
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW",
"property": "resetPassword",
"accessType": "EXECUTE"
}
],
"relations": {
"accessTokens": {
"type": "hasMany",
"model": "AccessToken",
"foreignKey": "userId"
}
}
}

View File

@ -16,8 +16,10 @@
{ "title": "Built-in models", "depth": 2 }, { "title": "Built-in models", "depth": 2 },
"common/models/access-token.js", "common/models/access-token.js",
"common/models/acl.js", "common/models/acl.js",
"common/models/scope.js",
"common/models/application.js", "common/models/application.js",
"common/models/email.js", "common/models/email.js",
"common/models/role-mapping.js",
"common/models/role.js", "common/models/role.js",
"common/models/user.js", "common/models/user.js",
"common/models/change.js" "common/models/change.js"

View File

@ -1,5 +1,4 @@
var loopback = require('./loopback'); var loopback = require('./loopback');
var AccessToken = require('../common/models/access-token');
var debug = require('debug')('loopback:security:access-context'); var debug = require('debug')('loopback:security:access-context');
/** /**
@ -49,7 +48,9 @@ function AccessContext(context) {
} }
this.accessType = context.accessType || AccessContext.ALL; 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 principalType = context.principalType || Principal.USER;
var principalId = context.principalId || undefined; var principalId = context.principalId || undefined;
@ -280,7 +281,7 @@ AccessRequest.prototype.exactlyMatches = function(acl) {
*/ */
AccessRequest.prototype.isAllowed = function() { AccessRequest.prototype.isAllowed = function() {
return this.permission !== require('../common/models/acl').ACL.DENY; return this.permission !== loopback.ACL.DENY;
} }
AccessRequest.prototype.debug = function() { AccessRequest.prototype.debug = function() {

View File

@ -1,13 +1,45 @@
module.exports = function(loopback) { module.exports = function(loopback) {
loopback.Email = require('../common/models/email'); // NOTE(bajtos) we must use static require() due to browserify limitations
loopback.User = require('../common/models/user');
loopback.Application = require('../common/models/application'); loopback.Email = createModel(
loopback.AccessToken = require('../common/models/access-token'); require('../common/models/email.json'),
loopback.Role = require('../common/models/role').Role; require('../common/models/email.js'));
loopback.RoleMapping = require('../common/models/role').RoleMapping;
loopback.ACL = require('../common/models/acl').ACL; loopback.Application = createModel(
loopback.Scope = require('../common/models/acl').Scope; require('../common/models/application.json'),
loopback.Change = require('../common/models/change'); 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 * Automatically attach these models to dataSources
@ -27,4 +59,10 @@ module.exports = function(loopback) {
loopback.ACL.autoAttach = dataSourceTypes.DB; loopback.ACL.autoAttach = dataSourceTypes.DB;
loopback.Scope.autoAttach = dataSourceTypes.DB; loopback.Scope.autoAttach = dataSourceTypes.DB;
loopback.Application.autoAttach = dataSourceTypes.DB; loopback.Application.autoAttach = dataSourceTypes.DB;
function createModel(definitionJson, customizeFn) {
var Model = loopback.createModel(definitionJson);
customizeFn(Model);
return Model;
}
}; };

View File

@ -960,7 +960,10 @@ PersistedModel.enableChangeTracking = function() {
} }
PersistedModel._defineChangeModel = function() { PersistedModel._defineChangeModel = function() {
var BaseChangeModel = require('./../common/models/change'); var BaseChangeModel = loopback.Change;
assert(BaseChangeModel,
'Change model must be defined before enabling change replication');
return this.Change = BaseChangeModel.extend(this.modelName + '-change', return this.Change = BaseChangeModel.extend(this.modelName + '-change',
{}, {},
{ {

View File

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

View File

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

View File

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

View File

@ -241,4 +241,28 @@ describe('loopback', function() {
expect(owner._targetClass).to.equal('User'); 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

@ -600,4 +600,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');
});
});
}); });