diff --git a/src/accesscontrol.js b/src/accesscontrol.js index ec71d53c9..dc5b75d78 100644 --- a/src/accesscontrol.js +++ b/src/accesscontrol.js @@ -19,14 +19,14 @@ exports = module.exports = { ROLE_OWNER: 'owner', - scopesForRoles: scopesForRoles, - validateRoles: validateRoles, validateScopeString: validateScopeString, hasScopes: hasScopes, + canonicalScopeString: canonicalScopeString, intersectScopes: intersectScopes, - canonicalScopeString: canonicalScopeString + validateToken: validateToken, + scopesForRoles: scopesForRoles }; // https://docs.microsoft.com/en-us/azure/role-based-access-control/role-definitions @@ -46,7 +46,11 @@ const ROLE_DEFINITIONS = { }; var assert = require('assert'), + DatabaseError = require('./databaseerror.js'), debug = require('debug')('box:accesscontrol'), + tokendb = require('./tokendb.js'), + users = require('./users.js'), + UsersError = users.UsersError, _ = require('underscore'); // returns scopes that does not have wildcards and is sorted @@ -60,6 +64,8 @@ function intersectScopes(allowedScopes, wantedScopes) { assert(Array.isArray(allowedScopes), 'Expecting sorted array'); assert(Array.isArray(wantedScopes), 'Expecting sorted array'); + if (_.isEqual(allowedScopes, wantedScopes)) return allowedScopes; // quick path + let wantedScopesMap = new Map(); let results = []; @@ -134,7 +140,7 @@ function hasScopes(authorizedScopes, requiredScopes) { function scopesForRoles(roles) { assert(Array.isArray(roles), 'Expecting array'); - var scopes = [ 'profile', 'apps:read' ]; + let scopes = [ 'profile', 'apps:read' ]; // the minimum scopes for (let r of roles) { if (!ROLE_DEFINITIONS[r]) continue; // unknown or some legacy role @@ -142,5 +148,35 @@ function scopesForRoles(roles) { scopes = scopes.concat(ROLE_DEFINITIONS[r].scopes); } - return _.uniq(scopes.sort(), true /* isSorted */); + // fold scopes so we don't have duplicate scopes + let sortedScopes = scopes.sort(); + let set = new Set(); + for (let s of sortedScopes) { + var parts = s.split(':'); + if (set.has(parts[0])) continue; + set.add(s); + } + return Array.from(set); +} + +function validateToken(accessToken, callback) { + assert.strictEqual(typeof accessToken, 'string'); + assert.strictEqual(typeof callback, 'function'); + + tokendb.get(accessToken, function (error, token) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, null /* user */, 'Invalid Token'); // will end up as a 401 + if (error) return callback(error); // this triggers 'internal error' in passport + + users.getWithRoles(token.identifier, function (error, user) { + if (error && error.reason === UsersError.NOT_FOUND) return callback(null, null /* user */, 'Invalid Token'); // will end up as a 401 + if (error) return callback(error); + + const userScopes = scopesForRoles(user.roles); + var authorizedScopes = intersectScopes(userScopes, token.scope.split(',')); + const skipPasswordVerification = token.clientId === 'cid-sdk' || token.clientId === 'cid-cli'; // these clients do not require password checks unlike UI + var info = { authorizedScopes: authorizedScopes, skipPasswordVerification: skipPasswordVerification }; // ends up in req.authInfo + + callback(null, user, info); + }); + }); } diff --git a/src/clients.js b/src/clients.js index c15ce6ce5..8a4f5ca2a 100644 --- a/src/clients.js +++ b/src/clients.js @@ -37,6 +37,7 @@ var apps = require('./apps.js'), accesscontrol = require('./accesscontrol.js'), tokendb = require('./tokendb.js'), users = require('./users.js'), + UsersError = users.UsersError, util = require('util'), uuid = require('uuid'); @@ -252,18 +253,26 @@ function addTokenByUserId(clientId, userId, expiresAt, callback) { get(clientId, function (error, result) { if (error) return callback(error); - var token = tokendb.generateToken(); - var scope = accesscontrol.canonicalScopeString(result.scope); - - tokendb.add(token, userId, result.id, expiresAt, scope, function (error) { + users.getWithRoles(userId, function (error, user) { + if (error && error.reason === UsersError.NOT_FOUND) return callback(new ClientsError(ClientsError.NOT_FOUND, 'No such user')); if (error) return callback(new ClientsError(ClientsError.INTERNAL_ERROR, error)); - callback(null, { - accessToken: token, - identifier: userId, - clientId: result.id, - scope: result.scope, - expires: expiresAt + const userScopes = accesscontrol.scopesForRoles(user.roles); + var scope = accesscontrol.canonicalScopeString(result.scope); + var authorizedScopes = accesscontrol.intersectScopes(userScopes, scope.split(',')).join(','); + + var token = tokendb.generateToken(); + + tokendb.add(token, userId, result.id, expiresAt, authorizedScopes, function (error) { + if (error) return callback(new ClientsError(ClientsError.INTERNAL_ERROR, error)); + + callback(null, { + accessToken: token, + identifier: userId, + clientId: result.id, + scope: authorizedScopes, + expires: expiresAt + }); }); }); }); diff --git a/src/routes/accesscontrol.js b/src/routes/accesscontrol.js index 19f39aef0..d41f98944 100644 --- a/src/routes/accesscontrol.js +++ b/src/routes/accesscontrol.js @@ -14,12 +14,9 @@ var accesscontrol = require('../accesscontrol.js'), clients = require('../clients.js'), ClientPasswordStrategy = require('passport-oauth2-client-password').Strategy, ClientsError = clients.ClientsError, - constants = require('../constants.js'), - DatabaseError = require('../databaseerror.js'), HttpError = require('connect-lastmile').HttpError, LocalStrategy = require('passport-local').Strategy, passport = require('passport'), - tokendb = require('../tokendb'), users = require('../users.js'), UsersError = users.UsersError; @@ -82,7 +79,9 @@ function initialize(callback) { })); // used for "Authorization: Bearer token" or access_token query param authentication - passport.use(new BearerStrategy(accessTokenAuth)); + passport.use(new BearerStrategy(function (token, callback) { + accesscontrol.validateToken(token, callback); + })); callback(null); } @@ -93,31 +92,6 @@ function uninitialize(callback) { callback(null); } -function accessTokenAuth(accessToken, callback) { - assert.strictEqual(typeof accessToken, 'string'); - assert.strictEqual(typeof callback, 'function'); - - tokendb.get(accessToken, function (error, token) { - if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, null /* user */, 'Invalid Token'); // will end up as a 401 - if (error) return callback(error); // this triggers 'internal error' in passport - - users.getWithRoles(token.identifier, function (error, user) { - if (error && error.reason === UsersError.NOT_FOUND) return callback(null, null /* user */, 'Invalid Token'); // will end up as a 401 - if (error) return callback(error); - - // scopes here can define what capabilities that token carries - // passport put the 'info' object into req.authInfo, where we can further validate the scopes - const userScopes = accesscontrol.scopesForRoles(user.roles); - var authorizedScopes = accesscontrol.intersectScopes(userScopes, token.scope.split(',')); - // these clients do not require password checks unlike UI - const skipPasswordVerification = token.clientId === 'cid-sdk' || token.clientId === 'cid-cli'; - var info = { authorizedScopes: authorizedScopes, skipPasswordVerification: skipPasswordVerification }; - - callback(null, user, info); - }); - }); -} - // The scope middleware provides an auth middleware for routes. // // It is used for API routes, which are authenticated using accesstokens. @@ -151,7 +125,7 @@ function websocketAuth(requiredScopes, req, res, next) { if (typeof req.query.access_token !== 'string') return next(new HttpError(401, 'Unauthorized')); - accessTokenAuth(req.query.access_token, function (error, user, info) { + accesscontrol.validateToken(req.query.access_token, function (error, user, info) { if (error) return next(new HttpError(500, error.message)); if (!user) return next(new HttpError(401, 'Unauthorized')); diff --git a/src/routes/oauth2.js b/src/routes/oauth2.js index 30c7a2d00..a5cca3b20 100644 --- a/src/routes/oauth2.js +++ b/src/routes/oauth2.js @@ -19,8 +19,7 @@ exports = module.exports = { csrf: csrf }; -var accesscontrol = require('../accesscontrol.js'), - apps = require('../apps.js'), +var apps = require('../apps.js'), assert = require('assert'), authcodedb = require('../authcodedb.js'), clients = require('../clients'), @@ -39,7 +38,6 @@ var accesscontrol = require('../accesscontrol.js'), session = require('connect-ensure-login'), settings = require('../settings.js'), speakeasy = require('speakeasy'), - tokendb = require('../tokendb.js'), url = require('url'), users = require('../users.js'), UsersError = users.UsersError, @@ -85,8 +83,6 @@ function initialize() { // exchange authorization codes for access tokens. this is used by external oauth clients gServer.exchange(oauth2orize.exchange.code(function (client, code, redirectURI, callback) { - debug('exchange:', client, code, redirectURI); - authcodedb.get(code, function (error, authCode) { if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, false); if (error) return callback(error); @@ -95,16 +91,12 @@ function initialize() { authcodedb.del(code, function (error) { if(error) return callback(error); - var token = tokendb.generateToken(); - var expires = Date.now() + constants.DEFAULT_TOKEN_EXPIRATION; - var scope = accesscontrol.canonicalScopeString(client.scope); - - tokendb.add(token, authCode.userId, authCode.clientId, expires, scope, function (error) { + clients.addTokenByUserId(client.id, authCode.userId, Date.now() + constants.DEFAULT_TOKEN_EXPIRATION, function (error, result) { if (error) return callback(error); - debug('exchange: new access token for client %s token %s (scope: %s)', client.id, token.slice(0, 6), scope); // partial token for security + debug('exchange: new access token for client %s user %s token %s', client.id, authCode.userId, result.accessToken.slice(0, 6)); // partial token for security - callback(null, token); + callback(null, result.accessToken); }); }); }); @@ -112,18 +104,12 @@ function initialize() { // implicit token grant that skips issuing auth codes. this is used by our webadmin gServer.grant(oauth2orize.grant.token({ scopeSeparator: ',' }, function (client, user, ares, callback) { - debug('grant token:', client.id, user.id, ares); - - var token = tokendb.generateToken(); - var expires = Date.now() + constants.DEFAULT_TOKEN_EXPIRATION; - var scope = accesscontrol.canonicalScopeString(client.scope); - - tokendb.add(token, user.id, client.id, expires, scope, function (error) { + clients.addTokenByUserId(client.id, user.id, Date.now() + constants.DEFAULT_TOKEN_EXPIRATION, function (error, result) { if (error) return callback(error); - debug('grant token: new access token for client %s token %s (scope: %s)', client.id, token.slice(0, 6), scope); // partial token for security + debug('grant token: new access token for client %s user %s token %s', client.id, user.id, result.accessToken.slice(0, 6)); // partial token for security - callback(null, token); + callback(null, result.accessToken); }); })); diff --git a/src/setup.js b/src/setup.js index bb361b883..ea1b504b7 100644 --- a/src/setup.js +++ b/src/setup.js @@ -246,22 +246,14 @@ function activate(username, password, email, displayName, ip, auditSource, callb if (error && error.reason === UsersError.BAD_FIELD) return callback(new SetupError(SetupError.BAD_FIELD, error.message)); if (error) return callback(new SetupError(SetupError.INTERNAL_ERROR, error)); - clients.get('cid-webadmin', function (error, result) { + clients.addTokenByUserId('cid-webadmin', userObject.id, Date.now() + constants.DEFAULT_TOKEN_EXPIRATION, function (error, result) { if (error) return callback(new SetupError(SetupError.INTERNAL_ERROR, error)); - // Also generate a token so the admin creation can also act as a login - var token = tokendb.generateToken(); - var expires = Date.now() + constants.DEFAULT_TOKEN_EXPIRATION; + eventlog.add(eventlog.ACTION_ACTIVATE, auditSource, { }); - tokendb.add(token, userObject.id, result.id, expires, accesscontrol.canonicalScopeString(result.scope), function (error) { - if (error) return callback(new SetupError(SetupError.INTERNAL_ERROR, error)); + callback(null, { token: result.accessToken, expires: result.expires }); - eventlog.add(eventlog.ACTION_ACTIVATE, auditSource, { }); - - callback(null, { token: token, expires: expires }); - - setTimeout(cloudron.onActivated, 3000); // hack for now to not block the above http response - }); + setTimeout(cloudron.onActivated, 3000); // hack for now to not block the above http response }); }); } diff --git a/src/test/accesscontrol-test.js b/src/test/accesscontrol-test.js index 30a6ae1d0..fb51e664a 100644 --- a/src/test/accesscontrol-test.js +++ b/src/test/accesscontrol-test.js @@ -22,16 +22,16 @@ describe('access control', function () { describe('intersectScopes', function () { // args: allowed, wanted it('both are same', function () { - expect(accesscontrol.intersectScopes([ 'apps', 'clients' ], [ 'clients', 'apps' ])).to.eql([ 'apps', 'clients' ]); + expect(accesscontrol.intersectScopes([ 'apps', 'clients' ], [ 'apps', 'clients' ])).to.eql([ 'apps', 'clients' ]); }); it('some are different', function () { - expect(accesscontrol.intersectScopes([ 'apps' ], [ 'clients', 'apps' ])).to.eql(['apps']); + expect(accesscontrol.intersectScopes([ 'apps' ], [ 'apps', 'clients' ])).to.eql(['apps']); expect(accesscontrol.intersectScopes([ 'clients', 'domains', 'mail' ], [ 'mail' ])).to.eql(['mail']); }); it('everything is different', function () { - expect(accesscontrol.intersectScopes(['cloudron', 'domains' ], ['clients', 'apps'])).to.eql([]); + expect(accesscontrol.intersectScopes(['cloudron', 'domains' ], ['apps','clients'])).to.eql([]); }); it('subscopes', function () {