From 05b00d87714bc2059d240b5c4c680e75678450d7 Mon Sep 17 00:00:00 2001 From: ilex Date: Mon, 9 Jan 2017 18:28:09 +0200 Subject: [PATCH 01/52] auth: Add setup function for auth middleware. With this function auth middleware can be set up in aiohttp fashion. --- aiohttp_auth/auth/__init__.py | 2 +- aiohttp_auth/auth/auth.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/aiohttp_auth/auth/__init__.py b/aiohttp_auth/auth/__init__.py index d2a8309..289f007 100644 --- a/aiohttp_auth/auth/__init__.py +++ b/aiohttp_auth/auth/__init__.py @@ -1,4 +1,4 @@ -from .auth import auth_middleware, get_auth, remember, forget +from .auth import auth_middleware, get_auth, remember, forget, setup from .decorators import auth_required from .cookie_ticket_auth import CookieTktAuthentication diff --git a/aiohttp_auth/auth/auth.py b/aiohttp_auth/auth/auth.py index 05b946f..a7d1c0b 100644 --- a/aiohttp_auth/auth/auth.py +++ b/aiohttp_auth/auth/auth.py @@ -94,3 +94,13 @@ async def forget(request): return await auth_policy.forget(request) + +def setup(app, policy): + """Setup middleware in aiohttp fashion. + + Args: + app: aiohttp Application object. + policy: An authentication policy with a base class of + AbstractAuthentication. + """ + app.middlewares.append(auth_middleware(policy)) From 0f80993981adaa310add49fda14b88baedf649fb Mon Sep 17 00:00:00 2001 From: ilex Date: Mon, 9 Jan 2017 18:52:33 +0200 Subject: [PATCH 02/52] tests: Add Pytest tests for auth middleware. - Add all tests that reflect original tests/test_auth.py with pytest library pytest-aiohttp plugin and using real aiohttp application. - Add some new tests to test untested functions in auth. - Add test for introduced auth.setup function. --- pytests/conftest.py | 7 + pytests/test_auth.py | 380 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 pytests/conftest.py create mode 100644 pytests/test_auth.py diff --git a/pytests/conftest.py b/pytests/conftest.py new file mode 100644 index 0000000..bde4635 --- /dev/null +++ b/pytests/conftest.py @@ -0,0 +1,7 @@ +"""Pytest configuration.""" +import os.path +import sys + + +my_path = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(my_path, '..')) diff --git a/pytests/test_auth.py b/pytests/test_auth.py new file mode 100644 index 0000000..5027fcf --- /dev/null +++ b/pytests/test_auth.py @@ -0,0 +1,380 @@ +import asyncio +import sys +import traceback +from os import urandom +import pytest +from aiohttp import web +from aiohttp_auth import auth +import aiohttp_session + + +async def assert_middleware(app, handler): + """Collect AssertionError in handler. + + Use this middleware to collect assertion errors in + aiohttp handlers. Formatted error will be passed as + text to web.Response. + """ + async def middleware_handler(request): + try: + response = await handler(request) + except AssertionError as e: + _, _, tb = sys.exc_info() + # traceback.print_tb(tb) # Fixed format + tb_info = traceback.extract_tb(tb) + filename, line, func, text = tb_info[-1] + message = '\n{0}:{1}\n> {2}\nE {3}\n'.format( + filename, line, text, str(e) + ) + return web.Response(text=message) + + return response + + return middleware_handler + + +@pytest.fixture +def client(loop, test_client): + """Fixture to create client with given app. + + Add assert_middleware to catch AssertionError in handlers. + Use as follow:: + + async def test_something(loop, client): + app = aiohttp.web.Application(loop=loop) + ... + cli = await client(app) + response = await cli.get('/some/path') + """ + async def go(app): + app.middlewares.append(assert_middleware) + return await test_client(app) + + yield go + + +@pytest.fixture +def app(loop): + """Default app fixture for tests.""" + async def handler_remember(request): + await auth.remember(request, 'some_user') + return web.Response(text='remember') + + async def handler_auth(request): + user_id = await auth.get_auth(request) + assert user_id == 'some_user' + assert user_id == await auth.get_auth(request) + return web.Response(text='auth') + + async def handler_forget(request): + user_id = await auth.get_auth(request) + assert user_id == 'some_user' + await auth.forget(request) + return web.Response(text='forget') + + application = web.Application(loop=loop) + application.router.add_get('/remember', handler_remember) + application.router.add_get('/auth', handler_auth) + application.router.add_get('/forget', handler_forget) + + yield application + + +async def assert_response(request, response_text): + """Helper function to reraise assertion errors from handlers if any. + + This function can be used in cooperation with assert_middleware + to reraise AssertionError from aiohttp handlers as follow:: + + async def test_something(app, client): + async def handler_test(request): + assert False # when test fail we will see this line. + return web.Response(text='text') + + app.router.add_get('/test', handler_test) + cli = await client(app) + + # Note missed await before cli.get + response = await assert_response(cli.get('/test'), 'test') + + Testing this with py.test give us right place of the assertion + error if any. + + Args: + request: awaitable request. + response_text: excepted text in response. + + Returns: + ClientResponse: response for given request. + + Raises: + AssertionError: when it was assertion error in tested hanlder. + """ + response = await request + text = await response.text() + if text != response_text: + raise AssertionError(text) + + return response + + +async def test_middleware_setup(app): + secret = b'01234567890abcdef' + policy = auth.CookieTktAuthentication(secret, 15, cookie_name='auth') + + auth.setup(app, policy) + + middleware = auth.auth_middleware(policy) + + assert app.middlewares[-1].__name__ == middleware.__name__ + + +async def test_no_middleware_installed(app, client): + async def handler_test(request): + with pytest.raises(RuntimeError) as ex_info: + await auth.get_auth(request) + + assert str(ex_info.value) == 'auth_middleware not installed' + + with pytest.raises(RuntimeError) as ex_info: + await auth.remember(request, 'some_user') + + assert str(ex_info.value) == 'auth_middleware not installed' + + with pytest.raises(RuntimeError) as ex_info: + await auth.forget(request) + + assert str(ex_info.value) == 'auth_middleware not installed' + + return web.Response(text='test') + + app.router.add_get('/test', handler_test) + + cli = await client(app) + + await assert_response(cli.get('/test'), 'test') + + +async def test_middleware_installed_no_session(app, client): + async def handler_test(request): + user_id = await auth.get_auth(request) + assert user_id is None + + return web.Response(text='test') + + app.router.add_get('/test', handler_test) + aiohttp_session.setup(app, aiohttp_session.SimpleCookieStorage()) + auth.setup(app, auth.SessionTktAuthentication(urandom(16), 15)) + + cli = await client(app) + + await assert_response(cli.get('/test'), 'test') + + +async def test_middleware_stores_auth_in_session(app, client): + secret = b'01234567890abcdef' + storage = aiohttp_session.SimpleCookieStorage() + policy = auth.SessionTktAuthentication(secret, 15, cookie_name='auth') + + aiohttp_session.setup(app, storage) + auth.setup(app, policy) + + cli = await client(app) + response = await cli.get('/remember') + text = await response.text() + assert text == 'remember' + + value = response.cookies.get(storage.cookie_name).value + assert policy.cookie_name in value + + +async def test_middleware_gets_auth_from_session(app, client): + secret = b'01234567890abcdef' + storage = aiohttp_session.SimpleCookieStorage() + policy = auth.SessionTktAuthentication(secret, 15, cookie_name='auth') + + aiohttp_session.setup(app, storage) + auth.setup(app, policy) + + cli = await client(app) + + response = await cli.get('/remember') + assert await response.text() == 'remember' + + await assert_response(cli.get('/auth'), 'auth') + + +async def test_middleware_stores_auth_in_cookie(app, client): + secret = b'01234567890abcdef' + policy = auth.CookieTktAuthentication(secret, 15, cookie_name='auth') + + auth.setup(app, policy) + + cli = await client(app) + + response = await cli.get('/remember') + text = await response.text() + + assert text == 'remember' + assert policy.cookie_name in response.cookies + + +async def test_middleware_gets_auth_from_cookie(app, client): + secret = b'01234567890abcdef' + policy = auth.CookieTktAuthentication(secret, 15, 2, cookie_name='auth') + + auth.setup(app, policy) + + cli = await client(app) + + response = await cli.get('/remember') + text = await response.text() + + assert text == 'remember' + assert policy.cookie_name in response.cookies + + assert_response(cli.get('/auth'), 'auth') + + +@pytest.mark.slow +async def test_middleware_reissues_ticket_auth(loop, app, client): + secret = b'01234567890abcdef' + policy = auth.CookieTktAuthentication(secret, 15, 0, cookie_name='auth') + + auth.setup(app, policy) + + cli = await client(app) + + response = await cli.get('/remember') + text = await response.text() + + assert text == 'remember' + data = response.cookies[policy.cookie_name] + + # wait a second that the ticket value has changed + await asyncio.sleep(1.0, loop=loop) + + response = await assert_response(cli.get('/auth'), 'auth') + + assert data != response.cookies[policy.cookie_name] + + +@pytest.mark.slow +async def test_middleware_doesnt_reissue_on_bad_response(loop, app, client): + async def handler_bad_response(request): + user_id = await auth.get_auth(request) + assert user_id == 'some_user' + return web.Response(status=400, text='bad_response') + + secret = b'01234567890abcdef' + policy = auth.CookieTktAuthentication(secret, 15, 0, cookie_name='auth') + + auth.setup(app, policy) + app.router.add_get('/bad_response', handler_bad_response) + + cli = await client(app) + + response = await cli.get('/remember') + text = await response.text() + data = response.cookies[policy.cookie_name] + + assert text == 'remember' + + # wait a second that the ticket value has changed + await asyncio.sleep(1.0, loop=loop) + + response = await assert_response(cli.get('/auth'), 'auth') + + assert data != response.cookies[policy.cookie_name] + data = response.cookies[policy.cookie_name] + + await asyncio.sleep(1.0, loop=loop) + + response = await assert_response(cli.get('/bad_response'), 'bad_response') + + assert response.status == 400 + assert policy.cookie_name not in response.cookies + + +async def test_middleware_forget_with_session(app, client): + secret = b'01234567890abcdef' + storage = aiohttp_session.SimpleCookieStorage() + policy = auth.SessionTktAuthentication(secret, 15, cookie_name='auth') + + aiohttp_session.setup(app, storage) + auth.setup(app, policy) + + cli = await client(app) + + response = await assert_response(cli.get('/remember'), 'remember') + value = response.cookies.get(storage.cookie_name).value + assert policy.cookie_name in value + + response = await assert_response(cli.get('/forget'), 'forget') + value = response.cookies.get(storage.cookie_name).value + assert policy.cookie_name not in value + + with pytest.raises(AssertionError): + await assert_response(cli.get('/auth'), 'auth') + + +async def test_middleware_forget_with_cookies(app, client): + secret = b'01234567890abcdef' + policy = auth.CookieTktAuthentication(secret, 120, cookie_name='auth') + + auth.setup(app, policy) + + cli = await client(app) + + response = await assert_response(cli.get('/remember'), 'remember') + assert policy.cookie_name in response.cookies + + response = await assert_response(cli.get('/forget'), 'forget') + # aiohttp set cookie_name with empty string when del_cookie + # assert policy.cookie_name not in response.cookies + assert response.cookies[policy.cookie_name].value == '' + + with pytest.raises(AssertionError): + await assert_response(cli.get('/auth'), 'auth') + + +async def test_middleware_auth_required_decorator(app, client): + @auth.auth_required + async def handler_test(request): + return web.Response(text='test') + + secret = b'01234567890abcdef' + policy = auth.CookieTktAuthentication(secret, 120, cookie_name='auth') + + auth.setup(app, policy) + app.router.add_get('/test', handler_test) + + cli = await client(app) + + response = await assert_response(cli.get('/test'), '403: Forbidden') + assert response.status == 403 + + response = await assert_response(cli.get('/remember'), 'remember') + + response = await assert_response(cli.get('/test'), 'test') + assert response.status == 200 + + +async def test_middleware_cannot_store_auth_in_cookie_when_response_started( + app, client): + async def handler_test(request): + await auth.remember(request, 'some_user') + response = web.Response(text='test') + await response.prepare(request) + return response + + secret = b'01234567890abcdef' + policy = auth.CookieTktAuthentication(secret, 15, cookie_name='auth') + + auth.setup(app, policy) + app.router.add_get('/test', handler_test) + + cli = await client(app) + + response = await cli.get('/test') + assert policy.cookie_name not in response.cookies From e3c749ef107fb378d9e06851f8a4a5a6e2f13387 Mon Sep 17 00:00:00 2001 From: ilex Date: Mon, 9 Jan 2017 19:49:05 +0200 Subject: [PATCH 03/52] docs: Improve documentation in README.rst. - Add docs for introduced auth.setup function. - Add syntax highlighting for code blocks. - Add information how to run pytests. --- README.rst | 73 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/README.rst b/README.rst index 95bb038..655a2e3 100644 --- a/README.rst +++ b/README.rst @@ -25,7 +25,9 @@ retrieving the authentication details for a user across http requests. Typically, an application would retrieve the login details for a user, and call the remember function to store the details. These details can then be recalled in future requests. A simplistic example of users stored in a python dict would -be:: +be: + +.. code-block:: python from aiohttp_auth import auth from aiohttp import web @@ -48,7 +50,9 @@ be:: raise web.HTTPForbidden() User data can be verified in later requests by checking that their username is -valid explicity, or by using the auth_required decorator:: +valid explicity, or by using the auth_required decorator: + +.. code-block:: python async def check_explicitly_view(request): user = await get_auth(request) @@ -64,7 +68,9 @@ valid explicity, or by using the auth_required decorator:: return web.Response(body='OK'.encode('utf-8')) To end the session, the user data can be forgotten by using the forget -function:: +function: + +.. code-block:: python @auth.auth_required async def logout_view(request): @@ -84,18 +90,22 @@ value known only to the server. The cookie contains the maximum age allowed before the ticket expires, and can also use the IP address (v4 or v6) of the user to link the cookie to that address. The cookies data is not encryptedd, but only holds the username of the user and the cookies expiration time, along -with its security hash:: +with its security hash: + +.. code-block:: python def init(loop): + app = web.Application(loop=loop) + # Create a auth ticket mechanism that expires after 1 minute (60 # seconds), and has a randomly generated secret. Also includes the # optional inclusion of the users IP address in the hash policy = auth.CookieTktAuthentication(urandom(32), 60, - include_ip=True)) + include_ip=True) + + # setup middleware in aiohttp fashion + auth.setup(app, policy) - app = web.Application(loop=loop, - middlewares=[auth.auth_middleware(policy)]) - app = web.Application() app.router.add_route('POST', '/login', login_view) app.router.add_route('GET', '/logout', logout_view) app.router.add_route('GET', '/test0', check_explicitly_view) @@ -106,27 +116,46 @@ with its security hash:: The SessionTktAuthentication policy provides many of the same features, but stores the same ticket credentials in a aiohttp_session object, allowing different storage mechanisms such as Redis storage, and -EncryptedCookieStorage:: +EncryptedCookieStorage: + +.. code-block:: python from aiohttp_session import get_session, session_middleware from aiohttp_session.cookie_storage import EncryptedCookieStorage def init(loop): + app = web.Application(loop=loop) + + # setup session middleware in aiohttp fashion + storage = EncryptedCookieStorage(urandom(32)) + aiohttp_session.setup(app, storage) # Create a auth ticket mechanism that expires after 1 minute (60 # seconds), and has a randomly generated secret. Also includes the # optional inclusion of the users IP address in the hash policy = auth.SessionTktAuthentication(urandom(32), 60, - include_ip=True)) + include_ip=True) - middlewares = [session_middleware(EncryptedCookieStorage(urandom(32))), - auth.auth_middleware(policy)] - - app = web.Application(loop=loop, middlewares=middlewares) + # setup aiohttp_auth.auth middleware in aiohttp fashion + auth.setup(app, policy) ... +In order to test this middleware with pytest you need to install:: + + $ pip install pytest pytest-aiohttp pytest-cov + +And then run tests:: + + $ py.test -v --cov-report=term-missing --cov=aiohttp_auth pytests + +There are two tests that use `sleep` function which marked as `slow` tests. +To avoid running them use:: + + $ py.test -v -m 'not slow' --cov-report=term-missing --cov=aiohttp_auth pytests + + acl_middleware Usage --------------------- @@ -146,7 +175,9 @@ auth.get_auth function) as a parameter, and expects a sequence of permitted ACL groups to be returned. This can be a empty tuple to represent no explicit permissions, or None to explicitly forbid this particular user_id. Note that the user_id passed may be None if no authenticated user exists. Building apon -our example, a function may be defined as:: +our example, a function may be defined as: + +.. code-block:: python from aiohttp_auth import acl @@ -178,7 +209,9 @@ is not None. With the groups defined, a ACL context can be specified for looking up what permissions each group is allowed to access. A context is a sequence of ACL tuples which consist of a Allow/Deny action, a group, and a sequence of -permissions for that ACL group. For example:: +permissions for that ACL group. For example: + +.. code-block:: python from aiohttp_auth.permissions import Group, Permission @@ -189,7 +222,9 @@ permissions for that ACL group. For example:: Views can then be defined using the acl_required decorator, allowing only specific users access to a particular view. The acl_required decorator specifies a permission required to access the view, and a context to check -against:: +against: + +.. code-block:: python @acl_required('view', context) async def view_view(request): @@ -211,7 +246,9 @@ ACL permission requested by the view, the decorator raises HTTPForbidden. ACL tuple sequences are checked in order, with the first tuple that matches the group the user is a member of, AND includes the permission passed to the function, declared to be the matching ACL group. This means that if the ACL -context was modified to:: +context was modified to: + +.. code-block:: python context = [(Permission.Allow, Group.Everyone, ('view',)), (Permission.Deny, 'super_user', ('view_extra')), From 6499569fd620ec028c37ddf98acb80056cb4a2a1 Mon Sep 17 00:00:00 2001 From: ilex Date: Tue, 10 Jan 2017 12:55:20 +0200 Subject: [PATCH 04/52] tests: Reorganize pytest utils and fixtures. - Move assert middleware and helper function to utils. - Move client fixture to conftest.py. - Edit test_auth.py in order to go with that changes. --- pytests/conftest.py | 22 ++++++++++++ pytests/test_auth.py | 86 +------------------------------------------- pytests/utils.py | 67 ++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 85 deletions(-) create mode 100644 pytests/utils.py diff --git a/pytests/conftest.py b/pytests/conftest.py index bde4635..4bcc08d 100644 --- a/pytests/conftest.py +++ b/pytests/conftest.py @@ -1,7 +1,29 @@ """Pytest configuration.""" import os.path +import pytest import sys +from utils import assert_middleware my_path = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(my_path, '..')) + + +@pytest.fixture +def client(loop, test_client): + """Fixture to create client with given app. + + Add assert_middleware to catch AssertionError in handlers. + Use as follow:: + + async def test_something(loop, client): + app = aiohttp.web.Application(loop=loop) + ... + cli = await client(app) + response = await cli.get('/some/path') + """ + async def go(app): + app.middlewares.append(assert_middleware) + return await test_client(app) + + yield go diff --git a/pytests/test_auth.py b/pytests/test_auth.py index 5027fcf..81f708b 100644 --- a/pytests/test_auth.py +++ b/pytests/test_auth.py @@ -1,56 +1,10 @@ import asyncio -import sys -import traceback from os import urandom import pytest from aiohttp import web from aiohttp_auth import auth import aiohttp_session - - -async def assert_middleware(app, handler): - """Collect AssertionError in handler. - - Use this middleware to collect assertion errors in - aiohttp handlers. Formatted error will be passed as - text to web.Response. - """ - async def middleware_handler(request): - try: - response = await handler(request) - except AssertionError as e: - _, _, tb = sys.exc_info() - # traceback.print_tb(tb) # Fixed format - tb_info = traceback.extract_tb(tb) - filename, line, func, text = tb_info[-1] - message = '\n{0}:{1}\n> {2}\nE {3}\n'.format( - filename, line, text, str(e) - ) - return web.Response(text=message) - - return response - - return middleware_handler - - -@pytest.fixture -def client(loop, test_client): - """Fixture to create client with given app. - - Add assert_middleware to catch AssertionError in handlers. - Use as follow:: - - async def test_something(loop, client): - app = aiohttp.web.Application(loop=loop) - ... - cli = await client(app) - response = await cli.get('/some/path') - """ - async def go(app): - app.middlewares.append(assert_middleware) - return await test_client(app) - - yield go +from utils import assert_response @pytest.fixture @@ -80,44 +34,6 @@ async def handler_forget(request): yield application -async def assert_response(request, response_text): - """Helper function to reraise assertion errors from handlers if any. - - This function can be used in cooperation with assert_middleware - to reraise AssertionError from aiohttp handlers as follow:: - - async def test_something(app, client): - async def handler_test(request): - assert False # when test fail we will see this line. - return web.Response(text='text') - - app.router.add_get('/test', handler_test) - cli = await client(app) - - # Note missed await before cli.get - response = await assert_response(cli.get('/test'), 'test') - - Testing this with py.test give us right place of the assertion - error if any. - - Args: - request: awaitable request. - response_text: excepted text in response. - - Returns: - ClientResponse: response for given request. - - Raises: - AssertionError: when it was assertion error in tested hanlder. - """ - response = await request - text = await response.text() - if text != response_text: - raise AssertionError(text) - - return response - - async def test_middleware_setup(app): secret = b'01234567890abcdef' policy = auth.CookieTktAuthentication(secret, 15, cookie_name='auth') diff --git a/pytests/utils.py b/pytests/utils.py new file mode 100644 index 0000000..a1a185e --- /dev/null +++ b/pytests/utils.py @@ -0,0 +1,67 @@ +"""Test utilites.""" +import sys +import traceback +from aiohttp import web + + +async def assert_middleware(app, handler): + """Collect AssertionError in handler. + + Use this middleware to collect assertion errors in + aiohttp handlers. Formatted error will be passed as + text to web.Response. + """ + async def middleware_handler(request): + try: + response = await handler(request) + except AssertionError as e: + _, _, tb = sys.exc_info() + # traceback.print_tb(tb) # Fixed format + tb_info = traceback.extract_tb(tb) + filename, line, func, text = tb_info[-1] + message = '\n{0}:{1}\n> {2}\nE {3}\n'.format( + filename, line, text, str(e) + ) + return web.Response(text=message) + + return response + + return middleware_handler + + +async def assert_response(request, response_text): + """Helper function to reraise assertion errors from handlers if any. + + This function can be used in cooperation with assert_middleware + to reraise AssertionError from aiohttp handlers as follow:: + + async def test_something(app, client): + async def handler_test(request): + assert False # when test fail we will see this line. + return web.Response(text='text') + + app.router.add_get('/test', handler_test) + cli = await client(app) + + # Note missed await before cli.get + response = await assert_response(cli.get('/test'), 'test') + + Testing this with py.test give us right place of the assertion + error if any. + + Args: + request: awaitable request. + response_text: excepted text in response. + + Returns: + ClientResponse: response for given request. + + Raises: + AssertionError: when it was assertion error in tested hanlder. + """ + response = await request + text = await response.text() + if text != response_text: + raise AssertionError(text) + + return response From 3667709885ea4490acc42f7b7219f3ab26193309 Mon Sep 17 00:00:00 2001 From: ilex Date: Tue, 10 Jan 2017 19:09:50 +0200 Subject: [PATCH 05/52] acl: Add setup function for acl middleware. With this function acl middleware can be set up in aiohttp fashion. --- aiohttp_auth/acl/__init__.py | 3 ++- aiohttp_auth/acl/acl.py | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/aiohttp_auth/acl/__init__.py b/aiohttp_auth/acl/__init__.py index 06e77ab..e967e10 100644 --- a/aiohttp_auth/acl/__init__.py +++ b/aiohttp_auth/acl/__init__.py @@ -1,3 +1,4 @@ from .acl import acl_middleware, get_permitted from .acl import get_user_groups -from .decorators import acl_required \ No newline at end of file +from .acl import setup +from .decorators import acl_required diff --git a/aiohttp_auth/acl/acl.py b/aiohttp_auth/acl/acl.py index 3f2069d..dbb70a6 100644 --- a/aiohttp_auth/acl/acl.py +++ b/aiohttp_auth/acl/acl.py @@ -1,5 +1,4 @@ import itertools -from aiohttp import web from ..auth import get_auth from ..permissions import Permission, Group @@ -112,3 +111,18 @@ async def get_permitted(request, permission, context): return action == Permission.Allow return False + + +def setup(app, groups_callback): + """Setup middleware in aiohttp fashion. + + Args: + app: aiohttp Application object. + groups_callback: This is a callable which takes a user_id (as returned + from the auth.get_auth function), and expects a sequence of + permitted ACL groups to be returned. This can be a empty tuple to + represent no explicit permissions, or None to explicitly forbid + this particular user_id. Note that the user_id passed may be None + if no authenticated user exists. + """ + app.middlewares.append(acl_middleware(groups_callback)) From a0261a4a2b3e8126c0f5a30a71168127822a8c10 Mon Sep 17 00:00:00 2001 From: ilex Date: Tue, 10 Jan 2017 19:11:39 +0200 Subject: [PATCH 06/52] tests: Add Pytest tests for acl middleware. Add all tests that reflect original tests/test_acl.py with pytest lib and real application. --- pytests/test_acl.py | 172 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 pytests/test_acl.py diff --git a/pytests/test_acl.py b/pytests/test_acl.py new file mode 100644 index 0000000..0321bfb --- /dev/null +++ b/pytests/test_acl.py @@ -0,0 +1,172 @@ +import pytest +import aiohttp_session +from aiohttp import web +from aiohttp_auth import acl, auth +from aiohttp_auth.permissions import Group, Permission +from utils import assert_response + + +@pytest.fixture +def app(loop): + """Default app fixture for tests.""" + async def handler_remember(request): + await auth.remember(request, 'some_user') + return web.Response(text='remember') + + application = web.Application(loop=loop) + + secret = b'01234567890abcdef' + storage = aiohttp_session.SimpleCookieStorage() + policy = auth.SessionTktAuthentication(secret, 15, cookie_name='auth') + + aiohttp_session.setup(application, storage) + auth.setup(application, policy) + + application.router.add_get('/remember', handler_remember) + + yield application + + +async def _groups_callback(user_id): + """Groups callback function that always returns two groups.""" + return ('group0', 'group1') + + +async def _auth_groups_callback(user_id): + """Groups callback function that always returns two groups.""" + if user_id: + return ('group0', 'group1') + + return () + + +async def _none_groups_callback(user_id): + """Groups callback function that always returns None.""" + return None + + +async def test_acl_middleware_setup(app): + acl.setup(app, _groups_callback) + + middleware = acl.acl_middleware(_groups_callback) + + assert app.middlewares[-1].__name__ == middleware.__name__ + + +async def test_no_middleware_installed(app, client): + async def handler_test(request): + with pytest.raises(RuntimeError): + await acl.get_user_groups(request) + + return web.Response(text='test') + + app.router.add_get('/test', handler_test) + cli = await client(app) + + await assert_response(cli.get('/remember'), 'remember') + + await assert_response(cli.get('/test'), 'test') + + +async def test_correct_groups_returned_for_authenticated_user(app, client): + async def handler_test(request): + groups = await acl.get_user_groups(request) + + assert 'group0' in groups + assert 'group1' in groups + assert 'some_user' in groups + assert Group.Everyone in groups + assert Group.AuthenticatedUser in groups + + return web.Response(text='test') + + acl.setup(app, _groups_callback) + app.router.add_get('/test', handler_test) + + cli = await client(app) + + await assert_response(cli.get('/remember'), 'remember') + + await assert_response(cli.get('/test'), 'test') + + +async def test_correct_groups_returned_for_unauthenticated_user(app, client): + async def handler_test(request): + groups = await acl.get_user_groups(request) + + assert 'group0' in groups + assert 'group1' in groups + assert 'some_user' not in groups + assert Group.Everyone in groups + assert Group.AuthenticatedUser not in groups + + return web.Response(text='test') + + acl.setup(app, _groups_callback) + app.router.add_get('/test', handler_test) + + cli = await client(app) + + await assert_response(cli.get('/test'), 'test') + + +async def test_no_groups_if_none_returned_from_callback(app, client): + async def handler_test(request): + groups = await acl.get_user_groups(request) + assert groups is None + + return web.Response(text='test') + + acl.setup(app, _none_groups_callback) + app.router.add_get('/test', handler_test) + + cli = await client(app) + + await assert_response(cli.get('/test'), 'test') + + +async def test_acl_permissions(app, client): + async def handler_test(request): + context = [(Permission.Allow, 'group0', ('test0',)), + (Permission.Deny, 'group1', ('test1',)), + (Permission.Allow, Group.Everyone, ('test1',))] + + assert (await acl.get_permitted(request, 'test0', context)) is True + assert (await acl.get_permitted(request, 'test1', context)) is False + + return web.Response(text='test') + + acl.setup(app, _groups_callback) + app.router.add_get('/test', handler_test) + + cli = await client(app) + + await assert_response(cli.get('/test'), 'test') + + +async def test_permission_order(app, client): + context = [(Permission.Allow, Group.Everyone, ('test0',)), + (Permission.Deny, 'group1', ('test1',)), + (Permission.Allow, Group.Everyone, ('test1',))] + + async def handler_test0(request): + assert (await acl.get_permitted(request, 'test0', context)) is True + assert (await acl.get_permitted(request, 'test1', context)) is False + + return web.Response(text='test0') + + async def handler_test1(request): + assert (await acl.get_permitted(request, 'test0', context)) is True + assert (await acl.get_permitted(request, 'test1', context)) is True + + return web.Response(text='test1') + + acl.setup(app, _auth_groups_callback) + app.router.add_get('/test0', handler_test0) + app.router.add_get('/test1', handler_test1) + + cli = await client(app) + + await assert_response(cli.get('/test1'), 'test1') + await assert_response(cli.get('/remember'), 'remember') + await assert_response(cli.get('/test0'), 'test0') From dbd2cb76c3067b7f7036976254e636524b0d83b0 Mon Sep 17 00:00:00 2001 From: ilex Date: Tue, 10 Jan 2017 21:56:47 +0200 Subject: [PATCH 07/52] tests: Add Pytest test for acl_required decorator. --- pytests/test_acl.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/pytests/test_acl.py b/pytests/test_acl.py index 0321bfb..8ed5bde 100644 --- a/pytests/test_acl.py +++ b/pytests/test_acl.py @@ -1,3 +1,4 @@ +import asyncio import pytest import aiohttp_session from aiohttp import web @@ -170,3 +171,39 @@ async def handler_test1(request): await assert_response(cli.get('/test1'), 'test1') await assert_response(cli.get('/remember'), 'remember') await assert_response(cli.get('/test0'), 'test0') + + +async def test_acl_required_decorator(loop, app, client): + context = [(Permission.Deny, 'group0', ('test0',)), + (Permission.Allow, 'group0', ('test1',)), + (Permission.Allow, 'group1', ('test0', 'test1'))] + + class GroupsCallback: + def __init__(self, group=None): + self.group = group + + def __call__(self, user_id): + future = asyncio.Future(loop=loop) + future.set_result((self.group, )) + return future + + @acl.acl_required('test0', context) + async def handler_test(request): + return web.Response(text='test') + + groups_callback = GroupsCallback() + acl.setup(app, groups_callback) + app.router.add_get('/test', handler_test) + + cli = await client(app) + + response = await cli.get('/test') + assert response.status == 403 + + groups_callback.group = 'group0' + response = await cli.get('/test') + assert response.status == 403 + + groups_callback.group = 'group1' + response = await cli.get('/test') + await assert_response(cli.get('/test'), 'test') From 3d39cd88b3904f6d7454a7bc4f357b962be9cc86 Mon Sep 17 00:00:00 2001 From: ilex Date: Tue, 10 Jan 2017 21:59:58 +0200 Subject: [PATCH 08/52] acl: Fixed bug in acl_required decorator. Fixed bug with UnboundLocalError raised in acl_required decorator. --- aiohttp_auth/acl/decorators.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/aiohttp_auth/acl/decorators.py b/aiohttp_auth/acl/decorators.py index a5ae5bb..5058d4e 100644 --- a/aiohttp_auth/acl/decorators.py +++ b/aiohttp_auth/acl/decorators.py @@ -1,10 +1,12 @@ +"""ACL middleware decorators.""" + from functools import wraps from aiohttp import web from .acl import get_permitted def acl_required(permission, context): - """Returns a decorator that checks if a user has the requested permission + """Return a decorator that checks if a user has the requested permission from the passed acl context. This function constructs a decorator that can be used to check a aiohttp's @@ -24,17 +26,15 @@ def acl_required(permission, context): the given context. The decorator will raise HTTPForbidden if the user does not have the correct permissions to access the view. """ - def decorator(func): @wraps(func) async def wrapper(*args): request = args[-1] - if callable(context): - context = context() + context_value = context() if callable(context) else context - if await get_permitted(request, permission, context): + if await get_permitted(request, permission, context_value): return await func(*args) raise web.HTTPForbidden() @@ -42,4 +42,3 @@ async def wrapper(*args): return wrapper return decorator - From 45ea2b13a74544a8a482b1541b41a742f9e94ca9 Mon Sep 17 00:00:00 2001 From: ilex Date: Tue, 10 Jan 2017 22:45:10 +0200 Subject: [PATCH 09/52] tests: Add some Pytest tests for acl. Add some new tests to test untested cases. --- pytests/test_acl.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/pytests/test_acl.py b/pytests/test_acl.py index 8ed5bde..9048965 100644 --- a/pytests/test_acl.py +++ b/pytests/test_acl.py @@ -1,4 +1,3 @@ -import asyncio import pytest import aiohttp_session from aiohttp import web @@ -182,10 +181,14 @@ class GroupsCallback: def __init__(self, group=None): self.group = group + async def groups(self): + if self.group is None: + return None + + return (self.group, ) + def __call__(self, user_id): - future = asyncio.Future(loop=loop) - future.set_result((self.group, )) - return future + return self.groups() @acl.acl_required('test0', context) async def handler_test(request): @@ -207,3 +210,21 @@ async def handler_test(request): groups_callback.group = 'group1' response = await cli.get('/test') await assert_response(cli.get('/test'), 'test') + + +async def test_acl_not_matching_acl_group(app, client): + async def handler_test(request): + context = [(Permission.Allow, 'group2', ('test0')), + (Permission.Allow, 'group3', ('test0', 'test1'))] + + assert (await acl.get_permitted(request, 'test0', context)) is False + assert (await acl.get_permitted(request, 'test1', context)) is False + + return web.Response(text='test') + + acl.setup(app, _groups_callback) + app.router.add_get('/test', handler_test) + + cli = await client(app) + + await assert_response(cli.get('/test'), 'test') From 387c58efa48d809a02530a10cb4c05ff6bad59b0 Mon Sep 17 00:00:00 2001 From: ilex Date: Tue, 10 Jan 2017 23:28:24 +0200 Subject: [PATCH 10/52] docs: Change docs for acl middleware. Change code examples in README.rst in order to show how to setup acl middleware in aiohttp fashion. --- README.rst | 50 ++++++++++++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/README.rst b/README.rst index 655a2e3..9aa683c 100644 --- a/README.rst +++ b/README.rst @@ -142,19 +142,6 @@ EncryptedCookieStorage: ... -In order to test this middleware with pytest you need to install:: - - $ pip install pytest pytest-aiohttp pytest-cov - -And then run tests:: - - $ py.test -v --cov-report=term-missing --cov=aiohttp_auth pytests - -There are two tests that use `sleep` function which marked as `slow` tests. -To avoid running them use:: - - $ py.test -v -m 'not slow' --cov-report=term-missing --cov=aiohttp_auth pytests - acl_middleware Usage --------------------- @@ -179,7 +166,9 @@ our example, a function may be defined as: .. code-block:: python - from aiohttp_auth import acl + from aiohttp import web + from aiohttp_auth import acl, auth + import aiohttp_session group_map = {'user': (,), 'super_user': ('edit_group',),} @@ -193,11 +182,18 @@ our example, a function may be defined as: def init(loop): ... - middlewares = [session_middleware(EncryptedCookieStorage(urandom(32))), - auth.auth_middleware(policy), - acl.acl_middleware(acl_group_callback)] + app = web.Application(loop=loop) + # setup session middleware + storage = aiohttp_session.EncryptedCookieStorage(urandom(32)) + aiohttp_session.setup(app, storage) + + # setup aiohttp_auth.auth middleware + policy = auth.SessionTktAuthentication(urandom(32), 60, include_ip=True) + auth.setup(app, policy) + + # setup aiohttp_auth.acl middleware + acl.setup(app, acl_group_callback) - app = web.Application(loop=loop, middlewares=middlewares) ... @@ -258,6 +254,24 @@ context was modified to: In this example the 'super_user' would be denied access to the view_extra_view even though they are an AuthenticatedUser and in the edit_group. + +Testing with Pytest +------------------- + +In order to test this middleware with pytest you need to install:: + + $ pip install pytest pytest-aiohttp pytest-cov + +And then run tests:: + + $ py.test -v --cov-report=term-missing --cov=aiohttp_auth pytests + +There are two tests that use `sleep` function which marked as `slow` tests. +To avoid running them use:: + + $ py.test -v -m 'not slow' --cov-report=term-missing --cov=aiohttp_auth pytests + + License ------- From 6dbbb8bf744f93d141acf660af238daab94d52a8 Mon Sep 17 00:00:00 2001 From: ilex Date: Wed, 11 Jan 2017 00:19:23 +0200 Subject: [PATCH 11/52] Add setup function for aiohttp_auth. auth_middleware and acl_middleware can be now set up at once in the aiohttp fashion. tests: Add Pytest test for this setup function. --- aiohttp_auth/__init__.py | 21 ++++++++++++++++++++- pytests/test_aiohttp_auth.py | 25 +++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 pytests/test_aiohttp_auth.py diff --git a/aiohttp_auth/__init__.py b/aiohttp_auth/__init__.py index 1264ece..130364b 100644 --- a/aiohttp_auth/__init__.py +++ b/aiohttp_auth/__init__.py @@ -1,2 +1,21 @@ from .auth import auth_middleware -from .acl import acl_middleware \ No newline at end of file +from .acl import acl_middleware +from . import auth, acl + + +def setup(app, auth_policy, acl_groups_callback): + """Setup auth and acl middleware in aiohttp fashion. + + Args: + app: aiohttp Application object. + auth_policy: An authentication policy with a base class of + AbstractAuthentication. + acl_groups_callback: This is a callable which takes a user_id (as + returned from the auth.get_auth function), and expects a sequence + of permitted ACL groups to be returned. This can be a empty tuple + to represent no explicit permissions, or None to explicitly forbid + this particular user_id. Note that the user_id passed may be None + if no authenticated user exists. + """ + auth.setup(app, auth_policy) + acl.setup(app, acl_groups_callback) diff --git a/pytests/test_aiohttp_auth.py b/pytests/test_aiohttp_auth.py new file mode 100644 index 0000000..150345d --- /dev/null +++ b/pytests/test_aiohttp_auth.py @@ -0,0 +1,25 @@ +import aiohttp_auth +import aiohttp_session +from aiohttp import web +from aiohttp_auth import acl, auth + + +async def test_aiohttp_auth_middleware_setup(loop): + app = web.Application(loop=loop) + + secret = b'01234567890abcdef' + storage = aiohttp_session.SimpleCookieStorage() + aiohttp_session.setup(app, storage) + + policy = auth.SessionTktAuthentication(secret, 15, cookie_name='auth') + + async def acl_groups_callback(user_id): + pass + + aiohttp_auth.setup(app, policy, acl_groups_callback) + + middleware = auth.auth_middleware(policy) + assert app.middlewares[-2].__name__ == middleware.__name__ + + middleware = acl.acl_middleware(acl_groups_callback) + assert app.middlewares[-1].__name__ == middleware.__name__ From 1dab09e7e04bbdb1ef027a7ab81988e3b5bc1aca Mon Sep 17 00:00:00 2001 From: ilex Date: Wed, 11 Jan 2017 00:31:40 +0200 Subject: [PATCH 12/52] tests: Test to reproduce user_id_as_group bug. Add Pytest test to reproduce bug when value of user_id is equal to some group name. As user_id is automatically added to groups by acl middleware such user can get unauthorized permissions. --- pytests/test_acl.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/pytests/test_acl.py b/pytests/test_acl.py index 9048965..56b21c1 100644 --- a/pytests/test_acl.py +++ b/pytests/test_acl.py @@ -228,3 +228,31 @@ async def handler_test(request): cli = await client(app) await assert_response(cli.get('/test'), 'test') + + +async def test_acl_permission_deny_for_user_id_equals_to_group_name(app, + client): + context = [(Permission.Allow, 'group0', ('test0',)), + (Permission.Deny, 'group1', ('test0',))] + + async def _groups1_callback(user_id): + return ('group1', ) + + async def handler_test(request): + assert (await acl.get_permitted(request, 'test0', context)) is False + + return web.Response(text='test') + + async def handler_remember_group0(request): + await auth.remember(request, 'group0') + return web.Response(text='remember_group0') + + acl.setup(app, _groups1_callback) + app.router.add_get('/test', handler_test) + app.router.add_get('/remember_group0', handler_remember_group0) + + cli = await client(app) + + await assert_response(cli.get('/test'), 'test') + await assert_response(cli.get('/remember_group0'), 'remember_group0') + await assert_response(cli.get('/test'), 'test') From b8bb3178786daebc828298dc0d1988b191890495 Mon Sep 17 00:00:00 2001 From: ilex Date: Wed, 11 Jan 2017 01:22:34 +0200 Subject: [PATCH 13/52] acl: Fix bug with user_id_as_group_name. acl middleware does not add user_id to user groups any more. So that fixes the bug when user_id is equal to group name user can have unauthorized permissions. --- aiohttp_auth/acl/acl.py | 2 +- pytests/test_acl.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/aiohttp_auth/acl/acl.py b/aiohttp_auth/acl/acl.py index dbb70a6..0ca265f 100644 --- a/aiohttp_auth/acl/acl.py +++ b/aiohttp_auth/acl/acl.py @@ -63,7 +63,7 @@ async def get_user_groups(request): if groups is None: return None - user_groups = (Group.AuthenticatedUser, user_id) if user_id is not None else () + user_groups = (Group.AuthenticatedUser, ) if user_id is not None else () return set(itertools.chain(groups, (Group.Everyone,), user_groups)) diff --git a/pytests/test_acl.py b/pytests/test_acl.py index 56b21c1..2f803d0 100644 --- a/pytests/test_acl.py +++ b/pytests/test_acl.py @@ -74,7 +74,7 @@ async def handler_test(request): assert 'group0' in groups assert 'group1' in groups - assert 'some_user' in groups + assert 'some_user' not in groups assert Group.Everyone in groups assert Group.AuthenticatedUser in groups From ffc44a1bc858793243e85b12e741c7ec775a8e09 Mon Sep 17 00:00:00 2001 From: ilex Date: Wed, 11 Jan 2017 12:31:22 +0200 Subject: [PATCH 14/52] alc: Fix docs to reflect user_id_as_group bug. Remove docs where it says that user_id is added to groups by acl middleware for authenticated user. --- README.rst | 2 +- aiohttp_auth/acl/acl.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 9aa683c..80106a2 100644 --- a/README.rst +++ b/README.rst @@ -199,7 +199,7 @@ our example, a function may be defined as: Note that the ACL groups returned by the function will be modified by the acl_middleware to also include the Group.Everyone group (if the value returned -is not None), and also the Group.AuthenticatedUser and user_id if the user_id +is not None), and also the Group.AuthenticatedUser if the user_id is not None. With the groups defined, a ACL context can be specified for looking up what diff --git a/aiohttp_auth/acl/acl.py b/aiohttp_auth/acl/acl.py index 0ca265f..17687ef 100644 --- a/aiohttp_auth/acl/acl.py +++ b/aiohttp_auth/acl/acl.py @@ -48,8 +48,8 @@ async def get_user_groups(request): If the ACL callback function returns None, this function returns None. Otherwise this function returns the sequence of group permissions provided by the callback, plus the Everyone group. If user_id is not - None, the AuthnticatedUser group and the user_id are added to the - groups returned by the function + None, the AuthnticatedUser group is added to the groups returned + by the function. Raises: RuntimeError: If the ACL middleware is not installed From 534a797ecced421917109439195f7fbb40eb328a Mon Sep 17 00:00:00 2001 From: ilex Date: Wed, 11 Jan 2017 13:02:53 +0200 Subject: [PATCH 15/52] acl: Add AbstractACLGroupsCallback class. Add abstract base class for callable objects which can be passed to acl setup as acl_groups_callback callable. tests: Add Pytest tests for this class. --- aiohttp_auth/acl/abc.py | 57 +++++++++++++++++++++++++++++++ pytests/test_acl.py | 75 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 aiohttp_auth/acl/abc.py diff --git a/aiohttp_auth/acl/abc.py b/aiohttp_auth/acl/abc.py new file mode 100644 index 0000000..d5b2f9c --- /dev/null +++ b/aiohttp_auth/acl/abc.py @@ -0,0 +1,57 @@ +"""ACL abstract classes.""" +import abc + + +class AbstractACLGroupsCallback(abc.ABC): + """Abstract base class for acl_groups_callback callabel. + + User should create class deriving from this one, override acl_groups + method and register object of that class with setup function as + acl_groups_callback. + + Usage example:: + + class ACLGroupsCallback(AbstractACLGroupsCallback): + + def __init__(self, cache): + self.cache = cache + + async def acl_groups(self, user_id): + user = await self.cache.get(user_id) + return user.groups() + + + def init(loop): + app = web.Application(loop=loop) + ... + cache = ... + acl_groups_callback = ACLGroupsCallback(cache) + + acl.setup(app, acl_groups_callback) + ... + + """ + + @abc.abstractmethod + async def acl_groups(self, user_id): + """Return ACL groups for given user identity. + + Note that the ACL groups returned by this method will be modified by + the acl_middleware to also include the Group.Everyone group (if the + value returned is not None), and also the Group.AuthenticatedUser + if the user_id is not None. + + Args: + user_id: User identity (as returned from the auth.get_auth + function). Note that the user_id passed may be None if no + authenticated user exists. + + Returns: + A sequence of permitted ACL groups. This can be a empty tuple to + represent no explicit permissions, or None to explicitly forbid + this particular user_id. + """ + pass # pragma: no cover + + def __call__(self, user_id): + return self.acl_groups(user_id) diff --git a/pytests/test_acl.py b/pytests/test_acl.py index 2f803d0..3a4e9f8 100644 --- a/pytests/test_acl.py +++ b/pytests/test_acl.py @@ -2,6 +2,7 @@ import aiohttp_session from aiohttp import web from aiohttp_auth import acl, auth +from aiohttp_auth.acl.abc import AbstractACLGroupsCallback from aiohttp_auth.permissions import Group, Permission from utils import assert_response @@ -32,6 +33,12 @@ async def _groups_callback(user_id): return ('group0', 'group1') +class ACLGroupsCallback(AbstractACLGroupsCallback): + """Groups callback callable class that always returns two groups.""" + async def acl_groups(self, user_id): + return ('group0', 'group1') + + async def _auth_groups_callback(user_id): """Groups callback function that always returns two groups.""" if user_id: @@ -45,6 +52,12 @@ async def _none_groups_callback(user_id): return None +class NoneACLGroupsCallback(AbstractACLGroupsCallback): + """Groups callback callable class that always returns None.""" + async def acl_groups(self, user_id): + return None + + async def test_acl_middleware_setup(app): acl.setup(app, _groups_callback) @@ -256,3 +269,65 @@ async def handler_remember_group0(request): await assert_response(cli.get('/test'), 'test') await assert_response(cli.get('/remember_group0'), 'remember_group0') await assert_response(cli.get('/test'), 'test') + + +async def test_correct_groups_returned_for_authenticated_user_with_abc( + app, client): + async def handler_test(request): + groups = await acl.get_user_groups(request) + + assert 'group0' in groups + assert 'group1' in groups + assert 'some_user' not in groups + assert Group.Everyone in groups + assert Group.AuthenticatedUser in groups + + return web.Response(text='test') + + acl_groups_callback = ACLGroupsCallback() + acl.setup(app, acl_groups_callback) + app.router.add_get('/test', handler_test) + + cli = await client(app) + + await assert_response(cli.get('/remember'), 'remember') + + await assert_response(cli.get('/test'), 'test') + + +async def test_correct_groups_returned_for_unauthenticated_user_with_abc( + app, client): + async def handler_test(request): + groups = await acl.get_user_groups(request) + + assert 'group0' in groups + assert 'group1' in groups + assert 'some_user' not in groups + assert Group.Everyone in groups + assert Group.AuthenticatedUser not in groups + + return web.Response(text='test') + + acl_groups_callback = ACLGroupsCallback() + acl.setup(app, acl_groups_callback) + app.router.add_get('/test', handler_test) + + cli = await client(app) + + await assert_response(cli.get('/test'), 'test') + + +async def test_no_groups_if_none_returned_from_callback_with_abc(app, client): + async def handler_test(request): + groups = await acl.get_user_groups(request) + assert groups is None + + return web.Response(text='test') + + acl_groups_callback = NoneACLGroupsCallback() + acl.setup(app, acl_groups_callback) + app.router.add_get('/test', handler_test) + + cli = await client(app) + + await assert_response(cli.get('/test'), 'test') From 3aec9b931fd6fa6d6647eb607def730a6709a805 Mon Sep 17 00:00:00 2001 From: ilex Date: Wed, 11 Jan 2017 13:27:19 +0200 Subject: [PATCH 16/52] docs: Add docs for AbstractACLGroupsCallback usage. --- README.rst | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/README.rst b/README.rst index 80106a2..aa58ab8 100644 --- a/README.rst +++ b/README.rst @@ -202,6 +202,52 @@ acl_middleware to also include the Group.Everyone group (if the value returned is not None), and also the Group.AuthenticatedUser if the user_id is not None. +Instead of acl_group_callback as a coroutine the AbstractACLGroupsCallback +class can be used (all you need is to override acl_groups method): + +.. code-block:: python + + from aiohttp import web + from aiohttp_auth import acl, auth + from aiohttp_auth.acl.abc import AbstractACLGroupsCallback + import aiohttp_session + + + class ACLGroupsCallback(AbstractACLGroupsCallback): + def __init__(self, cache): + # Save here data you need to retrieve groups + # for example cache or db connection + self.cache = cache + + async def acl_groups(self, user_id): + # override abstract method with needed logic + user = self.cache.get(user_id, None) + ... + groups = user.groups() if user else tuple() + return groups + + + def init(loop): + ... + + app = web.Application(loop=loop) + # setup session middleware + storage = aiohttp_session.EncryptedCookieStorage(urandom(32)) + aiohttp_session.setup(app, storage) + + # setup aiohttp_auth.auth middleware + policy = auth.SessionTktAuthentication(urandom(32), 60, include_ip=True) + auth.setup(app, policy) + + # setup aiohttp_auth.acl middleware + cache = ... + acl_groups_callback = ACLGroupsCallback(cache) + acl.setup(app, acl_group_callback) + + ... + + + With the groups defined, a ACL context can be specified for looking up what permissions each group is allowed to access. A context is a sequence of ACL tuples which consist of a Allow/Deny action, a group, and a sequence of From 79d34994d95794ea951206dba68f52061dd341de Mon Sep 17 00:00:00 2001 From: ilex Date: Thu, 19 Jan 2017 00:16:32 +0200 Subject: [PATCH 17/52] acl: Extract code into separate functions. - Extract acl permit logic into separate function. - Extract acl user groups modification logic into separate function. --- aiohttp_auth/acl/acl.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/aiohttp_auth/acl/acl.py b/aiohttp_auth/acl/acl.py index 17687ef..55cd1d1 100644 --- a/aiohttp_auth/acl/acl.py +++ b/aiohttp_auth/acl/acl.py @@ -60,6 +60,22 @@ async def get_user_groups(request): user_id = await get_auth(request) groups = await acl_callback(user_id) + + return extend_user_groups(user_id, groups) + + +def extend_user_groups(user_id, groups): + """Extend user groups with specific Groups. + + Args: + user_id: User identity from get_auth. + groups: User groups. + Returns: + If groups is None, this function returns None. + Otherwise this function extends groups with the Everyone group. + If user_id is not None, the AuthnticatedUser group is added to the + groups returned by the function. + """ if groups is None: return None @@ -102,6 +118,21 @@ async def get_permitted(request, permission, context): """ groups = await get_user_groups(request) + return get_groups_permitted(groups, permission, context) + + +def get_groups_permitted(groups, permission, context): + """Check if one of the groups has the requested permission. + + Args: + groups: A set of ACL groups. + permission: The specific permission requested. + context: A sequence of ACL tuples. + + Returns: + True if the groups are Allowed the requested permission, false + otherwise. + """ if groups is None: return False From 2e8cf11eb40c51597b56492de4b149558b5c440b Mon Sep 17 00:00:00 2001 From: ilex Date: Thu, 19 Jan 2017 00:21:45 +0200 Subject: [PATCH 18/52] autz: Introduce universal authorization middleware. - Add authorization middleware autz which define common authorization interface using different authorization policies. - Add ACL authorization policy based on acl middleware to use with autz. tests: - Add pytests to test autz middleware with acl policy. - Add pytests to test autz middleware with custom policy. --- aiohttp_auth/autz/__init__.py | 2 + aiohttp_auth/autz/abc.py | 25 ++ aiohttp_auth/autz/autz.py | 95 +++++++ aiohttp_auth/autz/decorators.py | 47 ++++ aiohttp_auth/autz/policy/__init__.py | 0 aiohttp_auth/autz/policy/acl.py | 248 +++++++++++++++++++ pytests/test_autz_acl_policy.py | 356 +++++++++++++++++++++++++++ pytests/test_autz_custom_policy.py | 77 ++++++ 8 files changed, 850 insertions(+) create mode 100644 aiohttp_auth/autz/__init__.py create mode 100644 aiohttp_auth/autz/abc.py create mode 100644 aiohttp_auth/autz/autz.py create mode 100644 aiohttp_auth/autz/decorators.py create mode 100644 aiohttp_auth/autz/policy/__init__.py create mode 100644 aiohttp_auth/autz/policy/acl.py create mode 100644 pytests/test_autz_acl_policy.py create mode 100644 pytests/test_autz_custom_policy.py diff --git a/aiohttp_auth/autz/__init__.py b/aiohttp_auth/autz/__init__.py new file mode 100644 index 0000000..056168f --- /dev/null +++ b/aiohttp_auth/autz/__init__.py @@ -0,0 +1,2 @@ +from .autz import autz_middleware, permit, setup +from .decorators import autz_required diff --git a/aiohttp_auth/autz/abc.py b/aiohttp_auth/autz/abc.py new file mode 100644 index 0000000..bd1910e --- /dev/null +++ b/aiohttp_auth/autz/abc.py @@ -0,0 +1,25 @@ +"""Abstract authorization classes.""" +import abc + + +class AbstractAutzPolicy(abc.ABC): + """Abstact base class for authentication policies. + + Each policy should be inherited from this class and implement + permit method. That is all needed to use such policy with + autz_middleware. + """ + + @abc.abstractmethod + async def permit(self, user_identity, permission, context): + """Check if user has permission accoding to context. + + Args: + user_identity: User identity returned from auth.get_auth. + permission: Permission method checks for. + context: Context which is used to determine permit of permission. + + Returns: + bool: True if permission is allowed and False otherwise. + """ + pass # pragma: no cover diff --git a/aiohttp_auth/autz/autz.py b/aiohttp_auth/autz/autz.py new file mode 100644 index 0000000..cb2062c --- /dev/null +++ b/aiohttp_auth/autz/autz.py @@ -0,0 +1,95 @@ +"""Authorization middleware.""" +from ..auth import get_auth +from .abc import AbstractAutzPolicy + + +AUTZ_POLICY_KEY = 'aiohttp_auth.autz.policy' + + +def autz_middleware(autz_policy): + """Return aiohttp_auth authorization middleware factory. + + Return aiohttp_auth.autz middleware factory for use by the aiohttp + application object. This middleware can be used only with + aiohttp_auth.auth middleware installed. + + The autz middleware provides follow interface to use in applications: + + - Using autz.permit function. + - Using autz.autz_required decorator for aiohttp handlers. + + Note that the recomended way to initialize this middleware is through + aiohttp_auth.autz.setup or aiohttp_auth.setup functions. As the autz + middleware can be used only with authentication aiohttp_auth.auth + middleware it is preferred to use aiohttp_auth.setup. + + Args: + autz_policy: a subclass of aiohttp_auth.autz.abc.AbstractAutzPolicy. + + Returns: + An aiohttp middleware factory. + """ + assert isinstance(autz_policy, AbstractAutzPolicy) + + async def _middleware_factory(app, handler): + + async def _middleware_handler(request): + # Save the policy in the request + request[AUTZ_POLICY_KEY] = autz_policy + + # Call the next handler in the chain + return await handler(request) + + return _middleware_handler + + return _middleware_factory + + +async def permit(request, permission, context=None): + """Check if permission is allowed for given request with given context. + + The authorization checking is provided by authorization policy which is + set by setup function. The nature of permission and context is also + determined by the given policy. + + Note that this function uses aiohttp_auth.auth.get_auth function + to determine user_identity for given request. So that middleware should + be installed too. + + Note that some additional exceptions could be raised by certain policy + while checking the permission. + + Args: + request: aiohttp Request object. + permission: The specific permission requested. + context: A context provided for checking permissions. Could be + optional if authorization policy provides a way to specify a + global application context. + + Returns: + True if permission is allowed False otherwise. + + Raises: + RuntimeError: If auth or autz middleware is not installed. + """ + user_identity = await get_auth(request) + + policy = request.get(AUTZ_POLICY_KEY, None) + if policy is None: + raise RuntimeError('autz_middleware not installed.') + + return await policy.permit(user_identity, permission, context) + + +def setup(app, autz_policy): + """Setup an authorization middleware in aiohttp fashion. + + Note that aiohttp_auth.auth middleware should be installed too to use autz + middleware. So the preferred way to install this middleware is to use + global aiohttp_auth.setup function. + + Args: + app: aiohttp Application object. + autz_policy: a subclass of aiohttp_auth.autz.abc.AbstractAutzPolicy. + """ + app.middlewares.append(autz_middleware(autz_policy)) diff --git a/aiohttp_auth/autz/decorators.py b/aiohttp_auth/autz/decorators.py new file mode 100644 index 0000000..ee36ad7 --- /dev/null +++ b/aiohttp_auth/autz/decorators.py @@ -0,0 +1,47 @@ +"""Authorization decorators.""" +from functools import wraps +from aiohttp import web +from . import autz + + +def autz_required(permission, context=None): + """Decorator to check if user has requested permission with given contex. + + This function constructs a decorator that can be used to check a aiohttp's + view for authorization before calling it. It uses the autz.permit + function to check the request against the passed permission and context. + If the user does not have the correct permission to run this function, it + raises HTTPForbidden. + + Note that context can be optional if authorization policy provides a way + to specify global application context. Also context parameter can be used + to override global context if it is provided by authorization policy. + + Note that some exceptions could be raised by certain policy while checking + the permission. + + Args: + permission: The specific permission requested. + context: A context provided for checking permissions. Could be + optional if authorization policy provides a way to specify a + global application context. + + Returns: + A decorator which will check the request passed has the permission for + the given context. The decorator will raise HTTPForbidden if the user + does not have the correct permissions to access the view. + """ + def decorator(func): + + @wraps(func) + async def wrapper(*args): + request = args[-1] + + if await autz.permit(request, permission, context): + return await func(*args) + + raise web.HTTPForbidden() + + return wrapper + + return decorator diff --git a/aiohttp_auth/autz/policy/__init__.py b/aiohttp_auth/autz/policy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aiohttp_auth/autz/policy/acl.py b/aiohttp_auth/autz/policy/acl.py new file mode 100644 index 0000000..fcd1963 --- /dev/null +++ b/aiohttp_auth/autz/policy/acl.py @@ -0,0 +1,248 @@ +"""ACL authorization policy. + +This module introduces: + AbsractACLAutzPolicy: Abstract base class to create acl authorization + policy class. The subclass should define how to retrieve users + groups. + AbstractACLContext: Abstract base class for ACL context containers. + Context container defines a representation of ACL data structure, + a storage method and how to process ACL context and groups + to authorize user with permissions. + NaiveACLContext: ACL context container which is initialized with list + of ACL tuples and stores them as they are. The implementation + of permit process is the same as used by acl_middleware. + ACLContext: The same as NaiveACLContext but makes some transformation + of incoming ACL tuples. This may helps with a perfomance of the permit + process. +""" +import abc +from ...acl.acl import extend_user_groups, get_groups_permitted +from ..abc import AbstractAutzPolicy + + +class AbstractACLContext(abc.ABC): + """Abstract base class for all ACL context classes. + + Policy uses context classes to wrap sequence of acl groups + permissions. This allows to implement different type of structures + to store permissions and/or to proccess them in a specific way. + + The only required method to implement in subclasses is permit. + """ + + @abc.abstractmethod + async def permit(self, user_identity, groups, permission): + """Check if permission is allowed for groups. + + Args: + user_identity: Identity of the user returned by auth.get_auth. + groups: Some set of groups for which permission is checked for. + permission: Permission that is checked. + + Returns: + True if permission is allowed False otherwise. + """ + pass # pragma: no cover + + +class NaiveACLContext(AbstractACLContext): + """Naive implementation of ACL context. + + This class does not make any transformation of the acl groups + permissions which are passed through constructor but stores + them as they are. + + It uses the default permit strategy from acl middleware. + """ + + def __init__(self, context): + """Constructor. + + Args: + context: is a sequence of ACL tuples which consist of + an Allow/Deny action, a group, and a sequence of permissions + for that ACL group. For example:: + + context = [(Permission.Allow, 'view_group', ('view',)), + (Permission.Allow, 'edit_group', ('view', + 'edit')),] + """ + self._context = context + + async def extended_user_groups(self, user_identity, groups): + """Extend user groups with ACL specific groups. + + See acl_middleware for more information. + """ + return extend_user_groups(user_identity, groups) + + async def permit(self, user_identity, groups, permission): + """Permit accoding to alc_middleware strategy. + + Args: + user_identity: Identity of the user returned by auth.get_auth. + groups: Set of user groups. + permission: Permission that is checked. + + Returns: + True if permission is allowed False otherwise. + """ + groups = await self.extended_user_groups(user_identity, groups) + return get_groups_permitted(groups, permission, self._context) + + +class ACLContext(NaiveACLContext): + """ACL context implementation. + + This acl context implementation works in the same way as NaiveACLContext + but acl groups permissions context is transformed to meet best perfomance + using permit strategy from acl_middleware. + """ + + def __init__(self, context): + """Constructor. + + Args: + context: The same as for NaiveACLContext. + """ + super().__init__(tuple((action, group, set(permissions)) + for action, group, permissions in context)) + + +class AbsractACLAutzPolicy(AbstractAutzPolicy): + """Abstract base class for ACL authorization policy. + + As the library does not know how to get groups for user and it is always + up to application, it provides abstract authorization acl policy + class. Subclass should implement acl_groups method to use it with + autz_middleware. + + Note that an acl context can be specified globally while initializing + policy or locally through autz.permit function's parameter. A local + context will always override a global one while checking permissions. + If there is no local context and global context is not set then a permit + method will raise a RuntimeError. + + A context is an instance of AbstractACLContext subclass or a sequence of + ACL tuples which consist of a Allow/Denyaction, a group, and a sequence + of permissions for that ACL group. + For example:: + + context = [(Permission.Allow, 'view_group', ('view',)), + (Permission.Allow, 'edit_group', ('view', 'edit')),] + + ACL tuple sequences are checked in order, with the first tuple that + matches the group the user is a member of, and includes the permission + passed to the function, to be the matching ACL group. If no ACL group is + found, the permit method returns False. + + Groups and permissions need only be immutable objects, so can be strings, + numbers, enumerations, or other immutable objects. + + Note that custom implementation of AbstractACLContext can be used to + change the context form and the way it is processed. + + Usage:: + + from aiohttp import web + from aiohttp_auth import autz, Permission + from aiohttp_auth.autz import autz_required + from aiohttp_auth.autz.policy import acl + + + class ACLAutzPolicy(acl.AbsractACLAutzPolicy): + def __init__(self, users, context=None): + super().__init__(context) + + # we will retrieve groups using some kind of users dict + # here you can use db or cache or any other needed data + self.cache = cache + + async def acl_groups(self, user_identity): + # implement application specific logic here + user = self.users.get(user_identity, None) + if user is None: + return None + + return user['groups'] + + + def init(loop): + app = web.Application(loop=loop) + ... + # here you need to initialize aiohttp_auth.auth middleware + ... + users = ... + # Create application global context. + # It can be overwritten in autz.permit fucntion or in + # autz_required decorator using local context explicitly. + context = [(Permission.Allow, 'view_group', {'view', }), + (Permission.Allow, 'edit_group', {'view', 'edit'})] + autz.setup(app, ACLAutzPolicy(users, context)) + + + # authorization using autz decorator applying to app handler + @autz_required('view') + async def handler_view(request): + # authorization using permit + if await autz.permit(request, 'edit'): + pass + + """ + + def __init__(self, context=None): + if context is None or isinstance(context, AbstractACLContext): + self.context = context + else: + self.context = ACLContext(context) + + @abc.abstractmethod + async def acl_groups(self, user_identity): + """Return acl groups for given user identity. + + Subclass should implement this method to return a set of + groups for given user_identity. + + Args: + user_identity: User identity returned by auth.get_auth. + + Returns: + Set of acl groups for the user identity. + """ + pass # pragma: no cover + + async def permit(self, user_identity, permission, context=None): + """Check if user is allowed for given permission with given context. + + Args: + user_identity: Identity of the user returned by + aiohttp_auth.auth.get_auth function + permission: The specific permission requested. + context: A context provided for checking permissions. Could be + optional if a global context is specified through policy + initialization. + + Returns: + True if permission is allowed False otherwise. + + Raises: + RuntimeError: If there is neither global context nor local one. + """ + if context is None: + # try global context + context = self.context + + if context is None: + raise RuntimeError('Context should be specified globally through ' + 'acl autz policy or passed as a parameter of ' + 'permit function or autz_required decorator.') + + if not isinstance(context, AbstractACLContext): + # if there is a raw acl context data + # we use NaiveACLContext wrapper which just + # stores it internally and makes no transformations + # which is important as of the perfomance point. + context = NaiveACLContext(context) + + groups = await self.acl_groups(user_identity) + return await context.permit(user_identity, groups, permission) diff --git a/pytests/test_autz_acl_policy.py b/pytests/test_autz_acl_policy.py new file mode 100644 index 0000000..cc06366 --- /dev/null +++ b/pytests/test_autz_acl_policy.py @@ -0,0 +1,356 @@ +import pytest +import aiohttp_session +from aiohttp import web +from aiohttp_auth import auth, autz +from aiohttp_auth.autz import autz_required +from aiohttp_auth.autz.policy import acl +from aiohttp_auth.permissions import Group, Permission +from utils import assert_response + + +@pytest.fixture +def app(loop): + """Default app fixture for tests.""" + async def handler_remember(request): + await auth.remember(request, 'some_user') + return web.Response(text='remember') + + application = web.Application(loop=loop) + + secret = b'01234567890abcdef' + storage = aiohttp_session.SimpleCookieStorage() + policy = auth.SessionTktAuthentication(secret, 15, cookie_name='auth') + + aiohttp_session.setup(application, storage) + auth.setup(application, policy) + + application.router.add_get('/remember', handler_remember) + + yield application + + +class ACLAutzPolicy(acl.AbsractACLAutzPolicy): + """Policy that always returns the same two groups.""" + async def acl_groups(self, user_identity): + return ('group0', 'group1') + + +class AuthACLAutzPolicy(acl.AbsractACLAutzPolicy): + """Policy that always returns the same two groups.""" + async def acl_groups(self, user_identity): + if user_identity: + return ('group0', 'group1') + + return () + + +class NoneACLAutzPolicy(acl.AbsractACLAutzPolicy): + """Policy that always returns None.""" + async def acl_groups(self, user_id): + return None + + +async def test_autz_middleware_setup(app): + autz_policy = ACLAutzPolicy([]) + autz.setup(app, autz_policy) + + middleware = autz.autz_middleware(autz_policy) + + assert app.middlewares[-1].__name__ == middleware.__name__ + + +async def test_no_middleware_installed(app, client): + async def handler_test(request): + with pytest.raises(RuntimeError) as exc_info: + await autz.permit(request, 'edit', []) + + assert str(exc_info.value) == 'autz_middleware not installed.' + + return web.Response(text='test') + + app.router.add_get('/test', handler_test) + cli = await client(app) + + await assert_response(cli.get('/remember'), 'remember') + + await assert_response(cli.get('/test'), 'test') + + +async def test_correct_groups_returned_for_authenticated_user(): + policy = ACLAutzPolicy([]) + groups = await policy.acl_groups('some_user') + groups = await policy.context.extended_user_groups('some_user', groups) + + assert 'group0' in groups + assert 'group1' in groups + assert 'some_user' not in groups + assert Group.Everyone in groups + assert Group.AuthenticatedUser in groups + + +async def test_correct_groups_returned_for_unauthenticated_user(): + policy = ACLAutzPolicy([]) + groups = await policy.acl_groups(None) + groups = await policy.context.extended_user_groups(None, groups) + + assert 'group0' in groups + assert 'group1' in groups + assert 'some_user' not in groups + assert Group.Everyone in groups + assert Group.AuthenticatedUser not in groups + + +async def test_no_groups_if_none_returned_from_acl_groups(): + policy = NoneACLAutzPolicy([]) + groups = await policy.acl_groups(None) + groups = await policy.context.extended_user_groups(None, groups) + + assert groups is None + + +async def test_autz_acl_policy_permit_with_local_context(): + context = [(Permission.Allow, 'group0', ('test0',)), + (Permission.Deny, 'group1', ('test1',)), + (Permission.Allow, Group.Everyone, ('test1',))] + + policy = ACLAutzPolicy(None) + + assert (await policy.permit(None, 'test0', context)) is True + assert (await policy.permit(None, 'test1', context)) is False + + +async def test_autz_acl_policy_permit_with_global_context(): + context = [(Permission.Allow, 'group0', ('test0',)), + (Permission.Deny, 'group1', ('test1',)), + (Permission.Allow, Group.Everyone, ('test1',))] + + policy = ACLAutzPolicy(context) + + assert (await policy.permit(None, 'test0')) is True + assert (await policy.permit(None, 'test1')) is False + + +async def test_autz_permit_with_acl_policy_local_context(app, client): + context = [(Permission.Allow, 'group0', ('test0',)), + (Permission.Deny, 'group1', ('test1',)), + (Permission.Allow, Group.Everyone, ('test1',))] + + async def handler_test(request): + assert (await autz.permit(request, 'test0', context)) is True + assert (await autz.permit(request, 'test1', context)) is False + + return web.Response(text='test') + + policy = ACLAutzPolicy(None) + autz.setup(app, policy) + app.router.add_get('/test', handler_test) + + cli = await client(app) + + await assert_response(cli.get('/test'), 'test') + + +async def test_autz_permit_with_acl_policy_global_context(app, client): + context = [(Permission.Allow, 'group0', ('test0',)), + (Permission.Deny, 'group1', ('test1',)), + (Permission.Allow, Group.Everyone, ('test1',))] + + async def handler_test(request): + assert (await autz.permit(request, 'test0')) is True + assert (await autz.permit(request, 'test1')) is False + + return web.Response(text='test') + + policy = ACLAutzPolicy(context) + autz.setup(app, policy) + app.router.add_get('/test', handler_test) + + cli = await client(app) + + await assert_response(cli.get('/test'), 'test') + + +async def test_autz_permit_acl_policy_replace_global_context_with_local( + app, client): + context = [(Permission.Deny, 'group1', ('test1', ))] + + async def handler_test(request): + local_context = [(Permission.Allow, 'group1', ('test1', ))] + + # with global context + assert (await autz.permit(request, 'test1')) is False + # with local context + assert (await autz.permit(request, 'test1', local_context)) is True + + return web.Response(text='test') + + policy = ACLAutzPolicy(context) + autz.setup(app, policy) + app.router.add_get('/test', handler_test) + + cli = await client(app) + + await assert_response(cli.get('/test'), 'test') + + +async def test_autz_acl_policy_permission_order(app, client): + context = [(Permission.Allow, Group.Everyone, ('test0',)), + (Permission.Deny, 'group1', ('test1',)), + (Permission.Allow, Group.Everyone, ('test1',))] + + async def handler_test0(request): + assert (await autz.permit(request, 'test0', context)) is True + assert (await autz.permit(request, 'test0')) is True + assert (await autz.permit(request, 'test1', context)) is False + assert (await autz.permit(request, 'test1')) is False + + return web.Response(text='test0') + + async def handler_test1(request): + assert (await autz.permit(request, 'test0', context)) is True + assert (await autz.permit(request, 'test0')) is True + assert (await autz.permit(request, 'test1', context)) is True + assert (await autz.permit(request, 'test1')) is True + + return web.Response(text='test1') + + autz.setup(app, AuthACLAutzPolicy(context)) + app.router.add_get('/test0', handler_test0) + app.router.add_get('/test1', handler_test1) + + cli = await client(app) + + await assert_response(cli.get('/test1'), 'test1') + await assert_response(cli.get('/remember'), 'remember') + await assert_response(cli.get('/test0'), 'test0') + + +async def test_autz_required_decorator_with_acl_policy(loop, app, client): + context = [(Permission.Deny, 'group0', ('test0',)), + (Permission.Allow, 'group0', ('test1',)), + (Permission.Allow, 'group1', ('test0', 'test1'))] + + class CustomACLAutzPolicy(acl.AbsractACLAutzPolicy): + def __init__(self, group=None, context=None): + super().__init__(context) + self.group = group + + async def acl_groups(self, user_identity): + if self.group is None: + return None + + return (self.group, ) + + @autz_required('test0', context) + async def handler_test(request): + return web.Response(text='test') + + @autz_required('test0') + async def handler_test_global(request): + return web.Response(text='test_global') + + policy = CustomACLAutzPolicy(context=context) + autz.setup(app, policy) + app.router.add_get('/test', handler_test) + app.router.add_get('/test_global', handler_test_global) + + cli = await client(app) + + response = await cli.get('/test') + assert response.status == 403 + response = await cli.get('/test_global') + assert response.status == 403 + + policy.group = 'group0' + response = await cli.get('/test') + assert response.status == 403 + response = await cli.get('/test_global') + assert response.status == 403 + + policy.group = 'group1' + await assert_response(cli.get('/test'), 'test') + await assert_response(cli.get('/test_global'), 'test_global') + + +async def test_autz_acl_not_matching_acl_group(app, client): + context = [(Permission.Allow, 'group2', ('test0')), + (Permission.Allow, 'group3', ('test0', 'test1'))] + + async def handler_test(request): + assert (await autz.permit(request, 'test0', context)) is False + assert (await autz.permit(request, 'test0')) is False + assert (await autz.permit(request, 'test1', context)) is False + assert (await autz.permit(request, 'test1')) is False + + return web.Response(text='test') + + autz.setup(app, ACLAutzPolicy(context)) + app.router.add_get('/test', handler_test) + + cli = await client(app) + + await assert_response(cli.get('/test'), 'test') + + +async def test_autz_acl_no_context_raises_error(app, client): + async def handler_test(request): + with pytest.raises(RuntimeError) as exc_info: + await autz.permit(request, 'test0') + + assert str(exc_info.value) == ('Context should be specified globally ' + 'through acl autz policy or passed as ' + 'a parameter of permit function or ' + 'autz_required decorator.') + + return web.Response(text='test') + + autz.setup(app, ACLAutzPolicy()) + app.router.add_get('/test', handler_test) + + cli = await client(app) + + await assert_response(cli.get('/test'), 'test') + + +async def test_autz_acl_naive_context(): + acl_context = [(Permission.Allow, 'group2', ('test0'))] + + context = acl.NaiveACLContext(acl_context) + + assert context._context == acl_context + + +async def test_autz_acl_context(): + acl_context = [(Permission.Allow, 'group0', ('test0'))] + + context = acl.ACLContext(acl_context) + + assert context._context != acl_context + assert isinstance(context._context, tuple) + assert isinstance(context._context[0][2], set) + + +@pytest.mark.parametrize('context_class', (acl.NaiveACLContext, + acl.ACLContext)) +async def test_autz_acl_context_permit(context_class): + acl_context = [(Permission.Allow, 'group0', ('test0', 'test2')), + (Permission.Deny, 'group1', ('test1',)), + (Permission.Allow, 'group0', ('test1', 'test0')), + (Permission.Allow, 'group1', ('test1', 'test0'))] + + context = context_class(acl_context) + + assert (await context.permit(None, {'group0', }, 'test0')) is True + assert (await context.permit(None, {'group0', }, 'test1')) is True + assert (await context.permit(None, {'group0', }, 'test2')) is True + assert (await context.permit(None, {'group0', }, 'test3')) is False + + assert (await context.permit(None, {'group1', }, 'test0')) is True + assert (await context.permit(None, {'group1', }, 'test1')) is False + assert (await context.permit(None, {'group1', }, 'test2')) is False + assert (await context.permit(None, {'group1', }, 'test3')) is False + + assert (await context.permit(None, {'group3', }, 'test1')) is False + assert (await context.permit(None, {'group3', }, 'test2')) is False + assert (await context.permit(None, {'group3', }, 'test3')) is False + assert (await context.permit(None, {'group3', }, 'test4')) is False diff --git a/pytests/test_autz_custom_policy.py b/pytests/test_autz_custom_policy.py new file mode 100644 index 0000000..ce9e0f5 --- /dev/null +++ b/pytests/test_autz_custom_policy.py @@ -0,0 +1,77 @@ +import pytest +from aiohttp import web +import aiohttp_session +from aiohttp_auth import autz, auth +from aiohttp_auth.autz import autz_required +from aiohttp_auth.autz.abc import AbstractAutzPolicy +from utils import assert_response + + +class CustomAutzPolicy(AbstractAutzPolicy): + + def __init__(self, admin_user_identity): + self.admin_user_identity = admin_user_identity + + async def permit(self, user_identity, permission, context=None): + if permission == 'admin': + if user_identity == self.admin_user_identity: + return True + + return False + + return True + + +@pytest.fixture +def app(loop): + """Default app fixture for tests.""" + async def handler_remember(request): + user_identity = request.match_info['user'] + await auth.remember(request, user_identity) + return web.Response(text='remember') + + @autz_required('admin') + async def handler_admin(request): + return web.Response(text='admin') + + @autz_required('guest') + async def handler_guest(request): + return web.Response(text='guest') + + application = web.Application(loop=loop) + + secret = b'01234567890abcdef' + storage = aiohttp_session.SimpleCookieStorage() + policy = auth.SessionTktAuthentication(secret, 15, cookie_name='auth') + + aiohttp_session.setup(application, storage) + auth.setup(application, policy) + + autz_policy = CustomAutzPolicy(admin_user_identity='alex') + autz.setup(application, autz_policy) + + application.router.add_get('/remember/{user}', handler_remember) + application.router.add_get('/admin', handler_admin) + application.router.add_get('/guest', handler_guest) + + yield application + + +async def test_autz_custom_policy_with_alex_identity(app, client): + + cli = await client(app) + + await assert_response(cli.get('/remember/alex'), 'remember') + await assert_response(cli.get('/admin'), 'admin') + await assert_response(cli.get('/guest'), 'guest') + + +async def test_autz_custom_policy_with_bob_identity(app, client): + + cli = await client(app) + + await assert_response(cli.get('/remember/bob'), 'remember') + await assert_response(cli.get('/guest'), 'guest') + + response = await cli.get('/admin') + assert response.status == 403 From ef30d899b3670f16f1bb3bcb38f05a601c66e2b9 Mon Sep 17 00:00:00 2001 From: ilex Date: Thu, 19 Jan 2017 00:30:06 +0200 Subject: [PATCH 19/52] Change global aiohttp_auth setup function. Replace setting up acl middleware with setting up autz middleware. --- aiohttp_auth/__init__.py | 17 +++++++---------- pytests/test_aiohttp_auth.py | 19 ++++++++++++------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/aiohttp_auth/__init__.py b/aiohttp_auth/__init__.py index 130364b..106b443 100644 --- a/aiohttp_auth/__init__.py +++ b/aiohttp_auth/__init__.py @@ -1,21 +1,18 @@ from .auth import auth_middleware +from .autz import autz_middleware from .acl import acl_middleware -from . import auth, acl +from . import auth, acl, autz -def setup(app, auth_policy, acl_groups_callback): - """Setup auth and acl middleware in aiohttp fashion. +def setup(app, auth_policy, autz_policy): + """Setup auth and autz middleware in aiohttp fashion. Args: app: aiohttp Application object. auth_policy: An authentication policy with a base class of AbstractAuthentication. - acl_groups_callback: This is a callable which takes a user_id (as - returned from the auth.get_auth function), and expects a sequence - of permitted ACL groups to be returned. This can be a empty tuple - to represent no explicit permissions, or None to explicitly forbid - this particular user_id. Note that the user_id passed may be None - if no authenticated user exists. + autz_policy: An authorization policy with a base class of + AbstractAutzPolicy """ auth.setup(app, auth_policy) - acl.setup(app, acl_groups_callback) + autz.setup(app, autz_policy) diff --git a/pytests/test_aiohttp_auth.py b/pytests/test_aiohttp_auth.py index 150345d..8e8c345 100644 --- a/pytests/test_aiohttp_auth.py +++ b/pytests/test_aiohttp_auth.py @@ -1,7 +1,8 @@ import aiohttp_auth import aiohttp_session from aiohttp import web -from aiohttp_auth import acl, auth +from aiohttp_auth import autz, auth +from aiohttp_auth.autz.policy import acl async def test_aiohttp_auth_middleware_setup(loop): @@ -11,15 +12,19 @@ async def test_aiohttp_auth_middleware_setup(loop): storage = aiohttp_session.SimpleCookieStorage() aiohttp_session.setup(app, storage) - policy = auth.SessionTktAuthentication(secret, 15, cookie_name='auth') + auth_policy = auth.SessionTktAuthentication(secret, 15, + cookie_name='auth') - async def acl_groups_callback(user_id): - pass + class ACLAutzPolicy(acl.AbsractACLAutzPolicy): + async def acl_groups(self, user_identity): + return None # pragma: no cover - aiohttp_auth.setup(app, policy, acl_groups_callback) + autz_policy = ACLAutzPolicy() - middleware = auth.auth_middleware(policy) + aiohttp_auth.setup(app, auth_policy, autz_policy) + + middleware = auth.auth_middleware(auth_policy) assert app.middlewares[-2].__name__ == middleware.__name__ - middleware = acl.acl_middleware(acl_groups_callback) + middleware = autz.autz_middleware(autz_policy) assert app.middlewares[-1].__name__ == middleware.__name__ From 2fbffe35f232c5451938dabf69cd51f18ca912a4 Mon Sep 17 00:00:00 2001 From: ilex Date: Thu, 19 Jan 2017 11:47:48 +0200 Subject: [PATCH 20/52] autz: Fix docs errors. --- aiohttp_auth/autz/autz.py | 6 +++--- aiohttp_auth/autz/policy/acl.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/aiohttp_auth/autz/autz.py b/aiohttp_auth/autz/autz.py index cb2062c..c737197 100644 --- a/aiohttp_auth/autz/autz.py +++ b/aiohttp_auth/autz/autz.py @@ -15,7 +15,7 @@ def autz_middleware(autz_policy): The autz middleware provides follow interface to use in applications: - - Using autz.permit function. + - Using autz.permit coroutine. - Using autz.autz_required decorator for aiohttp handlers. Note that the recomended way to initialize this middleware is through @@ -52,7 +52,7 @@ async def permit(request, permission, context=None): set by setup function. The nature of permission and context is also determined by the given policy. - Note that this function uses aiohttp_auth.auth.get_auth function + Note that this coroutine uses aiohttp_auth.auth.get_auth coroutine to determine user_identity for given request. So that middleware should be installed too. @@ -90,6 +90,6 @@ def setup(app, autz_policy): Args: app: aiohttp Application object. - autz_policy: a subclass of aiohttp_auth.autz.abc.AbstractAutzPolicy. + autz_policy: A subclass of aiohttp_auth.autz.abc.AbstractAutzPolicy. """ app.middlewares.append(autz_middleware(autz_policy)) diff --git a/aiohttp_auth/autz/policy/acl.py b/aiohttp_auth/autz/policy/acl.py index fcd1963..463a23e 100644 --- a/aiohttp_auth/autz/policy/acl.py +++ b/aiohttp_auth/autz/policy/acl.py @@ -124,7 +124,7 @@ class AbsractACLAutzPolicy(AbstractAutzPolicy): method will raise a RuntimeError. A context is an instance of AbstractACLContext subclass or a sequence of - ACL tuples which consist of a Allow/Denyaction, a group, and a sequence + ACL tuples which consist of a Allow/Deny action, a group, and a sequence of permissions for that ACL group. For example:: @@ -156,7 +156,7 @@ def __init__(self, users, context=None): # we will retrieve groups using some kind of users dict # here you can use db or cache or any other needed data - self.cache = cache + self.users = users async def acl_groups(self, user_identity): # implement application specific logic here @@ -174,7 +174,7 @@ def init(loop): ... users = ... # Create application global context. - # It can be overwritten in autz.permit fucntion or in + # It can be overridden in autz.permit fucntion or in # autz_required decorator using local context explicitly. context = [(Permission.Allow, 'view_group', {'view', }), (Permission.Allow, 'edit_group', {'view', 'edit'})] From 125ea971c67928682dca86e8b7a9f39e8ffec1a6 Mon Sep 17 00:00:00 2001 From: ilex Date: Thu, 19 Jan 2017 11:50:04 +0200 Subject: [PATCH 21/52] Improve docs in README.rst. - Introduce autz authorization plugin. - Introduce autz ACL authorization policy usage. - Introduce autz custom authorization policy usage. --- README.rst | 242 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 233 insertions(+), 9 deletions(-) diff --git a/README.rst b/README.rst index aa58ab8..e3b9b79 100644 --- a/README.rst +++ b/README.rst @@ -8,7 +8,7 @@ These plugins are designed to be lightweight, simple, and extensible, allowing the library to be reused regardless of the backend authentication mechanism. This provides a familiar framework across projects. -There are two middleware plugins provided by the library. The auth_middleware +There are three middleware plugins provided by the library. The auth_middleware plugin provides a simple system for authenticating a users credentials, and ensuring that the user is who they say they are. @@ -16,6 +16,10 @@ The acl_middleware plugin provides a simple access control list authorization mechanism, where users are provided access to different view handlers depending on what groups the user is a member of. +The autz_middleware plugin provides a generic way of authorization using +different authorization policies. There is the ACL authorization policy as a +part of the plugin. + auth_middleware Usage --------------------- @@ -144,7 +148,7 @@ EncryptedCookieStorage: acl_middleware Usage ---------------------- +-------------------- The acl_middleware plugin (provided by the aiohttp_auth library), is layered on top of the auth_middleware plugin, and provides a access control list (ACL) @@ -247,7 +251,6 @@ class can be used (all you need is to override acl_groups method): ... - With the groups defined, a ACL context can be specified for looking up what permissions each group is allowed to access. A context is a sequence of ACL tuples which consist of a Allow/Deny action, a group, and a sequence of @@ -301,6 +304,232 @@ In this example the 'super_user' would be denied access to the view_extra_view even though they are an AuthenticatedUser and in the edit_group. +autz_middleware Usage +--------------------- + +The autz middleware provides follow interface to use in applications: + + - Using `autz.permit` coroutine. + - Using `autz.autz_required` decorator for aiohttp handlers. + +The `async def autz.permit(request, permission, context=None)` coroutine checks +if permission is allowed for a given request with a given context. +The authorization checking is provided by authorization policy which is set by +setup function. The nature of permission and context is also determined by a policy. + +The `def autz_required(permission, context=None)` decorator for aiohttp's request +handlers checks if current user has requested permission with a given contex. +If the user does not have the correct permission it raises `HTTPForbidden`. + +Note that context can be optional if authorization policy provides a way +to specify global application context or if it does not require any. Also context +parameter can be used to override global context if it is provided by authorization policy. + +To use an authorization policy with autz middleware a class of policy should be created +inherited from `autz.abc.AbstractAutzPolicy`. The only thing that should be implemented +is `permit` method (see `Create custom authorization policy to use with autz middleware`_). +The autz middleware has a built in ACL authorization policy +(see `Use ACL authorization policy with autz middleware`_). + +The recomended way to initialize this middleware is through +`aiohttp_auth.autz.setup` or `aiohttp_auth.setup` functions. As the autz +middleware can be used only with authentication `aiohttp_auth.auth` +middleware it is preferred to use `aiohttp_auth.setup`. + +Use ACL authorization policy with autz middleware +------------------------------------------------- + +The autz plugin has a built in ACL authorization policy in `autz.policy.acl` module. +This module introduces a set of class: + + AbsractACLAutzPolicy: + Abstract base class to create acl authorization + policy class. The subclass should define how to retrieve users + groups. + + AbstractACLContext: + Abstract base class for ACL context containers. + Context container defines a representation of ACL data structure, + a storage method and how to process ACL context and groups + to authorize user with permissions. + + NaiveACLContext: + ACL context container which is initialized with list + of ACL tuples and stores them as they are. The implementation + of permit process is the same as used by acl_middleware. + + ACLContext: + The same as NaiveACLContext but makes some transformation + of incoming ACL tuples. This may helps with a perfomance of the permit + process. + +As the library does not know how to get groups for user and it is always +up to application, it provides abstract authorization acl policy +class. Subclass should implement `acl_groups` method to use it with +autz_middleware. + +Note that an acl context can be specified globally while initializing +policy or locally through autz.permit function's parameter. A local +context will always override a global one while checking permissions. +If there is no local context and global context is not set then a permit +method will raise a RuntimeError. + +A context is an instance of `AbstractACLContext` subclass or a sequence of +ACL tuples which consist of a Allow/Deny action, a group, and a sequence +of permissions for that ACL group (see `acl_middleware Usage`). + +Note that custom implementation of AbstractACLContext can be used to +change the context form and the way it is processed. + +Usage example: + +.. code-block:: python + + from aiohttp import web + from aiohttp_auth import autz, Permission + from aiohttp_auth.autz import autz_required + from aiohttp_auth.autz.policy import acl + + + # create an acl authorization policy class + class ACLAutzPolicy(acl.AbsractACLAutzPolicy): + """The concrete ACL authorization policy.""" + + def __init__(self, users, context=None): + # do not forget to call parent __init__ + super().__init__(context) + + # we will retrieve groups using some kind of users dict + # here you can use db or cache or any other needed data + self.users = users + + async def acl_groups(self, user_identity): + """Return acl groups for given user identity. + + This method should return a set of groups for given user_identity. + + Args: + user_identity: User identity returned by auth.get_auth. + + Returns: + Set of acl groups for the user identity. + """ + # implement application specific logic here + user = self.users.get(user_identity, None) + if user is None: + return None + + return user['groups'] + + + def init(loop): + app = web.Application(loop=loop) + ... + # here you need to initialize aiohttp_auth.auth middleware + auth_policy = ... + ... + users = ... + # Create application global context. + # It can be overridden in autz.permit fucntion or in + # autz_required decorator using local context explicitly. + context = [(Permission.Allow, 'view_group', {'view', }), + (Permission.Allow, 'edit_group', {'view', 'edit'})] + # this raw context will be wrapped by ACLContext container internally + # you can explicitly create acl context class you need and pass it here + autz_policy = ACLAutzPolicy(users, context) + + # install auth and autz middleware in aiohttp fashion + aiohttp_auth.setup(app, auth_policy, autz_policy) + + + # authorization using autz decorator applying to app handler + @autz_required('view') + async def handler_view(request): + # authorization using permit + if await autz.permit(request, 'edit'): + pass + + + # raw local context will wrapped with NaiveACLContext container internally + local_context = [(Permission.Deny, 'view_group', {'view', })] + + # authorization using autz decorator applying to app handler + # using local_context to override global one. + @autz_required('view', local_context) + async def handler_view_local(request): + # authorization using permit and local_context to + # override global one + if await autz.permit(request, 'edit', local_context): + pass + +Create custom authorization policy to use with autz middleware +-------------------------------------------------------------- + +Tha autz middleware makes it possible to use custom athorization policy with +the same autz public interface for checking user permissions. +The follow example shows how to create such simple custom policy: + +.. code-block:: python + + from aiohttp import web + from aiohttp_auth import autz, auth + from aiohttp_auth.autz import autz_required + from aiohttp_auth.autz.abc import AbstractAutzPolicy + + class CustomAutzPolicy(AbstractAutzPolicy): + + def __init__(self, admin_user_identity): + self.admin_user_identity = admin_user_identity + + async def permit(self, user_identity, permission, context=None): + # All we need is to implement this method + + if permission == 'admin': + # only admin_user_identity is allowed for 'admin' permission + if user_identity == self.admin_user_identity: + return True + + # forbid anyone else + return False + + # allow any other permissions for all users + return True + + + def init(loop): + app = web.Application(loop=loop) + ... + # here you need to initialize aiohttp_auth.auth middleware + auth_policy = ... + ... + # create custom authorization policy + autz_policy = CustomAutzPolicy(admin_user_identity='Bob') + + # install auth and autz middleware in aiohttp fashion + aiohttp_auth.setup(app, auth_policy, autz_policy) + + + # authorization using autz decorator applying to app handler + @autz_required('admin') + async def handler_admin(request): + # only Bob can run this handler + + # authorization using permit + if await autz.permit(request, 'admin'): + # only Bob can get here + pass + + + @autz_required('guest') + async def handler_guest(request): + # everyone can run this handler + + # authorization using permit + if await autz.permit(request, 'guest'): + # everyone can get here + pass + + Testing with Pytest ------------------- @@ -310,12 +539,7 @@ In order to test this middleware with pytest you need to install:: And then run tests:: - $ py.test -v --cov-report=term-missing --cov=aiohttp_auth pytests - -There are two tests that use `sleep` function which marked as `slow` tests. -To avoid running them use:: - - $ py.test -v -m 'not slow' --cov-report=term-missing --cov=aiohttp_auth pytests + $ py.test -v --cov-report=term-missing --cov=aiohttp_auth --cov=pytests pytests License From 5b5e110425d8ce9205bf3f9c6a7dbddf33af4391 Mon Sep 17 00:00:00 2001 From: ilex Date: Thu, 19 Jan 2017 13:50:02 +0200 Subject: [PATCH 22/52] tests: Add tox to run pytests. Add tox to run pytests with python3.5 and python3.6. --- tox.ini | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 tox.ini diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..41375cf --- /dev/null +++ b/tox.ini @@ -0,0 +1,10 @@ +[tox] +envlist = py35,py36 +skip_missing_interpreters = True +[testenv] +deps= + pytest + pytest-aiohttp + pytest-cov + aiohttp_session +commands=py.test -v --cov-report=term-missing --cov=aiohttp_auth --cov=pytests pytests From 60c2ccfd09cdcb58253f3c923a714f5fad041736 Mon Sep 17 00:00:00 2001 From: ilex Date: Thu, 19 Jan 2017 13:55:46 +0200 Subject: [PATCH 23/52] Add some gitignore patterns. --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 10f0e9d..6931c58 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ __pycache__ dist +.cache +.coverage +.tox +*.egg +*.egg-info From e13095267aadcf18c42af9467b621744cd567f88 Mon Sep 17 00:00:00 2001 From: ilex Date: Thu, 19 Jan 2017 14:00:16 +0200 Subject: [PATCH 24/52] aiohttp_auth: Create new development version. --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 656dbde..8a3ccdf 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ setup( name="aiohttp_auth", - version='0.1.1-dev', + version='0.2.0.dev0', description='Authorization and authentication middleware plugin for aiohttp.', long_description=open('README.rst').read(), install_requires=requires, @@ -28,4 +28,5 @@ 'License :: OSI Approved :: MIT License', 'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', ],) From 67d52ff7d95ddb30a4851052a14d134aec144ad3 Mon Sep 17 00:00:00 2001 From: ilex Date: Thu, 19 Jan 2017 14:16:06 +0200 Subject: [PATCH 25/52] docs: Improve README.rst docs. --- README.rst | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/README.rst b/README.rst index e3b9b79..2536364 100644 --- a/README.rst +++ b/README.rst @@ -309,37 +309,37 @@ autz_middleware Usage The autz middleware provides follow interface to use in applications: - - Using `autz.permit` coroutine. - - Using `autz.autz_required` decorator for aiohttp handlers. + - Using ``autz.permit`` coroutine. + - Using ``autz.autz_required`` decorator for aiohttp handlers. -The `async def autz.permit(request, permission, context=None)` coroutine checks +The ``async def autz.permit(request, permission, context=None)`` coroutine checks if permission is allowed for a given request with a given context. The authorization checking is provided by authorization policy which is set by setup function. The nature of permission and context is also determined by a policy. -The `def autz_required(permission, context=None)` decorator for aiohttp's request +The ``def autz_required(permission, context=None)`` decorator for aiohttp's request handlers checks if current user has requested permission with a given contex. -If the user does not have the correct permission it raises `HTTPForbidden`. +If the user does not have the correct permission it raises ``HTTPForbidden``. Note that context can be optional if authorization policy provides a way to specify global application context or if it does not require any. Also context parameter can be used to override global context if it is provided by authorization policy. To use an authorization policy with autz middleware a class of policy should be created -inherited from `autz.abc.AbstractAutzPolicy`. The only thing that should be implemented -is `permit` method (see `Create custom authorization policy to use with autz middleware`_). +inherited from ``autz.abc.AbstractAutzPolicy``. The only thing that should be implemented +is ``permit`` method (see `Create custom authorization policy to use with autz middleware`_). The autz middleware has a built in ACL authorization policy (see `Use ACL authorization policy with autz middleware`_). The recomended way to initialize this middleware is through -`aiohttp_auth.autz.setup` or `aiohttp_auth.setup` functions. As the autz -middleware can be used only with authentication `aiohttp_auth.auth` -middleware it is preferred to use `aiohttp_auth.setup`. +``aiohttp_auth.autz.setup`` or ``aiohttp_auth.setup`` functions. As the autz +middleware can be used only with authentication ``aiohttp_auth.auth`` +middleware it is preferred to use ``aiohttp_auth.setup``. Use ACL authorization policy with autz middleware ------------------------------------------------- -The autz plugin has a built in ACL authorization policy in `autz.policy.acl` module. +The autz plugin has a built in ACL authorization policy in ``autz.policy.acl`` module. This module introduces a set of class: AbsractACLAutzPolicy: @@ -365,7 +365,7 @@ This module introduces a set of class: As the library does not know how to get groups for user and it is always up to application, it provides abstract authorization acl policy -class. Subclass should implement `acl_groups` method to use it with +class. Subclass should implement ``acl_groups`` method to use it with autz_middleware. Note that an acl context can be specified globally while initializing @@ -374,9 +374,9 @@ context will always override a global one while checking permissions. If there is no local context and global context is not set then a permit method will raise a RuntimeError. -A context is an instance of `AbstractACLContext` subclass or a sequence of +A context is an instance of ``AbstractACLContext`` subclass or a sequence of ACL tuples which consist of a Allow/Deny action, a group, and a sequence -of permissions for that ACL group (see `acl_middleware Usage`). +of permissions for that ACL group (see `acl_middleware Usage`_). Note that custom implementation of AbstractACLContext can be used to change the context form and the way it is processed. @@ -533,14 +533,18 @@ The follow example shows how to create such simple custom policy: Testing with Pytest ------------------- -In order to test this middleware with pytest you need to install:: +In order to test this middleware with ``pytest`` you need to install:: - $ pip install pytest pytest-aiohttp pytest-cov + $ pip install pytest pytest-aiohttp pytest-cov aiohttp_session And then run tests:: $ py.test -v --cov-report=term-missing --cov=aiohttp_auth --cov=pytests pytests +Or using ``tox`` just run:: + + $ tox + License ------- From 6c3fd68f4f83cbb6287cf9ee8a9c89a8789c0188 Mon Sep 17 00:00:00 2001 From: ilex Date: Thu, 19 Jan 2017 17:44:07 +0200 Subject: [PATCH 26/52] autz: Fix misspelling in AbstractACLAutzPolicy class name. --- README.rst | 4 ++-- aiohttp_auth/autz/policy/acl.py | 6 +++--- pytests/test_aiohttp_auth.py | 2 +- pytests/test_autz_acl_policy.py | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.rst b/README.rst index 2536364..d41691e 100644 --- a/README.rst +++ b/README.rst @@ -342,7 +342,7 @@ Use ACL authorization policy with autz middleware The autz plugin has a built in ACL authorization policy in ``autz.policy.acl`` module. This module introduces a set of class: - AbsractACLAutzPolicy: + AbstractACLAutzPolicy: Abstract base class to create acl authorization policy class. The subclass should define how to retrieve users groups. @@ -392,7 +392,7 @@ Usage example: # create an acl authorization policy class - class ACLAutzPolicy(acl.AbsractACLAutzPolicy): + class ACLAutzPolicy(acl.AbstractACLAutzPolicy): """The concrete ACL authorization policy.""" def __init__(self, users, context=None): diff --git a/aiohttp_auth/autz/policy/acl.py b/aiohttp_auth/autz/policy/acl.py index 463a23e..ad8355e 100644 --- a/aiohttp_auth/autz/policy/acl.py +++ b/aiohttp_auth/autz/policy/acl.py @@ -1,7 +1,7 @@ """ACL authorization policy. This module introduces: - AbsractACLAutzPolicy: Abstract base class to create acl authorization + AbstractACLAutzPolicy: Abstract base class to create acl authorization policy class. The subclass should define how to retrieve users groups. AbstractACLContext: Abstract base class for ACL context containers. @@ -109,7 +109,7 @@ def __init__(self, context): for action, group, permissions in context)) -class AbsractACLAutzPolicy(AbstractAutzPolicy): +class AbstractACLAutzPolicy(AbstractAutzPolicy): """Abstract base class for ACL authorization policy. As the library does not know how to get groups for user and it is always @@ -150,7 +150,7 @@ class AbsractACLAutzPolicy(AbstractAutzPolicy): from aiohttp_auth.autz.policy import acl - class ACLAutzPolicy(acl.AbsractACLAutzPolicy): + class ACLAutzPolicy(acl.AbstractACLAutzPolicy): def __init__(self, users, context=None): super().__init__(context) diff --git a/pytests/test_aiohttp_auth.py b/pytests/test_aiohttp_auth.py index 8e8c345..670b633 100644 --- a/pytests/test_aiohttp_auth.py +++ b/pytests/test_aiohttp_auth.py @@ -15,7 +15,7 @@ async def test_aiohttp_auth_middleware_setup(loop): auth_policy = auth.SessionTktAuthentication(secret, 15, cookie_name='auth') - class ACLAutzPolicy(acl.AbsractACLAutzPolicy): + class ACLAutzPolicy(acl.AbstractACLAutzPolicy): async def acl_groups(self, user_identity): return None # pragma: no cover diff --git a/pytests/test_autz_acl_policy.py b/pytests/test_autz_acl_policy.py index cc06366..0ea3573 100644 --- a/pytests/test_autz_acl_policy.py +++ b/pytests/test_autz_acl_policy.py @@ -29,13 +29,13 @@ async def handler_remember(request): yield application -class ACLAutzPolicy(acl.AbsractACLAutzPolicy): +class ACLAutzPolicy(acl.AbstractACLAutzPolicy): """Policy that always returns the same two groups.""" async def acl_groups(self, user_identity): return ('group0', 'group1') -class AuthACLAutzPolicy(acl.AbsractACLAutzPolicy): +class AuthACLAutzPolicy(acl.AbstractACLAutzPolicy): """Policy that always returns the same two groups.""" async def acl_groups(self, user_identity): if user_identity: @@ -44,7 +44,7 @@ async def acl_groups(self, user_identity): return () -class NoneACLAutzPolicy(acl.AbsractACLAutzPolicy): +class NoneACLAutzPolicy(acl.AbstractACLAutzPolicy): """Policy that always returns None.""" async def acl_groups(self, user_id): return None @@ -230,7 +230,7 @@ async def test_autz_required_decorator_with_acl_policy(loop, app, client): (Permission.Allow, 'group0', ('test1',)), (Permission.Allow, 'group1', ('test0', 'test1'))] - class CustomACLAutzPolicy(acl.AbsractACLAutzPolicy): + class CustomACLAutzPolicy(acl.AbstractACLAutzPolicy): def __init__(self, group=None, context=None): super().__init__(context) self.group = group From ec23e30223870f28d17d899ec1283b597a836298 Mon Sep 17 00:00:00 2001 From: ilex Date: Sun, 12 Feb 2017 18:29:26 +0200 Subject: [PATCH 27/52] Move from aiohttp_auth to aiohttp_auth_autz. - Change name in setup. - Remove original tests. - Make pytests as default tests. --- .gitignore | 1 + aiohttp_auth/__init__.py | 12 +- pytests/test_acl.py | 333 --------------- pytests/test_auth.py | 296 ------------- requirements.txt | 2 + setup.cfg | 4 + setup.py | 40 +- tests/__init__.py | 0 {pytests => tests}/conftest.py | 0 tests/test_acl.py | 399 +++++++++++++----- {pytests => tests}/test_aiohttp_auth.py | 0 tests/test_auth.py | 384 ++++++++++++----- {pytests => tests}/test_autz_acl_policy.py | 0 {pytests => tests}/test_autz_custom_policy.py | 0 tests/util/__init__.py | 0 tests/util/aiohttp/__init__.py | 0 tests/util/aiohttp/test.py | 68 --- tests/util/asyncio.py | 24 -- {pytests => tests}/utils.py | 0 tox.ini | 2 +- 20 files changed, 620 insertions(+), 945 deletions(-) delete mode 100644 pytests/test_acl.py delete mode 100644 pytests/test_auth.py create mode 100644 requirements.txt create mode 100644 setup.cfg delete mode 100644 tests/__init__.py rename {pytests => tests}/conftest.py (100%) rename {pytests => tests}/test_aiohttp_auth.py (100%) rename {pytests => tests}/test_autz_acl_policy.py (100%) rename {pytests => tests}/test_autz_custom_policy.py (100%) delete mode 100644 tests/util/__init__.py delete mode 100644 tests/util/aiohttp/__init__.py delete mode 100644 tests/util/aiohttp/test.py delete mode 100644 tests/util/asyncio.py rename {pytests => tests}/utils.py (100%) diff --git a/.gitignore b/.gitignore index 6931c58..abfb3ab 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ dist .coverage .tox *.egg +*.eggs *.egg-info diff --git a/aiohttp_auth/__init__.py b/aiohttp_auth/__init__.py index 106b443..2669050 100644 --- a/aiohttp_auth/__init__.py +++ b/aiohttp_auth/__init__.py @@ -1,7 +1,17 @@ +"""Authentication and Authorization plugins for AIOHTTP.""" from .auth import auth_middleware from .autz import autz_middleware from .acl import acl_middleware -from . import auth, acl, autz +from . import auth, autz + +# silence pyflakes +assert auth_middleware +assert autz_middleware +assert acl_middleware +assert auth, autz + + +__version__ = '0.2.0.dev0' def setup(app, auth_policy, autz_policy): diff --git a/pytests/test_acl.py b/pytests/test_acl.py deleted file mode 100644 index 3a4e9f8..0000000 --- a/pytests/test_acl.py +++ /dev/null @@ -1,333 +0,0 @@ -import pytest -import aiohttp_session -from aiohttp import web -from aiohttp_auth import acl, auth -from aiohttp_auth.acl.abc import AbstractACLGroupsCallback -from aiohttp_auth.permissions import Group, Permission -from utils import assert_response - - -@pytest.fixture -def app(loop): - """Default app fixture for tests.""" - async def handler_remember(request): - await auth.remember(request, 'some_user') - return web.Response(text='remember') - - application = web.Application(loop=loop) - - secret = b'01234567890abcdef' - storage = aiohttp_session.SimpleCookieStorage() - policy = auth.SessionTktAuthentication(secret, 15, cookie_name='auth') - - aiohttp_session.setup(application, storage) - auth.setup(application, policy) - - application.router.add_get('/remember', handler_remember) - - yield application - - -async def _groups_callback(user_id): - """Groups callback function that always returns two groups.""" - return ('group0', 'group1') - - -class ACLGroupsCallback(AbstractACLGroupsCallback): - """Groups callback callable class that always returns two groups.""" - async def acl_groups(self, user_id): - return ('group0', 'group1') - - -async def _auth_groups_callback(user_id): - """Groups callback function that always returns two groups.""" - if user_id: - return ('group0', 'group1') - - return () - - -async def _none_groups_callback(user_id): - """Groups callback function that always returns None.""" - return None - - -class NoneACLGroupsCallback(AbstractACLGroupsCallback): - """Groups callback callable class that always returns None.""" - async def acl_groups(self, user_id): - return None - - -async def test_acl_middleware_setup(app): - acl.setup(app, _groups_callback) - - middleware = acl.acl_middleware(_groups_callback) - - assert app.middlewares[-1].__name__ == middleware.__name__ - - -async def test_no_middleware_installed(app, client): - async def handler_test(request): - with pytest.raises(RuntimeError): - await acl.get_user_groups(request) - - return web.Response(text='test') - - app.router.add_get('/test', handler_test) - cli = await client(app) - - await assert_response(cli.get('/remember'), 'remember') - - await assert_response(cli.get('/test'), 'test') - - -async def test_correct_groups_returned_for_authenticated_user(app, client): - async def handler_test(request): - groups = await acl.get_user_groups(request) - - assert 'group0' in groups - assert 'group1' in groups - assert 'some_user' not in groups - assert Group.Everyone in groups - assert Group.AuthenticatedUser in groups - - return web.Response(text='test') - - acl.setup(app, _groups_callback) - app.router.add_get('/test', handler_test) - - cli = await client(app) - - await assert_response(cli.get('/remember'), 'remember') - - await assert_response(cli.get('/test'), 'test') - - -async def test_correct_groups_returned_for_unauthenticated_user(app, client): - async def handler_test(request): - groups = await acl.get_user_groups(request) - - assert 'group0' in groups - assert 'group1' in groups - assert 'some_user' not in groups - assert Group.Everyone in groups - assert Group.AuthenticatedUser not in groups - - return web.Response(text='test') - - acl.setup(app, _groups_callback) - app.router.add_get('/test', handler_test) - - cli = await client(app) - - await assert_response(cli.get('/test'), 'test') - - -async def test_no_groups_if_none_returned_from_callback(app, client): - async def handler_test(request): - groups = await acl.get_user_groups(request) - assert groups is None - - return web.Response(text='test') - - acl.setup(app, _none_groups_callback) - app.router.add_get('/test', handler_test) - - cli = await client(app) - - await assert_response(cli.get('/test'), 'test') - - -async def test_acl_permissions(app, client): - async def handler_test(request): - context = [(Permission.Allow, 'group0', ('test0',)), - (Permission.Deny, 'group1', ('test1',)), - (Permission.Allow, Group.Everyone, ('test1',))] - - assert (await acl.get_permitted(request, 'test0', context)) is True - assert (await acl.get_permitted(request, 'test1', context)) is False - - return web.Response(text='test') - - acl.setup(app, _groups_callback) - app.router.add_get('/test', handler_test) - - cli = await client(app) - - await assert_response(cli.get('/test'), 'test') - - -async def test_permission_order(app, client): - context = [(Permission.Allow, Group.Everyone, ('test0',)), - (Permission.Deny, 'group1', ('test1',)), - (Permission.Allow, Group.Everyone, ('test1',))] - - async def handler_test0(request): - assert (await acl.get_permitted(request, 'test0', context)) is True - assert (await acl.get_permitted(request, 'test1', context)) is False - - return web.Response(text='test0') - - async def handler_test1(request): - assert (await acl.get_permitted(request, 'test0', context)) is True - assert (await acl.get_permitted(request, 'test1', context)) is True - - return web.Response(text='test1') - - acl.setup(app, _auth_groups_callback) - app.router.add_get('/test0', handler_test0) - app.router.add_get('/test1', handler_test1) - - cli = await client(app) - - await assert_response(cli.get('/test1'), 'test1') - await assert_response(cli.get('/remember'), 'remember') - await assert_response(cli.get('/test0'), 'test0') - - -async def test_acl_required_decorator(loop, app, client): - context = [(Permission.Deny, 'group0', ('test0',)), - (Permission.Allow, 'group0', ('test1',)), - (Permission.Allow, 'group1', ('test0', 'test1'))] - - class GroupsCallback: - def __init__(self, group=None): - self.group = group - - async def groups(self): - if self.group is None: - return None - - return (self.group, ) - - def __call__(self, user_id): - return self.groups() - - @acl.acl_required('test0', context) - async def handler_test(request): - return web.Response(text='test') - - groups_callback = GroupsCallback() - acl.setup(app, groups_callback) - app.router.add_get('/test', handler_test) - - cli = await client(app) - - response = await cli.get('/test') - assert response.status == 403 - - groups_callback.group = 'group0' - response = await cli.get('/test') - assert response.status == 403 - - groups_callback.group = 'group1' - response = await cli.get('/test') - await assert_response(cli.get('/test'), 'test') - - -async def test_acl_not_matching_acl_group(app, client): - async def handler_test(request): - context = [(Permission.Allow, 'group2', ('test0')), - (Permission.Allow, 'group3', ('test0', 'test1'))] - - assert (await acl.get_permitted(request, 'test0', context)) is False - assert (await acl.get_permitted(request, 'test1', context)) is False - - return web.Response(text='test') - - acl.setup(app, _groups_callback) - app.router.add_get('/test', handler_test) - - cli = await client(app) - - await assert_response(cli.get('/test'), 'test') - - -async def test_acl_permission_deny_for_user_id_equals_to_group_name(app, - client): - context = [(Permission.Allow, 'group0', ('test0',)), - (Permission.Deny, 'group1', ('test0',))] - - async def _groups1_callback(user_id): - return ('group1', ) - - async def handler_test(request): - assert (await acl.get_permitted(request, 'test0', context)) is False - - return web.Response(text='test') - - async def handler_remember_group0(request): - await auth.remember(request, 'group0') - return web.Response(text='remember_group0') - - acl.setup(app, _groups1_callback) - app.router.add_get('/test', handler_test) - app.router.add_get('/remember_group0', handler_remember_group0) - - cli = await client(app) - - await assert_response(cli.get('/test'), 'test') - await assert_response(cli.get('/remember_group0'), 'remember_group0') - await assert_response(cli.get('/test'), 'test') - - -async def test_correct_groups_returned_for_authenticated_user_with_abc( - app, client): - async def handler_test(request): - groups = await acl.get_user_groups(request) - - assert 'group0' in groups - assert 'group1' in groups - assert 'some_user' not in groups - assert Group.Everyone in groups - assert Group.AuthenticatedUser in groups - - return web.Response(text='test') - - acl_groups_callback = ACLGroupsCallback() - acl.setup(app, acl_groups_callback) - app.router.add_get('/test', handler_test) - - cli = await client(app) - - await assert_response(cli.get('/remember'), 'remember') - - await assert_response(cli.get('/test'), 'test') - - -async def test_correct_groups_returned_for_unauthenticated_user_with_abc( - app, client): - async def handler_test(request): - groups = await acl.get_user_groups(request) - - assert 'group0' in groups - assert 'group1' in groups - assert 'some_user' not in groups - assert Group.Everyone in groups - assert Group.AuthenticatedUser not in groups - - return web.Response(text='test') - - acl_groups_callback = ACLGroupsCallback() - acl.setup(app, acl_groups_callback) - app.router.add_get('/test', handler_test) - - cli = await client(app) - - await assert_response(cli.get('/test'), 'test') - - -async def test_no_groups_if_none_returned_from_callback_with_abc(app, client): - async def handler_test(request): - groups = await acl.get_user_groups(request) - assert groups is None - - return web.Response(text='test') - - acl_groups_callback = NoneACLGroupsCallback() - acl.setup(app, acl_groups_callback) - app.router.add_get('/test', handler_test) - - cli = await client(app) - - await assert_response(cli.get('/test'), 'test') diff --git a/pytests/test_auth.py b/pytests/test_auth.py deleted file mode 100644 index 81f708b..0000000 --- a/pytests/test_auth.py +++ /dev/null @@ -1,296 +0,0 @@ -import asyncio -from os import urandom -import pytest -from aiohttp import web -from aiohttp_auth import auth -import aiohttp_session -from utils import assert_response - - -@pytest.fixture -def app(loop): - """Default app fixture for tests.""" - async def handler_remember(request): - await auth.remember(request, 'some_user') - return web.Response(text='remember') - - async def handler_auth(request): - user_id = await auth.get_auth(request) - assert user_id == 'some_user' - assert user_id == await auth.get_auth(request) - return web.Response(text='auth') - - async def handler_forget(request): - user_id = await auth.get_auth(request) - assert user_id == 'some_user' - await auth.forget(request) - return web.Response(text='forget') - - application = web.Application(loop=loop) - application.router.add_get('/remember', handler_remember) - application.router.add_get('/auth', handler_auth) - application.router.add_get('/forget', handler_forget) - - yield application - - -async def test_middleware_setup(app): - secret = b'01234567890abcdef' - policy = auth.CookieTktAuthentication(secret, 15, cookie_name='auth') - - auth.setup(app, policy) - - middleware = auth.auth_middleware(policy) - - assert app.middlewares[-1].__name__ == middleware.__name__ - - -async def test_no_middleware_installed(app, client): - async def handler_test(request): - with pytest.raises(RuntimeError) as ex_info: - await auth.get_auth(request) - - assert str(ex_info.value) == 'auth_middleware not installed' - - with pytest.raises(RuntimeError) as ex_info: - await auth.remember(request, 'some_user') - - assert str(ex_info.value) == 'auth_middleware not installed' - - with pytest.raises(RuntimeError) as ex_info: - await auth.forget(request) - - assert str(ex_info.value) == 'auth_middleware not installed' - - return web.Response(text='test') - - app.router.add_get('/test', handler_test) - - cli = await client(app) - - await assert_response(cli.get('/test'), 'test') - - -async def test_middleware_installed_no_session(app, client): - async def handler_test(request): - user_id = await auth.get_auth(request) - assert user_id is None - - return web.Response(text='test') - - app.router.add_get('/test', handler_test) - aiohttp_session.setup(app, aiohttp_session.SimpleCookieStorage()) - auth.setup(app, auth.SessionTktAuthentication(urandom(16), 15)) - - cli = await client(app) - - await assert_response(cli.get('/test'), 'test') - - -async def test_middleware_stores_auth_in_session(app, client): - secret = b'01234567890abcdef' - storage = aiohttp_session.SimpleCookieStorage() - policy = auth.SessionTktAuthentication(secret, 15, cookie_name='auth') - - aiohttp_session.setup(app, storage) - auth.setup(app, policy) - - cli = await client(app) - response = await cli.get('/remember') - text = await response.text() - assert text == 'remember' - - value = response.cookies.get(storage.cookie_name).value - assert policy.cookie_name in value - - -async def test_middleware_gets_auth_from_session(app, client): - secret = b'01234567890abcdef' - storage = aiohttp_session.SimpleCookieStorage() - policy = auth.SessionTktAuthentication(secret, 15, cookie_name='auth') - - aiohttp_session.setup(app, storage) - auth.setup(app, policy) - - cli = await client(app) - - response = await cli.get('/remember') - assert await response.text() == 'remember' - - await assert_response(cli.get('/auth'), 'auth') - - -async def test_middleware_stores_auth_in_cookie(app, client): - secret = b'01234567890abcdef' - policy = auth.CookieTktAuthentication(secret, 15, cookie_name='auth') - - auth.setup(app, policy) - - cli = await client(app) - - response = await cli.get('/remember') - text = await response.text() - - assert text == 'remember' - assert policy.cookie_name in response.cookies - - -async def test_middleware_gets_auth_from_cookie(app, client): - secret = b'01234567890abcdef' - policy = auth.CookieTktAuthentication(secret, 15, 2, cookie_name='auth') - - auth.setup(app, policy) - - cli = await client(app) - - response = await cli.get('/remember') - text = await response.text() - - assert text == 'remember' - assert policy.cookie_name in response.cookies - - assert_response(cli.get('/auth'), 'auth') - - -@pytest.mark.slow -async def test_middleware_reissues_ticket_auth(loop, app, client): - secret = b'01234567890abcdef' - policy = auth.CookieTktAuthentication(secret, 15, 0, cookie_name='auth') - - auth.setup(app, policy) - - cli = await client(app) - - response = await cli.get('/remember') - text = await response.text() - - assert text == 'remember' - data = response.cookies[policy.cookie_name] - - # wait a second that the ticket value has changed - await asyncio.sleep(1.0, loop=loop) - - response = await assert_response(cli.get('/auth'), 'auth') - - assert data != response.cookies[policy.cookie_name] - - -@pytest.mark.slow -async def test_middleware_doesnt_reissue_on_bad_response(loop, app, client): - async def handler_bad_response(request): - user_id = await auth.get_auth(request) - assert user_id == 'some_user' - return web.Response(status=400, text='bad_response') - - secret = b'01234567890abcdef' - policy = auth.CookieTktAuthentication(secret, 15, 0, cookie_name='auth') - - auth.setup(app, policy) - app.router.add_get('/bad_response', handler_bad_response) - - cli = await client(app) - - response = await cli.get('/remember') - text = await response.text() - data = response.cookies[policy.cookie_name] - - assert text == 'remember' - - # wait a second that the ticket value has changed - await asyncio.sleep(1.0, loop=loop) - - response = await assert_response(cli.get('/auth'), 'auth') - - assert data != response.cookies[policy.cookie_name] - data = response.cookies[policy.cookie_name] - - await asyncio.sleep(1.0, loop=loop) - - response = await assert_response(cli.get('/bad_response'), 'bad_response') - - assert response.status == 400 - assert policy.cookie_name not in response.cookies - - -async def test_middleware_forget_with_session(app, client): - secret = b'01234567890abcdef' - storage = aiohttp_session.SimpleCookieStorage() - policy = auth.SessionTktAuthentication(secret, 15, cookie_name='auth') - - aiohttp_session.setup(app, storage) - auth.setup(app, policy) - - cli = await client(app) - - response = await assert_response(cli.get('/remember'), 'remember') - value = response.cookies.get(storage.cookie_name).value - assert policy.cookie_name in value - - response = await assert_response(cli.get('/forget'), 'forget') - value = response.cookies.get(storage.cookie_name).value - assert policy.cookie_name not in value - - with pytest.raises(AssertionError): - await assert_response(cli.get('/auth'), 'auth') - - -async def test_middleware_forget_with_cookies(app, client): - secret = b'01234567890abcdef' - policy = auth.CookieTktAuthentication(secret, 120, cookie_name='auth') - - auth.setup(app, policy) - - cli = await client(app) - - response = await assert_response(cli.get('/remember'), 'remember') - assert policy.cookie_name in response.cookies - - response = await assert_response(cli.get('/forget'), 'forget') - # aiohttp set cookie_name with empty string when del_cookie - # assert policy.cookie_name not in response.cookies - assert response.cookies[policy.cookie_name].value == '' - - with pytest.raises(AssertionError): - await assert_response(cli.get('/auth'), 'auth') - - -async def test_middleware_auth_required_decorator(app, client): - @auth.auth_required - async def handler_test(request): - return web.Response(text='test') - - secret = b'01234567890abcdef' - policy = auth.CookieTktAuthentication(secret, 120, cookie_name='auth') - - auth.setup(app, policy) - app.router.add_get('/test', handler_test) - - cli = await client(app) - - response = await assert_response(cli.get('/test'), '403: Forbidden') - assert response.status == 403 - - response = await assert_response(cli.get('/remember'), 'remember') - - response = await assert_response(cli.get('/test'), 'test') - assert response.status == 200 - - -async def test_middleware_cannot_store_auth_in_cookie_when_response_started( - app, client): - async def handler_test(request): - await auth.remember(request, 'some_user') - response = web.Response(text='test') - await response.prepare(request) - return response - - secret = b'01234567890abcdef' - policy = auth.CookieTktAuthentication(secret, 15, cookie_name='auth') - - auth.setup(app, policy) - app.router.add_get('/test', handler_test) - - cli = await client(app) - - response = await cli.get('/test') - assert policy.cookie_name not in response.cookies diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9dcbe7d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +aiohttp +ticket_auth==0.1.4 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..876e5c0 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,4 @@ +[aliases] +test=pytest +[tool:pytest] +addopts = -v --cov-report=term-missing --cov=aiohttp_auth --cov=tests diff --git a/setup.py b/setup.py index 8a3ccdf..71c5594 100644 --- a/setup.py +++ b/setup.py @@ -1,25 +1,42 @@ +"""Setup script.""" +import os.path +import re from setuptools import setup, find_packages -requires = ['aiohttp', - 'ticket_auth',] +install_requires = ['aiohttp', 'ticket_auth==0.1.4'] -tests_require = ['aiohttp_session'] +tests_require = ['pytest', 'pytest-aiohttp', 'pytest-cov', 'aiohttp_session'] + + +def version(): + cur_dir = os.path.abspath(os.path.dirname(__file__)) + with open(os.path.join(cur_dir, 'aiohttp_auth', '__init__.py'), 'r') as f: + try: + version = re.findall( + r"^__version__ = '([^']+)'\r?$", + f.read(), re.M)[0] + except IndexError: + raise RuntimeError('Could not determine version.') + + return version setup( - name="aiohttp_auth", - version='0.2.0.dev0', - description='Authorization and authentication middleware plugin for aiohttp.', + name="aiohttp_auth_autz", + version=version(), + description=('Authorization and authentication ' + 'middleware plugin for aiohttp.'), long_description=open('README.rst').read(), - install_requires=requires, + setup_requires=['pytest-runner'], + install_requires=install_requires, + tests_require=tests_require, packages=find_packages(exclude=['tests*']), author='Gnarly Chicken', author_email='gnarlychicken@gmx.com', - test_suite='tests', - tests_require=tests_require, - url='https://github.com/gnarlychicken/aiohttp_auth', + maintainer='ilex (fork author)', + url='https://github.com/ilex/aiohttp_auth_autz', license='MIT', classifiers=[ 'Development Status :: 3 - Alpha', @@ -29,4 +46,5 @@ 'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', - ],) + ] +) diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/pytests/conftest.py b/tests/conftest.py similarity index 100% rename from pytests/conftest.py rename to tests/conftest.py diff --git a/tests/test_acl.py b/tests/test_acl.py index b9782a8..3a4e9f8 100644 --- a/tests/test_acl.py +++ b/tests/test_acl.py @@ -1,132 +1,333 @@ -import unittest -import json +import pytest +import aiohttp_session from aiohttp import web -from aiohttp_auth import auth, auth_middleware -from aiohttp_auth import acl, acl_middleware +from aiohttp_auth import acl, auth +from aiohttp_auth.acl.abc import AbstractACLGroupsCallback from aiohttp_auth.permissions import Group, Permission -from aiohttp_session import session_middleware, SimpleCookieStorage -from .util import asyncio -from .util.aiohttp.test import ( - make_request, - make_response, - make_auth_session) +from utils import assert_response -class ACLMiddlewareTests(unittest.TestCase): - # Secret used in all the tests - SECRET = b'01234567890abcdef' +@pytest.fixture +def app(loop): + """Default app fixture for tests.""" + async def handler_remember(request): + await auth.remember(request, 'some_user') + return web.Response(text='remember') - def setUp(self): - """Creates the storage and middlewares objects""" - self.storage = SimpleCookieStorage() - self.auth = auth.SessionTktAuthentication( - self.SECRET, 15, cookie_name='auth') + application = web.Application(loop=loop) - @asyncio.run_until_complete() - async def test_no_middleware_installed(self): - session_data = make_auth_session( - self.SECRET, 'some_user', self.auth.cookie_name) + secret = b'01234567890abcdef' + storage = aiohttp_session.SimpleCookieStorage() + policy = auth.SessionTktAuthentication(secret, 15, cookie_name='auth') - request = await make_request('GET', '/', self._middleware(None), \ - [(self.storage.cookie_name, json.dumps(session_data))]) + aiohttp_session.setup(application, storage) + auth.setup(application, policy) - with self.assertRaises(RuntimeError): - groups = await acl.get_user_groups(request) + application.router.add_get('/remember', handler_remember) - @asyncio.run_until_complete() - async def test_correct_groups_returned_for_authenticated_user(self): - session_data = make_auth_session( - self.SECRET, 'some_user', self.auth.cookie_name) + yield application - request = await make_request('GET', '/', \ - self._middleware(self._groups_callback), \ - [(self.storage.cookie_name, json.dumps(session_data))]) +async def _groups_callback(user_id): + """Groups callback function that always returns two groups.""" + return ('group0', 'group1') + + +class ACLGroupsCallback(AbstractACLGroupsCallback): + """Groups callback callable class that always returns two groups.""" + async def acl_groups(self, user_id): + return ('group0', 'group1') + + +async def _auth_groups_callback(user_id): + """Groups callback function that always returns two groups.""" + if user_id: + return ('group0', 'group1') + + return () + + +async def _none_groups_callback(user_id): + """Groups callback function that always returns None.""" + return None + + +class NoneACLGroupsCallback(AbstractACLGroupsCallback): + """Groups callback callable class that always returns None.""" + async def acl_groups(self, user_id): + return None + + +async def test_acl_middleware_setup(app): + acl.setup(app, _groups_callback) + + middleware = acl.acl_middleware(_groups_callback) + + assert app.middlewares[-1].__name__ == middleware.__name__ + + +async def test_no_middleware_installed(app, client): + async def handler_test(request): + with pytest.raises(RuntimeError): + await acl.get_user_groups(request) + + return web.Response(text='test') + + app.router.add_get('/test', handler_test) + cli = await client(app) + + await assert_response(cli.get('/remember'), 'remember') + + await assert_response(cli.get('/test'), 'test') + + +async def test_correct_groups_returned_for_authenticated_user(app, client): + async def handler_test(request): groups = await acl.get_user_groups(request) - self.assertIn('group0', groups) - self.assertIn('group1', groups) - self.assertIn('some_user', groups) - self.assertIn(Group.Everyone, groups) - self.assertIn(Group.AuthenticatedUser, groups) - @asyncio.run_until_complete() - async def test_correct_groups_returned_for_unauthenticated_user(self): - request = await make_request('GET', '/', \ - self._middleware(self._groups_callback)) + assert 'group0' in groups + assert 'group1' in groups + assert 'some_user' not in groups + assert Group.Everyone in groups + assert Group.AuthenticatedUser in groups + + return web.Response(text='test') + acl.setup(app, _groups_callback) + app.router.add_get('/test', handler_test) + + cli = await client(app) + + await assert_response(cli.get('/remember'), 'remember') + + await assert_response(cli.get('/test'), 'test') + + +async def test_correct_groups_returned_for_unauthenticated_user(app, client): + async def handler_test(request): groups = await acl.get_user_groups(request) - self.assertIn('group0', groups) - self.assertIn('group1', groups) - self.assertNotIn('some_user', groups) - self.assertNotIn(None, groups) - self.assertIn(Group.Everyone, groups) - self.assertNotIn(Group.AuthenticatedUser, groups) - - @asyncio.run_until_complete() - async def test_no_groups_if_none_returned_from_callback(self): - request = await make_request('GET', '/', \ - self._middleware(self._none_groups_callback)) + assert 'group0' in groups + assert 'group1' in groups + assert 'some_user' not in groups + assert Group.Everyone in groups + assert Group.AuthenticatedUser not in groups + + return web.Response(text='test') + + acl.setup(app, _groups_callback) + app.router.add_get('/test', handler_test) + + cli = await client(app) + + await assert_response(cli.get('/test'), 'test') + + +async def test_no_groups_if_none_returned_from_callback(app, client): + async def handler_test(request): groups = await acl.get_user_groups(request) - self.assertIsNone(groups) + assert groups is None + + return web.Response(text='test') - @asyncio.run_until_complete() - async def test_acl_permissions(self): - request = await make_request('GET', '/', \ - self._middleware(self._groups_callback)) + acl.setup(app, _none_groups_callback) + app.router.add_get('/test', handler_test) + cli = await client(app) + + await assert_response(cli.get('/test'), 'test') + + +async def test_acl_permissions(app, client): + async def handler_test(request): context = [(Permission.Allow, 'group0', ('test0',)), (Permission.Deny, 'group1', ('test1',)), - (Permission.Allow, Group.Everyone, ('test1',)),] + (Permission.Allow, Group.Everyone, ('test1',))] - self.assertTrue(await acl.get_permitted(request, 'test0', context)) - self.assertFalse(await acl.get_permitted(request, 'test1', context)) + assert (await acl.get_permitted(request, 'test0', context)) is True + assert (await acl.get_permitted(request, 'test1', context)) is False - @asyncio.run_until_complete() - async def test_permission_order(self): - session_data = make_auth_session( - self.SECRET, 'some_user', self.auth.cookie_name) + return web.Response(text='test') - request0 = await make_request('GET', '/', \ - self._middleware(self._auth_groups_callback), \ - [(self.storage.cookie_name, json.dumps(session_data))]) + acl.setup(app, _groups_callback) + app.router.add_get('/test', handler_test) - request1 = await make_request('GET', '/', \ - self._middleware(self._auth_groups_callback)) + cli = await client(app) - context = [(Permission.Allow, Group.Everyone, ('test0',)), - (Permission.Deny, 'group1', ('test1',)), - (Permission.Allow, Group.Everyone, ('test1',)),] + await assert_response(cli.get('/test'), 'test') - self.assertTrue(await acl.get_permitted(request0, 'test0', context)) - self.assertTrue(await acl.get_permitted(request1, 'test0', context)) - self.assertFalse(await acl.get_permitted(request0, 'test1', context)) - self.assertTrue(await acl.get_permitted(request1, 'test1', context)) +async def test_permission_order(app, client): + context = [(Permission.Allow, Group.Everyone, ('test0',)), + (Permission.Deny, 'group1', ('test1',)), + (Permission.Allow, Group.Everyone, ('test1',))] - async def _groups_callback(self, user_id): - """Groups callback function that always returns two groups""" - return ('group0', 'group1') + async def handler_test0(request): + assert (await acl.get_permitted(request, 'test0', context)) is True + assert (await acl.get_permitted(request, 'test1', context)) is False - async def _auth_groups_callback(self, user_id): - """Groups callback function that always returns two groups""" - if user_id: - return ('group0', 'group1') + return web.Response(text='test0') - return () + async def handler_test1(request): + assert (await acl.get_permitted(request, 'test0', context)) is True + assert (await acl.get_permitted(request, 'test1', context)) is True - async def _none_groups_callback(self, user_id): - """Groups callback function that always returns None""" - return None + return web.Response(text='test1') + + acl.setup(app, _auth_groups_callback) + app.router.add_get('/test0', handler_test0) + app.router.add_get('/test1', handler_test1) + + cli = await client(app) + + await assert_response(cli.get('/test1'), 'test1') + await assert_response(cli.get('/remember'), 'remember') + await assert_response(cli.get('/test0'), 'test0') + + +async def test_acl_required_decorator(loop, app, client): + context = [(Permission.Deny, 'group0', ('test0',)), + (Permission.Allow, 'group0', ('test1',)), + (Permission.Allow, 'group1', ('test0', 'test1'))] + + class GroupsCallback: + def __init__(self, group=None): + self.group = group + + async def groups(self): + if self.group is None: + return None + + return (self.group, ) + + def __call__(self, user_id): + return self.groups() + + @acl.acl_required('test0', context) + async def handler_test(request): + return web.Response(text='test') + + groups_callback = GroupsCallback() + acl.setup(app, groups_callback) + app.router.add_get('/test', handler_test) + + cli = await client(app) + + response = await cli.get('/test') + assert response.status == 403 + + groups_callback.group = 'group0' + response = await cli.get('/test') + assert response.status == 403 + + groups_callback.group = 'group1' + response = await cli.get('/test') + await assert_response(cli.get('/test'), 'test') + + +async def test_acl_not_matching_acl_group(app, client): + async def handler_test(request): + context = [(Permission.Allow, 'group2', ('test0')), + (Permission.Allow, 'group3', ('test0', 'test1'))] + + assert (await acl.get_permitted(request, 'test0', context)) is False + assert (await acl.get_permitted(request, 'test1', context)) is False + + return web.Response(text='test') + + acl.setup(app, _groups_callback) + app.router.add_get('/test', handler_test) + + cli = await client(app) + + await assert_response(cli.get('/test'), 'test') + + +async def test_acl_permission_deny_for_user_id_equals_to_group_name(app, + client): + context = [(Permission.Allow, 'group0', ('test0',)), + (Permission.Deny, 'group1', ('test0',))] + + async def _groups1_callback(user_id): + return ('group1', ) + + async def handler_test(request): + assert (await acl.get_permitted(request, 'test0', context)) is False + + return web.Response(text='test') + + async def handler_remember_group0(request): + await auth.remember(request, 'group0') + return web.Response(text='remember_group0') + + acl.setup(app, _groups1_callback) + app.router.add_get('/test', handler_test) + app.router.add_get('/remember_group0', handler_remember_group0) + + cli = await client(app) + + await assert_response(cli.get('/test'), 'test') + await assert_response(cli.get('/remember_group0'), 'remember_group0') + await assert_response(cli.get('/test'), 'test') + + +async def test_correct_groups_returned_for_authenticated_user_with_abc( + app, client): + async def handler_test(request): + groups = await acl.get_user_groups(request) + + assert 'group0' in groups + assert 'group1' in groups + assert 'some_user' not in groups + assert Group.Everyone in groups + assert Group.AuthenticatedUser in groups + + return web.Response(text='test') + + acl_groups_callback = ACLGroupsCallback() + acl.setup(app, acl_groups_callback) + app.router.add_get('/test', handler_test) + + cli = await client(app) + + await assert_response(cli.get('/remember'), 'remember') + + await assert_response(cli.get('/test'), 'test') + + +async def test_correct_groups_returned_for_unauthenticated_user_with_abc( + app, client): + async def handler_test(request): + groups = await acl.get_user_groups(request) + + assert 'group0' in groups + assert 'group1' in groups + assert 'some_user' not in groups + assert Group.Everyone in groups + assert Group.AuthenticatedUser not in groups + + return web.Response(text='test') + + acl_groups_callback = ACLGroupsCallback() + acl.setup(app, acl_groups_callback) + app.router.add_get('/test', handler_test) + + cli = await client(app) + + await assert_response(cli.get('/test'), 'test') + + +async def test_no_groups_if_none_returned_from_callback_with_abc(app, client): + async def handler_test(request): + groups = await acl.get_user_groups(request) + assert groups is None + + return web.Response(text='test') + + acl_groups_callback = NoneACLGroupsCallback() + acl.setup(app, acl_groups_callback) + app.router.add_get('/test', handler_test) + + cli = await client(app) - def _middleware(self, acl_callback): - """Returns the middlewares used in the test""" - if acl_callback: - return [ - session_middleware(self.storage), - auth_middleware(self.auth), - acl_middleware(acl_callback)] - - return [ - session_middleware(self.storage), - auth_middleware(self.auth)] + await assert_response(cli.get('/test'), 'test') diff --git a/pytests/test_aiohttp_auth.py b/tests/test_aiohttp_auth.py similarity index 100% rename from pytests/test_aiohttp_auth.py rename to tests/test_aiohttp_auth.py diff --git a/tests/test_auth.py b/tests/test_auth.py index afa6876..81f708b 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,136 +1,296 @@ -import unittest -import json -import time +import asyncio from os import urandom -from aiohttp_auth import auth, auth_middleware -from aiohttp_session import session_middleware, SimpleCookieStorage +import pytest from aiohttp import web -from ticket_auth import TicketFactory -from .util import asyncio -from .util.aiohttp.test import ( - make_request, - make_response, - make_auth_session) +from aiohttp_auth import auth +import aiohttp_session +from utils import assert_response -class AuthMiddlewareTests(unittest.TestCase): +@pytest.fixture +def app(loop): + """Default app fixture for tests.""" + async def handler_remember(request): + await auth.remember(request, 'some_user') + return web.Response(text='remember') + + async def handler_auth(request): + user_id = await auth.get_auth(request) + assert user_id == 'some_user' + assert user_id == await auth.get_auth(request) + return web.Response(text='auth') + + async def handler_forget(request): + user_id = await auth.get_auth(request) + assert user_id == 'some_user' + await auth.forget(request) + return web.Response(text='forget') + + application = web.Application(loop=loop) + application.router.add_get('/remember', handler_remember) + application.router.add_get('/auth', handler_auth) + application.router.add_get('/forget', handler_forget) - @asyncio.run_until_complete() - async def test_no_middleware_installed(self): - middlewares = [ - session_middleware(SimpleCookieStorage()),] + yield application - request = await make_request('GET', '/', middlewares) - with self.assertRaises(RuntimeError): +async def test_middleware_setup(app): + secret = b'01234567890abcdef' + policy = auth.CookieTktAuthentication(secret, 15, cookie_name='auth') + + auth.setup(app, policy) + + middleware = auth.auth_middleware(policy) + + assert app.middlewares[-1].__name__ == middleware.__name__ + + +async def test_no_middleware_installed(app, client): + async def handler_test(request): + with pytest.raises(RuntimeError) as ex_info: await auth.get_auth(request) - @asyncio.run_until_complete() - async def test_middleware_installed_no_session(self): - middlewares = [ - session_middleware(SimpleCookieStorage()), - auth_middleware(auth.SessionTktAuthentication(urandom(16), 15))] + assert str(ex_info.value) == 'auth_middleware not installed' - request = await make_request('GET', '/', middlewares) - user_id = await auth.get_auth(request) - self.assertIsNone(user_id) - - @asyncio.run_until_complete() - async def test_middleware_stores_auth_in_session(self): - secret = b'01234567890abcdef' - storage = SimpleCookieStorage() - auth_ = auth.SessionTktAuthentication(secret, 15, cookie_name='auth') - middlewares = [ - session_middleware(storage), - auth_middleware(auth_)] - - request = await make_request('GET', '/', middlewares) - await auth.remember(request, 'some_user') - response = await make_response(request, middlewares) - self.assertTrue(auth_.cookie_name in \ - response.cookies.get(storage.cookie_name).value) - - @asyncio.run_until_complete() - async def test_middleware_gets_auth_from_session(self): - secret = b'01234567890abcdef' - storage = SimpleCookieStorage() - auth_ = auth.SessionTktAuthentication(secret, 15, cookie_name='auth') - middlewares = [ - session_middleware(storage), - auth_middleware(auth_)] - - session_data = make_auth_session(secret, 'some_user', auth_.cookie_name) - request = await make_request('GET', '/', middlewares, \ - [(storage.cookie_name, json.dumps(session_data))]) + with pytest.raises(RuntimeError) as ex_info: + await auth.remember(request, 'some_user') - user_id = await auth.get_auth(request) - self.assertEqual(user_id, 'some_user') + assert str(ex_info.value) == 'auth_middleware not installed' - @asyncio.run_until_complete() - async def test_middleware_stores_auth_in_cookie(self): - secret = b'01234567890abcdef' - auth_ = auth.CookieTktAuthentication(secret, 15, cookie_name='auth') - middlewares = [ - auth_middleware(auth_)] + with pytest.raises(RuntimeError) as ex_info: + await auth.forget(request) - request = await make_request('GET', '/', middlewares) - await auth.remember(request, 'some_user') - response = await make_response(request, middlewares) - self.assertTrue(auth_.cookie_name in response.cookies) + assert str(ex_info.value) == 'auth_middleware not installed' + + return web.Response(text='test') + + app.router.add_get('/test', handler_test) + + cli = await client(app) - @asyncio.run_until_complete() - async def test_middleware_gets_auth_from_cookie(self): - secret = b'01234567890abcdef' - auth_ = auth.CookieTktAuthentication(secret, 15, 2, cookie_name='auth') - middlewares = [ - auth_middleware(auth_)] + await assert_response(cli.get('/test'), 'test') - session_data = TicketFactory(secret).new('some_user') - request = await make_request('GET', '/', middlewares, \ - [(auth_.cookie_name, session_data)]) +async def test_middleware_installed_no_session(app, client): + async def handler_test(request): user_id = await auth.get_auth(request) - self.assertEqual(user_id, 'some_user') + assert user_id is None - response = await make_response(request, middlewares) - self.assertFalse(auth_.cookie_name in response.cookies) + return web.Response(text='test') - @asyncio.run_until_complete() - async def test_middleware_reissues_ticket_auth(self): - secret = b'01234567890abcdef' - auth_ = auth.CookieTktAuthentication(secret, 15, 0, cookie_name='auth') - middlewares = [ - auth_middleware(auth_)] + app.router.add_get('/test', handler_test) + aiohttp_session.setup(app, aiohttp_session.SimpleCookieStorage()) + auth.setup(app, auth.SessionTktAuthentication(urandom(16), 15)) - valid_until = time.time() + 15 - session_data = TicketFactory(secret).new('some_user', - valid_until=valid_until) - request = await make_request('GET', '/', middlewares, \ - [(auth_.cookie_name, session_data)]) + cli = await client(app) - user_id = await auth.get_auth(request) - self.assertEqual(user_id, 'some_user') - - response = await make_response(request, middlewares) - self.assertTrue(auth_.cookie_name in response.cookies) - self.assertNotEqual(response.cookies[auth_.cookie_name], - session_data) - - @asyncio.run_until_complete() - async def test_middleware_doesnt_reissue_on_bad_response(self): - secret = b'01234567890abcdef' - auth_ = auth.CookieTktAuthentication(secret, 15, 0, cookie_name='auth') - middlewares = [ - auth_middleware(auth_)] - - valid_until = time.time() + 15 - session_data = TicketFactory(secret).new('some_user', - valid_until=valid_until) - request = await make_request('GET', '/', middlewares, \ - [(auth_.cookie_name, session_data)]) + await assert_response(cli.get('/test'), 'test') + + +async def test_middleware_stores_auth_in_session(app, client): + secret = b'01234567890abcdef' + storage = aiohttp_session.SimpleCookieStorage() + policy = auth.SessionTktAuthentication(secret, 15, cookie_name='auth') + + aiohttp_session.setup(app, storage) + auth.setup(app, policy) + + cli = await client(app) + response = await cli.get('/remember') + text = await response.text() + assert text == 'remember' + + value = response.cookies.get(storage.cookie_name).value + assert policy.cookie_name in value + + +async def test_middleware_gets_auth_from_session(app, client): + secret = b'01234567890abcdef' + storage = aiohttp_session.SimpleCookieStorage() + policy = auth.SessionTktAuthentication(secret, 15, cookie_name='auth') + + aiohttp_session.setup(app, storage) + auth.setup(app, policy) + + cli = await client(app) + + response = await cli.get('/remember') + assert await response.text() == 'remember' + + await assert_response(cli.get('/auth'), 'auth') + + +async def test_middleware_stores_auth_in_cookie(app, client): + secret = b'01234567890abcdef' + policy = auth.CookieTktAuthentication(secret, 15, cookie_name='auth') + + auth.setup(app, policy) + + cli = await client(app) + + response = await cli.get('/remember') + text = await response.text() + + assert text == 'remember' + assert policy.cookie_name in response.cookies + + +async def test_middleware_gets_auth_from_cookie(app, client): + secret = b'01234567890abcdef' + policy = auth.CookieTktAuthentication(secret, 15, 2, cookie_name='auth') + + auth.setup(app, policy) + + cli = await client(app) + + response = await cli.get('/remember') + text = await response.text() + + assert text == 'remember' + assert policy.cookie_name in response.cookies + + assert_response(cli.get('/auth'), 'auth') + + +@pytest.mark.slow +async def test_middleware_reissues_ticket_auth(loop, app, client): + secret = b'01234567890abcdef' + policy = auth.CookieTktAuthentication(secret, 15, 0, cookie_name='auth') + + auth.setup(app, policy) + cli = await client(app) + + response = await cli.get('/remember') + text = await response.text() + + assert text == 'remember' + data = response.cookies[policy.cookie_name] + + # wait a second that the ticket value has changed + await asyncio.sleep(1.0, loop=loop) + + response = await assert_response(cli.get('/auth'), 'auth') + + assert data != response.cookies[policy.cookie_name] + + +@pytest.mark.slow +async def test_middleware_doesnt_reissue_on_bad_response(loop, app, client): + async def handler_bad_response(request): user_id = await auth.get_auth(request) - self.assertEqual(user_id, 'some_user') + assert user_id == 'some_user' + return web.Response(status=400, text='bad_response') + + secret = b'01234567890abcdef' + policy = auth.CookieTktAuthentication(secret, 15, 0, cookie_name='auth') + + auth.setup(app, policy) + app.router.add_get('/bad_response', handler_bad_response) + + cli = await client(app) + + response = await cli.get('/remember') + text = await response.text() + data = response.cookies[policy.cookie_name] + + assert text == 'remember' + + # wait a second that the ticket value has changed + await asyncio.sleep(1.0, loop=loop) + + response = await assert_response(cli.get('/auth'), 'auth') + + assert data != response.cookies[policy.cookie_name] + data = response.cookies[policy.cookie_name] + + await asyncio.sleep(1.0, loop=loop) + + response = await assert_response(cli.get('/bad_response'), 'bad_response') + + assert response.status == 400 + assert policy.cookie_name not in response.cookies + + +async def test_middleware_forget_with_session(app, client): + secret = b'01234567890abcdef' + storage = aiohttp_session.SimpleCookieStorage() + policy = auth.SessionTktAuthentication(secret, 15, cookie_name='auth') + + aiohttp_session.setup(app, storage) + auth.setup(app, policy) + + cli = await client(app) + + response = await assert_response(cli.get('/remember'), 'remember') + value = response.cookies.get(storage.cookie_name).value + assert policy.cookie_name in value + + response = await assert_response(cli.get('/forget'), 'forget') + value = response.cookies.get(storage.cookie_name).value + assert policy.cookie_name not in value + + with pytest.raises(AssertionError): + await assert_response(cli.get('/auth'), 'auth') + + +async def test_middleware_forget_with_cookies(app, client): + secret = b'01234567890abcdef' + policy = auth.CookieTktAuthentication(secret, 120, cookie_name='auth') + + auth.setup(app, policy) + + cli = await client(app) + + response = await assert_response(cli.get('/remember'), 'remember') + assert policy.cookie_name in response.cookies + + response = await assert_response(cli.get('/forget'), 'forget') + # aiohttp set cookie_name with empty string when del_cookie + # assert policy.cookie_name not in response.cookies + assert response.cookies[policy.cookie_name].value == '' + + with pytest.raises(AssertionError): + await assert_response(cli.get('/auth'), 'auth') + + +async def test_middleware_auth_required_decorator(app, client): + @auth.auth_required + async def handler_test(request): + return web.Response(text='test') + + secret = b'01234567890abcdef' + policy = auth.CookieTktAuthentication(secret, 120, cookie_name='auth') + + auth.setup(app, policy) + app.router.add_get('/test', handler_test) + + cli = await client(app) + + response = await assert_response(cli.get('/test'), '403: Forbidden') + assert response.status == 403 + + response = await assert_response(cli.get('/remember'), 'remember') + + response = await assert_response(cli.get('/test'), 'test') + assert response.status == 200 + + +async def test_middleware_cannot_store_auth_in_cookie_when_response_started( + app, client): + async def handler_test(request): + await auth.remember(request, 'some_user') + response = web.Response(text='test') + await response.prepare(request) + return response + + secret = b'01234567890abcdef' + policy = auth.CookieTktAuthentication(secret, 15, cookie_name='auth') + + auth.setup(app, policy) + app.router.add_get('/test', handler_test) + + cli = await client(app) - response = await make_response(request, middlewares, web.Response(status=400)) - self.assertFalse(auth_.cookie_name in response.cookies) + response = await cli.get('/test') + assert policy.cookie_name not in response.cookies diff --git a/pytests/test_autz_acl_policy.py b/tests/test_autz_acl_policy.py similarity index 100% rename from pytests/test_autz_acl_policy.py rename to tests/test_autz_acl_policy.py diff --git a/pytests/test_autz_custom_policy.py b/tests/test_autz_custom_policy.py similarity index 100% rename from pytests/test_autz_custom_policy.py rename to tests/test_autz_custom_policy.py diff --git a/tests/util/__init__.py b/tests/util/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/util/aiohttp/__init__.py b/tests/util/aiohttp/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/util/aiohttp/test.py b/tests/util/aiohttp/test.py deleted file mode 100644 index d89a5d3..0000000 --- a/tests/util/aiohttp/test.py +++ /dev/null @@ -1,68 +0,0 @@ -import asyncio -from aiohttp import web, protocol -from aiohttp.multidict import CIMultiDict -from aiohttp.streams import EmptyStreamReader -from http.cookies import SimpleCookie - - -def _identity(response): - async def handler(request): - return response - - return handler - -async def prepare_request(request, middlewares): - """Mainly used in testing, passes the request through the middlewares to - much like the aiohttp application does, to shortcut the need for a aiohttp - application object when testing - """ - handler = _identity(web.Response()) - for factory in reversed(middlewares): - handler = await factory(None, handler) - response = await handler(request) - - return request - - -async def make_request(method, path, middlewares, cookies=None): - headers = CIMultiDict() - if cookies: - for key, value in cookies: - headers.add('Cookie', _cookie_value(key, value)) - - message = protocol.RawRequestMessage(method, path, protocol.HttpVersion11, - headers, True, False) - request = web.Request({}, message, EmptyStreamReader(), None, None, None) - - if middlewares: - return await prepare_request(request, middlewares) - - return request - - -async def make_response(request, middlewares, response=None): - if response is None: - response = web.Response() - - handler = _identity(response) - for factory in reversed(middlewares): - handler = await factory(None, handler) - - return await handler(request) - - -def make_auth_session(secret, user_id, cookie_name): - from ticket_auth import TicketFactory - import time - json = {} - tf = TicketFactory(secret) - ticket = tf.new(user_id) - json['created'] = int(time.time()) - json['session'] = { cookie_name: ticket } - return json - - -def _cookie_value(key, value): - m = SimpleCookie() - m[key] = value - return m.output(header='') \ No newline at end of file diff --git a/tests/util/asyncio.py b/tests/util/asyncio.py deleted file mode 100644 index 9385ccd..0000000 --- a/tests/util/asyncio.py +++ /dev/null @@ -1,24 +0,0 @@ -import asyncio -from functools import wraps - - -def run_until_complete(loop=None): - """Returns a decorator that runs the function passed in an async event - loop, using the supplied event loop. - - If no loop is supllied, loop is set to asyncio.get_event_loop. This - decorator is useful when writing unit tests, allowing asyncrhonous tests - to be run like a normal function - """ - if loop is None: - loop = asyncio.get_event_loop() - - def decorator(func): - - @wraps(func) - def wrapper(*args, **kwargs): - return loop.run_until_complete(func(*args, **kwargs)) - - return wrapper - - return decorator \ No newline at end of file diff --git a/pytests/utils.py b/tests/utils.py similarity index 100% rename from pytests/utils.py rename to tests/utils.py diff --git a/tox.ini b/tox.ini index 41375cf..b74b4e2 100644 --- a/tox.ini +++ b/tox.ini @@ -7,4 +7,4 @@ deps= pytest-aiohttp pytest-cov aiohttp_session -commands=py.test -v --cov-report=term-missing --cov=aiohttp_auth --cov=pytests pytests +commands=py.test -v --cov-report=term-missing --cov=aiohttp_auth --cov=tests tests From 5b162d3c27491e32d0ca911c1be806671ea6e8bb Mon Sep 17 00:00:00 2001 From: ilex Date: Sun, 12 Feb 2017 18:50:51 +0200 Subject: [PATCH 28/52] Add travis ci. --- .travis.yml | 10 ++++++++++ requirements-dev.txt | 6 ++++++ 2 files changed, 16 insertions(+) create mode 100644 .travis.yml create mode 100644 requirements-dev.txt diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..269309b --- /dev/null +++ b/.travis.yml @@ -0,0 +1,10 @@ +language: python +python: + - "3.5" + - "3.6" + +install: + - pip install --upgrade pip + - pip install -r requirements-dev.txt + +script: py.test -v --cov-report=term-missing --cov=aiohttp_auth --cov=tests tests diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..cdf5cde --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,6 @@ +aiohttp +ticket_auth==0.1.4 +pytest +pytest-aiohttp +pytest-cov +aiohttp_session From 5ec17380a9b206f476664325b9361ad22d2ce72c Mon Sep 17 00:00:00 2001 From: ilex Date: Mon, 13 Feb 2017 13:41:54 +0200 Subject: [PATCH 29/52] auth_required decorator now raises HTTPUnauthorized. In order to distinguish auth error from autz error auth_required decorator now raises a web.HTTPUnauthorized error instead of web.HTTPForbidden. --- aiohttp_auth/auth/decorators.py | 29 +++++++++++++++++++---------- tests/test_auth.py | 4 ++-- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/aiohttp_auth/auth/decorators.py b/aiohttp_auth/auth/decorators.py index 50182c2..2be0362 100644 --- a/aiohttp_auth/auth/decorators.py +++ b/aiohttp_auth/auth/decorators.py @@ -1,34 +1,43 @@ +"""Authentication decorators.""" from functools import wraps from aiohttp import web from .auth import get_auth def auth_required(func): - """Utility decorator that checks if a user has been authenticated for this - request. + """Decorator to check if an user has been authenticated for this request. Allows views to be decorated like: + .. code-block:: python + @auth_required - def view_func(request): + async def view_func(request): pass - providing a simple means to ensure that whoever is calling the function has - the correct authentication details. + providing a simple means to ensure that whoever is calling the function + has the correct authentication details. + + .. warning:: + + In version 0.1.1 decorator raised a ``web.HTTPForbidden`` (status + code 403) error if user was not authenticated. And now it raises a + ``web.HTTPUnauthorized`` (status code 401) to distinguish + authentication error from authorization one. Args: - func: Function object being decorated and raises HTTPForbidden if not + func: Function object being decorated. Returns: - A function object that will raise web.HTTPForbidden() if the passed - request does not have the correct permissions to access the view. + A function object that will raise ``web.HTTPUnauthorized()`` if the + passed request does not have the correct permissions to access the + view. """ @wraps(func) async def wrapper(*args): if (await get_auth(args[-1])) is None: - raise web.HTTPForbidden() + raise web.HTTPUnauthorized() return await func(*args) return wrapper - diff --git a/tests/test_auth.py b/tests/test_auth.py index 81f708b..57fd33f 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -267,8 +267,8 @@ async def handler_test(request): cli = await client(app) - response = await assert_response(cli.get('/test'), '403: Forbidden') - assert response.status == 403 + response = await assert_response(cli.get('/test'), '401: Unauthorized') + assert response.status == 401 response = await assert_response(cli.get('/remember'), 'remember') From ebea10048e003fde95a669dacba44758e6f5ecae Mon Sep 17 00:00:00 2001 From: ilex Date: Tue, 14 Feb 2017 10:34:40 +0200 Subject: [PATCH 30/52] Improve docstrings. --- aiohttp_auth/__init__.py | 6 --- aiohttp_auth/acl/abc.py | 6 ++- aiohttp_auth/acl/acl.py | 38 ++++++++------- aiohttp_auth/acl/decorators.py | 10 ++-- aiohttp_auth/auth/auth.py | 17 +++---- aiohttp_auth/auth/decorators.py | 9 ++-- aiohttp_auth/autz/abc.py | 8 ++-- aiohttp_auth/autz/autz.py | 49 ++++++++++--------- aiohttp_auth/autz/decorators.py | 10 ++-- aiohttp_auth/autz/policy/acl.py | 85 +++++++++++++++++++-------------- 10 files changed, 131 insertions(+), 107 deletions(-) diff --git a/aiohttp_auth/__init__.py b/aiohttp_auth/__init__.py index 2669050..54ec910 100644 --- a/aiohttp_auth/__init__.py +++ b/aiohttp_auth/__init__.py @@ -4,12 +4,6 @@ from .acl import acl_middleware from . import auth, autz -# silence pyflakes -assert auth_middleware -assert autz_middleware -assert acl_middleware -assert auth, autz - __version__ = '0.2.0.dev0' diff --git a/aiohttp_auth/acl/abc.py b/aiohttp_auth/acl/abc.py index d5b2f9c..e69d1a7 100644 --- a/aiohttp_auth/acl/abc.py +++ b/aiohttp_auth/acl/abc.py @@ -9,14 +9,18 @@ class AbstractACLGroupsCallback(abc.ABC): method and register object of that class with setup function as acl_groups_callback. - Usage example:: + Usage example: + + .. code-block:: python class ACLGroupsCallback(AbstractACLGroupsCallback): def __init__(self, cache): + # store some kind of cache self.cache = cache async def acl_groups(self, user_id): + # implement logic to return user's groups. user = await self.cache.get(user_id) return user.groups() diff --git a/aiohttp_auth/acl/acl.py b/aiohttp_auth/acl/acl.py index 55cd1d1..89032b4 100644 --- a/aiohttp_auth/acl/acl.py +++ b/aiohttp_auth/acl/acl.py @@ -1,3 +1,4 @@ +"""ACL middleware.""" import itertools from ..auth import get_auth from ..permissions import Permission, Group @@ -7,15 +8,16 @@ def acl_middleware(callback): - """Returns a aiohttp_auth.acl middleware factory for use by the aiohttp - application object. + """Return ACL middleware factory. + + The middleware is for use by the aiohttp application object. Args: callback: This is a callable which takes a user_id (as returned from - the auth.get_auth function), and expects a sequence of permitted ACL - groups to be returned. This can be a empty tuple to represent no - explicit permissions, or None to explicitly forbid this particular - user_id. Note that the user_id passed may be None if no + the auth.get_auth function), and expects a sequence of permitted + ACL groups to be returned. This can be a empty tuple to represent + no explicit permissions, or None to explicitly forbid this + particular user_id. Note that the user_id passed may be None if no authenticated user exists. Returns: @@ -36,13 +38,13 @@ async def _middleware_handler(request): async def get_user_groups(request): - """Returns the groups that the user in this request has access to. + """Return the groups that the user in this request has access to. This function gets the user id from the auth.get_auth function, and passes it to the ACL callback function to get the groups. Args: - request: aiohttp Request object + request: aiohttp Request object. Returns: If the ACL callback function returns None, this function returns None. @@ -52,7 +54,7 @@ async def get_user_groups(request): by the function. Raises: - RuntimeError: If the ACL middleware is not installed + RuntimeError: If the ACL middleware is not installed. """ acl_callback = request.get(GROUPS_KEY) if acl_callback is None: @@ -70,6 +72,7 @@ def extend_user_groups(user_id, groups): Args: user_id: User identity from get_auth. groups: User groups. + Returns: If groups is None, this function returns None. Otherwise this function extends groups with the Everyone group. @@ -85,12 +88,16 @@ def extend_user_groups(user_id, groups): async def get_permitted(request, permission, context): - """Returns true if the one of the groups in the request has the requested + """Check permission for the given request with the given context. + + Return True if the one of the groups in the request has the requested permission. The function takes a request, a permission to check for and a context. A context is a sequence of ACL tuples which consist of a Allow/Deny action, - a group, and a sequence of permissions for that ACL group. For example:: + a group, and a sequence of permissions for that ACL group. For example: + + .. code-block:: python context = [(Permission.Allow, 'view_group', ('view',)), (Permission.Allow, 'edit_group', ('view', 'edit')),] @@ -104,9 +111,9 @@ async def get_permitted(request, permission, context): numbers, enumerations, or other immutable objects. Args: - request: aiohttp Request object + request: aiohttp Request object. permission: The specific permission requested. - context: A sequence of ACL tuples + context: A sequence of ACL tuples. Returns: The function gets the groups by calling get_user_groups() and returns @@ -114,9 +121,8 @@ async def get_permitted(request, permission, context): otherwise. Raises: - RuntimeError: If the ACL middleware is not installed + RuntimeError: If the ACL middleware is not installed. """ - groups = await get_user_groups(request) return get_groups_permitted(groups, permission, context) @@ -130,7 +136,7 @@ def get_groups_permitted(groups, permission, context): context: A sequence of ACL tuples. Returns: - True if the groups are Allowed the requested permission, false + True if the groups are Allowed the requested permission, False otherwise. """ if groups is None: diff --git a/aiohttp_auth/acl/decorators.py b/aiohttp_auth/acl/decorators.py index 5058d4e..234effa 100644 --- a/aiohttp_auth/acl/decorators.py +++ b/aiohttp_auth/acl/decorators.py @@ -6,20 +6,22 @@ def acl_required(permission, context): - """Return a decorator that checks if a user has the requested permission + """Create decorator to check given permission with given context. + + Return a decorator that checks if a user has the requested permission from the passed acl context. This function constructs a decorator that can be used to check a aiohttp's - view for authorization before calling it. It uses the get_permission() + view for authorization before calling it. It uses the ``get_permission()`` function to check the request against the passed permission and context. If the user does not have the correct permission to run this function, it - raises HTTPForbidden. + raises ``web.HTTPForbidden``. Args: permission: The specific permission requested. context: Either a sequence of ACL tuples, or a callable that returns a sequence of ACL tuples. For more information on ACL tuples, see - get_permission() + ``get_permission()``. Returns: A decorator which will check the request passed has the permission for diff --git a/aiohttp_auth/auth/auth.py b/aiohttp_auth/auth/auth.py index a7d1c0b..cc9c883 100644 --- a/aiohttp_auth/auth/auth.py +++ b/aiohttp_auth/auth/auth.py @@ -1,3 +1,4 @@ +"""Athentication middleware.""" from .abstract_auth import AbstractAuthentication """Key used to store the auth policy in the request object""" @@ -8,8 +9,9 @@ def auth_middleware(policy): - """Returns a aiohttp_auth middleware factory for use by the aiohttp - application object. + """Return an authentication middleware factory. + + The middleware is for use by the aiohttp application object. Args: policy: A authentication policy with a base class of @@ -37,7 +39,7 @@ async def _middleware_handler(request): async def get_auth(request): - """Returns the user_id associated with a particular request. + """Return the user_id associated with a particular request. Args: request: aiohttp Request object. @@ -49,7 +51,6 @@ async def get_auth(request): Raises: RuntimeError: Middleware is not installed """ - auth_val = request.get(AUTH_KEY) if auth_val: return auth_val @@ -63,7 +64,7 @@ async def get_auth(request): async def remember(request, user_id): - """Called to store and remember the userid for a request + """Called to store and remember the userid for a request. Args: request: aiohttp Request object. @@ -80,13 +81,13 @@ async def remember(request, user_id): async def forget(request): - """Called to forget the userid for a request + """Called to forget the userid for a request. Args: - request: aiohttp Request object + request: aiohttp Request object. Raises: - RuntimeError: Middleware is not installed + RuntimeError: Middleware is not installed. """ auth_policy = request.get(POLICY_KEY) if auth_policy is None: diff --git a/aiohttp_auth/auth/decorators.py b/aiohttp_auth/auth/decorators.py index 2be0362..0f05b1c 100644 --- a/aiohttp_auth/auth/decorators.py +++ b/aiohttp_auth/auth/decorators.py @@ -20,10 +20,11 @@ async def view_func(request): .. warning:: - In version 0.1.1 decorator raised a ``web.HTTPForbidden`` (status - code 403) error if user was not authenticated. And now it raises a - ``web.HTTPUnauthorized`` (status code 401) to distinguish - authentication error from authorization one. + .. versionchanged:: 0.2.0 + In versions prior 0.2.0 the ``web.HTTPForbidden`` was raised + (status code 403) if user was not authenticated. Now the + ``web.HTTPUnauthorized`` (status code 401) is raised to distinguish + authentication error from authorization one. Args: func: Function object being decorated. diff --git a/aiohttp_auth/autz/abc.py b/aiohttp_auth/autz/abc.py index bd1910e..377fb09 100644 --- a/aiohttp_auth/autz/abc.py +++ b/aiohttp_auth/autz/abc.py @@ -6,8 +6,8 @@ class AbstractAutzPolicy(abc.ABC): """Abstact base class for authentication policies. Each policy should be inherited from this class and implement - permit method. That is all needed to use such policy with - autz_middleware. + ``permit`` method. That is all needed to use such policy with + ``autz_middleware``. """ @abc.abstractmethod @@ -15,11 +15,11 @@ async def permit(self, user_identity, permission, context): """Check if user has permission accoding to context. Args: - user_identity: User identity returned from auth.get_auth. + user_identity: User identity returned from ``auth.get_auth``. permission: Permission method checks for. context: Context which is used to determine permit of permission. Returns: - bool: True if permission is allowed and False otherwise. + ``True`` if permission is allowed and ``False`` otherwise. """ pass # pragma: no cover diff --git a/aiohttp_auth/autz/autz.py b/aiohttp_auth/autz/autz.py index c737197..5c94f8b 100644 --- a/aiohttp_auth/autz/autz.py +++ b/aiohttp_auth/autz/autz.py @@ -7,27 +7,29 @@ def autz_middleware(autz_policy): - """Return aiohttp_auth authorization middleware factory. + """Return authorization middleware factory. - Return aiohttp_auth.autz middleware factory for use by the aiohttp + Return ``aiohttp_auth.autz`` middleware factory for use by the ``aiohttp`` application object. This middleware can be used only with - aiohttp_auth.auth middleware installed. + ``aiohttp_auth.auth`` middleware installed. - The autz middleware provides follow interface to use in applications: + The ``autz`` middleware provides follow interface to use in applications: - - Using autz.permit coroutine. - - Using autz.autz_required decorator for aiohttp handlers. + - Using ``autz.permit`` coroutine. + - Using ``autz.autz_required`` decorator for ``aiohttp`` handlers. Note that the recomended way to initialize this middleware is through - aiohttp_auth.autz.setup or aiohttp_auth.setup functions. As the autz - middleware can be used only with authentication aiohttp_auth.auth - middleware it is preferred to use aiohttp_auth.setup. + ``aiohttp_auth.autz.setup`` or ``aiohttp_auth.setup`` functions. As the + ``autz`` middleware can be used only with authentication + ``aiohttp_auth.auth`` middleware it is preferred to use + ``aiohttp_auth.setup``. Args: - autz_policy: a subclass of aiohttp_auth.autz.abc.AbstractAutzPolicy. + autz_policy: a subclass of + ``aiohttp_auth.autz.abc.AbstractAutzPolicy``. Returns: - An aiohttp middleware factory. + An ``aiohttp`` middleware factory. """ assert isinstance(autz_policy, AbstractAutzPolicy) @@ -52,25 +54,25 @@ async def permit(request, permission, context=None): set by setup function. The nature of permission and context is also determined by the given policy. - Note that this coroutine uses aiohttp_auth.auth.get_auth coroutine - to determine user_identity for given request. So that middleware should - be installed too. + Note that this coroutine uses ``aiohttp_auth.auth.get_auth`` coroutine + to determine ``user_identity`` for given request. So that middleware + should be installed too. Note that some additional exceptions could be raised by certain policy while checking the permission. Args: - request: aiohttp Request object. + request: aiohttp ``Request`` object. permission: The specific permission requested. context: A context provided for checking permissions. Could be optional if authorization policy provides a way to specify a global application context. Returns: - True if permission is allowed False otherwise. + ``True`` if permission is allowed ``False`` otherwise. Raises: - RuntimeError: If auth or autz middleware is not installed. + RuntimeError: If ``auth`` or ``autz`` middleware is not installed. """ user_identity = await get_auth(request) @@ -82,14 +84,15 @@ async def permit(request, permission, context=None): def setup(app, autz_policy): - """Setup an authorization middleware in aiohttp fashion. + """Setup an authorization middleware in ``aiohttp`` fashion. - Note that aiohttp_auth.auth middleware should be installed too to use autz - middleware. So the preferred way to install this middleware is to use - global aiohttp_auth.setup function. + Note that ``aiohttp_auth.auth`` middleware should be installed too to use + ``autz`` middleware. So the preferred way to install this middleware is to + use global ``aiohttp_auth.setup`` function. Args: - app: aiohttp Application object. - autz_policy: A subclass of aiohttp_auth.autz.abc.AbstractAutzPolicy. + app: aiohttp ``Application`` object. + autz_policy: A subclass of + ``aiohttp_auth.autz.abc.AbstractAutzPolicy``. """ app.middlewares.append(autz_middleware(autz_policy)) diff --git a/aiohttp_auth/autz/decorators.py b/aiohttp_auth/autz/decorators.py index ee36ad7..c651b0d 100644 --- a/aiohttp_auth/autz/decorators.py +++ b/aiohttp_auth/autz/decorators.py @@ -5,13 +5,13 @@ def autz_required(permission, context=None): - """Decorator to check if user has requested permission with given contex. + """Create decorator to check if user has requested permission. This function constructs a decorator that can be used to check a aiohttp's - view for authorization before calling it. It uses the autz.permit + view for authorization before calling it. It uses the ``autz.permit`` function to check the request against the passed permission and context. If the user does not have the correct permission to run this function, it - raises HTTPForbidden. + raises ``web.HTTPForbidden``. Note that context can be optional if authorization policy provides a way to specify global application context. Also context parameter can be used @@ -28,8 +28,8 @@ def autz_required(permission, context=None): Returns: A decorator which will check the request passed has the permission for - the given context. The decorator will raise HTTPForbidden if the user - does not have the correct permissions to access the view. + the given context. The decorator will raise ``web.HTTPForbidden`` if + the user does not have the correct permissions to access the view. """ def decorator(func): diff --git a/aiohttp_auth/autz/policy/acl.py b/aiohttp_auth/autz/policy/acl.py index ad8355e..45df3d4 100644 --- a/aiohttp_auth/autz/policy/acl.py +++ b/aiohttp_auth/autz/policy/acl.py @@ -1,19 +1,19 @@ """ACL authorization policy. This module introduces: - AbstractACLAutzPolicy: Abstract base class to create acl authorization + ``AbstractACLAutzPolicy``: Abstract base class to create ACL authorization policy class. The subclass should define how to retrieve users groups. - AbstractACLContext: Abstract base class for ACL context containers. + ``AbstractACLContext``: Abstract base class for ACL context containers. Context container defines a representation of ACL data structure, a storage method and how to process ACL context and groups to authorize user with permissions. - NaiveACLContext: ACL context container which is initialized with list + ``NaiveACLContext``: ACL context container which is initialized with list of ACL tuples and stores them as they are. The implementation - of permit process is the same as used by acl_middleware. - ACLContext: The same as NaiveACLContext but makes some transformation - of incoming ACL tuples. This may helps with a perfomance of the permit - process. + of permit process is the same as used by ``acl_middleware``. + ``ACLContext``: The same as ``NaiveACLContext`` but makes some + transformation of incoming ACL tuples. This may helps with a + perfomance of the permit process. """ import abc from ...acl.acl import extend_user_groups, get_groups_permitted @@ -23,7 +23,7 @@ class AbstractACLContext(abc.ABC): """Abstract base class for all ACL context classes. - Policy uses context classes to wrap sequence of acl groups + Policy uses context classes to wrap sequence of ACL groups permissions. This allows to implement different type of structures to store permissions and/or to proccess them in a specific way. @@ -35,12 +35,12 @@ async def permit(self, user_identity, groups, permission): """Check if permission is allowed for groups. Args: - user_identity: Identity of the user returned by auth.get_auth. + user_identity: Identity of the user returned by ``auth.get_auth``. groups: Some set of groups for which permission is checked for. permission: Permission that is checked. Returns: - True if permission is allowed False otherwise. + ``True`` if permission is allowed, ``False`` otherwise. """ pass # pragma: no cover @@ -48,11 +48,11 @@ async def permit(self, user_identity, groups, permission): class NaiveACLContext(AbstractACLContext): """Naive implementation of ACL context. - This class does not make any transformation of the acl groups + This class does not make any transformation of the ACL groups permissions which are passed through constructor but stores them as they are. - It uses the default permit strategy from acl middleware. + It uses the default permit strategy from ``acl_middleware``. """ def __init__(self, context): @@ -60,8 +60,10 @@ def __init__(self, context): Args: context: is a sequence of ACL tuples which consist of - an Allow/Deny action, a group, and a sequence of permissions - for that ACL group. For example:: + an ``Allow``/``Deny`` action, a group, and a sequence of + permissions for that ACL group. For example: + + .. code-block:: python context = [(Permission.Allow, 'view_group', ('view',)), (Permission.Allow, 'edit_group', ('view', @@ -72,20 +74,20 @@ def __init__(self, context): async def extended_user_groups(self, user_identity, groups): """Extend user groups with ACL specific groups. - See acl_middleware for more information. + See ``acl_middleware`` for more information. """ return extend_user_groups(user_identity, groups) async def permit(self, user_identity, groups, permission): - """Permit accoding to alc_middleware strategy. + """Permit accoding to ``alc_middleware`` strategy. Args: - user_identity: Identity of the user returned by auth.get_auth. + user_identity: Identity of the user returned by ``auth.get_auth``. groups: Set of user groups. permission: Permission that is checked. Returns: - True if permission is allowed False otherwise. + ``True`` if permission is allowed, ``False`` otherwise. """ groups = await self.extended_user_groups(user_identity, groups) return get_groups_permitted(groups, permission, self._context) @@ -113,20 +115,22 @@ class AbstractACLAutzPolicy(AbstractAutzPolicy): """Abstract base class for ACL authorization policy. As the library does not know how to get groups for user and it is always - up to application, it provides abstract authorization acl policy - class. Subclass should implement acl_groups method to use it with - autz_middleware. + up to application, it provides abstract authorization ACL policy + class. Subclass should implement ``acl_groups`` method to use it with + ``autz_middleware``. Note that an acl context can be specified globally while initializing - policy or locally through autz.permit function's parameter. A local + policy or locally through ``autz.permit`` function's parameter. A local context will always override a global one while checking permissions. - If there is no local context and global context is not set then a permit - method will raise a RuntimeError. + If there is no local context and global context is not set then the + ``permit`` method will raise a ``RuntimeError``. + + A context is an instance of ``AbstractACLContext`` subclass or a sequence + of ACL tuples which consist of a ``Allow``/``Deny`` action, a group, and a + sequence of permissions for that ACL group. + For example: - A context is an instance of AbstractACLContext subclass or a sequence of - ACL tuples which consist of a Allow/Deny action, a group, and a sequence - of permissions for that ACL group. - For example:: + .. code-block:: python context = [(Permission.Allow, 'view_group', ('view',)), (Permission.Allow, 'edit_group', ('view', 'edit')),] @@ -139,10 +143,12 @@ class AbstractACLAutzPolicy(AbstractAutzPolicy): Groups and permissions need only be immutable objects, so can be strings, numbers, enumerations, or other immutable objects. - Note that custom implementation of AbstractACLContext can be used to + Note that custom implementation of ``AbstractACLContext`` can be used to change the context form and the way it is processed. - Usage:: + Usage example: + + .. code-block:: python from aiohttp import web from aiohttp_auth import autz, Permission @@ -191,6 +197,13 @@ async def handler_view(request): """ def __init__(self, context=None): + """Initialize ACL authorization policy. + + Args: + context: global ACL context, default to ``None``. Could be an + ``AbstractACLContext`` subclass instance or raw list of ACL + rules. + """ if context is None or isinstance(context, AbstractACLContext): self.context = context else: @@ -198,16 +211,16 @@ def __init__(self, context=None): @abc.abstractmethod async def acl_groups(self, user_identity): - """Return acl groups for given user identity. + """Return ACL groups for given user identity. Subclass should implement this method to return a set of - groups for given user_identity. + groups for given ``user_identity``. Args: - user_identity: User identity returned by auth.get_auth. + user_identity: User identity returned by ``auth.get_auth``. Returns: - Set of acl groups for the user identity. + Set of ACL groups for the user identity. """ pass # pragma: no cover @@ -216,14 +229,14 @@ async def permit(self, user_identity, permission, context=None): Args: user_identity: Identity of the user returned by - aiohttp_auth.auth.get_auth function + ``aiohttp_auth.auth.get_auth`` function permission: The specific permission requested. context: A context provided for checking permissions. Could be optional if a global context is specified through policy initialization. Returns: - True if permission is allowed False otherwise. + ``True`` if permission is allowed, ``False`` otherwise. Raises: RuntimeError: If there is neither global context nor local one. From c83bdbf1dc97626dae4f03bde3526e8a7cacd3b1 Mon Sep 17 00:00:00 2001 From: ilex Date: Tue, 14 Feb 2017 11:15:55 +0200 Subject: [PATCH 31/52] docs: Add documentation using Sphinx. --- CHANGELOG.rst | 39 +++ README.rst | 569 ++++++--------------------------------- docs/CHANGELOG.rst | 39 +++ docs/Makefile | 20 ++ docs/api/acl.rst | 21 ++ docs/api/auth.rst | 41 +++ docs/api/autz.rst | 43 +++ docs/api/index.rst | 9 + docs/conf.py | 163 +++++++++++ docs/getting-started.rst | 126 +++++++++ docs/index.rst | 53 ++++ docs/middleware.rst | 531 ++++++++++++++++++++++++++++++++++++ requirements-dev.txt | 1 + setup.py | 6 +- 14 files changed, 1179 insertions(+), 482 deletions(-) create mode 100644 CHANGELOG.rst create mode 100644 docs/CHANGELOG.rst create mode 100644 docs/Makefile create mode 100644 docs/api/acl.rst create mode 100644 docs/api/auth.rst create mode 100644 docs/api/autz.rst create mode 100644 docs/api/index.rst create mode 100644 docs/conf.py create mode 100644 docs/getting-started.rst create mode 100644 docs/index.rst create mode 100644 docs/middleware.rst diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..9f7fb7c --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,39 @@ +Changelog +========= + +0.2.0 (XXXX-XX-XX) +------------------ + +- ``acl`` middleware: + + - Add ``setup`` function for ``acl`` middleware to install it in aiohttp fashion. + + - Fix bug in ``acl_required`` decorator. + + - Fix a possible security issue with ``acl`` groups. The issue is follow: the default behavior is + to add ``user_id`` to groups for authenticated users by the acl middleware, but if + ``user_id`` is equal to some of acl groups that user suddenly has the permissions he is not + allowed for. So to avoid this kind of issue ``user_id`` is not added to groups any more. + + - Introduce ``AbstractACLGroupsCallback`` class in ``acl`` middleware to make it possible easily create + callable object by inheriting from the abstract class and implementing ``acl_groups`` method. It + can be useful to store additional information (such database connection etc.) within such class. + An instance of this subclass can be used in place of ``acl_groups_callback`` parameter. + +- ``auth`` middleware: + + - Add ``setup`` function for ``auth`` middleware to install it in aiohttp fashion. + + - ``auth.auth_required`` raised now a ``web.HTTPUnauthorized`` instead of a ``web.HTTPForbidden``. + +- Introduce generic authorization middleware ``autz`` that performs authorization through the same + interface (``autz.permit`` coroutine and ``autz_required`` decorator) but using different policies. + Middleware has the ACL authorization as the built in policy which works in the same way as ``acl`` + middleware. Users are free to add their own custom policies or to modify ACL one. + +- Add global ``aiohttp_auth.setup`` function to install ``auth`` and ``autz`` middlewares at once + in aiohttp fashion. + +- Add docs. + +- Rewrite tests using ``pytest`` and ``pytest-aiohttp``. diff --git a/README.rst b/README.rst index d41691e..da28c3b 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ -aiohttp_auth -============ +aiohttp_auth_autz +================= This library provides authorization and authentication middleware plugins for aiohttp servers. @@ -8,400 +8,64 @@ These plugins are designed to be lightweight, simple, and extensible, allowing the library to be reused regardless of the backend authentication mechanism. This provides a familiar framework across projects. -There are three middleware plugins provided by the library. The auth_middleware +There are three middleware plugins provided by the library. The ``auth_middleware`` plugin provides a simple system for authenticating a users credentials, and ensuring that the user is who they say they are. -The acl_middleware plugin provides a simple access control list authorization -mechanism, where users are provided access to different view handlers depending -on what groups the user is a member of. - -The autz_middleware plugin provides a generic way of authorization using +The ``autz_middleware`` plugin provides a generic way of authorization using different authorization policies. There is the ACL authorization policy as a part of the plugin. +The ``acl_middleware`` plugin provides a simple access control list authorization +mechanism, where users are provided access to different view handlers depending +on what groups the user is a member of. It is recomended to use ``autz_middleware`` +with ACL policy instead of this middleware. -auth_middleware Usage ---------------------- - -The auth_middleware plugin provides a simple abstraction for remembering and -retrieving the authentication details for a user across http requests. -Typically, an application would retrieve the login details for a user, and call -the remember function to store the details. These details can then be recalled -in future requests. A simplistic example of users stored in a python dict would -be: - -.. code-block:: python - - from aiohttp_auth import auth - from aiohttp import web - - # Simplistic name/password map - db = {'user': 'password', - 'super_user': 'super_password'} - - - async def login_view(request): - params = await request.post() - user = params.get('username', None) - if (user in db and - params.get('password', None) == db[user]): - - # User is in our database, remember their login details - await auth.remember(request, user) - return web.Response(body='OK'.encode('utf-8')) - - raise web.HTTPForbidden() - -User data can be verified in later requests by checking that their username is -valid explicity, or by using the auth_required decorator: - -.. code-block:: python - - async def check_explicitly_view(request): - user = await get_auth(request) - if user is None: - # Show login page - return web.Response(body='Not authenticated'.encode('utf-8')) - - return web.Response(body='OK'.encode('utf-8')) - - @auth.auth_required - async def check_implicitly_view(request): - # HTTPForbidden is raised by the decorator if user is not valid - return web.Response(body='OK'.encode('utf-8')) - -To end the session, the user data can be forgotten by using the forget -function: - -.. code-block:: python - - @auth.auth_required - async def logout_view(request): - await auth.forget(request) - return web.Response(body='OK'.encode('utf-8')) - -The actual mechanisms for storing the authentication credentials are passed as -a policy to the session manager middleware. New policies can be implemented -quite simply by overriding the AbstractAuthentication class. The aiohttp_auth -package currently provides two authentication policies, a cookie based policy -based loosely on mod_auth_tkt (Apache ticket module), and a second policy that -uses the aiohttp_session class to store authentication tickets. - -The cookie based policy (CookieTktAuthentication) is a simple mechanism for -storing the username of the authenticated user in a cookie, along with a hash -value known only to the server. The cookie contains the maximum age allowed -before the ticket expires, and can also use the IP address (v4 or v6) of the -user to link the cookie to that address. The cookies data is not encryptedd, -but only holds the username of the user and the cookies expiration time, along -with its security hash: - -.. code-block:: python - - def init(loop): - app = web.Application(loop=loop) - - # Create a auth ticket mechanism that expires after 1 minute (60 - # seconds), and has a randomly generated secret. Also includes the - # optional inclusion of the users IP address in the hash - policy = auth.CookieTktAuthentication(urandom(32), 60, - include_ip=True) - - # setup middleware in aiohttp fashion - auth.setup(app, policy) - - app.router.add_route('POST', '/login', login_view) - app.router.add_route('GET', '/logout', logout_view) - app.router.add_route('GET', '/test0', check_explicitly_view) - app.router.add_route('GET', '/test1', check_implicitly_view) - - return app - -The SessionTktAuthentication policy provides many of the same features, but -stores the same ticket credentials in a aiohttp_session object, allowing -different storage mechanisms such as Redis storage, and -EncryptedCookieStorage: - -.. code-block:: python - - from aiohttp_session import get_session, session_middleware - from aiohttp_session.cookie_storage import EncryptedCookieStorage - - def init(loop): - app = web.Application(loop=loop) - - # setup session middleware in aiohttp fashion - storage = EncryptedCookieStorage(urandom(32)) - aiohttp_session.setup(app, storage) - - # Create a auth ticket mechanism that expires after 1 minute (60 - # seconds), and has a randomly generated secret. Also includes the - # optional inclusion of the users IP address in the hash - policy = auth.SessionTktAuthentication(urandom(32), 60, - include_ip=True) - - # setup aiohttp_auth.auth middleware in aiohttp fashion - auth.setup(app, policy) - - ... - - - -acl_middleware Usage --------------------- - -The acl_middleware plugin (provided by the aiohttp_auth library), is layered -on top of the auth_middleware plugin, and provides a access control list (ACL) -system similar to that used by the Pyramid WSGI module. - -Each user in the system is assigned a series of groups. Each group in the -system can then be assigned permissions that they are allowed (or not allowed) -to access. Groups and permissions are user defined, and need only be immutable -objects, so they can be strings, numbers, enumerations, or other immutable -objects. - -To specify what groups a user is a member of, a function is passed to the -acl_middleware factory which taks a user_id (as returned from the -auth.get_auth function) as a parameter, and expects a sequence of permitted ACL -groups to be returned. This can be a empty tuple to represent no explicit -permissions, or None to explicitly forbid this particular user_id. Note that -the user_id passed may be None if no authenticated user exists. Building apon -our example, a function may be defined as: - -.. code-block:: python - - from aiohttp import web - from aiohttp_auth import acl, auth - import aiohttp_session - - group_map = {'user': (,), - 'super_user': ('edit_group',),} - - async def acl_group_callback(user_id): - # The user_id could be None if the user is not authenticated, but in - # our example, we allow unauthenticated users access to some things, so - # we return an empty tuple. - return group_map.get(user_id, tuple()) - - def init(loop): - ... - - app = web.Application(loop=loop) - # setup session middleware - storage = aiohttp_session.EncryptedCookieStorage(urandom(32)) - aiohttp_session.setup(app, storage) - - # setup aiohttp_auth.auth middleware - policy = auth.SessionTktAuthentication(urandom(32), 60, include_ip=True) - auth.setup(app, policy) - - # setup aiohttp_auth.acl middleware - acl.setup(app, acl_group_callback) - - ... - - -Note that the ACL groups returned by the function will be modified by the -acl_middleware to also include the Group.Everyone group (if the value returned -is not None), and also the Group.AuthenticatedUser if the user_id -is not None. - -Instead of acl_group_callback as a coroutine the AbstractACLGroupsCallback -class can be used (all you need is to override acl_groups method): - -.. code-block:: python - - from aiohttp import web - from aiohttp_auth import acl, auth - from aiohttp_auth.acl.abc import AbstractACLGroupsCallback - import aiohttp_session - - - class ACLGroupsCallback(AbstractACLGroupsCallback): - def __init__(self, cache): - # Save here data you need to retrieve groups - # for example cache or db connection - self.cache = cache - - async def acl_groups(self, user_id): - # override abstract method with needed logic - user = self.cache.get(user_id, None) - ... - groups = user.groups() if user else tuple() - return groups - - - def init(loop): - ... - - app = web.Application(loop=loop) - # setup session middleware - storage = aiohttp_session.EncryptedCookieStorage(urandom(32)) - aiohttp_session.setup(app, storage) - - # setup aiohttp_auth.auth middleware - policy = auth.SessionTktAuthentication(urandom(32), 60, include_ip=True) - auth.setup(app, policy) - - # setup aiohttp_auth.acl middleware - cache = ... - acl_groups_callback = ACLGroupsCallback(cache) - acl.setup(app, acl_group_callback) - - ... - - -With the groups defined, a ACL context can be specified for looking up what -permissions each group is allowed to access. A context is a sequence of ACL -tuples which consist of a Allow/Deny action, a group, and a sequence of -permissions for that ACL group. For example: - -.. code-block:: python - - from aiohttp_auth.permissions import Group, Permission - - context = [(Permission.Allow, Group.Everyone, ('view',)), - (Permission.Allow, Group.AuthenticatedUser, ('view', 'view_extra')), - (Permission.Allow, 'edit_group', ('view', 'view_extra', 'edit')),] - -Views can then be defined using the acl_required decorator, allowing only -specific users access to a particular view. The acl_required decorator -specifies a permission required to access the view, and a context to check -against: - -.. code-block:: python - - @acl_required('view', context) - async def view_view(request): - return web.Response(body='OK'.encode('utf-8')) - - @acl_required('view_extra', context) - async def view_extra_view(request): - return web.Response(body='OK'.encode('utf-8')) - - @acl_required('edit', context) - async def edit_view(request): - return web.Response(body='OK'.encode('utf-8')) +This is a fork of `aiohttp_auth `_ +library that fixes some bugs and security issues and also introduces a generic +authorization ``autz`` middleware with built in ACL authorization policy. -In our example, non-logged in users will have access to the view_view, 'user' -will have access to both the view_view and view_extra_view, and 'super_user' -will have access to all three views. If no ACL group of the user matches the -ACL permission requested by the view, the decorator raises HTTPForbidden. +Documentation +------------- -ACL tuple sequences are checked in order, with the first tuple that matches the -group the user is a member of, AND includes the permission passed to the -function, declared to be the matching ACL group. This means that if the ACL -context was modified to: +Getting Started +--------------- .. code-block:: python - context = [(Permission.Allow, Group.Everyone, ('view',)), - (Permission.Deny, 'super_user', ('view_extra')), - (Permission.Allow, Group.AuthenticatedUser, ('view', 'view_extra')), - (Permission.Allow, 'edit_group', ('view', 'view_extra', 'edit')),] - -In this example the 'super_user' would be denied access to the view_extra_view -even though they are an AuthenticatedUser and in the edit_group. - - -autz_middleware Usage ---------------------- - -The autz middleware provides follow interface to use in applications: - - - Using ``autz.permit`` coroutine. - - Using ``autz.autz_required`` decorator for aiohttp handlers. - -The ``async def autz.permit(request, permission, context=None)`` coroutine checks -if permission is allowed for a given request with a given context. -The authorization checking is provided by authorization policy which is set by -setup function. The nature of permission and context is also determined by a policy. - -The ``def autz_required(permission, context=None)`` decorator for aiohttp's request -handlers checks if current user has requested permission with a given contex. -If the user does not have the correct permission it raises ``HTTPForbidden``. - -Note that context can be optional if authorization policy provides a way -to specify global application context or if it does not require any. Also context -parameter can be used to override global context if it is provided by authorization policy. - -To use an authorization policy with autz middleware a class of policy should be created -inherited from ``autz.abc.AbstractAutzPolicy``. The only thing that should be implemented -is ``permit`` method (see `Create custom authorization policy to use with autz middleware`_). -The autz middleware has a built in ACL authorization policy -(see `Use ACL authorization policy with autz middleware`_). - -The recomended way to initialize this middleware is through -``aiohttp_auth.autz.setup`` or ``aiohttp_auth.setup`` functions. As the autz -middleware can be used only with authentication ``aiohttp_auth.auth`` -middleware it is preferred to use ``aiohttp_auth.setup``. - -Use ACL authorization policy with autz middleware -------------------------------------------------- - -The autz plugin has a built in ACL authorization policy in ``autz.policy.acl`` module. -This module introduces a set of class: - - AbstractACLAutzPolicy: - Abstract base class to create acl authorization - policy class. The subclass should define how to retrieve users - groups. - - AbstractACLContext: - Abstract base class for ACL context containers. - Context container defines a representation of ACL data structure, - a storage method and how to process ACL context and groups - to authorize user with permissions. - - NaiveACLContext: - ACL context container which is initialized with list - of ACL tuples and stores them as they are. The implementation - of permit process is the same as used by acl_middleware. - - ACLContext: - The same as NaiveACLContext but makes some transformation - of incoming ACL tuples. This may helps with a perfomance of the permit - process. - -As the library does not know how to get groups for user and it is always -up to application, it provides abstract authorization acl policy -class. Subclass should implement ``acl_groups`` method to use it with -autz_middleware. + import asyncio -Note that an acl context can be specified globally while initializing -policy or locally through autz.permit function's parameter. A local -context will always override a global one while checking permissions. -If there is no local context and global context is not set then a permit -method will raise a RuntimeError. - -A context is an instance of ``AbstractACLContext`` subclass or a sequence of -ACL tuples which consist of a Allow/Deny action, a group, and a sequence -of permissions for that ACL group (see `acl_middleware Usage`_). - -Note that custom implementation of AbstractACLContext can be used to -change the context form and the way it is processed. - -Usage example: - -.. code-block:: python - from aiohttp import web - from aiohttp_auth import autz, Permission + from aiohttp_auth import auth, autz, Permission + from aiohttp_auth.auth import auth_required from aiohttp_auth.autz import autz_required from aiohttp_auth.autz.policy import acl - - # create an acl authorization policy class + db = { + 'bob': { + 'password': 'bob_password', + 'groups': ['guest', 'staff'] + }, + 'alice': { + 'password': 'alice_password', + 'groups': ['guest'] + } + } + + # global ACL context + context = [(Permission.Allow, 'guest', {'view', }), + (Permission.Deny, 'guest', {'edit', }), + (Permission.Allow, 'staff', {'view', 'edit', 'admin_view'})] + + # create an ACL authorization policy class class ACLAutzPolicy(acl.AbstractACLAutzPolicy): """The concrete ACL authorization policy.""" - def __init__(self, users, context=None): + def __init__(self, db, context=None): # do not forget to call parent __init__ super().__init__(context) - # we will retrieve groups using some kind of users dict - # here you can use db or cache or any other needed data - self.users = users + self.db = db async def acl_groups(self, user_identity): """Return acl groups for given user identity. @@ -415,135 +79,78 @@ Usage example: Set of acl groups for the user identity. """ # implement application specific logic here - user = self.users.get(user_identity, None) + user = self.db.get(user_identity, None) if user is None: - return None + return None return user['groups'] - def init(loop): - app = web.Application(loop=loop) - ... - # here you need to initialize aiohttp_auth.auth middleware - auth_policy = ... - ... - users = ... - # Create application global context. - # It can be overridden in autz.permit fucntion or in - # autz_required decorator using local context explicitly. - context = [(Permission.Allow, 'view_group', {'view', }), - (Permission.Allow, 'edit_group', {'view', 'edit'})] - # this raw context will be wrapped by ACLContext container internally - # you can explicitly create acl context class you need and pass it here - autz_policy = ACLAutzPolicy(users, context) - - # install auth and autz middleware in aiohttp fashion - aiohttp_auth.setup(app, auth_policy, autz_policy) + async def login(request): + post = await request.post() + user_identity = post.get('username', None) + password = post.get('password', None) + if user_identity in db and password == db[user_identity]['password']: + # remember user identity + await auth.remember(request, user_identity) + return web.Response(text='Ok') - # authorization using autz decorator applying to app handler - @autz_required('view') - async def handler_view(request): - # authorization using permit - if await autz.permit(request, 'edit'): - pass + raise web.HTTPUnauthorized() + # only authenticated users can logout + # if user is not authenticated auth_required decorator + # will raise a web.HTTPUnauthorized + @auth_required + async def logout(request): + # forget user identity + await auth.forget(request) + return web.Response(text='Ok') - # raw local context will wrapped with NaiveACLContext container internally - local_context = [(Permission.Deny, 'view_group', {'view', })] - - # authorization using autz decorator applying to app handler - # using local_context to override global one. - @autz_required('view', local_context) - async def handler_view_local(request): - # authorization using permit and local_context to - # override global one - if await autz.permit(request, 'edit', local_context): - pass - -Create custom authorization policy to use with autz middleware --------------------------------------------------------------- - -Tha autz middleware makes it possible to use custom athorization policy with -the same autz public interface for checking user permissions. -The follow example shows how to create such simple custom policy: - -.. code-block:: python - - from aiohttp import web - from aiohttp_auth import autz, auth - from aiohttp_auth.autz import autz_required - from aiohttp_auth.autz.abc import AbstractAutzPolicy - - class CustomAutzPolicy(AbstractAutzPolicy): - - def __init__(self, admin_user_identity): - self.admin_user_identity = admin_user_identity - - async def permit(self, user_identity, permission, context=None): - # All we need is to implement this method + # user should have a group with 'admin_view' permission allowed + # if he does not autz_required will raise a web.HTTPForbidden + @autz_required('admin_view') + async def admin(request): + return web.Response(text='Admin Page') - if permission == 'admin': - # only admin_user_identity is allowed for 'admin' permission - if user_identity == self.admin_user_identity: - return True + async def home(request): + text = 'Home page.' + # check if current user is permitted with 'admin_view' permission + if await autz.permit(request, 'admin_view'): + text += ' Go to admin' + return web.Response(text=text) - # forbid anyone else - return False + @autz_required('view') + async def view(request): + return web.Response(text='View Page') - # allow any other permissions for all users - return True - def init(loop): app = web.Application(loop=loop) - ... - # here you need to initialize aiohttp_auth.auth middleware - auth_policy = ... - ... - # create custom authorization policy - autz_policy = CustomAutzPolicy(admin_user_identity='Bob') - - # install auth and autz middleware in aiohttp fashion - aiohttp_auth.setup(app, auth_policy, autz_policy) - - - # authorization using autz decorator applying to app handler - @autz_required('admin') - async def handler_admin(request): - # only Bob can run this handler - - # authorization using permit - if await autz.permit(request, 'admin'): - # only Bob can get here - pass + # Create an auth ticket mechanism that expires after 10 minutes (600 + # seconds), and has a randomly generated secret. Also includes the + # optional inclusion of the users IP address in the hash + auth_policy = auth.CookieTktAuthentication(urandom(32), 600, + include_ip=True) + + # Create an ACL authorization policy + autz_policy = ACLAutzPolicy(db, context) - @autz_required('guest') - async def handler_guest(request): - # everyone can run this handler - - # authorization using permit - if await autz.permit(request, 'guest'): - # everyone can get here - pass - - -Testing with Pytest -------------------- - -In order to test this middleware with ``pytest`` you need to install:: - - $ pip install pytest pytest-aiohttp pytest-cov aiohttp_session + # setup middlewares in aiohttp fashion + aiohttp_auth.setup(app, auth_policy, autz_policy) -And then run tests:: + app.router.add_post('/login', login) + app.router.add_get('/logout', logout) + app.router.add_get('/admin', admin) + app.router.add_get('/view', view) + app.router.add_get('/', home) - $ py.test -v --cov-report=term-missing --cov=aiohttp_auth --cov=pytests pytests + web.run_app(app) -Or using ``tox`` just run:: - $ tox + loop = asyncio.get_event_loop() + loop.run_until_complete(init(loop)) License diff --git a/docs/CHANGELOG.rst b/docs/CHANGELOG.rst new file mode 100644 index 0000000..9f7fb7c --- /dev/null +++ b/docs/CHANGELOG.rst @@ -0,0 +1,39 @@ +Changelog +========= + +0.2.0 (XXXX-XX-XX) +------------------ + +- ``acl`` middleware: + + - Add ``setup`` function for ``acl`` middleware to install it in aiohttp fashion. + + - Fix bug in ``acl_required`` decorator. + + - Fix a possible security issue with ``acl`` groups. The issue is follow: the default behavior is + to add ``user_id`` to groups for authenticated users by the acl middleware, but if + ``user_id`` is equal to some of acl groups that user suddenly has the permissions he is not + allowed for. So to avoid this kind of issue ``user_id`` is not added to groups any more. + + - Introduce ``AbstractACLGroupsCallback`` class in ``acl`` middleware to make it possible easily create + callable object by inheriting from the abstract class and implementing ``acl_groups`` method. It + can be useful to store additional information (such database connection etc.) within such class. + An instance of this subclass can be used in place of ``acl_groups_callback`` parameter. + +- ``auth`` middleware: + + - Add ``setup`` function for ``auth`` middleware to install it in aiohttp fashion. + + - ``auth.auth_required`` raised now a ``web.HTTPUnauthorized`` instead of a ``web.HTTPForbidden``. + +- Introduce generic authorization middleware ``autz`` that performs authorization through the same + interface (``autz.permit`` coroutine and ``autz_required`` decorator) but using different policies. + Middleware has the ACL authorization as the built in policy which works in the same way as ``acl`` + middleware. Users are free to add their own custom policies or to modify ACL one. + +- Add global ``aiohttp_auth.setup`` function to install ``auth`` and ``autz`` middlewares at once + in aiohttp fashion. + +- Add docs. + +- Rewrite tests using ``pytest`` and ``pytest-aiohttp``. diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..4d7e4c8 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SPHINXPROJ = aiohttp_auth_autz +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/docs/api/acl.rst b/docs/api/acl.rst new file mode 100644 index 0000000..9cd8945 --- /dev/null +++ b/docs/api/acl.rst @@ -0,0 +1,21 @@ +ACL Middleware API +================== + +Public Middleware API +--------------------- + +.. automodule:: aiohttp_auth.acl.acl + :members: setup, acl_middleware, get_permitted, get_user_groups, extend_user_groups, get_groups_permitted + +Decorators +---------- + +.. automodule:: aiohttp_auth.acl.decorators + :members: + +AbstractACLGroupsCallback Class +------------------------------- + +.. autoclass:: aiohttp_auth.acl.abc.AbstractACLGroupsCallback + :members: + :special-members: __init__ diff --git a/docs/api/auth.rst b/docs/api/auth.rst new file mode 100644 index 0000000..82b192c --- /dev/null +++ b/docs/api/auth.rst @@ -0,0 +1,41 @@ +Authentication Middleware API +============================= + +Public Middleware API +--------------------- + +.. automodule:: aiohttp_auth.auth.auth + :members: setup, auth_middleware, get_auth, remember, forget + +Decorators +---------- + +.. automodule:: aiohttp_auth.auth.decorators + :members: + +Abstract Authentication Policy +------------------------------ + +.. autoclass:: aiohttp_auth.auth.abstract_auth.AbstractAuthentication + :members: + +Abstract Ticket Authentication Policy +------------------------------------- + +.. autoclass:: aiohttp_auth.auth.ticket_auth.TktAuthentication + :members: + :special-members: __init__ + + +Concrete Ticket Authentication Policies +--------------------------------------- + +.. autoclass:: aiohttp_auth.auth.cookie_ticket_auth.CookieTktAuthentication + :members: + :special-members: __init__ + :inherited-members: __init__ + +.. autoclass:: aiohttp_auth.auth.session_ticket_auth.SessionTktAuthentication + :members: + :special-members: __init__ + :inherited-members: __init__ diff --git a/docs/api/autz.rst b/docs/api/autz.rst new file mode 100644 index 0000000..78d6105 --- /dev/null +++ b/docs/api/autz.rst @@ -0,0 +1,43 @@ +Authorization Middleware API +============================ + +Setup auth and autz +------------------- + +.. autofunction:: aiohttp_auth.setup + + +Public Middleware API +--------------------- + +.. automodule:: aiohttp_auth.autz.autz + :members: setup, autz_middleware, permit + +Decorators +---------- + +.. automodule:: aiohttp_auth.autz.decorators + :members: + +ACL Authorization Policy +------------------------ + +.. automodule:: aiohttp_auth.autz.policy.acl + +.. autoclass:: aiohttp_auth.autz.policy.acl.AbstractACLAutzPolicy + :members: + :special-members: __init__ + +.. autoclass:: aiohttp_auth.autz.policy.acl.AbstractACLContext + :members: + +.. autoclass:: aiohttp_auth.autz.policy.acl.NaiveACLContext + :members: + :special-members: __init__ + +.. autoclass:: aiohttp_auth.autz.policy.acl.NaiveACLContext + :members: + :special-members: __init__ + :inherited-members: + + diff --git a/docs/api/index.rst b/docs/api/index.rst new file mode 100644 index 0000000..d168f53 --- /dev/null +++ b/docs/api/index.rst @@ -0,0 +1,9 @@ +API Documentation +================= + +.. toctree:: + :maxdepth: 2 + + auth + autz + acl diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..d39bf63 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# aiohttp_auth_autz documentation build configuration file, created by +# sphinx-quickstart on Sun Feb 12 19:19:36 2017. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) +import os +import sys +sys.path.insert(0, os.path.abspath('../')) + + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.napoleon' +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = 'aiohttp_auth_autz' +copyright = '2017, ilex' +author = 'ilex' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '' +# The full version, including alpha/beta/rc tags. +release = '' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This patterns also effect to html_static_path and html_extra_path +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'alabaster' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + + +# -- Options for HTMLHelp output ------------------------------------------ + +# Output file base name for HTML help builder. +htmlhelp_basename = 'aiohttp_auth_autzdoc' + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'aiohttp_auth_autz.tex', 'aiohttp\\_auth\\_autz Documentation', + 'ilex', 'manual'), +] + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'aiohttp_auth_autz', 'aiohttp_auth_autz Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'aiohttp_auth_autz', 'aiohttp_auth_autz Documentation', + author, 'aiohttp_auth_autz', 'One line description of project.', + 'Miscellaneous'), +] + + + diff --git a/docs/getting-started.rst b/docs/getting-started.rst new file mode 100644 index 0000000..1b17c17 --- /dev/null +++ b/docs/getting-started.rst @@ -0,0 +1,126 @@ +Getting Started +=============== + +A simple example how to use authentication and authorization middleware +with an aiohttp application. + +.. code-block:: python + + import asyncio + + from aiohttp import web + from aiohttp_auth import auth, autz, Permission + from aiohttp_auth.auth import auth_required + from aiohttp_auth.autz import autz_required + from aiohttp_auth.autz.policy import acl + + db = { + 'bob': { + 'password': 'bob_password', + 'groups': ['guest', 'staff'] + }, + 'alice': { + 'password': 'alice_password', + 'groups': ['guest'] + } + } + + # global ACL context + context = [(Permission.Allow, 'guest', {'view', }), + (Permission.Deny, 'guest', {'edit', }), + (Permission.Allow, 'staff', {'view', 'edit', 'admin_view'})] + + # create an ACL authorization policy class + class ACLAutzPolicy(acl.AbstractACLAutzPolicy): + """The concrete ACL authorization policy.""" + + def __init__(self, db, context=None): + # do not forget to call parent __init__ + super().__init__(context) + + self.db = db + + async def acl_groups(self, user_identity): + """Return acl groups for given user identity. + + This method should return a set of groups for given user_identity. + + Args: + user_identity: User identity returned by auth.get_auth. + + Returns: + Set of acl groups for the user identity. + """ + # implement application specific logic here + user = self.db.get(user_identity, None) + if user is None: + return None + + return user['groups'] + + + + async def login(request): + post = await request.post() + user_identity = post.get('username', None) + password = post.get('password', None) + if user_identity in db and password == db[user_identity]['password']: + # remember user identity + await auth.remember(request, user_identity) + return web.Response(text='Ok') + + raise web.HTTPUnauthorized() + + # only authenticated users can logout + # if user is not authenticated auth_required decorator + # will raise a web.HTTPUnauthorized + @auth_required + async def logout(request): + # forget user identity + await auth.forget(request) + return web.Response(text='Ok') + + # user should have a group with 'admin_view' permission allowed + # if he does not autz_required will raise a web.HTTPForbidden + @autz_required('admin_view') + async def admin(request): + return web.Response(text='Admin Page') + + async def home(request): + text = 'Home page.' + # check if current user is permitted with 'admin_view' permission + if await autz.permit(request, 'admin_view'): + text += ' Go to admin' + return web.Response(text=text) + + @autz_required('view') + async def view(request): + return web.Response(text='View Page') + + + def init(loop): + app = web.Application(loop=loop) + + # Create an auth ticket mechanism that expires after 1 minute (60 + # seconds), and has a randomly generated secret. Also includes the + # optional inclusion of the users IP address in the hash + auth_policy = auth.CookieTktAuthentication(urandom(32), 60, + include_ip=True) + + # Create an ACL authorization policy + autz_policy = ACLAutzPolicy(db, context) + + # setup middlewares in aiohttp fashion + aiohttp_auth.setup(app, auth_policy, autz_policy) + + app.router.add_post('/login', login) + app.router.add_get('/logout', logout) + app.router.add_get('/admin', admin) + app.router.add_get('/view', view) + app.router.add_get('/', home) + + web.run_app(app) + + + loop = asyncio.get_event_loop() + loop.run_until_complete(init(loop)) diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..0d9366d --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,53 @@ +.. aiohttp_auth_autz documentation master file, created by + sphinx-quickstart on Sun Feb 12 19:19:36 2017. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to aiohttp_auth_autz's documentation! +============================================= + +This library provides authorization and authentication middleware plugins for +aiohttp servers. + +These plugins are designed to be lightweight, simple, and extensible, allowing +the library to be reused regardless of the backend authentication mechanism. +This provides a familiar framework across projects. + +There are three middleware plugins provided by the library. The ``auth_middleware`` +plugin provides a simple system for authenticating a users credentials, and +ensuring that the user is who they say they are. + +The ``autz_middleware`` plugin provides a generic way of authorization using +different authorization policies. There is the ACL authorization policy as a +part of the plugin. + +The ``acl_middleware`` plugin provides a simple access control list authorization +mechanism, where users are provided access to different view handlers depending +on what groups the user is a member of. It is recomended to use ``autz_middleware`` +with ACL policy instead of this middleware. + +This is a fork of `aiohttp_auth `_ +library that fixes some bugs and security issues and also introduces a generic +authorization ``autz`` middleware with built in ACL authorization policy. + +License +------- +The library is licensed under a MIT license. + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + getting-started + middleware + api/index + CHANGELOG + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/middleware.rst b/docs/middleware.rst new file mode 100644 index 0000000..22b08db --- /dev/null +++ b/docs/middleware.rst @@ -0,0 +1,531 @@ +================== +Middleware plugins +================== + +This library provides authorization and authentication middleware plugins for +aiohttp servers. + +These plugins are designed to be lightweight, simple, and extensible, allowing +the library to be reused regardless of the backend authentication mechanism. +This provides a familiar framework across projects. + +There are three middleware plugins provided by the library. The ``auth_middleware`` +plugin provides a simple system for authenticating a users credentials, and +ensuring that the user is who they say they are. + +The ``autz_middleware`` plugin provides a generic way of authorization using +different authorization policies. There is the ACL authorization policy as a +part of the plugin. + +The ``acl_middleware`` plugin provides a simple access control list authorization +mechanism, where users are provided access to different view handlers depending +on what groups the user is a member of. It is recomended to use ``autz_middleware`` +with ACL policy instead of this middleware. + + +Authentication Middleware Usage +=============================== + +The ``auth_middleware`` plugin provides a simple abstraction for remembering and +retrieving the authentication details for a user across http requests. +Typically, an application would retrieve the login details for a user, and call +the remember function to store the details. These details can then be recalled +in future requests. A simplistic example of users stored in a python dict would +be: + +.. code-block:: python + + from aiohttp_auth import auth + from aiohttp import web + + # Simplistic name/password map + db = {'user': 'password', + 'super_user': 'super_password'} + + + async def login_view(request): + params = await request.post() + user = params.get('username', None) + if (user in db and + params.get('password', None) == db[user]): + + # User is in our database, remember their login details + await auth.remember(request, user) + return web.Response(body='OK'.encode('utf-8')) + + raise web.HTTPUnauthorized() + +User data can be verified in later requests by checking that their username is +valid explicity, or by using the ``auth_required`` decorator: + +.. code-block:: python + + async def check_explicitly_view(request): + user = await auth.get_auth(request) + if user is None: + # Show login page + return web.Response(body='Not authenticated'.encode('utf-8')) + + return web.Response(body='OK'.encode('utf-8')) + + @auth.auth_required + async def check_implicitly_view(request): + # HTTPUnauthorized is raised by the decorator if user is not valid + return web.Response(body='OK'.encode('utf-8')) + +To end the session, the user data can be forgotten by using the ``forget`` +function: + +.. code-block:: python + + @auth.auth_required + async def logout_view(request): + await auth.forget(request) + return web.Response(body='OK'.encode('utf-8')) + +The actual mechanisms for storing the authentication credentials are passed as +a policy to the session manager middleware. New policies can be implemented +quite simply by overriding the ``AbstractAuthentication`` class. The ``aiohttp_auth`` +package currently provides two authentication policies, a cookie based policy +based loosely on ``mod_auth_tkt`` (Apache ticket module), and a second policy that +uses the ``aiohttp_session`` class to store authentication tickets. + +The cookie based policy (``CookieTktAuthentication``) is a simple mechanism for +storing the username of the authenticated user in a cookie, along with a hash +value known only to the server. The cookie contains the maximum age allowed +before the ticket expires, and can also use the IP address (v4 or v6) of the +user to link the cookie to that address. The cookies data is not encrypted, +but only holds the username of the user and the cookies expiration time, along +with its security hash: + +.. code-block:: python + + def init(loop): + app = web.Application(loop=loop) + + # Create a auth ticket mechanism that expires after 1 minute (60 + # seconds), and has a randomly generated secret. Also includes the + # optional inclusion of the users IP address in the hash + policy = auth.CookieTktAuthentication(urandom(32), 60, + include_ip=True) + + # setup middleware in aiohttp fashion + auth.setup(app, policy) + + app.router.add_route('POST', '/login', login_view) + app.router.add_route('GET', '/logout', logout_view) + app.router.add_route('GET', '/test0', check_explicitly_view) + app.router.add_route('GET', '/test1', check_implicitly_view) + + return app + +The ``SessionTktAuthentication`` policy provides many of the same features, but +stores the same ticket credentials in a ``aiohttp_session`` object, allowing +different storage mechanisms such as ``Redis`` storage, and +``EncryptedCookieStorage``: + +.. code-block:: python + + from aiohttp_session import get_session, session_middleware + from aiohttp_session.cookie_storage import EncryptedCookieStorage + + def init(loop): + app = web.Application(loop=loop) + + # setup session middleware in aiohttp fashion + storage = EncryptedCookieStorage(urandom(32)) + aiohttp_session.setup(app, storage) + + # Create an auth ticket mechanism that expires after 1 minute (60 + # seconds), and has a randomly generated secret. Also includes the + # optional inclusion of the users IP address in the hash + policy = auth.SessionTktAuthentication(urandom(32), 60, + include_ip=True) + + # setup aiohttp_auth.auth middleware in aiohttp fashion + auth.setup(app, policy) + + ... + + +Authorization Middleware Usage +============================== + +The autz middleware provides follow interface to use in applications: + + - Using ``autz.permit`` coroutine. + - Using ``autz.autz_required`` decorator for aiohttp handlers. + +The ``async def autz.permit(request, permission, context=None)`` coroutine checks +if permission is allowed for a given request with a given context. +The authorization checking is provided by authorization policy which is set by +setup function. The nature of permission and context is also determined by a policy. + +The ``def autz_required(permission, context=None)`` decorator for aiohttp's request +handlers checks if current user has requested permission with a given context. +If the user does not have the correct permission it raises ``web.HTTPForbidden``. + +Note that context can be optional if authorization policy provides a way +to specify global application context or if it does not require any. Also context +parameter can be used to override global context if it is provided by authorization policy. + +To use an authorization policy with autz middleware a class of policy should be created +inherited from ``autz.abc.AbstractAutzPolicy``. The only thing that should be implemented +is ``permit`` method (see `Custom authorization policy for autz middleware`_). +The ``autz`` middleware has a built in ACL authorization policy +(see `ACL authorization policy for autz middleware`_). + +The recomended way to initialize this middleware is through +``aiohttp_auth.autz.setup`` or ``aiohttp_auth.setup`` functions. As the ``autz`` +middleware can be used only with authentication ``aiohttp_auth.auth`` +middleware it is preferred to use ``aiohttp_auth.setup``. + +ACL authorization policy for autz middleware +-------------------------------------------- + +The ``autz`` plugin has a built in ACL authorization policy in ``autz.policy.acl`` module. +This module introduces a set of classes: + + ``AbstractACLAutzPolicy``: + Abstract base class to create ACL authorization + policy class. The subclass should define how to retrieve users + groups. + + ``AbstractACLContext``: + Abstract base class for ACL context containers. + Context container defines a representation of ACL data structure, + a storage method and how to process ACL context and groups + to authorize user with permissions. + + ``NaiveACLContext``: + ACL context container which is initialized with list + of ACL tuples and stores them as they are. The implementation + of permit process is the same as used by ``acl_middleware``. + + ``ACLContext``: + The same as ``NaiveACLContext`` but makes some transformation + of incoming ACL tuples. This may helps with a perfomance of the permit + process. + +As the library does not know how to get groups for user and it is always +up to application, it provides abstract authorization ACL policy +class. Subclass should implement ``acl_groups`` method to use it with +``autz_middleware``. + +Note that an ACL context can be specified globally while initializing +policy or locally through ``autz.permit`` function's parameter. A local +context will always override a global one while checking permissions. +If there is no local context and global context is not set then a permit +method will raise a ``RuntimeError``. + +A context is an instance of ``AbstractACLContext`` subclass or a sequence of +ACL tuples which consist of a ``Allow``/``Deny`` action, a group, and a sequence +of permissions for that ACL group (see `ACL Middleware Usage`_). + +Note that custom implementation of ``AbstractACLContext`` can be used to +change the context form and the way it is processed. + +Usage example: + +.. code-block:: python + + from aiohttp import web + from aiohttp_auth import autz, Permission + from aiohttp_auth.autz import autz_required + from aiohttp_auth.autz.policy import acl + + + # create an acl authorization policy class + class ACLAutzPolicy(acl.AbstractACLAutzPolicy): + """The concrete ACL authorization policy.""" + + def __init__(self, users, context=None): + # do not forget to call parent __init__ + super().__init__(context) + + # we will retrieve groups using some kind of users dict + # here you can use db or cache or any other needed data + self.users = users + + async def acl_groups(self, user_identity): + """Return acl groups for given user identity. + + This method should return a set of groups for given user_identity. + + Args: + user_identity: User identity returned by auth.get_auth. + + Returns: + Set of acl groups for the user identity. + """ + # implement application specific logic here + user = self.users.get(user_identity, None) + if user is None: + return None + + return user['groups'] + + + def init(loop): + app = web.Application(loop=loop) + ... + # here you need to initialize aiohttp_auth.auth middleware + auth_policy = ... + ... + users = ... + # Create application global context. + # It can be overridden in autz.permit fucntion or in + # autz_required decorator using local context explicitly. + context = [(Permission.Allow, 'view_group', {'view', }), + (Permission.Allow, 'edit_group', {'view', 'edit'})] + # this raw context will be wrapped by ACLContext container internally + # you can explicitly create acl context class you need and pass it here + autz_policy = ACLAutzPolicy(users, context) + + # install auth and autz middleware in aiohttp fashion + aiohttp_auth.setup(app, auth_policy, autz_policy) + + + # authorization using autz decorator applying to app handler + @autz_required('view') + async def handler_view(request): + # authorization using permit + if await autz.permit(request, 'edit'): + pass + + + # raw local context will wrapped with NaiveACLContext container internally + local_context = [(Permission.Deny, 'view_group', {'view', })] + + # authorization using autz decorator applying to app handler + # using local_context to override global one. + @autz_required('view', local_context) + async def handler_view_local(request): + # authorization using permit and local_context to + # override global one + if await autz.permit(request, 'edit', local_context): + pass + +Custom authorization policy for autz middleware +----------------------------------------------- + +Tha ``autz`` middleware makes it possible to use custom athorization policy with +the same ``autz`` public interface for checking user permissions. +The follow example shows how to create such simple custom policy: + +.. code-block:: python + + from aiohttp import web + from aiohttp_auth import autz, auth + from aiohttp_auth.autz import autz_required + from aiohttp_auth.autz.abc import AbstractAutzPolicy + + class CustomAutzPolicy(AbstractAutzPolicy): + + def __init__(self, admin_user_identity): + self.admin_user_identity = admin_user_identity + + async def permit(self, user_identity, permission, context=None): + # All we need is to implement this method + + if permission == 'admin': + # only admin_user_identity is allowed for 'admin' permission + if user_identity == self.admin_user_identity: + return True + + # forbid anyone else + return False + + # allow any other permissions for all users + return True + + + def init(loop): + app = web.Application(loop=loop) + ... + # here you need to initialize aiohttp_auth.auth middleware + auth_policy = ... + ... + # create custom authorization policy + autz_policy = CustomAutzPolicy(admin_user_identity='Bob') + + # install auth and autz middleware in aiohttp fashion + aiohttp_auth.setup(app, auth_policy, autz_policy) + + + # authorization using autz decorator applying to app handler + @autz_required('admin') + async def handler_admin(request): + # only Bob can run this handler + + # authorization using permit + if await autz.permit(request, 'admin'): + # only Bob can get here + pass + + + @autz_required('guest') + async def handler_guest(request): + # everyone can run this handler + + # authorization using permit + if await autz.permit(request, 'guest'): + # everyone can get here + pass + + +ACL Middleware Usage +==================== + +The ``acl_middleware``` plugin (provided by the ``aiohttp_auth`` library), is layered +on top of the ``auth_middleware`` plugin, and provides a access control list (ACL) +system similar to that used by the Pyramid WSGI module. + +Each user in the system is assigned a series of groups. Each group in the +system can then be assigned permissions that they are allowed (or not allowed) +to access. Groups and permissions are user defined, and need only be immutable +objects, so they can be strings, numbers, enumerations, or other immutable +objects. + +To specify what groups a user is a member of, a function is passed to the +``acl_middleware`` factory which taks a ``user_id`` (as returned from the +``auth.get_auth`` function) as a parameter, and expects a sequence of permitted ACL +groups to be returned. This can be a empty tuple to represent no explicit +permissions, or None to explicitly forbid this particular ``user_id``. Note that +the ``user_id`` passed may be ``None`` if no authenticated user exists. Building apon +our example, a function may be defined as: + +.. code-block:: python + + from aiohttp import web + from aiohttp_auth import acl, auth + import aiohttp_session + + group_map = {'user': (,), + 'super_user': ('edit_group',),} + + async def acl_group_callback(user_id): + # The user_id could be None if the user is not authenticated, but in + # our example, we allow unauthenticated users access to some things, so + # we return an empty tuple. + return group_map.get(user_id, tuple()) + + def init(loop): + ... + + app = web.Application(loop=loop) + # setup session middleware + storage = aiohttp_session.EncryptedCookieStorage(urandom(32)) + aiohttp_session.setup(app, storage) + + # setup aiohttp_auth.auth middleware + policy = auth.SessionTktAuthentication(urandom(32), 60, include_ip=True) + auth.setup(app, policy) + + # setup aiohttp_auth.acl middleware + acl.setup(app, acl_group_callback) + + ... + + +Note that the ACL groups returned by the function will be modified by the +``acl_middleware`` to also include the ``Group.Everyone`` group (if the value returned +is not ``None``), and also the ``Group.AuthenticatedUser`` if the ``user_id`` +is not ``None``. + +Instead of ``acl_group_callback`` as a coroutine the ``AbstractACLGroupsCallback`` +class can be used (all you need is to override ``acl_groups`` method): + +.. code-block:: python + + from aiohttp import web + from aiohttp_auth import acl, auth + from aiohttp_auth.acl.abc import AbstractACLGroupsCallback + import aiohttp_session + + + class ACLGroupsCallback(AbstractACLGroupsCallback): + def __init__(self, cache): + # Save here data you need to retrieve groups + # for example cache or db connection + self.cache = cache + + async def acl_groups(self, user_id): + # override abstract method with needed logic + user = self.cache.get(user_id, None) + ... + groups = user.groups() if user else tuple() + return groups + + + def init(loop): + ... + + app = web.Application(loop=loop) + # setup session middleware + storage = aiohttp_session.EncryptedCookieStorage(urandom(32)) + aiohttp_session.setup(app, storage) + + # setup aiohttp_auth.auth middleware + policy = auth.SessionTktAuthentication(urandom(32), 60, include_ip=True) + auth.setup(app, policy) + + # setup aiohttp_auth.acl middleware + cache = ... + acl_groups_callback = ACLGroupsCallback(cache) + acl.setup(app, acl_group_callback) + + ... + + +With the groups defined, an ACL context can be specified for looking up what +permissions each group is allowed to access. A context is a sequence of ACL +tuples which consist of a ``Allow``/``Deny`` action, a group, and a sequence of +permissions for that ACL group. For example: + +.. code-block:: python + + from aiohttp_auth.permissions import Group, Permission + + context = [(Permission.Allow, Group.Everyone, ('view',)), + (Permission.Allow, Group.AuthenticatedUser, ('view', 'view_extra')), + (Permission.Allow, 'edit_group', ('view', 'view_extra', 'edit')),] + +Views can then be defined using the ``acl_required`` decorator, allowing only +specific users access to a particular view. The ``acl_required`` decorator +specifies a permission required to access the view, and a context to check +against: + +.. code-block:: python + + @acl_required('view', context) + async def view_view(request): + return web.Response(body='OK'.encode('utf-8')) + + @acl_required('view_extra', context) + async def view_extra_view(request): + return web.Response(body='OK'.encode('utf-8')) + + @acl_required('edit', context) + async def edit_view(request): + return web.Response(body='OK'.encode('utf-8')) + +In our example, non-logged in users will have access to the view_view, 'user' +will have access to both the view_view and view_extra_view, and 'super_user' +will have access to all three views. If no ACL group of the user matches the +ACL permission requested by the view, the decorator raises ``web.HTTPForbidden``. + +ACL tuple sequences are checked in order, with the first tuple that matches the +group the user is a member of, AND includes the permission passed to the +function, declared to be the matching ACL group. This means that if the ACL +context was modified to: + +.. code-block:: python + + context = [(Permission.Allow, Group.Everyone, ('view',)), + (Permission.Deny, 'super_user', ('view_extra')), + (Permission.Allow, Group.AuthenticatedUser, ('view', 'view_extra')), + (Permission.Allow, 'edit_group', ('view', 'view_extra', 'edit')),] + +In this example the 'super_user' would be denied access to the view_extra_view +even though they are an ``AuthenticatedUser`` and in the 'edit_group'. diff --git a/requirements-dev.txt b/requirements-dev.txt index cdf5cde..17c5865 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,3 +4,4 @@ pytest pytest-aiohttp pytest-cov aiohttp_session +Sphinx diff --git a/setup.py b/setup.py index 71c5594..e3f46ea 100644 --- a/setup.py +++ b/setup.py @@ -23,12 +23,16 @@ def version(): return version +long_description = '\n'.join((open('README.rst').read(), + open('CHANGELOG.rst').read())) + + setup( name="aiohttp_auth_autz", version=version(), description=('Authorization and authentication ' 'middleware plugin for aiohttp.'), - long_description=open('README.rst').read(), + long_description=long_description, setup_requires=['pytest-runner'], install_requires=install_requires, tests_require=tests_require, From b4b511960df2b95f832387640aa78f13b01da6cc Mon Sep 17 00:00:00 2001 From: ilex Date: Tue, 14 Feb 2017 11:46:02 +0200 Subject: [PATCH 32/52] Add Manifest file. --- MANIFEST.in | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 MANIFEST.in diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..a4f4cc9 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include README.rst +include CHANGELOG.rst +include LICENSE From ac8b13340cf4cf0e6bacec7c61e644b3883eb15f Mon Sep 17 00:00:00 2001 From: ilex Date: Tue, 14 Feb 2017 11:58:44 +0200 Subject: [PATCH 33/52] Bump version 0.2.0.rc0. --- aiohttp_auth/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiohttp_auth/__init__.py b/aiohttp_auth/__init__.py index 54ec910..03df6f2 100644 --- a/aiohttp_auth/__init__.py +++ b/aiohttp_auth/__init__.py @@ -5,7 +5,7 @@ from . import auth, autz -__version__ = '0.2.0.dev0' +__version__ = '0.2.0.rc0' def setup(app, auth_policy, autz_policy): From 516d78494cc0eb7e47e94da0b31e98f998c3aac5 Mon Sep 17 00:00:00 2001 From: ilex Date: Tue, 14 Feb 2017 18:07:19 +0200 Subject: [PATCH 34/52] docs: Improve docs. --- README.rst | 85 +++++++++++++++++++++++++++++----------- docs/CHANGELOG.rst | 40 +------------------ docs/conf.py | 2 +- docs/getting-started.rst | 62 +++++++++++++++++++---------- docs/index.rst | 8 ++++ 5 files changed, 113 insertions(+), 84 deletions(-) diff --git a/README.rst b/README.rst index da28c3b..3ed1da5 100644 --- a/README.rst +++ b/README.rst @@ -1,6 +1,13 @@ aiohttp_auth_autz ================= +.. image:: https://travis-ci.org/ilex/aiohttp_auth_autz.svg?branch=master + :target: https://travis-ci.org/ilex/aiohttp_auth_autz + +.. image:: https://readthedocs.org/projects/aiohttp-auth-autz/badge/?version=latest + :target: http://aiohttp-auth-autz.readthedocs.io/en/latest/?badge=latest + :alt: Documentation Status + This library provides authorization and authentication middleware plugins for aiohttp servers. @@ -25,9 +32,21 @@ This is a fork of `aiohttp_auth ` library that fixes some bugs and security issues and also introduces a generic authorization ``autz`` middleware with built in ACL authorization policy. + Documentation ------------- +http://aiohttp-auth-autz.readthedocs.io/ + + +Install +------- + +Install ``aiohttp_auth_autz`` using ``pip``:: + + $ pip install aiohttp_auth_autz + + Getting Started --------------- @@ -35,11 +54,16 @@ Getting Started import asyncio + from os import urandom + + import aiohttp_auth + from aiohttp import web - from aiohttp_auth import auth, autz, Permission - from aiohttp_auth.auth import auth_required + from aiohttp_auth import auth, autz + from aiohttp_auth.auth import auth_required from aiohttp_auth.autz import autz_required from aiohttp_auth.autz.policy import acl + from aiohttp_auth.permissions import Permission, Group db = { 'bob': { @@ -51,11 +75,13 @@ Getting Started 'groups': ['guest'] } } - + # global ACL context context = [(Permission.Allow, 'guest', {'view', }), - (Permission.Deny, 'guest', {'edit', }), - (Permission.Allow, 'staff', {'view', 'edit', 'admin_view'})] + (Permission.Deny, 'guest', {'edit', }), + (Permission.Allow, 'staff', {'view', 'edit', 'admin_view'}), + (Permission.Allow, Group.Everyone, {'view_home', })] + # create an ACL authorization policy class class ACLAutzPolicy(acl.AbstractACLAutzPolicy): @@ -81,16 +107,17 @@ Getting Started # implement application specific logic here user = self.db.get(user_identity, None) if user is None: - return None + # return empty set of groups for not authenticated users + # middleware will fill it with Group.Everyone + return set() return user['groups'] - async def login(request): - post = await request.post() - user_identity = post.get('username', None) - password = post.get('password', None) + # http://127.0.0.1:8080/login?username=bob&password=bob_password + user_identity = request.GET.get('username', None) + password = request.GET.get('password', None) if user_identity in db and password == db[user_identity]['password']: # remember user identity await auth.remember(request, user_identity) @@ -98,59 +125,71 @@ Getting Started raise web.HTTPUnauthorized() + # only authenticated users can logout # if user is not authenticated auth_required decorator - # will raise a web.HTTPUnauthorized + # will raise a web.HTTPUnauthorized @auth_required async def logout(request): # forget user identity await auth.forget(request) return web.Response(text='Ok') + # user should have a group with 'admin_view' permission allowed # if he does not autz_required will raise a web.HTTPForbidden @autz_required('admin_view') async def admin(request): return web.Response(text='Admin Page') + + @autz_required('view_home') async def home(request): text = 'Home page.' # check if current user is permitted with 'admin_view' permission if await autz.permit(request, 'admin_view'): - text += ' Go to admin' + text += ' Admin page: http://127.0.0.1:8080/admin' + # get current user identity + user_identity = await auth.get_auth(request) + if user_identity is not None: + # user is authenticated + text += ' Logout: http://127.0.0.1:8080/logout' return web.Response(text=text) + @autz_required('view') async def view(request): return web.Response(text='View Page') - - def init(loop): + + def init_app(loop): app = web.Application(loop=loop) - # Create an auth ticket mechanism that expires after 10 minutes (600 + # Create an auth ticket mechanism that expires after 1 minute (60 # seconds), and has a randomly generated secret. Also includes the # optional inclusion of the users IP address in the hash - auth_policy = auth.CookieTktAuthentication(urandom(32), 600, - include_ip=True) - - # Create an ACL authorization policy + auth_policy = auth.CookieTktAuthentication(urandom(32), 60, + include_ip=True) + + # Create an ACL authorization policy autz_policy = ACLAutzPolicy(db, context) # setup middlewares in aiohttp fashion aiohttp_auth.setup(app, auth_policy, autz_policy) - app.router.add_post('/login', login) + app.router.add_get('/', home) + app.router.add_get('/login', login) app.router.add_get('/logout', logout) app.router.add_get('/admin', admin) app.router.add_get('/view', view) - app.router.add_get('/', home) - web.run_app(app) + return app loop = asyncio.get_event_loop() - loop.run_until_complete(init(loop)) + app = init_app(loop) + + web.run_app(app, host='127.0.0.1') License diff --git a/docs/CHANGELOG.rst b/docs/CHANGELOG.rst index 9f7fb7c..565b052 100644 --- a/docs/CHANGELOG.rst +++ b/docs/CHANGELOG.rst @@ -1,39 +1 @@ -Changelog -========= - -0.2.0 (XXXX-XX-XX) ------------------- - -- ``acl`` middleware: - - - Add ``setup`` function for ``acl`` middleware to install it in aiohttp fashion. - - - Fix bug in ``acl_required`` decorator. - - - Fix a possible security issue with ``acl`` groups. The issue is follow: the default behavior is - to add ``user_id`` to groups for authenticated users by the acl middleware, but if - ``user_id`` is equal to some of acl groups that user suddenly has the permissions he is not - allowed for. So to avoid this kind of issue ``user_id`` is not added to groups any more. - - - Introduce ``AbstractACLGroupsCallback`` class in ``acl`` middleware to make it possible easily create - callable object by inheriting from the abstract class and implementing ``acl_groups`` method. It - can be useful to store additional information (such database connection etc.) within such class. - An instance of this subclass can be used in place of ``acl_groups_callback`` parameter. - -- ``auth`` middleware: - - - Add ``setup`` function for ``auth`` middleware to install it in aiohttp fashion. - - - ``auth.auth_required`` raised now a ``web.HTTPUnauthorized`` instead of a ``web.HTTPForbidden``. - -- Introduce generic authorization middleware ``autz`` that performs authorization through the same - interface (``autz.permit`` coroutine and ``autz_required`` decorator) but using different policies. - Middleware has the ACL authorization as the built in policy which works in the same way as ``acl`` - middleware. Users are free to add their own custom policies or to modify ACL one. - -- Add global ``aiohttp_auth.setup`` function to install ``auth`` and ``autz`` middlewares at once - in aiohttp fashion. - -- Add docs. - -- Rewrite tests using ``pytest`` and ``pytest-aiohttp``. +.. include:: ../CHANGELOG.rst diff --git a/docs/conf.py b/docs/conf.py index d39bf63..a3b8fc3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -89,7 +89,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'alabaster' +html_theme = 'default' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the diff --git a/docs/getting-started.rst b/docs/getting-started.rst index 1b17c17..b231e65 100644 --- a/docs/getting-started.rst +++ b/docs/getting-started.rst @@ -8,11 +8,16 @@ with an aiohttp application. import asyncio + from os import urandom + + import aiohttp_auth + from aiohttp import web - from aiohttp_auth import auth, autz, Permission - from aiohttp_auth.auth import auth_required + from aiohttp_auth import auth, autz + from aiohttp_auth.auth import auth_required from aiohttp_auth.autz import autz_required from aiohttp_auth.autz.policy import acl + from aiohttp_auth.permissions import Permission, Group db = { 'bob': { @@ -24,11 +29,13 @@ with an aiohttp application. 'groups': ['guest'] } } - + # global ACL context context = [(Permission.Allow, 'guest', {'view', }), - (Permission.Deny, 'guest', {'edit', }), - (Permission.Allow, 'staff', {'view', 'edit', 'admin_view'})] + (Permission.Deny, 'guest', {'edit', }), + (Permission.Allow, 'staff', {'view', 'edit', 'admin_view'}), + (Permission.Allow, Group.Everyone, {'view_home', })] + # create an ACL authorization policy class class ACLAutzPolicy(acl.AbstractACLAutzPolicy): @@ -54,16 +61,17 @@ with an aiohttp application. # implement application specific logic here user = self.db.get(user_identity, None) if user is None: - return None + # return empty set of groups for not authenticated users + # middleware will fill it with Group.Everyone + return set() return user['groups'] - async def login(request): - post = await request.post() - user_identity = post.get('username', None) - password = post.get('password', None) + # http://127.0.0.1:8080/login?username=bob&password=bob_password + user_identity = request.GET.get('username', None) + password = request.GET.get('password', None) if user_identity in db and password == db[user_identity]['password']: # remember user identity await auth.remember(request, user_identity) @@ -71,56 +79,68 @@ with an aiohttp application. raise web.HTTPUnauthorized() + # only authenticated users can logout # if user is not authenticated auth_required decorator - # will raise a web.HTTPUnauthorized + # will raise a web.HTTPUnauthorized @auth_required async def logout(request): # forget user identity await auth.forget(request) return web.Response(text='Ok') + # user should have a group with 'admin_view' permission allowed # if he does not autz_required will raise a web.HTTPForbidden @autz_required('admin_view') async def admin(request): return web.Response(text='Admin Page') + + @autz_required('view_home') async def home(request): text = 'Home page.' # check if current user is permitted with 'admin_view' permission if await autz.permit(request, 'admin_view'): - text += ' Go to admin' + text += ' Admin page: http://127.0.0.1:8080/admin' + # get current user identity + user_identity = await auth.get_auth(request) + if user_identity is not None: + # user is authenticated + text += ' Logout: http://127.0.0.1:8080/logout' return web.Response(text=text) + @autz_required('view') async def view(request): return web.Response(text='View Page') - - def init(loop): + + def init_app(loop): app = web.Application(loop=loop) # Create an auth ticket mechanism that expires after 1 minute (60 # seconds), and has a randomly generated secret. Also includes the # optional inclusion of the users IP address in the hash auth_policy = auth.CookieTktAuthentication(urandom(32), 60, - include_ip=True) - - # Create an ACL authorization policy + include_ip=True) + + # Create an ACL authorization policy autz_policy = ACLAutzPolicy(db, context) # setup middlewares in aiohttp fashion aiohttp_auth.setup(app, auth_policy, autz_policy) - app.router.add_post('/login', login) + app.router.add_get('/', home) + app.router.add_get('/login', login) app.router.add_get('/logout', logout) app.router.add_get('/admin', admin) app.router.add_get('/view', view) - app.router.add_get('/', home) - web.run_app(app) + return app loop = asyncio.get_event_loop() - loop.run_until_complete(init(loop)) + app = init_app(loop) + + web.run_app(app, host='127.0.0.1') diff --git a/docs/index.rst b/docs/index.rst index 0d9366d..89aabc5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -30,6 +30,14 @@ This is a fork of `aiohttp_auth ` library that fixes some bugs and security issues and also introduces a generic authorization ``autz`` middleware with built in ACL authorization policy. +Install +------- + +Install ``aiohttp_auth_autz`` using ``pip``:: + + $ pip install aiohttp_auth_autz + + License ------- The library is licensed under a MIT license. From 9bd279e064caa4e324f5d98122c5d0ec3386d0ea Mon Sep 17 00:00:00 2001 From: ilex Date: Tue, 14 Feb 2017 18:20:13 +0200 Subject: [PATCH 35/52] Bump release version 0.2.0. --- CHANGELOG.rst | 2 +- aiohttp_auth/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9f7fb7c..8cf2d33 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,7 +1,7 @@ Changelog ========= -0.2.0 (XXXX-XX-XX) +0.2.0 (2017-02-14) ------------------ - ``acl`` middleware: diff --git a/aiohttp_auth/__init__.py b/aiohttp_auth/__init__.py index 03df6f2..30b21fd 100644 --- a/aiohttp_auth/__init__.py +++ b/aiohttp_auth/__init__.py @@ -5,7 +5,7 @@ from . import auth, autz -__version__ = '0.2.0.rc0' +__version__ = '0.2.0' def setup(app, auth_policy, autz_policy): From 6f877e3d1f8b235c2cd5e9aa85d6e2cd94c4271e Mon Sep 17 00:00:00 2001 From: ilex Date: Tue, 14 Feb 2017 23:05:57 +0200 Subject: [PATCH 36/52] docs: Add python 3.5 env to build API docs with rtd. Readthedocs requires ``conda`` with python 3.5 to build API docs. --- environment.yml | 15 +++++++++++++++ readthedocs.yml | 2 ++ 2 files changed, 17 insertions(+) create mode 100644 environment.yml create mode 100644 readthedocs.yml diff --git a/environment.yml b/environment.yml new file mode 100644 index 0000000..c4b951a --- /dev/null +++ b/environment.yml @@ -0,0 +1,15 @@ +name: py35 +dependencies: + - openssl=1.0.2g=0 + - pip=8.1.1=py35_0 + - python=3.5.1=0 + - readline=6.2=2 + - setuptools=20.3=py35_0 + - sqlite=3.9.2=0 + - tk=8.5.18=0 + - wheel=0.29.0=py35_0 + - xz=5.0.5=1 + - zlib=1.2.8=0 + - pip: + - aiohttp + - ticket_auth==0.1.4 diff --git a/readthedocs.yml b/readthedocs.yml new file mode 100644 index 0000000..5d3b36c --- /dev/null +++ b/readthedocs.yml @@ -0,0 +1,2 @@ +conda: + file: environment.yml From 35732f6a46aae88e2a6aa84a5f88c192f8d359cc Mon Sep 17 00:00:00 2001 From: ilex Date: Tue, 14 Feb 2017 23:19:27 +0200 Subject: [PATCH 37/52] Bump post release version 0.2.0.post0. --- aiohttp_auth/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiohttp_auth/__init__.py b/aiohttp_auth/__init__.py index 30b21fd..ab7e6d9 100644 --- a/aiohttp_auth/__init__.py +++ b/aiohttp_auth/__init__.py @@ -5,7 +5,7 @@ from . import auth, autz -__version__ = '0.2.0' +__version__ = '0.2.0.post0' def setup(app, auth_policy, autz_policy): From 0a9def1abcddf139ca5ce42a3019e41ae8621f86 Mon Sep 17 00:00:00 2001 From: ilex Date: Wed, 15 Feb 2017 13:42:38 +0200 Subject: [PATCH 38/52] New development version 0.2.1.dev0. --- CHANGELOG.rst | 3 +++ aiohttp_auth/__init__.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8cf2d33..4ff2023 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,9 @@ Changelog ========= +0.2.1 (XXXX-XX-XX) +------------------ + 0.2.0 (2017-02-14) ------------------ diff --git a/aiohttp_auth/__init__.py b/aiohttp_auth/__init__.py index ab7e6d9..4bc3c6a 100644 --- a/aiohttp_auth/__init__.py +++ b/aiohttp_auth/__init__.py @@ -5,7 +5,7 @@ from . import auth, autz -__version__ = '0.2.0.post0' +__version__ = '0.2.1.dev0' def setup(app, auth_policy, autz_policy): From cf991b7a6bcde9e24e4eff2b124d26f9c6fce846 Mon Sep 17 00:00:00 2001 From: ilex Date: Wed, 15 Feb 2017 13:43:58 +0200 Subject: [PATCH 39/52] Explicitly set yarl version to 0.9.0. As current aiohttp version is not ready to work with yarl>=0.9.0 it requires to explicitly set version of the yarl lib. --- requirements-dev.txt | 1 + requirements.txt | 1 + setup.py | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 17c5865..714e56c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,5 @@ aiohttp +yarl==0.9.0 ticket_auth==0.1.4 pytest pytest-aiohttp diff --git a/requirements.txt b/requirements.txt index 9dcbe7d..502939d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ aiohttp ticket_auth==0.1.4 +yarl==0.9.0 diff --git a/setup.py b/setup.py index e3f46ea..f188a7d 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import setup, find_packages -install_requires = ['aiohttp', 'ticket_auth==0.1.4'] +install_requires = ['aiohttp', 'ticket_auth==0.1.4', 'yarl==0.9.0'] tests_require = ['pytest', 'pytest-aiohttp', 'pytest-cov', 'aiohttp_session'] From f74435c07501088a70d6a7691c3f50f7c1b16841 Mon Sep 17 00:00:00 2001 From: ilex Date: Wed, 15 Feb 2017 13:58:05 +0200 Subject: [PATCH 40/52] autz: Simplify ACL policy. - Move permit logic from context classes to AbstractACLAutzPolicy. - Remove AbstractACLContext, NaiveACLContext and ACLContex classes. - Change policy.acl module docstrings to reflect changes. --- CHANGELOG.rst | 10 ++ aiohttp_auth/autz/policy/acl.py | 160 ++++++-------------------------- tests/test_autz_acl_policy.py | 110 ++++++++++------------ 3 files changed, 87 insertions(+), 193 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4ff2023..e3672f7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,6 +3,16 @@ Changelog 0.2.1 (XXXX-XX-XX) ------------------ +- ``autz`` middleware: + + - Simplify ``acl`` authorization policy by moving permit logic into ``policy.acl.AbstractACLAutzPolicy``. + + - Remove ``policy.acl.AbstractACLContext`` class. + + - Remove ``policy.acl.NaiveACLContext`` class. + + - Remove ``policy.acl.ACLContext`` class. + 0.2.0 (2017-02-14) ------------------ diff --git a/aiohttp_auth/autz/policy/acl.py b/aiohttp_auth/autz/policy/acl.py index 45df3d4..be708ea 100644 --- a/aiohttp_auth/autz/policy/acl.py +++ b/aiohttp_auth/autz/policy/acl.py @@ -1,116 +1,14 @@ """ACL authorization policy. -This module introduces: - ``AbstractACLAutzPolicy``: Abstract base class to create ACL authorization - policy class. The subclass should define how to retrieve users - groups. - ``AbstractACLContext``: Abstract base class for ACL context containers. - Context container defines a representation of ACL data structure, - a storage method and how to process ACL context and groups - to authorize user with permissions. - ``NaiveACLContext``: ACL context container which is initialized with list - of ACL tuples and stores them as they are. The implementation - of permit process is the same as used by ``acl_middleware``. - ``ACLContext``: The same as ``NaiveACLContext`` but makes some - transformation of incoming ACL tuples. This may helps with a - perfomance of the permit process. +This module introduces ``AbstractACLAutzPolicy`` - an abstract base class to +create ACL authorization policy class. The subclass should define how to +retrieve user's groups. """ import abc from ...acl.acl import extend_user_groups, get_groups_permitted from ..abc import AbstractAutzPolicy -class AbstractACLContext(abc.ABC): - """Abstract base class for all ACL context classes. - - Policy uses context classes to wrap sequence of ACL groups - permissions. This allows to implement different type of structures - to store permissions and/or to proccess them in a specific way. - - The only required method to implement in subclasses is permit. - """ - - @abc.abstractmethod - async def permit(self, user_identity, groups, permission): - """Check if permission is allowed for groups. - - Args: - user_identity: Identity of the user returned by ``auth.get_auth``. - groups: Some set of groups for which permission is checked for. - permission: Permission that is checked. - - Returns: - ``True`` if permission is allowed, ``False`` otherwise. - """ - pass # pragma: no cover - - -class NaiveACLContext(AbstractACLContext): - """Naive implementation of ACL context. - - This class does not make any transformation of the ACL groups - permissions which are passed through constructor but stores - them as they are. - - It uses the default permit strategy from ``acl_middleware``. - """ - - def __init__(self, context): - """Constructor. - - Args: - context: is a sequence of ACL tuples which consist of - an ``Allow``/``Deny`` action, a group, and a sequence of - permissions for that ACL group. For example: - - .. code-block:: python - - context = [(Permission.Allow, 'view_group', ('view',)), - (Permission.Allow, 'edit_group', ('view', - 'edit')),] - """ - self._context = context - - async def extended_user_groups(self, user_identity, groups): - """Extend user groups with ACL specific groups. - - See ``acl_middleware`` for more information. - """ - return extend_user_groups(user_identity, groups) - - async def permit(self, user_identity, groups, permission): - """Permit accoding to ``alc_middleware`` strategy. - - Args: - user_identity: Identity of the user returned by ``auth.get_auth``. - groups: Set of user groups. - permission: Permission that is checked. - - Returns: - ``True`` if permission is allowed, ``False`` otherwise. - """ - groups = await self.extended_user_groups(user_identity, groups) - return get_groups_permitted(groups, permission, self._context) - - -class ACLContext(NaiveACLContext): - """ACL context implementation. - - This acl context implementation works in the same way as NaiveACLContext - but acl groups permissions context is transformed to meet best perfomance - using permit strategy from acl_middleware. - """ - - def __init__(self, context): - """Constructor. - - Args: - context: The same as for NaiveACLContext. - """ - super().__init__(tuple((action, group, set(permissions)) - for action, group, permissions in context)) - - class AbstractACLAutzPolicy(AbstractAutzPolicy): """Abstract base class for ACL authorization policy. @@ -119,41 +17,42 @@ class AbstractACLAutzPolicy(AbstractAutzPolicy): class. Subclass should implement ``acl_groups`` method to use it with ``autz_middleware``. - Note that an acl context can be specified globally while initializing + Note that an ACL context can be specified globally while initializing policy or locally through ``autz.permit`` function's parameter. A local context will always override a global one while checking permissions. If there is no local context and global context is not set then the ``permit`` method will raise a ``RuntimeError``. - A context is an instance of ``AbstractACLContext`` subclass or a sequence - of ACL tuples which consist of a ``Allow``/``Deny`` action, a group, and a - sequence of permissions for that ACL group. - For example: + A context is a sequence of ACL tuples which consist of an + ``Allow``/``Deny`` action, a group, and a set of permissions for that ACL + group. For example: .. code-block:: python - context = [(Permission.Allow, 'view_group', ('view',)), - (Permission.Allow, 'edit_group', ('view', 'edit')),] + context = [(Permission.Allow, 'view_group', {'view', }), + (Permission.Allow, 'edit_group', {'view', 'edit'}),] ACL tuple sequences are checked in order, with the first tuple that matches the group the user is a member of, and includes the permission passed to the function, to be the matching ACL group. If no ACL group is - found, the permit method returns False. + found, the ``permit`` method returns ``False``. Groups and permissions need only be immutable objects, so can be strings, numbers, enumerations, or other immutable objects. - Note that custom implementation of ``AbstractACLContext`` can be used to - change the context form and the way it is processed. + .. note:: Groups that are returned by ``acl_groups`` (if they are not + ``None``) will then be extended internally with ``Group.Everyone`` and + ``Group.AuthenticatedUser``. Usage example: .. code-block:: python from aiohttp import web - from aiohttp_auth import autz, Permission + from aiohttp_auth import autz from aiohttp_auth.autz import autz_required from aiohttp_auth.autz.policy import acl + from aiohttp_auth.permissions import Permission class ACLAutzPolicy(acl.AbstractACLAutzPolicy): @@ -187,7 +86,7 @@ def init(loop): autz.setup(app, ACLAutzPolicy(users, context)) - # authorization using autz decorator applying to app handler + # authorization using autz decorator applying to app request handler @autz_required('view') async def handler_view(request): # authorization using permit @@ -200,14 +99,10 @@ def __init__(self, context=None): """Initialize ACL authorization policy. Args: - context: global ACL context, default to ``None``. Could be an - ``AbstractACLContext`` subclass instance or raw list of ACL - rules. + context: global ACL context, default to ``None``. Should be a list + of ACL rules. """ - if context is None or isinstance(context, AbstractACLContext): - self.context = context - else: - self.context = ACLContext(context) + self.context = context @abc.abstractmethod async def acl_groups(self, user_identity): @@ -220,7 +115,10 @@ async def acl_groups(self, user_identity): user_identity: User identity returned by ``auth.get_auth``. Returns: - Set of ACL groups for the user identity. + Sequence of ACL groups for the user identity (could be empty to + give a chance to ``Group.Everyone`` and + ``Group.AuthenticatedUser``) or ``None`` (``permit`` will always + return ``False``). """ pass # pragma: no cover @@ -250,12 +148,6 @@ async def permit(self, user_identity, permission, context=None): 'acl autz policy or passed as a parameter of ' 'permit function or autz_required decorator.') - if not isinstance(context, AbstractACLContext): - # if there is a raw acl context data - # we use NaiveACLContext wrapper which just - # stores it internally and makes no transformations - # which is important as of the perfomance point. - context = NaiveACLContext(context) - - groups = await self.acl_groups(user_identity) - return await context.permit(user_identity, groups, permission) + groups = extend_user_groups(user_identity, + await self.acl_groups(user_identity)) + return get_groups_permitted(groups, permission, context) diff --git a/tests/test_autz_acl_policy.py b/tests/test_autz_acl_policy.py index 0ea3573..1965eca 100644 --- a/tests/test_autz_acl_policy.py +++ b/tests/test_autz_acl_policy.py @@ -79,7 +79,7 @@ async def handler_test(request): async def test_correct_groups_returned_for_authenticated_user(): policy = ACLAutzPolicy([]) groups = await policy.acl_groups('some_user') - groups = await policy.context.extended_user_groups('some_user', groups) + groups = acl.extend_user_groups('some_user', groups) assert 'group0' in groups assert 'group1' in groups @@ -91,7 +91,7 @@ async def test_correct_groups_returned_for_authenticated_user(): async def test_correct_groups_returned_for_unauthenticated_user(): policy = ACLAutzPolicy([]) groups = await policy.acl_groups(None) - groups = await policy.context.extended_user_groups(None, groups) + groups = acl.extend_user_groups(None, groups) assert 'group0' in groups assert 'group1' in groups @@ -103,15 +103,15 @@ async def test_correct_groups_returned_for_unauthenticated_user(): async def test_no_groups_if_none_returned_from_acl_groups(): policy = NoneACLAutzPolicy([]) groups = await policy.acl_groups(None) - groups = await policy.context.extended_user_groups(None, groups) + groups = acl.extend_user_groups(None, groups) assert groups is None async def test_autz_acl_policy_permit_with_local_context(): - context = [(Permission.Allow, 'group0', ('test0',)), - (Permission.Deny, 'group1', ('test1',)), - (Permission.Allow, Group.Everyone, ('test1',))] + context = [(Permission.Allow, 'group0', {'test0', }), + (Permission.Deny, 'group1', {'test1', }), + (Permission.Allow, Group.Everyone, {'test1', })] policy = ACLAutzPolicy(None) @@ -120,9 +120,9 @@ async def test_autz_acl_policy_permit_with_local_context(): async def test_autz_acl_policy_permit_with_global_context(): - context = [(Permission.Allow, 'group0', ('test0',)), - (Permission.Deny, 'group1', ('test1',)), - (Permission.Allow, Group.Everyone, ('test1',))] + context = [(Permission.Allow, 'group0', {'test0', }), + (Permission.Deny, 'group1', {'test1', }), + (Permission.Allow, Group.Everyone, {'test1', })] policy = ACLAutzPolicy(context) @@ -131,9 +131,9 @@ async def test_autz_acl_policy_permit_with_global_context(): async def test_autz_permit_with_acl_policy_local_context(app, client): - context = [(Permission.Allow, 'group0', ('test0',)), - (Permission.Deny, 'group1', ('test1',)), - (Permission.Allow, Group.Everyone, ('test1',))] + context = [(Permission.Allow, 'group0', {'test0', }), + (Permission.Deny, 'group1', {'test1', }), + (Permission.Allow, Group.Everyone, {'test1', })] async def handler_test(request): assert (await autz.permit(request, 'test0', context)) is True @@ -151,9 +151,9 @@ async def handler_test(request): async def test_autz_permit_with_acl_policy_global_context(app, client): - context = [(Permission.Allow, 'group0', ('test0',)), - (Permission.Deny, 'group1', ('test1',)), - (Permission.Allow, Group.Everyone, ('test1',))] + context = [(Permission.Allow, 'group0', {'test0', }), + (Permission.Deny, 'group1', {'test1', }), + (Permission.Allow, Group.Everyone, {'test1', })] async def handler_test(request): assert (await autz.permit(request, 'test0')) is True @@ -172,10 +172,10 @@ async def handler_test(request): async def test_autz_permit_acl_policy_replace_global_context_with_local( app, client): - context = [(Permission.Deny, 'group1', ('test1', ))] + context = [(Permission.Deny, 'group1', {'test1', })] async def handler_test(request): - local_context = [(Permission.Allow, 'group1', ('test1', ))] + local_context = [(Permission.Allow, 'group1', {'test1', })] # with global context assert (await autz.permit(request, 'test1')) is False @@ -194,9 +194,9 @@ async def handler_test(request): async def test_autz_acl_policy_permission_order(app, client): - context = [(Permission.Allow, Group.Everyone, ('test0',)), - (Permission.Deny, 'group1', ('test1',)), - (Permission.Allow, Group.Everyone, ('test1',))] + context = [(Permission.Allow, Group.Everyone, {'test0', }), + (Permission.Deny, 'group1', {'test1', }), + (Permission.Allow, Group.Everyone, {'test1', })] async def handler_test0(request): assert (await autz.permit(request, 'test0', context)) is True @@ -226,9 +226,9 @@ async def handler_test1(request): async def test_autz_required_decorator_with_acl_policy(loop, app, client): - context = [(Permission.Deny, 'group0', ('test0',)), - (Permission.Allow, 'group0', ('test1',)), - (Permission.Allow, 'group1', ('test0', 'test1'))] + context = [(Permission.Deny, 'group0', {'test0', }), + (Permission.Allow, 'group0', {'test1', }), + (Permission.Allow, 'group1', {'test0', 'test1'})] class CustomACLAutzPolicy(acl.AbstractACLAutzPolicy): def __init__(self, group=None, context=None): @@ -273,8 +273,8 @@ async def handler_test_global(request): async def test_autz_acl_not_matching_acl_group(app, client): - context = [(Permission.Allow, 'group2', ('test0')), - (Permission.Allow, 'group3', ('test0', 'test1'))] + context = [(Permission.Allow, 'group2', {'test0', }), + (Permission.Allow, 'group3', {'test0', 'test1'})] async def handler_test(request): assert (await autz.permit(request, 'test0', context)) is False @@ -312,45 +312,37 @@ async def handler_test(request): await assert_response(cli.get('/test'), 'test') -async def test_autz_acl_naive_context(): - acl_context = [(Permission.Allow, 'group2', ('test0'))] +async def test_autz_acl_permit(): + acl_context = [(Permission.Allow, 'group0', {'test0', 'test2'}), + (Permission.Deny, 'group1', {'test1', }), + (Permission.Allow, 'group0', {'test1', 'test0'}), + (Permission.Allow, 'group1', {'test1', 'test0'})] - context = acl.NaiveACLContext(acl_context) - - assert context._context == acl_context - - -async def test_autz_acl_context(): - acl_context = [(Permission.Allow, 'group0', ('test0'))] - - context = acl.ACLContext(acl_context) + class CustomACLAutzPolicy(acl.AbstractACLAutzPolicy): + def __init__(self, group=None, context=None): + super().__init__(context) + self.group = group - assert context._context != acl_context - assert isinstance(context._context, tuple) - assert isinstance(context._context[0][2], set) + async def acl_groups(self, user_identity): + return (self.group, ) + policy = CustomACLAutzPolicy(group='group0', context=acl_context) -@pytest.mark.parametrize('context_class', (acl.NaiveACLContext, - acl.ACLContext)) -async def test_autz_acl_context_permit(context_class): - acl_context = [(Permission.Allow, 'group0', ('test0', 'test2')), - (Permission.Deny, 'group1', ('test1',)), - (Permission.Allow, 'group0', ('test1', 'test0')), - (Permission.Allow, 'group1', ('test1', 'test0'))] + assert (await policy.permit(None, 'test0')) is True + assert (await policy.permit(None, 'test1')) is True + assert (await policy.permit(None, 'test2')) is True + assert (await policy.permit(None, 'test3')) is False - context = context_class(acl_context) + policy = CustomACLAutzPolicy(group='group1', context=acl_context) - assert (await context.permit(None, {'group0', }, 'test0')) is True - assert (await context.permit(None, {'group0', }, 'test1')) is True - assert (await context.permit(None, {'group0', }, 'test2')) is True - assert (await context.permit(None, {'group0', }, 'test3')) is False + assert (await policy.permit(None, 'test0')) is True + assert (await policy.permit(None, 'test1')) is False + assert (await policy.permit(None, 'test2')) is False + assert (await policy.permit(None, 'test3')) is False - assert (await context.permit(None, {'group1', }, 'test0')) is True - assert (await context.permit(None, {'group1', }, 'test1')) is False - assert (await context.permit(None, {'group1', }, 'test2')) is False - assert (await context.permit(None, {'group1', }, 'test3')) is False + policy = CustomACLAutzPolicy(group='group3', context=acl_context) - assert (await context.permit(None, {'group3', }, 'test1')) is False - assert (await context.permit(None, {'group3', }, 'test2')) is False - assert (await context.permit(None, {'group3', }, 'test3')) is False - assert (await context.permit(None, {'group3', }, 'test4')) is False + assert (await policy.permit(None, 'test1')) is False + assert (await policy.permit(None, 'test2')) is False + assert (await policy.permit(None, 'test3')) is False + assert (await policy.permit(None, 'test4')) is False From 888e123fe94e2180c985110297f8f067be567c6c Mon Sep 17 00:00:00 2001 From: ilex Date: Wed, 15 Feb 2017 14:50:23 +0200 Subject: [PATCH 41/52] docs: Change docs to reflect acl policy changes. --- README.rst | 10 +++--- aiohttp_auth/autz/policy/acl.py | 6 ++-- docs/api/autz.rst | 14 -------- docs/getting-started.rst | 10 +++--- docs/middleware.rst | 62 ++++++++++++++------------------- 5 files changed, 40 insertions(+), 62 deletions(-) diff --git a/README.rst b/README.rst index 3ed1da5..f404f8a 100644 --- a/README.rst +++ b/README.rst @@ -96,20 +96,20 @@ Getting Started async def acl_groups(self, user_identity): """Return acl groups for given user identity. - This method should return a set of groups for given user_identity. + This method should return a sequence of groups for given user_identity. Args: user_identity: User identity returned by auth.get_auth. Returns: - Set of acl groups for the user identity. + Sequence of acl groups for the user identity. """ # implement application specific logic here user = self.db.get(user_identity, None) if user is None: - # return empty set of groups for not authenticated users - # middleware will fill it with Group.Everyone - return set() + # return empty tuple in order to give a chance + # to Group.Everyone + return tuple() return user['groups'] diff --git a/aiohttp_auth/autz/policy/acl.py b/aiohttp_auth/autz/policy/acl.py index be708ea..7397356 100644 --- a/aiohttp_auth/autz/policy/acl.py +++ b/aiohttp_auth/autz/policy/acl.py @@ -41,8 +41,8 @@ class AbstractACLAutzPolicy(AbstractAutzPolicy): numbers, enumerations, or other immutable objects. .. note:: Groups that are returned by ``acl_groups`` (if they are not - ``None``) will then be extended internally with ``Group.Everyone`` and - ``Group.AuthenticatedUser``. + ``None``) will then be extended internally with ``Group.Everyone`` and + ``Group.AuthenticatedUser``. Usage example: @@ -108,7 +108,7 @@ def __init__(self, context=None): async def acl_groups(self, user_identity): """Return ACL groups for given user identity. - Subclass should implement this method to return a set of + Subclass should implement this method to return a sequence of groups for given ``user_identity``. Args: diff --git a/docs/api/autz.rst b/docs/api/autz.rst index 78d6105..2702a99 100644 --- a/docs/api/autz.rst +++ b/docs/api/autz.rst @@ -27,17 +27,3 @@ ACL Authorization Policy .. autoclass:: aiohttp_auth.autz.policy.acl.AbstractACLAutzPolicy :members: :special-members: __init__ - -.. autoclass:: aiohttp_auth.autz.policy.acl.AbstractACLContext - :members: - -.. autoclass:: aiohttp_auth.autz.policy.acl.NaiveACLContext - :members: - :special-members: __init__ - -.. autoclass:: aiohttp_auth.autz.policy.acl.NaiveACLContext - :members: - :special-members: __init__ - :inherited-members: - - diff --git a/docs/getting-started.rst b/docs/getting-started.rst index b231e65..4563531 100644 --- a/docs/getting-started.rst +++ b/docs/getting-started.rst @@ -50,20 +50,20 @@ with an aiohttp application. async def acl_groups(self, user_identity): """Return acl groups for given user identity. - This method should return a set of groups for given user_identity. + This method should return a sequence of groups for given user_identity. Args: user_identity: User identity returned by auth.get_auth. Returns: - Set of acl groups for the user identity. + Sequence of acl groups for the user identity. """ # implement application specific logic here user = self.db.get(user_identity, None) if user is None: - # return empty set of groups for not authenticated users - # middleware will fill it with Group.Everyone - return set() + # return empty tuple in order to give a chance + # to Group.Everyone + return tuple() return user['groups'] diff --git a/docs/middleware.rst b/docs/middleware.rst index 22b08db..479bb86 100644 --- a/docs/middleware.rst +++ b/docs/middleware.rst @@ -184,28 +184,8 @@ ACL authorization policy for autz middleware -------------------------------------------- The ``autz`` plugin has a built in ACL authorization policy in ``autz.policy.acl`` module. -This module introduces a set of classes: - - ``AbstractACLAutzPolicy``: - Abstract base class to create ACL authorization - policy class. The subclass should define how to retrieve users - groups. - - ``AbstractACLContext``: - Abstract base class for ACL context containers. - Context container defines a representation of ACL data structure, - a storage method and how to process ACL context and groups - to authorize user with permissions. - - ``NaiveACLContext``: - ACL context container which is initialized with list - of ACL tuples and stores them as they are. The implementation - of permit process is the same as used by ``acl_middleware``. - - ``ACLContext``: - The same as ``NaiveACLContext`` but makes some transformation - of incoming ACL tuples. This may helps with a perfomance of the permit - process. +This module introduces an ``AbstractACLAutzPolicy`` - the abstract base class to create an ACL +authorization policy class. The subclass should define how to retrieve user's groups. As the library does not know how to get groups for user and it is always up to application, it provides abstract authorization ACL policy @@ -215,24 +195,39 @@ class. Subclass should implement ``acl_groups`` method to use it with Note that an ACL context can be specified globally while initializing policy or locally through ``autz.permit`` function's parameter. A local context will always override a global one while checking permissions. -If there is no local context and global context is not set then a permit -method will raise a ``RuntimeError``. +If there is no local context and global context is not set then the +``permit`` method will raise a ``RuntimeError``. + +A context is a sequence of ACL tuples which consist of an +``Allow``/``Deny`` action, a group, and a set of permissions for that ACL +group. For example: + +.. code-block:: python + + context = [(Permission.Allow, 'view_group', {'view', }), + (Permission.Allow, 'edit_group', {'view', 'edit'}),] + +ACL tuple sequences are checked in order, with the first tuple that +matches the group the user is a member of, and includes the permission +passed to the function, to be the matching ACL group. If no ACL group is +found, the ``permit`` method returns ``False``. -A context is an instance of ``AbstractACLContext`` subclass or a sequence of -ACL tuples which consist of a ``Allow``/``Deny`` action, a group, and a sequence -of permissions for that ACL group (see `ACL Middleware Usage`_). +Groups and permissions need only be immutable objects, so can be strings, +numbers, enumerations, or other immutable objects. -Note that custom implementation of ``AbstractACLContext`` can be used to -change the context form and the way it is processed. +.. note:: Groups that are returned by ``acl_groups`` (if they are not + ``None``) will then be extended internally with ``Group.Everyone`` and + ``Group.AuthenticatedUser``. Usage example: .. code-block:: python from aiohttp import web - from aiohttp_auth import autz, Permission + from aiohttp_auth import autz from aiohttp_auth.autz import autz_required from aiohttp_auth.autz.policy import acl + from aiohttp_auth.permissions import Permission # create an acl authorization policy class @@ -250,13 +245,13 @@ Usage example: async def acl_groups(self, user_identity): """Return acl groups for given user identity. - This method should return a set of groups for given user_identity. + This method should return a sequence of groups for given user_identity. Args: user_identity: User identity returned by auth.get_auth. Returns: - Set of acl groups for the user identity. + Sequence of acl groups (possibly empty) for the user identity or None. """ # implement application specific logic here user = self.users.get(user_identity, None) @@ -278,8 +273,6 @@ Usage example: # autz_required decorator using local context explicitly. context = [(Permission.Allow, 'view_group', {'view', }), (Permission.Allow, 'edit_group', {'view', 'edit'})] - # this raw context will be wrapped by ACLContext container internally - # you can explicitly create acl context class you need and pass it here autz_policy = ACLAutzPolicy(users, context) # install auth and autz middleware in aiohttp fashion @@ -294,7 +287,6 @@ Usage example: pass - # raw local context will wrapped with NaiveACLContext container internally local_context = [(Permission.Deny, 'view_group', {'view', })] # authorization using autz decorator applying to app handler From e90b425192b0e280f212142d1b1588bb30e385a5 Mon Sep 17 00:00:00 2001 From: ilex Date: Thu, 16 Feb 2017 10:26:54 +0200 Subject: [PATCH 42/52] Remove yarl from requirements. Yarl is fixed so no need in certain version. --- requirements-dev.txt | 1 - requirements.txt | 1 - setup.py | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 714e56c..17c5865 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,4 @@ aiohttp -yarl==0.9.0 ticket_auth==0.1.4 pytest pytest-aiohttp diff --git a/requirements.txt b/requirements.txt index 502939d..9dcbe7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,2 @@ aiohttp ticket_auth==0.1.4 -yarl==0.9.0 diff --git a/setup.py b/setup.py index f188a7d..e3f46ea 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import setup, find_packages -install_requires = ['aiohttp', 'ticket_auth==0.1.4', 'yarl==0.9.0'] +install_requires = ['aiohttp', 'ticket_auth==0.1.4'] tests_require = ['pytest', 'pytest-aiohttp', 'pytest-cov', 'aiohttp_session'] From 06aeb4b6197ae765072b2e3a1bef314eca752399 Mon Sep 17 00:00:00 2001 From: ilex Date: Thu, 16 Feb 2017 10:43:06 +0200 Subject: [PATCH 43/52] Add Makefile for routine. --- .gitignore | 2 ++ Makefile | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 Makefile diff --git a/.gitignore b/.gitignore index abfb3ab..ffd2c27 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ dist *.egg *.eggs *.egg-info +docs/_build +.python-version diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..666d167 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +tox: clean test +install: + pip install -r requirements-dev.txt +clean: + find . -type f -name "*.py[co]" -delete + find . -type d -name "__pycache__" -delete + rm -r -f *.egg-info + rm -r -f .cache + rm -r -f .eggs + rm -r -f .tox +sdist: clean + python setup.py sdist +test: + rm -f .python-version + tox + pyenv local aiolibs +doc: + cd docs && make html +testpypi: clean + pip install wheel + python setup.py sdist bdist_wheel upload -r testpypi +pypi: + pip install wheel + python setup.py sdist bdist_wheel upload From 451499226f3dfad170b4113c357f5370960e0e59 Mon Sep 17 00:00:00 2001 From: ilex Date: Thu, 16 Feb 2017 11:01:16 +0200 Subject: [PATCH 44/52] Improve make tasks. --- .gitignore | 1 + Makefile | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ffd2c27..8f240f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ __pycache__ dist +build .cache .coverage .tox diff --git a/Makefile b/Makefile index 666d167..305cd27 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,8 @@ clean: rm -r -f .cache rm -r -f .eggs rm -r -f .tox + rm -r -f build + rm -r -f dist sdist: clean python setup.py sdist test: @@ -19,6 +21,6 @@ doc: testpypi: clean pip install wheel python setup.py sdist bdist_wheel upload -r testpypi -pypi: +pypi: clean pip install wheel python setup.py sdist bdist_wheel upload From 98a32b0c80a5a5e8e8348fc407154ac8660f3878 Mon Sep 17 00:00:00 2001 From: ilex Date: Thu, 16 Feb 2017 11:03:43 +0200 Subject: [PATCH 45/52] Bump release version 0.2.1. --- CHANGELOG.rst | 2 +- aiohttp_auth/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e3672f7..7a3b8e0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,7 +1,7 @@ Changelog ========= -0.2.1 (XXXX-XX-XX) +0.2.1 (2017-02-16) ------------------ - ``autz`` middleware: diff --git a/aiohttp_auth/__init__.py b/aiohttp_auth/__init__.py index 4bc3c6a..94eb90e 100644 --- a/aiohttp_auth/__init__.py +++ b/aiohttp_auth/__init__.py @@ -5,7 +5,7 @@ from . import auth, autz -__version__ = '0.2.1.dev0' +__version__ = '0.2.1' def setup(app, auth_policy, autz_policy): From 762bf812457e97dde4cf638e580feec21455e876 Mon Sep 17 00:00:00 2001 From: ilex Date: Tue, 18 Apr 2017 12:47:40 +0300 Subject: [PATCH 46/52] Move to aiohttp 2.x and add decorators support for aiohttp.web.View. - Move to aiohttp 2.x. - Add support of middlewares decorators for class based views. - Correct code in order to meet requirements of aiohttp 2.x. - Add uvloop as IO loop in tests. --- CHANGELOG.rst | 9 +++++++ aiohttp_auth/__init__.py | 2 +- aiohttp_auth/acl/decorators.py | 4 +++- aiohttp_auth/auth/cookie_ticket_auth.py | 8 +++---- aiohttp_auth/auth/decorators.py | 5 +++- aiohttp_auth/auth/ticket_auth.py | 18 +++++++------- aiohttp_auth/autz/decorators.py | 4 +++- environment.yml | 2 +- requirements-dev.txt | 3 ++- requirements.txt | 2 +- setup.py | 5 ++-- tests/test_acl.py | 12 +++++++++- tests/test_auth.py | 31 +++++++++++++++++++++---- tests/test_autz_acl_policy.py | 11 +++++++++ tox.ini | 1 + 15 files changed, 90 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7a3b8e0..aabf122 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,15 @@ Changelog ========= +0.2.2 (XXXX-XX-XX) +------------------ + +- Move to ``aiohttp`` 2.x. + +- Add support of middlewares decorators for ``aiohttp.web.View`` handlers. + +- Add ``uvloop`` as IO loop for tests. + 0.2.1 (2017-02-16) ------------------ - ``autz`` middleware: diff --git a/aiohttp_auth/__init__.py b/aiohttp_auth/__init__.py index 94eb90e..18582d7 100644 --- a/aiohttp_auth/__init__.py +++ b/aiohttp_auth/__init__.py @@ -5,7 +5,7 @@ from . import auth, autz -__version__ = '0.2.1' +__version__ = '0.2.2.dev0' def setup(app, auth_policy, autz_policy): diff --git a/aiohttp_auth/acl/decorators.py b/aiohttp_auth/acl/decorators.py index 234effa..79057ec 100644 --- a/aiohttp_auth/acl/decorators.py +++ b/aiohttp_auth/acl/decorators.py @@ -32,7 +32,9 @@ def decorator(func): @wraps(func) async def wrapper(*args): - request = args[-1] + request = (args[-1].request + if isinstance(args[-1], web.View) + else args[-1]) context_value = context() if callable(context) else context diff --git a/aiohttp_auth/auth/cookie_ticket_auth.py b/aiohttp_auth/auth/cookie_ticket_auth.py index 37cce9e..5c63971 100644 --- a/aiohttp_auth/auth/cookie_ticket_auth.py +++ b/aiohttp_auth/auth/cookie_ticket_auth.py @@ -50,7 +50,7 @@ async def process_response(self, request, response): associated cookie is deleted instead. This function requires the response to be a aiohttp Response object, - and assumes that the response has not started if the remember or + and assumes that the response has not prepared if the remember or forget functions are called during the request. Args: @@ -58,12 +58,12 @@ async def process_response(self, request, response): response: response object returned from the handled view Raises: - RuntimeError: Raised if response has already started. + RuntimeError: Raised if response has already prepared. """ await super().process_response(request, response) if COOKIE_AUTH_KEY in request: - if response.started: - raise RuntimeError("Cannot save cookie into started response") + if response.prepared: + raise RuntimeError("Cannot save cookie into prepared response") cookie = request[COOKIE_AUTH_KEY] if cookie == '': diff --git a/aiohttp_auth/auth/decorators.py b/aiohttp_auth/auth/decorators.py index 0f05b1c..ae6a17e 100644 --- a/aiohttp_auth/auth/decorators.py +++ b/aiohttp_auth/auth/decorators.py @@ -36,7 +36,10 @@ async def view_func(request): """ @wraps(func) async def wrapper(*args): - if (await get_auth(args[-1])) is None: + request = (args[-1].request + if isinstance(args[-1], web.View) + else args[-1]) + if (await get_auth(request)) is None: raise web.HTTPUnauthorized() return await func(*args) diff --git a/aiohttp_auth/auth/ticket_auth.py b/aiohttp_auth/auth/ticket_auth.py index e9451e1..d816912 100644 --- a/aiohttp_auth/auth/ticket_auth.py +++ b/aiohttp_auth/auth/ticket_auth.py @@ -44,8 +44,8 @@ def __init__( self._ticket = TicketFactory(secret) self._max_age = max_age if (self._max_age is not None and - reissue_time is not None and - reissue_time < self._max_age): + reissue_time is not None and + reissue_time < self._max_age): self._reissue_time = max_age - reissue_time else: self._reissue_time = None @@ -106,10 +106,10 @@ async def get(self, request): # Check if we need to reissue a ticket if (self._reissue_time is not None and - now >= (fields.valid_until - self._reissue_time)): - + now >= (fields.valid_until - self._reissue_time)): # Reissue our ticket, and save it in our request. - request[_REISSUE_KEY] = self._new_ticket(request, fields.user_id) + request[_REISSUE_KEY] = self._new_ticket(request, + fields.user_id) return fields.user_id @@ -117,13 +117,13 @@ async def get(self, request): return None async def process_response(self, request, response): - """If a reissue was requested, only reiisue if the response was a + """If a reissue was requested, only reissue if the response was a valid 2xx response """ if _REISSUE_KEY in request: - if (response.started or - not isinstance(response, web.Response) or - response.status < 200 or response.status > 299): + if (response.prepared or + not isinstance(response, web.Response) or + response.status < 200 or response.status > 299): return await self.remember_ticket(request, request[_REISSUE_KEY]) diff --git a/aiohttp_auth/autz/decorators.py b/aiohttp_auth/autz/decorators.py index c651b0d..91ef6b5 100644 --- a/aiohttp_auth/autz/decorators.py +++ b/aiohttp_auth/autz/decorators.py @@ -35,7 +35,9 @@ def decorator(func): @wraps(func) async def wrapper(*args): - request = args[-1] + request = (args[-1].request + if isinstance(args[-1], web.View) + else args[-1]) if await autz.permit(request, permission, context): return await func(*args) diff --git a/environment.yml b/environment.yml index c4b951a..262951f 100644 --- a/environment.yml +++ b/environment.yml @@ -11,5 +11,5 @@ dependencies: - xz=5.0.5=1 - zlib=1.2.8=0 - pip: - - aiohttp + - aiohttp>=2.0.7 - ticket_auth==0.1.4 diff --git a/requirements-dev.txt b/requirements-dev.txt index 17c5865..d40b7f6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,7 +1,8 @@ -aiohttp +aiohttp>=2.0.7 ticket_auth==0.1.4 pytest pytest-aiohttp pytest-cov aiohttp_session +uvloop Sphinx diff --git a/requirements.txt b/requirements.txt index 9dcbe7d..0f30466 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -aiohttp +aiohttp>=2.0.7 ticket_auth==0.1.4 diff --git a/setup.py b/setup.py index e3f46ea..5d8aa80 100644 --- a/setup.py +++ b/setup.py @@ -4,10 +4,11 @@ from setuptools import setup, find_packages -install_requires = ['aiohttp', 'ticket_auth==0.1.4'] +install_requires = ['aiohttp>=2.0.7', 'ticket_auth==0.1.4'] -tests_require = ['pytest', 'pytest-aiohttp', 'pytest-cov', 'aiohttp_session'] +tests_require = ['pytest', 'pytest-aiohttp', 'pytest-cov', + 'aiohttp_session', 'uvloop'] def version(): diff --git a/tests/test_acl.py b/tests/test_acl.py index 3a4e9f8..a341753 100644 --- a/tests/test_acl.py +++ b/tests/test_acl.py @@ -207,22 +207,32 @@ def __call__(self, user_id): async def handler_test(request): return web.Response(text='test') + class MyView(web.View): + @acl.acl_required('test0', context) + async def get(self): + return web.Response(text='test_view') + groups_callback = GroupsCallback() acl.setup(app, groups_callback) app.router.add_get('/test', handler_test) + app.router.add_route('*', '/test_view', MyView) cli = await client(app) response = await cli.get('/test') assert response.status == 403 + response = await cli.get('/test_view') + assert response.status == 403 groups_callback.group = 'group0' response = await cli.get('/test') assert response.status == 403 + response = await cli.get('/test_view') + assert response.status == 403 groups_callback.group = 'group1' - response = await cli.get('/test') await assert_response(cli.get('/test'), 'test') + await assert_response(cli.get('/test_view'), 'test_view') async def test_acl_not_matching_acl_group(app, client): diff --git a/tests/test_auth.py b/tests/test_auth.py index 57fd33f..71d97aa 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,7 +1,7 @@ import asyncio from os import urandom import pytest -from aiohttp import web +from aiohttp import web, ServerDisconnectedError from aiohttp_auth import auth import aiohttp_session from utils import assert_response @@ -276,7 +276,30 @@ async def handler_test(request): assert response.status == 200 -async def test_middleware_cannot_store_auth_in_cookie_when_response_started( +async def test_middleware_auth_required_decorator_with_view(app, client): + class MyView(web.View): + @auth.auth_required + async def get(self): + return web.Response(text='test') + + secret = b'01234567890abcdef' + policy = auth.CookieTktAuthentication(secret, 120, cookie_name='auth') + + auth.setup(app, policy) + app.router.add_route('*', '/test', MyView) + + cli = await client(app) + + response = await assert_response(cli.get('/test'), '401: Unauthorized') + assert response.status == 401 + + response = await assert_response(cli.get('/remember'), 'remember') + + response = await assert_response(cli.get('/test'), 'test') + assert response.status == 200 + + +async def test_middleware_cannot_store_auth_in_cookie_when_response_prepared( app, client): async def handler_test(request): await auth.remember(request, 'some_user') @@ -292,5 +315,5 @@ async def handler_test(request): cli = await client(app) - response = await cli.get('/test') - assert policy.cookie_name not in response.cookies + with pytest.raises(ServerDisconnectedError): + await cli.get('/test') diff --git a/tests/test_autz_acl_policy.py b/tests/test_autz_acl_policy.py index 1965eca..641822d 100644 --- a/tests/test_autz_acl_policy.py +++ b/tests/test_autz_acl_policy.py @@ -249,10 +249,16 @@ async def handler_test(request): async def handler_test_global(request): return web.Response(text='test_global') + class MyView(web.View): + @autz_required('test0', context) + async def get(self): + return web.Response(text='test_view') + policy = CustomACLAutzPolicy(context=context) autz.setup(app, policy) app.router.add_get('/test', handler_test) app.router.add_get('/test_global', handler_test_global) + app.router.add_route('*', '/test_view', MyView) cli = await client(app) @@ -260,16 +266,21 @@ async def handler_test_global(request): assert response.status == 403 response = await cli.get('/test_global') assert response.status == 403 + response = await cli.get('/test_view') + assert response.status == 403 policy.group = 'group0' response = await cli.get('/test') assert response.status == 403 response = await cli.get('/test_global') assert response.status == 403 + response = await cli.get('/test_view') + assert response.status == 403 policy.group = 'group1' await assert_response(cli.get('/test'), 'test') await assert_response(cli.get('/test_global'), 'test_global') + await assert_response(cli.get('/test_view'), 'test_view') async def test_autz_acl_not_matching_acl_group(app, client): diff --git a/tox.ini b/tox.ini index b74b4e2..cbbd8be 100644 --- a/tox.ini +++ b/tox.ini @@ -7,4 +7,5 @@ deps= pytest-aiohttp pytest-cov aiohttp_session + uvloop commands=py.test -v --cov-report=term-missing --cov=aiohttp_auth --cov=tests tests From b1fb2805ae10139cfd0a6fc1d08340b8d13d483e Mon Sep 17 00:00:00 2001 From: ilex Date: Tue, 18 Apr 2017 12:52:48 +0300 Subject: [PATCH 47/52] Improve docs. --- README.rst | 142 +-------------------------------------- docs/getting-started.rst | 27 +++++--- 2 files changed, 18 insertions(+), 151 deletions(-) diff --git a/README.rst b/README.rst index f404f8a..149d298 100644 --- a/README.rst +++ b/README.rst @@ -50,147 +50,7 @@ Install ``aiohttp_auth_autz`` using ``pip``:: Getting Started --------------- -.. code-block:: python - - import asyncio - - from os import urandom - - import aiohttp_auth - - from aiohttp import web - from aiohttp_auth import auth, autz - from aiohttp_auth.auth import auth_required - from aiohttp_auth.autz import autz_required - from aiohttp_auth.autz.policy import acl - from aiohttp_auth.permissions import Permission, Group - - db = { - 'bob': { - 'password': 'bob_password', - 'groups': ['guest', 'staff'] - }, - 'alice': { - 'password': 'alice_password', - 'groups': ['guest'] - } - } - - # global ACL context - context = [(Permission.Allow, 'guest', {'view', }), - (Permission.Deny, 'guest', {'edit', }), - (Permission.Allow, 'staff', {'view', 'edit', 'admin_view'}), - (Permission.Allow, Group.Everyone, {'view_home', })] - - - # create an ACL authorization policy class - class ACLAutzPolicy(acl.AbstractACLAutzPolicy): - """The concrete ACL authorization policy.""" - - def __init__(self, db, context=None): - # do not forget to call parent __init__ - super().__init__(context) - - self.db = db - - async def acl_groups(self, user_identity): - """Return acl groups for given user identity. - - This method should return a sequence of groups for given user_identity. - - Args: - user_identity: User identity returned by auth.get_auth. - - Returns: - Sequence of acl groups for the user identity. - """ - # implement application specific logic here - user = self.db.get(user_identity, None) - if user is None: - # return empty tuple in order to give a chance - # to Group.Everyone - return tuple() - - return user['groups'] - - - async def login(request): - # http://127.0.0.1:8080/login?username=bob&password=bob_password - user_identity = request.GET.get('username', None) - password = request.GET.get('password', None) - if user_identity in db and password == db[user_identity]['password']: - # remember user identity - await auth.remember(request, user_identity) - return web.Response(text='Ok') - - raise web.HTTPUnauthorized() - - - # only authenticated users can logout - # if user is not authenticated auth_required decorator - # will raise a web.HTTPUnauthorized - @auth_required - async def logout(request): - # forget user identity - await auth.forget(request) - return web.Response(text='Ok') - - - # user should have a group with 'admin_view' permission allowed - # if he does not autz_required will raise a web.HTTPForbidden - @autz_required('admin_view') - async def admin(request): - return web.Response(text='Admin Page') - - - @autz_required('view_home') - async def home(request): - text = 'Home page.' - # check if current user is permitted with 'admin_view' permission - if await autz.permit(request, 'admin_view'): - text += ' Admin page: http://127.0.0.1:8080/admin' - # get current user identity - user_identity = await auth.get_auth(request) - if user_identity is not None: - # user is authenticated - text += ' Logout: http://127.0.0.1:8080/logout' - return web.Response(text=text) - - - @autz_required('view') - async def view(request): - return web.Response(text='View Page') - - - def init_app(loop): - app = web.Application(loop=loop) - - # Create an auth ticket mechanism that expires after 1 minute (60 - # seconds), and has a randomly generated secret. Also includes the - # optional inclusion of the users IP address in the hash - auth_policy = auth.CookieTktAuthentication(urandom(32), 60, - include_ip=True) - - # Create an ACL authorization policy - autz_policy = ACLAutzPolicy(db, context) - - # setup middlewares in aiohttp fashion - aiohttp_auth.setup(app, auth_policy, autz_policy) - - app.router.add_get('/', home) - app.router.add_get('/login', login) - app.router.add_get('/logout', logout) - app.router.add_get('/admin', admin) - app.router.add_get('/view', view) - - return app - - - loop = asyncio.get_event_loop() - app = init_app(loop) - - web.run_app(app, host='127.0.0.1') - +.. include:: docs/getting-started.rst License ------- diff --git a/docs/getting-started.rst b/docs/getting-started.rst index 4563531..538fd19 100644 --- a/docs/getting-started.rst +++ b/docs/getting-started.rst @@ -32,9 +32,9 @@ with an aiohttp application. # global ACL context context = [(Permission.Allow, 'guest', {'view', }), - (Permission.Deny, 'guest', {'edit', }), - (Permission.Allow, 'staff', {'view', 'edit', 'admin_view'}), - (Permission.Allow, Group.Everyone, {'view_home', })] + (Permission.Deny, 'guest', {'edit', }), + (Permission.Allow, 'staff', {'view', 'edit', 'admin_view'}), + (Permission.Allow, Group.Everyone, {'view_home', })] # create an ACL authorization policy class @@ -111,19 +111,26 @@ with an aiohttp application. return web.Response(text=text) - @autz_required('view') - async def view(request): - return web.Response(text='View Page') + # decorators can work with class based views + class MyView(web.View): + """Class based view.""" + + @autz_required('view') + async def get(self): + # example of permit using + if await autz.permit(self.request, 'view'): + return web.Response(text='View Page') + return web.Response(text='View is not permitted') def init_app(loop): - app = web.Application(loop=loop) + app = web.Application() # Create an auth ticket mechanism that expires after 1 minute (60 # seconds), and has a randomly generated secret. Also includes the # optional inclusion of the users IP address in the hash auth_policy = auth.CookieTktAuthentication(urandom(32), 60, - include_ip=True) + include_ip=True) # Create an ACL authorization policy autz_policy = ACLAutzPolicy(db, context) @@ -135,7 +142,7 @@ with an aiohttp application. app.router.add_get('/login', login) app.router.add_get('/logout', logout) app.router.add_get('/admin', admin) - app.router.add_get('/view', view) + app.router.add_route('*', '/view', MyView) return app @@ -143,4 +150,4 @@ with an aiohttp application. loop = asyncio.get_event_loop() app = init_app(loop) - web.run_app(app, host='127.0.0.1') + web.run_app(app, host='127.0.0.1', loop=loop) From 899a88259b6b7b015b8bf45495ee52d39e729eed Mon Sep 17 00:00:00 2001 From: ilex Date: Tue, 18 Apr 2017 13:13:25 +0300 Subject: [PATCH 48/52] Copy getting started to README.rst. --- README.rst | 152 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 151 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 149d298..3e390ab 100644 --- a/README.rst +++ b/README.rst @@ -50,7 +50,157 @@ Install ``aiohttp_auth_autz`` using ``pip``:: Getting Started --------------- -.. include:: docs/getting-started.rst +A simple example how to use authentication and authorization middleware +with an aiohttp application. + +.. code-block:: python + + import asyncio + + from os import urandom + + import aiohttp_auth + + from aiohttp import web + from aiohttp_auth import auth, autz + from aiohttp_auth.auth import auth_required + from aiohttp_auth.autz import autz_required + from aiohttp_auth.autz.policy import acl + from aiohttp_auth.permissions import Permission, Group + + db = { + 'bob': { + 'password': 'bob_password', + 'groups': ['guest', 'staff'] + }, + 'alice': { + 'password': 'alice_password', + 'groups': ['guest'] + } + } + + # global ACL context + context = [(Permission.Allow, 'guest', {'view', }), + (Permission.Deny, 'guest', {'edit', }), + (Permission.Allow, 'staff', {'view', 'edit', 'admin_view'}), + (Permission.Allow, Group.Everyone, {'view_home', })] + + + # create an ACL authorization policy class + class ACLAutzPolicy(acl.AbstractACLAutzPolicy): + """The concrete ACL authorization policy.""" + + def __init__(self, db, context=None): + # do not forget to call parent __init__ + super().__init__(context) + + self.db = db + + async def acl_groups(self, user_identity): + """Return acl groups for given user identity. + + This method should return a sequence of groups for given user_identity. + + Args: + user_identity: User identity returned by auth.get_auth. + + Returns: + Sequence of acl groups for the user identity. + """ + # implement application specific logic here + user = self.db.get(user_identity, None) + if user is None: + # return empty tuple in order to give a chance + # to Group.Everyone + return tuple() + + return user['groups'] + + + async def login(request): + # http://127.0.0.1:8080/login?username=bob&password=bob_password + user_identity = request.GET.get('username', None) + password = request.GET.get('password', None) + if user_identity in db and password == db[user_identity]['password']: + # remember user identity + await auth.remember(request, user_identity) + return web.Response(text='Ok') + + raise web.HTTPUnauthorized() + + + # only authenticated users can logout + # if user is not authenticated auth_required decorator + # will raise a web.HTTPUnauthorized + @auth_required + async def logout(request): + # forget user identity + await auth.forget(request) + return web.Response(text='Ok') + + + # user should have a group with 'admin_view' permission allowed + # if he does not autz_required will raise a web.HTTPForbidden + @autz_required('admin_view') + async def admin(request): + return web.Response(text='Admin Page') + + + @autz_required('view_home') + async def home(request): + text = 'Home page.' + # check if current user is permitted with 'admin_view' permission + if await autz.permit(request, 'admin_view'): + text += ' Admin page: http://127.0.0.1:8080/admin' + # get current user identity + user_identity = await auth.get_auth(request) + if user_identity is not None: + # user is authenticated + text += ' Logout: http://127.0.0.1:8080/logout' + return web.Response(text=text) + + + # decorators can work with class based views + class MyView(web.View): + """Class based view.""" + + @autz_required('view') + async def get(self): + # example of permit using + if await autz.permit(self.request, 'view'): + return web.Response(text='View Page') + return web.Response(text='View is not permitted') + + + def init_app(loop): + app = web.Application() + + # Create an auth ticket mechanism that expires after 1 minute (60 + # seconds), and has a randomly generated secret. Also includes the + # optional inclusion of the users IP address in the hash + auth_policy = auth.CookieTktAuthentication(urandom(32), 60, + include_ip=True) + + # Create an ACL authorization policy + autz_policy = ACLAutzPolicy(db, context) + + # setup middlewares in aiohttp fashion + aiohttp_auth.setup(app, auth_policy, autz_policy) + + app.router.add_get('/', home) + app.router.add_get('/login', login) + app.router.add_get('/logout', logout) + app.router.add_get('/admin', admin) + app.router.add_route('*', '/view', MyView) + + return app + + + loop = asyncio.get_event_loop() + app = init_app(loop) + + web.run_app(app, host='127.0.0.1', loop=loop) + License ------- From 6d9ec9bdbce707f3238614e12fd54f77ae15e530 Mon Sep 17 00:00:00 2001 From: ilex Date: Tue, 18 Apr 2017 13:21:26 +0300 Subject: [PATCH 49/52] Bump release version 0.2.2. --- CHANGELOG.rst | 2 +- aiohttp_auth/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index aabf122..b5663d7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,7 +1,7 @@ Changelog ========= -0.2.2 (XXXX-XX-XX) +0.2.2 (2017-04-18) ------------------ - Move to ``aiohttp`` 2.x. diff --git a/aiohttp_auth/__init__.py b/aiohttp_auth/__init__.py index 18582d7..e44ce67 100644 --- a/aiohttp_auth/__init__.py +++ b/aiohttp_auth/__init__.py @@ -5,7 +5,7 @@ from . import auth, autz -__version__ = '0.2.2.dev0' +__version__ = '0.2.2' def setup(app, auth_policy, autz_policy): From e6f925b004b60dc7d2f20a2d06d02c4978f9402d Mon Sep 17 00:00:00 2001 From: ilex Date: Thu, 7 Sep 2017 23:17:34 +0300 Subject: [PATCH 50/52] Fix lib missed for readthedocs docs build. --- environment.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/environment.yml b/environment.yml index 262951f..a583db6 100644 --- a/environment.yml +++ b/environment.yml @@ -12,4 +12,5 @@ dependencies: - zlib=1.2.8=0 - pip: - aiohttp>=2.0.7 + - aiohttp_session>=1.0.0 - ticket_auth==0.1.4 From 1c88c1dcd2307208de75dfac5041318ef7c0b90f Mon Sep 17 00:00:00 2001 From: ilex Date: Tue, 6 Mar 2018 21:27:42 +0200 Subject: [PATCH 51/52] Fix missed await in test --- .gitignore | 1 + tests/test_auth.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8f240f6..9c3b13b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ build *.egg-info docs/_build .python-version +.pytest_cache diff --git a/tests/test_auth.py b/tests/test_auth.py index 71d97aa..abb19d5 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -149,7 +149,7 @@ async def test_middleware_gets_auth_from_cookie(app, client): assert text == 'remember' assert policy.cookie_name in response.cookies - assert_response(cli.get('/auth'), 'auth') + await assert_response(cli.get('/auth'), 'auth') @pytest.mark.slow From a5ba033a5013e73d040e65774d0b3ec6743e8580 Mon Sep 17 00:00:00 2001 From: ilex Date: Tue, 6 Mar 2018 22:02:56 +0200 Subject: [PATCH 52/52] Fix tests to meet aiohttp 3.0 Add aiohttp 2.x and 3.x to tox environment --- environment.yml | 2 +- tests/test_auth.py | 6 +++--- tox.ini | 4 +++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/environment.yml b/environment.yml index a583db6..0751e74 100644 --- a/environment.yml +++ b/environment.yml @@ -2,7 +2,7 @@ name: py35 dependencies: - openssl=1.0.2g=0 - pip=8.1.1=py35_0 - - python=3.5.1=0 + - python=3.5.3=0 - readline=6.2=2 - setuptools=20.3=py35_0 - sqlite=3.9.2=0 diff --git a/tests/test_auth.py b/tests/test_auth.py index abb19d5..2b14d1d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,7 +1,7 @@ import asyncio from os import urandom import pytest -from aiohttp import web, ServerDisconnectedError +from aiohttp import web from aiohttp_auth import auth import aiohttp_session from utils import assert_response @@ -315,5 +315,5 @@ async def handler_test(request): cli = await client(app) - with pytest.raises(ServerDisconnectedError): - await cli.get('/test') + with pytest.raises(Exception): + await assert_response(cli.get('/test'), 'test') diff --git a/tox.ini b/tox.ini index cbbd8be..b387070 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,10 @@ [tox] -envlist = py35,py36 +envlist = py{35,36}-aiohttp{20,30} skip_missing_interpreters = True [testenv] deps= + aiohttp20: aiohttp>=2.0.7,<3.0.0 + aiohttp30: aiohttp>=3.0.0 pytest pytest-aiohttp pytest-cov