Merge pull request #3313 from strongloop/feature/access-token-scopes

Add support for scoped access tokens
This commit is contained in:
Miroslav Bajtoš 2017-04-07 13:28:32 +02:00 committed by GitHub
commit 95240598b3
6 changed files with 200 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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