Add support for scoped access tokens
Define a new property `AccessToken.scopes` to contain the list of scopes granted to this access token. Define a new remote method metadata `accessScopes` to contain a list of scope name required by this method. Define a special built-in scope name "DEFAULT" that's used when a method/token does not provide any scopes. This allows access tokens to grant access to both the default scope and any additional custom scopes at the same time. Modify the authorization algorithm to ensure that at least one of the scopes required by a remote method is allowed by the scopes granted to the requesting access token. The "DEFAULT" scope preserve backwards compatibility because existing remote methods with no `accessScopes` can be accessed by (existing) access tokens with no `scopes` defined. Impact on existing applications: - Database schema must be updated after upgrading the loopback version - If the application was already using a custom `AccessToken.scopes` property with a type different from an array, then the relevant code must be updated to work with the new type "array of strings".
This commit is contained in:
parent
9fef5284c7
commit
c5145bdf34
|
@ -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