From 9ead1c5c91e3c75274aa56dca2b17036cdc45573 Mon Sep 17 00:00:00 2001 From: Lars Wirzenius Date: Tue, 31 Jul 2018 11:14:13 +0300 Subject: Add: FileStore, managers for users, clients, applications Add: UserManager --- qvisqve/__init__.py | 20 +++++- qvisqve/api.py | 22 ++++++- qvisqve/app.py | 3 +- qvisqve/auth_router.py | 4 +- qvisqve/authn_entity_manager.py | 80 ++++++++++++++++++++++++ qvisqve/authn_entity_manager_tests.py | 113 ++++++++++++++++++++++++++++++++++ qvisqve/entity_manager.py | 50 +++++++++++++++ qvisqve/entity_manager_tests.py | 69 +++++++++++++++++++++ qvisqve/file_store.py | 70 +++++++++++++++++++++ qvisqve/file_store_tests.py | 97 +++++++++++++++++++++++++++++ qvisqve/token_router.py | 12 +++- yarns/200-client-creds.yarn | 35 ++++++----- yarns/lib.py | 30 ++++++--- 13 files changed, 573 insertions(+), 32 deletions(-) create mode 100644 qvisqve/authn_entity_manager.py create mode 100644 qvisqve/authn_entity_manager_tests.py create mode 100644 qvisqve/entity_manager.py create mode 100644 qvisqve/entity_manager_tests.py create mode 100644 qvisqve/file_store.py create mode 100644 qvisqve/file_store_tests.py diff --git a/qvisqve/__init__.py b/qvisqve/__init__.py index 32032b6..7d2b68f 100644 --- a/qvisqve/__init__.py +++ b/qvisqve/__init__.py @@ -14,6 +14,25 @@ # along with this program. If not, see . from .version import __version__, __version_info__ +from .log_setup import setup_logging, log + +from .file_store import ( + FileStore, + ResourceStoreError, + ResourceDoesNotExist, +) + +from .entity_manager import ( + EntityManager, + ApplicationManager, +) + +from .authn_entity_manager import ( + AuthenticatingEntityManager, + ClientManager, + UserManager, +) + from .responses import ( bad_request_response, created_response, @@ -21,7 +40,6 @@ from .responses import ( unauthorized_response, found_response, ) -from .log_setup import setup_logging, log from .token import TokenGenerator from .router import Router diff --git a/qvisqve/api.py b/qvisqve/api.py index 58f09c3..6c3fe34 100644 --- a/qvisqve/api.py +++ b/qvisqve/api.py @@ -21,22 +21,25 @@ class API: def __init__(self, config): self._config = config + self._rs = None def find_missing_route(self, path): qvisqve.log.log('info', msg_text='find_missing_route', path=path) routers = [ qvisqve.VersionRouter(), - qvisqve.LoginRouter(), - qvisqve.AuthRouter(self._config.get('applications', {})), qvisqve.TokenRouter( self._create_token_generator(), self._get_clients()), + qvisqve.LoginRouter(), + qvisqve.AuthRouter(self._get_applications()), ] routes = [] for router in routers: routes.extend(router.get_routes()) + qvisqve.log.log( + 'debug', msg_text='missing routes created', routes=routes) return routes def _create_token_generator(self): @@ -47,5 +50,18 @@ class API: tg.set_signing_key(cfg['token-private-key']) return tg + def _create_resource_store(self): + qvisqve.log.log('debug', msg_text='_c_r_s 1', c=self._config) + if self._rs is None: + self._rs = qvisqve.FileStore(self._config['store']) + return self._rs + def _get_clients(self): - return self._config.get('clients', {}) + rs = self._create_resource_store() + cm = qvisqve.ClientManager(rs) + return cm + + def _get_applications(self): + rs = self._create_resource_store() + am = qvisqve.ApplicationManager(rs) + return am diff --git a/qvisqve/app.py b/qvisqve/app.py index a1f1910..517c326 100644 --- a/qvisqve/app.py +++ b/qvisqve/app.py @@ -57,8 +57,7 @@ default_config = { 'token-public-key': None, 'token-private-key': None, 'token-lifetime': None, - 'clients': None, - 'applications': None, + 'store': None, } diff --git a/qvisqve/auth_router.py b/qvisqve/auth_router.py index 4e4bec1..717e46f 100644 --- a/qvisqve/auth_router.py +++ b/qvisqve/auth_router.py @@ -49,7 +49,9 @@ class AuthRouter(qvisqve.Router): # - create and store auth code # - use callback url provided in request - callback_url = self._apps.get('facade') # FIXME get real app name + # FIXME use real app name here + callbacks = self._apps.get_callbacks('facade') + callback_url = callbacks[0] params = urllib.parse.urlencode({'code': 123}) url = '{}?{}'.format(callback_url, params) diff --git a/qvisqve/authn_entity_manager.py b/qvisqve/authn_entity_manager.py new file mode 100644 index 0000000..32e2da3 --- /dev/null +++ b/qvisqve/authn_entity_manager.py @@ -0,0 +1,80 @@ +# Copyright (C) 2018 Lars Wirzenius +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +import os + +import qvisqve +import qvisqve_secrets + + +class AuthenticatingEntityManager(qvisqve.EntityManager): + + _hashed = 'hashed_secret' + + def set_secret(self, entity_id, cleartext_secret): + entity = self.get(entity_id) + hasher = qvisqve_secrets.SecretHasher() + entity[self._hashed] = hasher.hash(cleartext_secret) + self.create(entity_id, entity) + + def is_valid_secret(self, entity_id, cleartext_secret): + try: + entity = self.get(entity_id) + except qvisqve.ResourceDoesNotExist: + qvisqve.log.log( + 'error', msg_text='Entity does not exist', + entity_id=entity_id) + return False + + hashed_secret = entity.get(self._hashed) + if not hashed_secret: + qvisqve.log.log( + 'error', msg_text='Entity does not have a hashed secret', + entity_id=entity_id) + return False + + hasher = qvisqve_secrets.SecretHasher() + if not hasher.is_correct(hashed_secret, cleartext_secret): + qvisqve.log.log( + 'error', msg_text='Client-supplied secret is WRONG', + entity_id=entity_id) + return False + + qvisqve.log.log( + 'debug', msg_text='Client-supplied secret IS correct', + entity_id=entity_id) + return True + + +class ClientManager(AuthenticatingEntityManager): + + def __init__(self, rs): + super().__init__(rs, 'client') + + def set_allowed_scopes(self, client_id, scopes): + client = self.get(client_id) + client['allowed_scopes'] = scopes + self.create(client_id, client) + + def get_allowed_scopes(self, client_id): + client = self.get(client_id) + return client.get('allowed_scopes', []) + + +class UserManager(AuthenticatingEntityManager): + + def __init__(self, rs): + super().__init__(rs, 'user') diff --git a/qvisqve/authn_entity_manager_tests.py b/qvisqve/authn_entity_manager_tests.py new file mode 100644 index 0000000..d46cd03 --- /dev/null +++ b/qvisqve/authn_entity_manager_tests.py @@ -0,0 +1,113 @@ +# Copyright (C) 2018 Lars Wirzenius +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +import shutil +import tempfile +import unittest + +import qvisqve + + +class AuthenticatingEntityManagerTests(unittest.TestCase): + + def setUp(self): + self.tempdir = tempfile.mkdtemp() + fs = qvisqve.FileStore(self.tempdir) + self.aem = qvisqve.AuthenticatingEntityManager(fs, 'client') + + def tearDown(self): + shutil.rmtree(self.tempdir) + + def test_does_not_validate_secret_if_entity_does_not_exist(self): + self.assertFalse( + self.aem.is_valid_secret('does-not-exist', 'whatever')) + + def test_does_not_validate_secret_if_not_stored(self): + secret = 'hunter2' + client = { + 'id': 'test-client', + } + + self.aem.create(client['id'], client) + self.assertFalse(self.aem.is_valid_secret(client['id'], secret)) + + def test_validates_secret(self): + secret = 'hunter2' + client = { + 'id': 'test-client', + } + + self.aem.create(client['id'], client) + self.aem.set_secret(client['id'], secret) + self.assertFalse(self.aem.is_valid_secret(client['id'], 'invalid')) + self.assertTrue(self.aem.is_valid_secret(client['id'], secret)) + + +class ClientManagerTests(unittest.TestCase): + + def setUp(self): + self.tempdir = tempfile.mkdtemp() + fs = qvisqve.FileStore(self.tempdir) + self.cm = qvisqve.ClientManager(fs) + + def tearDown(self): + shutil.rmtree(self.tempdir) + + def test_validates_client_secret(self): + secret = 'hunter2' + client = { + 'id': 'test-client', + } + + self.cm.create(client['id'], client) + self.cm.set_secret(client['id'], secret) + self.assertTrue(self.cm.is_valid_secret(client['id'], secret)) + + def test_returns_empty_list_of_scopes_initially(self): + client = { + 'id': 'test-client', + } + + self.cm.create(client['id'], client) + self.assertEqual(self.cm.get_allowed_scopes(client['id']), []) + + def test_sets_allowed_scopes(self): + client = { + 'id': 'test-client', + } + scopes = ['foo', 'bar'] + + self.cm.create(client['id'], client) + self.cm.set_allowed_scopes(client['id'], scopes) + self.assertEqual(self.cm.get_allowed_scopes(client['id']), scopes) + + +class UserManagerTests(unittest.TestCase): + + def setUp(self): + self.tempdir = tempfile.mkdtemp() + fs = qvisqve.FileStore(self.tempdir) + self.um = qvisqve.UserManager(fs) + + def tearDown(self): + shutil.rmtree(self.tempdir) + + def test_creates_user(self): + user = { + 'id': 'tomjon', + } + self.um.create(user['id'], user) + self.assertEqual(self.um.get(user['id']), user) diff --git a/qvisqve/entity_manager.py b/qvisqve/entity_manager.py new file mode 100644 index 0000000..da42f5b --- /dev/null +++ b/qvisqve/entity_manager.py @@ -0,0 +1,50 @@ +# Copyright (C) 2018 Lars Wirzenius +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +import os + +import yaml + + +class EntityManager: + + def __init__(self, rs, resource_type): + self._store = rs + self._type = resource_type + + def list(self): + return self._store.list(self._type) + + def get(self, entity_id): + return self._store.get(self._type, entity_id) + + def create(self, entity_id, entity): + self._store.create(self._type, entity_id, entity) + + +class ApplicationManager(EntityManager): + + def __init__(self, rs): + super().__init__(rs, 'application') + + def get_callbacks(self, app_id): + app = self.get(app_id) + return app.get('callbacks', []) + + def add_callback(self, app_id, url): + app = self.get(app_id) + app['callbacks'] = app.get('callbacks', []) + [url] + self.create(app_id, app) diff --git a/qvisqve/entity_manager_tests.py b/qvisqve/entity_manager_tests.py new file mode 100644 index 0000000..5088da4 --- /dev/null +++ b/qvisqve/entity_manager_tests.py @@ -0,0 +1,69 @@ +# Copyright (C) 2018 Lars Wirzenius +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +import shutil +import tempfile +import unittest + +import qvisqve + + +class EntityManagerTests(unittest.TestCase): + + def setUp(self): + self.tempdir = tempfile.mkdtemp() + fs = qvisqve.FileStore(self.tempdir) + self.em = qvisqve.EntityManager(fs, 'foo') + + def tearDown(self): + shutil.rmtree(self.tempdir) + + def test_has_no_entities_initially(self): + self.assertEqual(self.em.list(), []) + with self.assertRaises(qvisqve.ResourceDoesNotExist): + self.em.get('does-not-exist') + + def test_creates_an_entity(self): + foo = { + 'foo': 'foo is cool', + } + foo_id = 'foo is my entity' + self.em.create(foo_id, foo) + self.assertEqual(self.em.list(), [foo_id]) + self.assertEqual(self.em.get(foo_id), foo) + + +class ApplicationManagerTests(unittest.TestCase): + + def setUp(self): + self.tempdir = tempfile.mkdtemp() + fs = qvisqve.FileStore(self.tempdir) + self.am = qvisqve.ApplicationManager(fs) + + def tearDown(self): + shutil.rmtree(self.tempdir) + + def test_returns_no_callbacks_initially(self): + appid = 'test-app' + self.am.create(appid, {}) + self.assertEqual(self.am.get_callbacks(appid), []) + + def test_sets_callbacks(self): + appid = 'test-app' + url = 'http://facade.example.com/callback' + self.am.create(appid, {}) + self.am.add_callback(appid, url) + self.assertEqual(self.am.get_callbacks(appid), [url]) diff --git a/qvisqve/file_store.py b/qvisqve/file_store.py new file mode 100644 index 0000000..3fd5186 --- /dev/null +++ b/qvisqve/file_store.py @@ -0,0 +1,70 @@ +# Copyright (C) 2018 Lars Wirzenius +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +import os + +import yaml + + +class FileStore: + + def __init__(self, dirname): + self._dirname = dirname + + def _typedir(self, resource_type): + return os.path.join(self._dirname, resource_type) + + def _filename(self, resource_type, resource_id): + dirname = self._typedir(resource_type) + return os.path.join(dirname, resource_id) + + def list(self, resource_type): + dirname = self._typedir(resource_type) + if not os.path.isdir(dirname): + return [] + return [ + x + for x in sorted(os.listdir(self._typedir(resource_type))) + ] + + def get(self, resource_type, resource_id): + filename = self._filename(resource_type, resource_id) + if os.path.exists(filename): + with open(filename) as f: + return yaml.safe_load(f) + raise ResourceDoesNotExist(resource_type, resource_id) + + def create(self, resource_type, resource_id, resource): + dirname = self._typedir(resource_type) + if not os.path.exists(dirname): + os.mkdir(dirname) + + filename = self._filename(resource_type, resource_id) + with open(filename, 'w') as f: + yaml.safe_dump(resource, stream=f) + + +class ResourceStoreError(Exception): + + pass + + +class ResourceDoesNotExist(ResourceStoreError): + + def __init__(self, resource_type, resource_id): + super().__init__( + 'Resource does not exist: {}:{}'.format( + resource_type, resource_id)) diff --git a/qvisqve/file_store_tests.py b/qvisqve/file_store_tests.py new file mode 100644 index 0000000..6d27afc --- /dev/null +++ b/qvisqve/file_store_tests.py @@ -0,0 +1,97 @@ +# Copyright (C) 2018 Lars Wirzenius +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +import shutil +import tempfile +import unittest + +import qvisqve + + +class FileStoreTests(unittest.TestCase): + + def setUp(self): + self.tempdir = tempfile.mkdtemp() + self.fs = qvisqve.FileStore(self.tempdir) + + def tearDown(self): + shutil.rmtree(self.tempdir) + + def test_has_no_clients_initially(self): + self.assertEqual(self.fs.list('client'), []) + + def test_has_no_users_initially(self): + self.assertEqual(self.fs.list('user'), []) + + def test_raises_error_getting_nonexistent_resource(self): + with self.assertRaises(qvisqve.ResourceDoesNotExist): + self.fs.get('client', 'does-not-exist') + + def test_creates_client(self): + client = { + 'id': 'test-client', + } + self.fs.create('client', client['id'], client) + self.assertEqual(self.fs.list('client'), [client['id']]) + + gotten = self.fs.get('client', client['id']) + self.assertEqual(client, gotten) + + def test_updates_client(self): + client = { + 'id': 'test-client', + } + self.fs.create('client', client['id'], client) + + updated = dict(client) + updated['foo'] = 'bar' + self.fs.create('client', client['id'], updated) + self.assertEqual(self.fs.get('client', client['id']), updated) + + def test_two_clients(self): + one = { + 'id': 'first-client', + } + self.fs.create('client', one['id'], one) + self.assertEqual(self.fs.list('client'), [one['id']]) + + two = { + 'id': 'second-client', + } + self.fs.create('client', two['id'], two) + self.assertEqual(self.fs.list('client'), [one['id'], two['id']]) + + gotten = self.fs.get('client', two['id']) + self.assertEqual(two, gotten) + + def test_creates_client_and_user(self): + client = { + 'id': 'test', + 'type': 'client', + } + + user = { + 'id': 'test', + 'type': 'user', + } + + self.fs.create('client', client['id'], client) + self.fs.create('user', user['id'], user) + self.assertEqual(self.fs.list('client'), [client['id']]) + self.assertEqual(self.fs.list('user'), [user['id']]) + + self.assertEqual(client, self.fs.get('client', client['id'])) + self.assertEqual(user, self.fs.get('user', user['id'])) diff --git a/qvisqve/token_router.py b/qvisqve/token_router.py index dd01587..911e899 100644 --- a/qvisqve/token_router.py +++ b/qvisqve/token_router.py @@ -27,12 +27,14 @@ import qvisqve_secrets class TokenRouter(qvisqve.Router): def __init__(self, token_generator, clients): + qvisqve.log.log('debug', msg_text='TokenRouter init starts') super().__init__() - args = (Clients(clients), token_generator) + args = (clients, token_generator) self._grants = { 'client_credentials': ClientCredentialsGrant(*args), 'authorization_code': AuthorizationCodeGrant(*args), } + qvisqve.log.log('debug', msg_text='TokenRouter created') def get_routes(self): return [ @@ -81,8 +83,14 @@ class Grant: class ClientCredentialsGrant(Grant): def get_token(self, request, params): + qvisqve.log.log( + 'debug', msg_text='ClientCredentialGrant.get_token called', + request=request, params=params) + client_id, client_secret = request.auth - if not self._clients.is_correct_secret(client_id, client_secret): + if not self._clients.is_valid_secret(client_id, client_secret): + qvisqve.log.log( + 'error', msg_text='Client token request is unauthorized') return qvisqve.unauthorized_response('Unauthorized') scope = self._get_scope(params) diff --git a/yarns/200-client-creds.yarn b/yarns/200-client-creds.yarn index f251c71..9eff22a 100644 --- a/yarns/200-client-creds.yarn +++ b/yarns/200-client-creds.yarn @@ -21,8 +21,8 @@ The `USERPASS` has the client id and secret encoded as is usual for [HTTP Basic authentication]: https://en.wikipedia.org/wiki/Basic_access_authentication Qvisqve checks the `grant_type` parameter, and extracts `USERPASS` to -get the client id and secret. It compares them against a static list -of clients, which it reads at startup from its configuration file: +get the client id and secret. It compares them against statically +created clients, which it reads from the filesystem. EXAMPLE Qvisqve configuration file in YAML config: @@ -34,19 +34,24 @@ of clients, which it reads at startup from its configuration file: ... deleted from example LkLFQC7Y66OYjna457hU545hfF99j7nxdseXQEhV96E4RUIub+6vS8TYDEk= -----END RSA PRIVATE KEY----- - clients: - test_api: - client_secret: - N: 16384 - hash: 5cf3b9cab1eacc818b73d229db...a023e938ee598f6c49749ef0429a889f7 - key_len: 128 - p: 1 - r: 8 - salt: 18112c4c50993ca5db908a15519c51e1 - version: 1 - allowed_scopes: - - foo - - bar + store: /var/lib/qvisqve + +Each client will be stored as a separate YAML file under the directory +configured in the "store" configuration variable. For example, the +client `test_api` is stored in `/var/lib/qvisqve/clients/test_api`: + + EXAMPLE + client_secret: + N: 16384 + hash: 5cf3b9cab1eacc818b73d229db...a023e938ee598f6c49749ef0429a889f7 + key_len: 128 + p: 1 + r: 8 + salt: 18112c4c50993ca5db908a15519c51e1 + version: 1 + allowed_scopes: + - foo + - bar Qvisqve checks that the client id given by the client is found, and that the offered client secret matches what's in the configuration diff --git a/yarns/lib.py b/yarns/lib.py index 9ed7f59..56707ba 100644 --- a/yarns/lib.py +++ b/yarns/lib.py @@ -177,15 +177,30 @@ def start_qvisqve(): V['port'] = cliapp.runcmd([os.path.join(srcdir, 'randport' )]).strip() V['API_URL'] = 'http://127.0.0.1:{}'.format(V['port']) - clients = {} + store = os.path.join(datadir, 'store') + os.mkdir(store) + os.mkdir(os.path.join(store, 'client')) + os.mkdir(os.path.join(store, 'application')) + if V['client_id'] and V['client_secret']: sh = qvisqve_secrets.SecretHasher() - clients = { - V['client_id']: { - 'client_secret': sh.hash(V['client_secret']), - 'allowed_scopes': V['allowed_scopes'], - }, + client = { + 'hashed_secret': sh.hash(V['client_secret']), + 'allowed_scopes': V['allowed_scopes'], + } + + filename = os.path.join(store, 'client', V['client_id']) + with open(filename, 'w') as f: + yaml.safe_dump(client, stream=f) + + apps = V['applications'] + for name in apps or []: + filename = os.path.join(store, 'application', name) + spec = { + 'callbacks': [apps[name]], } + with open(filename, 'w') as f: + yaml.safe_dump(spec, stream=f) config = { 'gunicorn': 'background', @@ -201,8 +216,7 @@ def start_qvisqve(): 'token-public-key': V['pubkey'], 'token-issuer': V['iss'], 'token-lifetime': 3600, - 'clients': clients, - 'applications': V['applications'] or {}, + 'store': store, } env = dict(os.environ) env['QVISQVE_CONFIG'] = os.path.join(datadir, 'qvisqve.yaml') -- cgit v1.2.1