diff --git a/common/models/access-token.json b/common/models/access-token.json index 85fdf910..9e3d4d46 100644 --- a/common/models/access-token.json +++ b/common/models/access-token.json @@ -11,6 +11,10 @@ "default": 1209600, "description": "time to live in seconds (2 weeks by default)" }, + "scopes": { + "type": ["string"], + "description": "Array of scopes granted to this access token." + }, "created": { "type": "Date", "defaultFn": "now" diff --git a/common/models/acl.js b/common/models/acl.js index e849312f..5a726759 100644 --- a/common/models/acl.js +++ b/common/models/acl.js @@ -98,6 +98,8 @@ module.exports = function(ACL) { ACL.ROLE = Principal.ROLE; ACL.SCOPE = Principal.SCOPE; + ACL.DEFAULT_SCOPE = ctx.DEFAULT_SCOPES[0]; + /** * Calculate the matching score for the given rule and request * @param {ACL} rule The ACL entry @@ -462,6 +464,16 @@ module.exports = function(ACL) { methodNames, registry: this.registry}); + if (!context.isScopeAllowed()) { + req.permission = ACL.DENY; + debug('--Denied by scope config--'); + debug('Scopes allowed:', context.accessToken.scopes || ctx.DEFAULT_SCOPES); + debug('Scope required:', context.getScopes()); + context.debug(); + callback(null, req); + return callback.promise; + } + var effectiveACLs = []; var staticACLs = self.getStaticACLs(model.modelName, property); diff --git a/lib/access-context.js b/lib/access-context.js index 15662034..0040020c 100644 --- a/lib/access-context.js +++ b/lib/access-context.js @@ -8,6 +8,8 @@ var assert = require('assert'); var loopback = require('./loopback'); var debug = require('debug')('loopback:security:access-context'); +const DEFAULT_SCOPES = ['DEFAULT']; + /** * Access context represents the context for a request to access protected * resources @@ -70,6 +72,7 @@ function AccessContext(context) { } this.accessType = context.accessType || AccessContext.ALL; + assert(loopback.AccessToken, 'AccessToken model must be defined before AccessContext model'); this.accessToken = context.accessToken || loopback.AccessToken.ANONYMOUS; @@ -193,6 +196,45 @@ AccessContext.prototype.isAuthenticated = function() { return !!(this.getUserId() || this.getAppId()); }; +/** + * Get the list of scopes required by the current access context. + */ +AccessContext.prototype.getScopes = function() { + if (!this.sharedMethod) + return DEFAULT_SCOPES; + + // For backwards compatibility, methods with no scopes defined + // are assigned a single "DEFAULT" scope + const methodLevel = this.sharedMethod.accessScopes || DEFAULT_SCOPES; + + // TODO add model-level and app-level scopes + + debug('--Context scopes of %s()--', this.sharedMethod.stringName); + debug(' method-level: %j', methodLevel); + + return methodLevel; +}; + +/** + * Check if the scope required by the remote method is allowed + * by the scopes granted to the requesting access token. + * @return {boolean} + */ +AccessContext.prototype.isScopeAllowed = function() { + if (!this.accessToken) return false; + + // For backwards compatibility, tokens with no scopes are treated + // as if they have "DEFAULT" scope granted + const tokenScopes = this.accessToken.scopes || DEFAULT_SCOPES; + + const resourceScopes = this.getScopes(); + + // Scope is allowed when at least one of token's scopes + // is found in method's (resource's) scopes. + return Array.isArray(tokenScopes) && Array.isArray(resourceScopes) && + resourceScopes.some(s => tokenScopes.indexOf(s) !== -1); +}; + /*! * Print debug info for access context. */ @@ -213,10 +255,12 @@ AccessContext.prototype.debug = function() { debug('property %s', this.property); debug('method %s', this.method); debug('accessType %s', this.accessType); + debug('accessScopes %j', this.getScopes()); if (this.accessToken) { debug('accessToken:'); debug(' id %j', this.accessToken.id); debug(' ttl %j', this.accessToken.ttl); + debug(' scopes %j', this.accessToken.scopes || DEFAULT_SCOPES); } debug('getUserId() %s', this.getUserId()); debug('isAuthenticated() %s', this.isAuthenticated()); @@ -379,3 +423,4 @@ AccessRequest.prototype.debug = function() { module.exports.AccessContext = AccessContext; module.exports.Principal = Principal; module.exports.AccessRequest = AccessRequest; +module.exports.DEFAULT_SCOPES = DEFAULT_SCOPES; diff --git a/test/acl.test.js b/test/acl.test.js index 15cd8706..3b9b0ef3 100644 --- a/test/acl.test.js +++ b/test/acl.test.js @@ -30,6 +30,12 @@ before(function() { ds = loopback.createDataSource({connector: loopback.Memory}); }); +describe('ACL model', function() { + it('provides DEFAULT_SCOPE constant', () => { + expect(ACL).to.have.property('DEFAULT_SCOPE', 'DEFAULT'); + }); +}); + describe('security scopes', function() { beforeEach(function() { var ds = this.ds = loopback.createDataSource({connector: loopback.Memory}); diff --git a/test/authorization-scopes.test.js b/test/authorization-scopes.test.js new file mode 100644 index 00000000..797cbd48 --- /dev/null +++ b/test/authorization-scopes.test.js @@ -0,0 +1,130 @@ +'use strict'; + +const loopback = require('../'); +const supertest = require('supertest'); +const strongErrorHandler = require('strong-error-handler'); + +describe('Authorization scopes', () => { + const CUSTOM_SCOPE = 'read:custom'; + + let app, request, User, testUser, regularToken, scopedToken; + beforeEach(givenAppAndRequest); + beforeEach(givenRemoteMethodWithCustomScope); + beforeEach(givenUser); + beforeEach(givenDefaultToken); + beforeEach(givenScopedToken); + + it('denies regular token to invoke custom-scoped method', () => { + logServerErrorsOtherThan(401); + return request.get('/users/scoped') + .set('Authorization', regularToken.id) + .expect(401); + }); + + it('allows regular tokens to invoke default-scoped method', () => { + logAllServerErrors(); + return request.get('/users/' + testUser.id) + .set('Authorization', regularToken.id) + .expect(200); + }); + + it('allows scoped token to invoke custom-scoped method', () => { + logAllServerErrors(); + return request.get('/users/scoped') + .set('Authorization', scopedToken.id) + .expect(204); + }); + + it('denies scoped token to invoke default-scoped method', () => { + logServerErrorsOtherThan(401); + return request.get('/users/' + testUser.id) + .set('Authorization', scopedToken.id) + .expect(401); + }); + + describe('token granted both default and custom scope', () => { + beforeEach('given token with default and custom scope', + () => givenScopedToken(['DEFAULT', CUSTOM_SCOPE])); + beforeEach(logAllServerErrors); + + it('allows invocation of default-scoped method', () => { + return request.get('/users/' + testUser.id) + .set('Authorization', scopedToken.id) + .expect(200); + }); + + it('allows invocation of custom-scoped method', () => { + return request.get('/users/scoped') + .set('Authorization', scopedToken.id) + .expect(204); + }); + }); + + it('allows invocation when at least one method scope is matched', () => { + givenRemoteMethodWithCustomScope(['read', 'write']); + return givenScopedToken(['read', 'execute']).then(() => { + return request.get('/users/scoped') + .set('Authorization', scopedToken.id) + .expect(204); + }); + }); + + function givenAppAndRequest() { + app = loopback({localRegistry: true, loadBuiltinModels: true}); + app.set('remoting', {rest: {handleErrors: false}}); + app.dataSource('db', {connector: 'memory'}); + app.enableAuth({dataSource: 'db'}); + request = supertest(app); + + app.use(loopback.rest()); + + User = app.models.User; + } + + function givenRemoteMethodWithCustomScope() { + const accessScopes = arguments[0] || [CUSTOM_SCOPE]; + User.scoped = function(cb) { cb(); }; + User.remoteMethod('scoped', { + accessScopes, + http: {verb: 'GET', path: '/scoped'}, + }); + User.settings.acls.push({ + principalType: 'ROLE', + principalId: '$authenticated', + permission: 'ALLOW', + property: 'scoped', + accessType: 'EXECUTE', + }); + } + + function givenUser() { + return User.create({email: 'test@example.com', password: 'pass'}) + .then(u => testUser = u); + } + + function givenDefaultToken() { + return testUser.createAccessToken(60) + .then(t => regularToken = t); + } + + function givenScopedToken() { + const scopes = arguments[0] || [CUSTOM_SCOPE]; + return testUser.accessTokens.create({ttl: 60, scopes}) + .then(t => scopedToken = t); + } + + function logAllServerErrors() { + logServerErrorsOtherThan(-1); + } + + function logServerErrorsOtherThan(statusCode) { + app.use((err, req, res, next) => { + if ((err.statusCode || 500) !== statusCode) { + console.log('Unhandled error for request %s %s: %s', + req.method, req.url, err.stack || err); + } + res.statusCode = err.statusCode || 500; + res.json(err); + }); + } +}); diff --git a/test/user.test.js b/test/user.test.js index 1004a348..e7da06ec 100644 --- a/test/user.test.js +++ b/test/user.test.js @@ -624,18 +624,18 @@ describe('User', function() { // Override createAccessToken User.prototype.createAccessToken = function(ttl, options, cb) { // Reduce the ttl by half for testing purpose - this.accessTokens.create({ttl: ttl / 2, scopes: options.scope}, cb); + this.accessTokens.create({ttl: ttl / 2, scopes: [options.scope]}, cb); }; User.login(validCredentialsWithTTLAndScope, function(err, accessToken) { assertGoodToken(accessToken, validCredentialsUser); assert.equal(accessToken.ttl, 1800); - assert.equal(accessToken.scopes, 'all'); + assert.deepEqual(accessToken.scopes, ['all']); User.findById(accessToken.userId, function(err, user) { user.createAccessToken(120, {scope: 'default'}, function(err, accessToken) { assertGoodToken(accessToken, validCredentialsUser); assert.equal(accessToken.ttl, 60); - assert.equal(accessToken.scopes, 'default'); + assert.deepEqual(accessToken.scopes, ['default']); // Restore create access token User.prototype.createAccessToken = createToken;