Merge pull request #3313 from strongloop/feature/access-token-scopes
Add support for scoped access tokens
This commit is contained in:
commit
95240598b3
|
@ -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"
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
});
|
|
@ -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;
|
||||
|
||||
|
|
Loading…
Reference in New Issue