diff --git a/.gitignore b/.gitignore index 10f0e9d..9c3b13b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,12 @@ __pycache__ dist +build +.cache +.coverage +.tox +*.egg +*.eggs +*.egg-info +docs/_build +.python-version +.pytest_cache 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/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..b5663d7 --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,61 @@ +Changelog +========= + +0.2.2 (2017-04-18) +------------------ + +- 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: + + - 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) +------------------ + +- ``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/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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..305cd27 --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +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 + rm -r -f build + rm -r -f dist +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: clean + pip install wheel + python setup.py sdist bdist_wheel upload diff --git a/README.rst b/README.rst index 95bb038..3e390ab 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,12 @@ -aiohttp_auth -============ +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. @@ -8,218 +15,192 @@ 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. -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. - - -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:: - - 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:: - - 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')) +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.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:: +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.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:: - - def init(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)) +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. - 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) - app.router.add_route('GET', '/test1', check_implicitly_view) - return app +Documentation +------------- -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:: +http://aiohttp-auth-autz.readthedocs.io/ - from aiohttp_session import get_session, session_middleware - from aiohttp_session.cookie_storage import EncryptedCookieStorage - def init(loop): +Install +------- - # 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)) +Install ``aiohttp_auth_autz`` using ``pip``:: - middlewares = [session_middleware(EncryptedCookieStorage(urandom(32))), - auth.auth_middleware(policy)] + $ pip install aiohttp_auth_autz - app = web.Application(loop=loop, middlewares=middlewares) - ... +Getting Started +--------------- +A simple example how to use authentication and authorization middleware +with an aiohttp application. -acl_middleware Usage ---------------------- +.. code-block:: python -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. + import asyncio -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. + from os import urandom -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:: + import aiohttp_auth - from aiohttp_auth import acl + 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') - 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()) + # 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') - def init(loop): - ... - middlewares = [session_middleware(EncryptedCookieStorage(urandom(32))), - auth.auth_middleware(policy), - acl.acl_middleware(acl_group_callback)] + @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) - app = web.Application(loop=loop, middlewares=middlewares) - ... + # decorators can work with class based views + class MyView(web.View): + """Class based view.""" -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. + @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') -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:: - from aiohttp_auth.permissions import Group, Permission + def init_app(loop): + app = web.Application() - context = [(Permission.Allow, Group.Everyone, ('view',)), - (Permission.Allow, Group.AuthenticatedUser, ('view', 'view_extra')), - (Permission.Allow, 'edit_group', ('view', 'view_extra', 'edit')),] + # 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) -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:: + # Create an ACL authorization policy + autz_policy = ACLAutzPolicy(db, context) - @acl_required('view', context) - async def view_view(request): - return web.Response(body='OK'.encode('utf-8')) + # setup middlewares in aiohttp fashion + aiohttp_auth.setup(app, auth_policy, autz_policy) - @acl_required('view_extra', context) - async def view_extra_view(request): - return web.Response(body='OK'.encode('utf-8')) + 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) - @acl_required('edit', context) - async def edit_view(request): - return web.Response(body='OK'.encode('utf-8')) + return app -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. -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:: + loop = asyncio.get_event_loop() + app = init_app(loop) - 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')),] + web.run_app(app, host='127.0.0.1', loop=loop) -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. License ------- diff --git a/aiohttp_auth/__init__.py b/aiohttp_auth/__init__.py index 1264ece..e44ce67 100644 --- a/aiohttp_auth/__init__.py +++ b/aiohttp_auth/__init__.py @@ -1,2 +1,22 @@ +"""Authentication and Authorization plugins for AIOHTTP.""" from .auth import auth_middleware -from .acl import acl_middleware \ No newline at end of file +from .autz import autz_middleware +from .acl import acl_middleware +from . import auth, autz + + +__version__ = '0.2.2' + + +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. + autz_policy: An authorization policy with a base class of + AbstractAutzPolicy + """ + auth.setup(app, auth_policy) + autz.setup(app, autz_policy) 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/abc.py b/aiohttp_auth/acl/abc.py new file mode 100644 index 0000000..e69d1a7 --- /dev/null +++ b/aiohttp_auth/acl/abc.py @@ -0,0 +1,61 @@ +"""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: + + .. 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() + + + 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/aiohttp_auth/acl/acl.py b/aiohttp_auth/acl/acl.py index 3f2069d..89032b4 100644 --- a/aiohttp_auth/acl/acl.py +++ b/aiohttp_auth/acl/acl.py @@ -1,5 +1,5 @@ +"""ACL middleware.""" import itertools -from aiohttp import web from ..auth import get_auth from ..permissions import Permission, Group @@ -8,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: @@ -37,23 +38,23 @@ 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. 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 + RuntimeError: If the ACL middleware is not installed. """ acl_callback = request.get(GROUPS_KEY) if acl_callback is None: @@ -61,21 +62,42 @@ 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 - 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)) 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')),] @@ -89,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 @@ -99,10 +121,24 @@ 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) + + +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 @@ -112,3 +148,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)) diff --git a/aiohttp_auth/acl/decorators.py b/aiohttp_auth/acl/decorators.py index a5ae5bb..79057ec 100644 --- a/aiohttp_auth/acl/decorators.py +++ b/aiohttp_auth/acl/decorators.py @@ -1,40 +1,44 @@ +"""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 + """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 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] + request = (args[-1].request + if isinstance(args[-1], web.View) + else 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 +46,3 @@ async def wrapper(*args): return wrapper return decorator - 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..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: @@ -94,3 +95,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)) 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 50182c2..ae6a17e 100644 --- a/aiohttp_auth/auth/decorators.py +++ b/aiohttp_auth/auth/decorators.py @@ -1,34 +1,47 @@ +"""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:: + + .. 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 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() + 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) return wrapper - 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/__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..377fb09 --- /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: + ``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..5c94f8b --- /dev/null +++ b/aiohttp_auth/autz/autz.py @@ -0,0 +1,98 @@ +"""Authorization middleware.""" +from ..auth import get_auth +from .abc import AbstractAutzPolicy + + +AUTZ_POLICY_KEY = 'aiohttp_auth.autz.policy' + + +def autz_middleware(autz_policy): + """Return 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`` 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``. + + 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 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. + 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..91ef6b5 --- /dev/null +++ b/aiohttp_auth/autz/decorators.py @@ -0,0 +1,49 @@ +"""Authorization decorators.""" +from functools import wraps +from aiohttp import web +from . import autz + + +def autz_required(permission, context=None): + """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`` + 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 ``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 + 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 ``web.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].request + if isinstance(args[-1], web.View) + else args[-1]) + + if await autz.permit(request, permission, context): + return await func(*args) + + raise web.HTTPForbidden() + + return wrapper + + return decorator diff --git a/tests/__init__.py b/aiohttp_auth/autz/policy/__init__.py similarity index 100% rename from tests/__init__.py rename to aiohttp_auth/autz/policy/__init__.py diff --git a/aiohttp_auth/autz/policy/acl.py b/aiohttp_auth/autz/policy/acl.py new file mode 100644 index 0000000..7397356 --- /dev/null +++ b/aiohttp_auth/autz/policy/acl.py @@ -0,0 +1,153 @@ +"""ACL authorization policy. + +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 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``. + + 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 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``. + + Groups and permissions need only be immutable objects, so can be strings, + 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``. + + Usage example: + + .. code-block:: python + + from aiohttp import web + 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): + 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.users = users + + 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 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'})] + autz.setup(app, ACLAutzPolicy(users, context)) + + + # authorization using autz decorator applying to app request handler + @autz_required('view') + async def handler_view(request): + # authorization using permit + if await autz.permit(request, 'edit'): + pass + + """ + + def __init__(self, context=None): + """Initialize ACL authorization policy. + + Args: + context: global ACL context, default to ``None``. Should be a list + of ACL rules. + """ + self.context = 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 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 (could be empty to + give a chance to ``Group.Everyone`` and + ``Group.AuthenticatedUser``) or ``None`` (``permit`` will always + return ``False``). + """ + 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.') + + groups = extend_user_groups(user_identity, + await self.acl_groups(user_identity)) + return get_groups_permitted(groups, permission, context) diff --git a/docs/CHANGELOG.rst b/docs/CHANGELOG.rst new file mode 100644 index 0000000..565b052 --- /dev/null +++ b/docs/CHANGELOG.rst @@ -0,0 +1 @@ +.. include:: ../CHANGELOG.rst 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..2702a99 --- /dev/null +++ b/docs/api/autz.rst @@ -0,0 +1,29 @@ +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__ 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..a3b8fc3 --- /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 = '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 +# 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..538fd19 --- /dev/null +++ b/docs/getting-started.rst @@ -0,0 +1,153 @@ +Getting Started +=============== + +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) diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..89aabc5 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,61 @@ +.. 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. + +Install +------- + +Install ``aiohttp_auth_autz`` using ``pip``:: + + $ pip install aiohttp_auth_autz + + +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..479bb86 --- /dev/null +++ b/docs/middleware.rst @@ -0,0 +1,523 @@ +================== +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 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 +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 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``. + +Groups and permissions need only be immutable objects, so can be strings, +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``. + +Usage example: + +.. code-block:: python + + from aiohttp import web + 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 + 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 sequence of groups for given user_identity. + + Args: + user_identity: User identity returned by auth.get_auth. + + Returns: + Sequence of acl groups (possibly empty) for the user identity or None. + """ + # 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'})] + 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 + + + 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/environment.yml b/environment.yml new file mode 100644 index 0000000..0751e74 --- /dev/null +++ b/environment.yml @@ -0,0 +1,16 @@ +name: py35 +dependencies: + - openssl=1.0.2g=0 + - pip=8.1.1=py35_0 + - python=3.5.3=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>=2.0.7 + - aiohttp_session>=1.0.0 + - 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 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..d40b7f6 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000..0f30466 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +aiohttp>=2.0.7 +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 656dbde..5d8aa80 100644 --- a/setup.py +++ b/setup.py @@ -1,25 +1,47 @@ +"""Setup script.""" +import os.path +import re from setuptools import setup, find_packages -requires = ['aiohttp', - 'ticket_auth',] +install_requires = ['aiohttp>=2.0.7', 'ticket_auth==0.1.4'] -tests_require = ['aiohttp_session'] +tests_require = ['pytest', 'pytest-aiohttp', 'pytest-cov', + 'aiohttp_session', 'uvloop'] + + +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 + + +long_description = '\n'.join((open('README.rst').read(), + open('CHANGELOG.rst').read())) setup( - name="aiohttp_auth", - version='0.1.1-dev', - description='Authorization and authentication middleware plugin for aiohttp.', - long_description=open('README.rst').read(), - install_requires=requires, + name="aiohttp_auth_autz", + version=version(), + description=('Authorization and authentication ' + 'middleware plugin for aiohttp.'), + long_description=long_description, + 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', @@ -28,4 +50,6 @@ 'License :: OSI Approved :: MIT License', 'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: 3.5', - ],) + 'Programming Language :: Python :: 3.6', + ] +) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4bcc08d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +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/tests/test_acl.py b/tests/test_acl.py index b9782a8..a341753 100644 --- a/tests/test_acl.py +++ b/tests/test_acl.py @@ -1,132 +1,343 @@ -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') + + 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' + 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): + 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/tests/test_aiohttp_auth.py b/tests/test_aiohttp_auth.py new file mode 100644 index 0000000..670b633 --- /dev/null +++ b/tests/test_aiohttp_auth.py @@ -0,0 +1,30 @@ +import aiohttp_auth +import aiohttp_session +from aiohttp import web +from aiohttp_auth import autz, auth +from aiohttp_auth.autz.policy import acl + + +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) + + auth_policy = auth.SessionTktAuthentication(secret, 15, + cookie_name='auth') + + class ACLAutzPolicy(acl.AbstractACLAutzPolicy): + async def acl_groups(self, user_identity): + return None # pragma: no cover + + autz_policy = ACLAutzPolicy() + + aiohttp_auth.setup(app, auth_policy, autz_policy) + + middleware = auth.auth_middleware(auth_policy) + assert app.middlewares[-2].__name__ == middleware.__name__ + + middleware = autz.autz_middleware(autz_policy) + assert app.middlewares[-1].__name__ == middleware.__name__ diff --git a/tests/test_auth.py b/tests/test_auth.py index afa6876..2b14d1d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,136 +1,319 @@ -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) + + yield application - @asyncio.run_until_complete() - async def test_no_middleware_installed(self): - middlewares = [ - session_middleware(SimpleCookieStorage()),] - request = await make_request('GET', '/', middlewares) +async def test_middleware_setup(app): + secret = b'01234567890abcdef' + policy = auth.CookieTktAuthentication(secret, 15, cookie_name='auth') - with self.assertRaises(RuntimeError): + 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') - @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_)] + app.router.add_get('/test', handler_test) - session_data = TicketFactory(secret).new('some_user') - request = await make_request('GET', '/', middlewares, \ - [(auth_.cookie_name, session_data)]) + 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) - 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 + + await 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'), '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_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') + 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) + with pytest.raises(Exception): + await assert_response(cli.get('/test'), 'test') diff --git a/tests/test_autz_acl_policy.py b/tests/test_autz_acl_policy.py new file mode 100644 index 0000000..641822d --- /dev/null +++ b/tests/test_autz_acl_policy.py @@ -0,0 +1,359 @@ +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.AbstractACLAutzPolicy): + """Policy that always returns the same two groups.""" + async def acl_groups(self, user_identity): + return ('group0', 'group1') + + +class AuthACLAutzPolicy(acl.AbstractACLAutzPolicy): + """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.AbstractACLAutzPolicy): + """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 = acl.extend_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 = acl.extend_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 = 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', })] + + 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.AbstractACLAutzPolicy): + 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') + + 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) + + 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 = '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): + 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_permit(): + acl_context = [(Permission.Allow, 'group0', {'test0', 'test2'}), + (Permission.Deny, 'group1', {'test1', }), + (Permission.Allow, 'group0', {'test1', 'test0'}), + (Permission.Allow, 'group1', {'test1', 'test0'})] + + class CustomACLAutzPolicy(acl.AbstractACLAutzPolicy): + def __init__(self, group=None, context=None): + super().__init__(context) + self.group = group + + async def acl_groups(self, user_identity): + return (self.group, ) + + policy = CustomACLAutzPolicy(group='group0', context=acl_context) + + 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 + + policy = CustomACLAutzPolicy(group='group1', context=acl_context) + + 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 + + policy = CustomACLAutzPolicy(group='group3', context=acl_context) + + 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 diff --git a/tests/test_autz_custom_policy.py b/tests/test_autz_custom_policy.py new file mode 100644 index 0000000..ce9e0f5 --- /dev/null +++ b/tests/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 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/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..a1a185e --- /dev/null +++ b/tests/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 diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..b387070 --- /dev/null +++ b/tox.ini @@ -0,0 +1,13 @@ +[tox] +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 + aiohttp_session + uvloop +commands=py.test -v --cov-report=term-missing --cov=aiohttp_auth --cov=tests tests