From fdd597263d95757971f8e29ce3b10a0ebf885596 Mon Sep 17 00:00:00 2001 From: mjaworski Date: Fri, 18 Nov 2016 18:09:45 +0100 Subject: [PATCH 01/16] auth: authentication middleware draft and some authorization stub --- src/graceful/authentication.py | 383 +++++++++++++++++++++++++++++++++ src/graceful/authorization.py | 21 ++ 2 files changed, 404 insertions(+) create mode 100644 src/graceful/authentication.py create mode 100644 src/graceful/authorization.py diff --git a/src/graceful/authentication.py b/src/graceful/authentication.py new file mode 100644 index 0000000..7586867 --- /dev/null +++ b/src/graceful/authentication.py @@ -0,0 +1,383 @@ +# -*- coding: utf-8 -*- +import json + +from falcon import HTTPMissingHeader + + +class BaseUserStorage: + """Base user storage class that defines required API for user storages. + + All built in graceful authentication middleware classes expect user storage + to have compatible API. Custom authentication middlewares do not need + to use storages and even they use any they do not neet to have compatible + interfaces. + """ + + def get_user( + self, identified_with, identity, req, resp, resource, uri_kwargs + ): + """Get user from the storage. + + Args: + identified_with (str): name of the authentication middleware used + to identify the user. + identify (str): string that identifies the user (it is specific + for every authentication middleware implementation). + req (falcon.Request): the request object. + resp (falcon.Response): the response object. + resource (object): the resource object. + uri_kwargs (dict): keyword arguments from the URI template. + + Returns: + the deserialized user object. Preferably a ``dict`` but it is + application-specific. + """ + raise NotImplementedError + + +class DummyUserStorage(BaseUserStorage): + """A dummy storage that always returns no users or specified default. + + This storage is part of :any:`Anonymous` authentication middleware + but also may be useful for testing or disabling specific authentication + middlewares through app configuration. + + Args: + user: user to return. Defaults to ``None`` (will never authenticate). + """ + + def __init__(self, user=None): + """Initialize dummy storage.""" + self.user = user + + def get_user( + self, identified_with, identity, req, resp, resource, uri_kwargs + ): + """Return default user object.""" + return self.user + + +class IPWhitelistStorage(BaseUserStorage): + """Simple storage dedicated for :any:`XForwardedFor` authentication. + + This storage expects that is used with authentication middleware that + returns client address from its ``identify()`` method. + + Args: + ip_range: any object that supports ``in`` operator in order to check + if identity falls into specified whitelist. Tip: use ``iptools``. + user: default user to return on successful authentication. + """ + + def __init__(self, ip_range, user): + """Initialize IP whitelist storage.""" + self.ip_range = ip_range + self.user = user + + def get_user( + self, identified_with, identity, req, resp, resource, uri_kwargs + ): + """Return default user object. + + .. note:: + This implementation expects that ``identity`` is an user address. + """ + if identity in self.ip_range: + return self.user + + +class RedisUserStorage(BaseUserStorage): + """Basic API key identity storage in Redis. + + Client identities are stored as string under keys mathing following + template: + + :: + + Args: + redis: Redis client instance + key_prefix: key prefix used to store client identities. + serialization: serialization object/module that uses the + ``dumps()``/``loads()`` protocol. Defaults to ``json``. + """ + + def __init__(self, redis, key_prefix='users', serialization=json): + """Initialize redis user storage.""" + self.redis = redis + self.key_prefix = key_prefix + self.serialization = serialization + + def _get_storage_key(self, identified_with, identity): + """Consistently get Redis key name of identity string for api key. + + Args: + identified_with (str): name of the authentication middleware used + to identify the user. + identity (str): user identity string + + Return: + str: user object key name + """ + return ':'.join((self.key_prefix, identified_with, identity)) + + def get_user( + self, identified_with, identity, req, resp, resource, uri_kwargs + ): + """Get identity string for given API key. + + Args: + identified_with (str): name of the authentication middleware used + to identify the user. + identity (str): user identity. + + Returns: + dict: user objet stored in Redis if it exists, otherwise ``None`` + """ + stored_value = self.redis.get( + self._get_storage_key(identified_with, identity) + ) + if stored_value is not None: + user = self.serialization.loads(stored_value.decode()) + else: + user = None + + return user + + def register(self, identified_with, identity, user): + """Register new key for given client identity. + + This is only a helper method that allows to register new + user objects for client identities (keys, tokens, addresses etc.). + + Args: + identified_with (str): name of the authentication middleware used + to identify the user. + identity (str): user identity. + user (str): user object to be stored in the backend. + """ + self.redis.set( + self._get_storage_key(identified_with, identity), + self.serialization.dumps(user).encode(), + ) + + +class BaseAuthenticationMiddleware: + """Base class for all authentication middleware classes. + + Args: + user_storage (BaseUserStorage): a storage object used to retrieve + user object using their identity lookup. + name (str): custom name of the authentication middleware useful + for handling custom user storage backends. Defaults to middleware + class name. + """ + + #: challenge returned in WWW-Authenticate header on non authorized + #: requests. + challenge = None + + #: defines if Authentication middleware requires valid storage + #: object to identify users + storage_required = False + + def __init__(self, user_storage=None, name=None): + """Initialize authentication middleware.""" + self.user_storage = user_storage + self.name = ( + name if name else self.__class__.__name__ + ) + + if self.storage_required and self.user_storage is None: + raise ValueError( + "{} authentication middleware requires valid storage" + "".format(self.__class__.__name__) + ) + + def process_resource(self, req, resp, resource, uri_kwargs=None): + """Process resource after routing to it. + + This is basic falcon middleware handler. + + Args: + req (falcon.Request): request object + resp (falcon.Response): response object + resource (object): resource object matched by falcon router + uri_kwargs (dict): additional keyword argument from uri template. + For ``falcon<1.0.0`` this is always ``None`` + """ + if 'user' in req.context: + return + + identity = self.identify(req, resp, resource, uri_kwargs) + user = self.try_storage(identity, req, resp, resource, uri_kwargs) + + if user is not None: + req.context['user'] = user + + # if did not succeed then we need to add this to list of available + # challenges. + elif self.challenge is not None: + req.context.setdefault( + 'challenges', list() + ).append(self.challenge) + + def identify(self, req, resp, resource, uri_kwargs): + """Identify the user that made the request. + + Args: + req (falcon.Request): request object + resp (falcon.Response): response object + resource (object): resource object matched by falcon router + uri_kwargs (dict): additional keyword argument from uri template. + For ``falcon<1.0.0`` this is always ``None`` + + Returns: + object: a user object (preferably a dictionary). + """ + raise NotImplementedError + + def try_storage(self, identity, req, resp, resource, uri_kwargs): + """Try to find user in configured user storage object. + + Args: + identity (str): user identity. + + Returns: + user object + """ + # note: if user_storage is defined, always use it in order to + # authenticate user. + if self.user_storage is not None: + user = self.user_storage.get_user( + self.name, identity, req, resp, resource, uri_kwargs + ) + + # note: some authentication middleware classes may not require + # to be initialized with their own user_storage. In such + # case this will always authenticate with "syntetic user" + # if there is valid indentity. + # todo: consider renaming "storage_required" to something else + elif self.user_storage is None and not self.storage_required: + user = { + 'identified_with': self.name, + 'identity': identity + } + + else: + user = None + + return user + + +class XAPIKey(BaseAuthenticationMiddleware): + """Authenticate user with ``X-Api-Key`` header. + + This middleware must be configured with ``user_storage`` that provides + access to database of client API keys and their identities. + """ + + challenge = 'X-Api-Key' + storage_required = True + + def identify(self, req, resp, resource, uri_kwargs): + """Initialize X-Api-Key authentication middleware.""" + try: + return req.get_header('X-Api-Key', True) + except (KeyError, HTTPMissingHeader): + pass + + +class Token(BaseAuthenticationMiddleware): + """Authenticate user using Token authentication. + + .. todo:: documentation and RFC link. + """ + + challenge = 'Token' + storage_required = True + + def identify(self, req, resp, resource, uri_kwargs): + """Identify user using Authenticate header with Token.""" + try: + # todo: verify correctness + header = req.get_header('Authenticate', True) + parts = header.split(' ') + + if len(parts) == 2 and parts[0] == 'Token': + return parts[1] + + except (KeyError, HTTPMissingHeader): + pass + + +class XForwardedFor(BaseAuthenticationMiddleware): + """Authenticate user with ``X-Forwarded-For`` header or remote address. + + .. note:: + Using this middleware class is **highly unrecommended** if you + are not able to ensure that contents of ``X-Forwarded-For`` header + can be trusted. This requires proper reverse proxy and network + configuration. It is also recommended to at least use the static + :any:`IPWhitelistStorage` as the user storage. + """ + + challenge = None + storage_required = False + + @staticmethod + def _get_client_address(req): + """Get address from ``X-Forwarded-For`` header or use remote address. + + Remote address is used if the ``X-Forwarded-For`` header is not + available. Note that this may not be safe to depend on both without + proper authorization backend. + + Args: + req (falcon.Request): falcon.Request object. + + Returns: + str: client address. + """ + try: + # in case our worker is behind reverse proxy + forwarded_for = req.get_header('X-Forwarded-For', True) + return forwarded_for.split(',')[0].strip() + except (KeyError, HTTPMissingHeader): # pragma: nocover + return req.env.get('REMOTE_ADDR') + + def identify(self, req, resp, resource, uri_kwargs): + """Identify client using his address.""" + return self._get_client_address(req) + + +class Anonymous(BaseAuthenticationMiddleware): + """Dummy authentication middleware that authenticates every request. + + It makes every every request authenticated with default value of + anonymous user. + + .. note:: + This middleware will always add the default user to the request + context if no other previous authentication middleware resolved. + So if this middleware is used it makes no sense to: + * Use the :any:`is_authenticated` decorator. + * Define any other authentication middleware after this one. + + Args: + user: default anonymous user object. + """ + + challenge = None + storage_required = True + + def __init__(self, user): + """Initialize anonymous authentication middleware.""" + # note: DummyUserStorage allows to always return the same user + # object that was passed as initialization value + super().__init__(user_storage=DummyUserStorage(user)) + + def identify(self, req, resp, resource, uri_kwargs): + """Identify user with a dummy sentinel value.""" + # note: this is just a sentinel value to trigger successful + # lookup in the dummy user storage + return ... diff --git a/src/graceful/authorization.py b/src/graceful/authorization.py new file mode 100644 index 0000000..1db4766 --- /dev/null +++ b/src/graceful/authorization.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +from falcon import hooks, HTTPUnauthorized + + +@hooks.before +def is_authenticated(req, resp, resource, uri_kwargs): + """Ensure that user is authenticated otherwise return ``401 Unauthorized``. + + Args: + req (falcon.Request): the request object. + resp (falcon.Response): the response object. + resource (object): the resource object. + uri_kwargs (dict): keyword arguments from the URI template. + + """ + if 'user' not in req.context: + raise HTTPUnauthorized( + "Unauthorized", + "This resource requires authentication", + req.context.get('challenges', []) + ) From c6631637e0dc8765792d6d40dc96783399cba30b Mon Sep 17 00:00:00 2001 From: mjaworski Date: Mon, 21 Nov 2016 16:56:51 +0100 Subject: [PATCH 02/16] auth: upate the docs and refine api --- README.md | 1 + docs/guide/auth.rst | 275 +++++++++++++++++++++++++++++++ docs/guide/index.rst | 1 + docs/reference/graceful.rst | 16 ++ src/graceful/authentication.py | 290 ++++++++++++++++++++++++++------- src/graceful/authorization.py | 19 ++- 6 files changed, 538 insertions(+), 64 deletions(-) create mode 100644 docs/guide/auth.rst diff --git a/README.md b/README.md index 6dd6837..f50d74a 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Features: * generic classes for list and single object resources * simple but extendable pagination +* simple but extendable authentication and authorization * structured responses with content/meta separation * declarative fields and parameters * self-descriptive-everything: API description accessible both in python and diff --git a/docs/guide/auth.rst b/docs/guide/auth.rst new file mode 100644 index 0000000..641223a --- /dev/null +++ b/docs/guide/auth.rst @@ -0,0 +1,275 @@ +Authentication and authorization +-------------------------------- + +Graceful offers very simple and extendable authentication and authorization +mechanism. The main design principles for authentication and authorization +in graceful are: + +* **Authentication** (identifying users) and **authorization** + (restricting access to the endpoint) are separate processes and + because of that they should be declared separately. +* Available authentication schemes are gloabl and always the same for whole + application. +* Different resources usually require different permissions so authorization + is always defined on per-resource or per-method level. +* Authentication and authorization layers communicate only through request + context (the ``req.context`` attribute). + +Thanks to these principles we are able to keep auth implementation very simple +and also allow both mechanisms to be completely optional: + +* You can replace the built-in authorization tools with your own custom + middleware classes and hooks. You can also implement authorization layer + inside of the resource modification methods (list/create/retrieve/etc.). +* If your use case is very simple and successful authentication + (user identification) allows for implicit access grant you can use only + the :any:`authentication_required` decorator. +* If you want to move whole authentication layer outside of your application + code (e.g. using specialized reverse proxy) you can easily do that. + The only thing you need to do is to create some middleware that will properly + modify your request context dictionary to include proper user object. + + +Authentication - identifying the users +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In order to define authentication for your application you need to instantiate +one or more of the built in authentication middleware classes and configure +falcon application to use them. For example: + +.. code-block:: python + + api = application = falcon.API(middleware=[ + authentication.XForwardedFor(), + authentication.Anonymous({"user": "anonymous"}) + ]) + + +If request made the by the user meets all the requirements that are specific to +any authentication flow, the generated/retrieved user object will be included +in request context under ``req.context['user']`` key. If this context variable +exists it is a clear sign that request was succesfully authenticated. + +If you use multiple different middleware classes only the first middleware +that succedded to identify the user will be resolved. This allows for having +fallback authentication mechanism like anonymous users or users identified +by remote address. + + +User objects and working with user storages +``````````````````````````````````````````` + +Most of authentication middleware classes provided in graceful require +``user_storage`` as one of initializations argument. This is the object +that abstracts access to the authentication database and should implement +at least the ``get_user()`` method: + +.. code-block:: python + + from graceful.authentication import BaseUserStorage + + class CustomUserStorage(BaseUserStorage): + def get_user( + self, identified_with, identifier, + req, resp, resource, uri_kwargs + ): + ... + + +Accepted ``get_user()`` method arguments are: + +* **identified_with** *(str)*: instance of the authentication middleware that + provided the ``identifier`` value. It allows to distinguish different types + of user credentials. +* **identifier** *(str)*: string that identifies the user (it is specific + for every authentication middleware implementation). +* **req** *(falcon.Request)*: the request object. +* **resp** *(falcon.Response)*: the response object. + resource (object): the resource object. +* **uri_kwargs** *(dict)*: keyword arguments from the URI template. + +If user exists in the storage (user can be identified) the method should +return user object. This object is usually just a simple Python dictionary. +This object will be later included in the request context as +``req.context['user']`` variable. If user cannot be found in the storage +it means that his identifier is either fake or invalid. In such case this +method should always return ``None``. + +.. note:: + + Note that at this stage you should not verify any user permissions. If you + can identify user but it is unpriviledged client you should still return + the user object. Actual permission checking belongs to authorization layer. + You should definitely inlcude all user metadata data that will be later + required in the authorization process. + +Graceful inlcudes a few useful concrete user storage implementations: + +* :any:`RedisUserStorage`: simple implementetion of user storage using Redis + as a storage backend. +* :any:`DummyUserStorage`: a dummy user storage that will always return + the configured default user. It is useful only for testing purposed. +* :any:`IPWhitelistStorage`: an user storage with IP whitelist intended to be + used exclusively with the :any:`XForwardedFor` authentication middleware. + + +Implictit authentication without user storages +`````````````````````````````````````````````` + +Some built-in authentication implementations for graceful do not require +any user storage to be defined in order to work. These authentication methods +are: + +* :any:`authentication.XForwardedFor`: the ``user_storage`` argument is + completely optional. +* :any:`authentication.Anonymous`: does not support ``user_storage`` argument + at all. + +If :any:`XForwardedFor` is used without any storage it will sucessfully +identify **every** request. The resulting request object will be syntetic user +dictionary in following form:: + + { + 'identified_with': , + 'identifier': + } + +Where ```` will be the configured name of authentication +middleware (here defaults to ``XForwardedFor``) and the ``indentity`` will be +client's address (either value of ``X-Forwarded-For`` header or remote address +directly from WSGI enviroment dictionary). + +In case of :any:`Anonymous` the resulting user context variable will be always +the same as the value of middleware's ``user`` initialization argument. + +Both :any:`XForwardedFor` (without user storage) and :any:`Anonymous` are +intended to be used only as authentication fallbacks for applications that +expect ``req.context['user']`` variable to be always available. This can be +useful for applications that identify every user to track and throttle API +usage on endpoints that do not require any authorization. + + +Custom authentication middleware +```````````````````````````````` + +The easiest way to implement custom authentication middleware is by subclassing +the :any:`BaseAuthenticationMiddleware`. The only method you need to implement +is ``identify()``. It has access to following arguments: +identify(self, req, resp, resource, uri_kwargs): + +* **req** *(falcon.Request)*: falcon request object. You can read headers and + get arguments from it. +* **resp** *(falcon.Response)*: falcon response object. Usually not accessed + during authentication. +* **resource** *(object)*: resource object that request is routed to. May be + useful if you want to provide dynamic realms. +* **uri_kwags** *(dict)*: dictionary of keyword arguments from URI template. + +Aditionally you can control further the behaviour of authentication middleware +using following class attributes: + +* ``only_with_storage``: if it is set to True, it will be impossible to + initialize the middleware without ``user_storage`` argument. +* ``challenge``: returns the challenge string that will be inlcuded in + ``WWW-Authenticate`` header on unauthorized request responses. This has + effect only in resources protected with :any:`authentication_required`. + + +Authorization - restricting access to the endpoint +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The recommended way to implement authorization in graceful is through falcon +hooks that can be applied to whole resources and HTTP method handlers: + +.. code-block:: python + + import falcon + + from graceful.resources.generic import ListAPI + + falcon.before(my_authorization_hook) + class MyListResource(ListAPI): + ... + + @falcon.before(my_other_authorization_hook) + def on_post(self, *args, **kwargs) + return super().on_post() + + +Authorization hooks depend solely on user context stored under +``req.context['user']``. The usual authorization hook implementation does two +things: + +* Check if the ``'user'`` variable is available in ``req.context`` dictionary. + If it isn't then raise the ``falcon.HTTPForbidden`` exception. +* Verify user object content (e.g. check his group) and raise the + ``falcon.HTTPForbidden`` exception if does not meet specific requirements. + +Example of customizable authorization hook implementation that requires +specific user group to be assigned could be as follows: + +.. code-block:: python + + import falcon + + def group_required(user_group): + + @falcon.before + def authorization_hook(req, resp, resource, uri_kwargs) + try: + user = req.context['user'] + + except KeyError: + raise falcon.HTTPForbidden( + "Forbidden", + "Could not identify the user!" + ) + + if user_group not in user.get('groups', set()): + raise falcon.HTTPForbidden( + "Forbidden", + "'{}' group required!".format(user_group) + ) + +Depending on your application design and complexity you will need different +authorization handling. The way how you grant/deny access also depends highly +on the structure of your user objects and the preferred user storage. +This is why graceful provides only one basic authorization utility - the +:any:`authentication_required` decorator. + +The :any:`authentication_required` decorator ensures that request successfully +passed authentication. If none of the authentication middlewares succeeded +to identify the user it will raise ``falcon.HTTPUnauthorized`` +exception and include list of available authentication challenges in the +``WWW-Authenticate`` response header. If you use this decorator you don't need +to check for ``req.context['user']`` existence in your custom authorization +hooks (still, it is a good practice to do so). + +Example usage is: + +.. code-block:: python + + from graceful import authorization + from graceful.resources.generic import ListAPI + + from myapp.auth import group_required + + @authentication_required + @group_required("admin") + class MyListResource(ListAPI): + ... + + @falcon.before(my_other_authorization_hook) + def on_post(self, *args, **kwargs) + return super().on_post() + +Heterogenous authentication +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Graceful does not allow you to specify unique per-resource or per-method +authentication schemes. This allows for easier implementation but may not +cover every use case possible. + +If you need to restrict some authentication methods to specific resources +(e.g. some custom auth only for internal use) the best way to handle that +is through separate application deployments. diff --git a/docs/guide/index.rst b/docs/guide/index.rst index f19fe7a..f0245aa 100644 --- a/docs/guide/index.rst +++ b/docs/guide/index.rst @@ -11,6 +11,7 @@ Graceful guide generic-resources parameters serializers + auth working-with-resources content-types documenting-your-api diff --git a/docs/reference/graceful.rst b/docs/reference/graceful.rst index cbe5e1e..f27224e 100644 --- a/docs/reference/graceful.rst +++ b/docs/reference/graceful.rst @@ -25,6 +25,22 @@ graceful.serializers module :undoc-members: +graceful.authentication module +------------------------------ + +.. automodule:: graceful.authentication + :members: + :undoc-members: + + +graceful.authorization module +----------------------------- + +.. automodule:: graceful.authorization + :members: + :undoc-members: + + graceful.validators module -------------------------- diff --git a/src/graceful/authentication.py b/src/graceful/authentication.py index 7586867..075a73c 100644 --- a/src/graceful/authentication.py +++ b/src/graceful/authentication.py @@ -1,7 +1,10 @@ # -*- coding: utf-8 -*- import json +import base64 +import binascii +import re -from falcon import HTTPMissingHeader +from falcon import HTTPMissingHeader, HTTPBadRequest class BaseUserStorage: @@ -9,19 +12,19 @@ class BaseUserStorage: All built in graceful authentication middleware classes expect user storage to have compatible API. Custom authentication middlewares do not need - to use storages and even they use any they do not neet to have compatible + to use storages and even they use any they do not need to have compatible interfaces. """ def get_user( - self, identified_with, identity, req, resp, resource, uri_kwargs + self, identified_with, identifier, req, resp, resource, uri_kwargs ): """Get user from the storage. Args: - identified_with (str): name of the authentication middleware used - to identify the user. - identify (str): string that identifies the user (it is specific + identified_with (str): instance of the authentication middleware + that provided the ``identifier`` value. + identifier (str): string that identifies the user (it is specific for every authentication middleware implementation). req (falcon.Request): the request object. resp (falcon.Response): the response object. @@ -51,7 +54,7 @@ def __init__(self, user=None): self.user = user def get_user( - self, identified_with, identity, req, resp, resource, uri_kwargs + self, identified_with, identifier, req, resp, resource, uri_kwargs ): """Return default user object.""" return self.user @@ -61,11 +64,13 @@ class IPWhitelistStorage(BaseUserStorage): """Simple storage dedicated for :any:`XForwardedFor` authentication. This storage expects that is used with authentication middleware that - returns client address from its ``identify()`` method. + returns client address from its ``identify()`` method. For example usage + see :any:`XForwardedFor`. + Args: ip_range: any object that supports ``in`` operator in order to check - if identity falls into specified whitelist. Tip: use ``iptools``. + if identifier falls into specified whitelist. Tip: use ``iptools``. user: default user to return on successful authentication. """ @@ -75,24 +80,32 @@ def __init__(self, ip_range, user): self.user = user def get_user( - self, identified_with, identity, req, resp, resource, uri_kwargs + self, identified_with, identifier, req, resp, resource, uri_kwargs ): """Return default user object. .. note:: - This implementation expects that ``identity`` is an user address. + This implementation expects that ``identifier`` is an user address. """ - if identity in self.ip_range: + if identifier in self.ip_range: return self.user class RedisUserStorage(BaseUserStorage): - """Basic API key identity storage in Redis. + """Basic user storage using Redis as authentication backend. Client identities are stored as string under keys mathing following - template: + template:: + + :: + + Where: - :: + * ```` is the configured key prefix (same as the initialization + argument, + * ```` is the name of authentication middleware that + provided user identifier, + * ```` is the string that identifies the user. Args: redis: Redis client instance @@ -101,40 +114,41 @@ class RedisUserStorage(BaseUserStorage): ``dumps()``/``loads()`` protocol. Defaults to ``json``. """ - def __init__(self, redis, key_prefix='users', serialization=json): + def __init__(self, redis, key_prefix='users', serialization=None): """Initialize redis user storage.""" self.redis = redis self.key_prefix = key_prefix - self.serialization = serialization + self.serialization = serialization or json - def _get_storage_key(self, identified_with, identity): - """Consistently get Redis key name of identity string for api key. + def _get_storage_key(self, identified_with, identifier): + """Consistently get Redis key name of identifier string for api key. Args: identified_with (str): name of the authentication middleware used to identify the user. - identity (str): user identity string + identifier (str): user identifier string Return: str: user object key name """ - return ':'.join((self.key_prefix, identified_with, identity)) + return ':'.join((self.key_prefix, identified_with, identifier)) def get_user( - self, identified_with, identity, req, resp, resource, uri_kwargs + self, identified_with, identifier, req, resp, resource, uri_kwargs ): - """Get identity string for given API key. + """Get user object for given identifier. Args: identified_with (str): name of the authentication middleware used to identify the user. - identity (str): user identity. + identifier: middleware specifix user identifier (string in case of + all built in authentication middleware classes). Returns: - dict: user objet stored in Redis if it exists, otherwise ``None`` + dict: user object stored in Redis if it exists, otherwise ``None`` """ stored_value = self.redis.get( - self._get_storage_key(identified_with, identity) + self._get_storage_key(identified_with, identifier) ) if stored_value is not None: user = self.serialization.loads(stored_value.decode()) @@ -143,8 +157,8 @@ def get_user( return user - def register(self, identified_with, identity, user): - """Register new key for given client identity. + def register(self, identified_with, identifier, user): + """Register new key for given client identifier. This is only a helper method that allows to register new user objects for client identities (keys, tokens, addresses etc.). @@ -152,11 +166,11 @@ def register(self, identified_with, identity, user): Args: identified_with (str): name of the authentication middleware used to identify the user. - identity (str): user identity. + identifier (str): user identifier. user (str): user object to be stored in the backend. """ self.redis.set( - self._get_storage_key(identified_with, identity), + self._get_storage_key(identified_with.name, identifier), self.serialization.dumps(user).encode(), ) @@ -166,7 +180,7 @@ class BaseAuthenticationMiddleware: Args: user_storage (BaseUserStorage): a storage object used to retrieve - user object using their identity lookup. + user object using their identifier lookup. name (str): custom name of the authentication middleware useful for handling custom user storage backends. Defaults to middleware class name. @@ -178,7 +192,7 @@ class name. #: defines if Authentication middleware requires valid storage #: object to identify users - storage_required = False + only_with_storage = False def __init__(self, user_storage=None, name=None): """Initialize authentication middleware.""" @@ -187,7 +201,7 @@ def __init__(self, user_storage=None, name=None): name if name else self.__class__.__name__ ) - if self.storage_required and self.user_storage is None: + if self.only_with_storage and self.user_storage is None: raise ValueError( "{} authentication middleware requires valid storage" "".format(self.__class__.__name__) @@ -208,8 +222,8 @@ def process_resource(self, req, resp, resource, uri_kwargs=None): if 'user' in req.context: return - identity = self.identify(req, resp, resource, uri_kwargs) - user = self.try_storage(identity, req, resp, resource, uri_kwargs) + identifier = self.identify(req, resp, resource, uri_kwargs) + user = self.try_storage(identifier, req, resp, resource, uri_kwargs) if user is not None: req.context['user'] = user @@ -236,11 +250,11 @@ def identify(self, req, resp, resource, uri_kwargs): """ raise NotImplementedError - def try_storage(self, identity, req, resp, resource, uri_kwargs): + def try_storage(self, identifier, req, resp, resource, uri_kwargs): """Try to find user in configured user storage object. Args: - identity (str): user identity. + identifier (str): user identifier. Returns: user object @@ -249,18 +263,17 @@ def try_storage(self, identity, req, resp, resource, uri_kwargs): # authenticate user. if self.user_storage is not None: user = self.user_storage.get_user( - self.name, identity, req, resp, resource, uri_kwargs + self, identifier, req, resp, resource, uri_kwargs ) # note: some authentication middleware classes may not require # to be initialized with their own user_storage. In such # case this will always authenticate with "syntetic user" - # if there is valid indentity. - # todo: consider renaming "storage_required" to something else - elif self.user_storage is None and not self.storage_required: + # if there is a valid indentity. + elif self.user_storage is None and not self.only_with_storage: user = { 'identified_with': self.name, - 'identity': identity + 'identifier': identifier } else: @@ -269,20 +282,129 @@ def try_storage(self, identity, req, resp, resource, uri_kwargs): return user +class Basic(BaseAuthenticationMiddleware): + """Authenticate user with Basic auth as specified by `RFC-7617`_. + + Token authentication takes form of ``Authorization`` header in the + following form:: + + Authorization: Basic + + Whre `` is base64 encoded username and password separated by + single colon charactes (refer to official RFC). Usernames must not contain + colon characters! + + If client fails to authenticate on protected endpoint the response will + include following challenge:: + + WWW-Authenticate: Basic realm="" + + Where ```` is the value of configured authentication realm. + + This middleware **must** be configured with ``user_storage`` that provides + access to database of client API keys and their identities. Additionally. + the ``identifier`` received by user storage in the ``get_user()`` method + is a decoded ``:`` string. If you need to apply any + hash function before hitting database in your user storage handler, you + should split it using followitg code:: + + username, _, password = identifier.partition(":") + + + Args: + realm (str): name of the protected realm. This can be only alphanumeric + string with spaces (see: the ``REALM_RE`` pattern). + user_storage (BaseUserStorage): a storage object used to retrieve + user object using their identifier lookup. + name (str): custom name of the authentication middleware useful + for handling custom user storage backends. Defaults to middleware + class name. + + .. _RFC-7617: https://tools.ietf.org/html/rfc7616 + """ + + only_with_storage = True + + #: regular expression used to validate configured realm + REALM_RE = re.compile(r"^[\w ]+$") + + def __init__(self, user_storage=None, name=None, realm="api"): + if not self.REALM_RE.match(realm): + raise ValueError( + "realm argument should match '{}' regular expression" + "".format(self.REALM_RE.pattern) + ) + + self.challenge = "Basic realm={}".format(realm) + super(Basic, self).__init__(user_storage, name) + + def identify(self, req, resp, resource, uri_kwargs): + """Identify user using Authenticate header with Basic auth.""" + header = req.get_header("Authorization", False) + + auth = header.split(" ") + + if auth is None or auth[0].lower != 'basic': + return None + + if len(auth) != 2: + raise HTTPBadRequest( + "Invalid Authorization header", + "The Authorization header for Basic auth should be in form:\n" + "Authorization: Basic " + ) + + user_pass = auth[1] + + try: + decoded = base64.b64decode(user_pass).decode() + + except (TypeError, UnicodeDecodeError, binascii.Error): + raise HTTPBadRequest( + "Invalid Authorization header", + "Credentials for Basic auth not correctly base64 encoded." + ) + + username, _, password = decoded.partition(":") + return username, password + + class XAPIKey(BaseAuthenticationMiddleware): """Authenticate user with ``X-Api-Key`` header. - This middleware must be configured with ``user_storage`` that provides + The X-Api-Key authentication takes a form of ``X-Api-Key`` header in the + following form:: + + X-Api-Key: + + Where ```` is a secret string known to both client and server. + Example of valid header:: + + X-Api-Key: 6fa459ea-ee8a-3ca4-894e-db77e160355e + + If client fails to authenticate on protected endpoint the response will + include following challenge:: + + WWW-Authenticate: X-Api-Key + + .. note:: + This method functionally equivalent to :any:`Token` and is included + only to ease migration of old applications that could use such + authentication method in past. If you're building new API and require + only simple token-based authentication you should prefere + :any:`Token` middleware. + + This middleware **must** be configured with ``user_storage`` that provides access to database of client API keys and their identities. """ challenge = 'X-Api-Key' - storage_required = True + only_with_storage = True def identify(self, req, resp, resource, uri_kwargs): """Initialize X-Api-Key authentication middleware.""" try: - return req.get_header('X-Api-Key', True) + return req.get_header('X-Api-Key', False) except (KeyError, HTTPMissingHeader): pass @@ -290,29 +412,70 @@ def identify(self, req, resp, resource, uri_kwargs): class Token(BaseAuthenticationMiddleware): """Authenticate user using Token authentication. - .. todo:: documentation and RFC link. + Token authentication takes form of ``Authorization`` header:: + + Authorization: Token + + Where ```` is a secret string known to both client and server. + Example of valid header:: + + Authorization: Token 6fa459ea-ee8a-3ca4-894e-db77e160355e + + If client fails to authenticate on protected endpoint the response will + include following challenge:: + + WWW-Authenticate: Token + + + This middleware **must** be configured with ``user_storage`` that provides + access to database of client tokens and their identities. """ challenge = 'Token' - storage_required = True + only_with_storage = True def identify(self, req, resp, resource, uri_kwargs): - """Identify user using Authenticate header with Token.""" - try: - # todo: verify correctness - header = req.get_header('Authenticate', True) - parts = header.split(' ') - - if len(parts) == 2 and parts[0] == 'Token': - return parts[1] - - except (KeyError, HTTPMissingHeader): - pass + """Identify user using Authenticate header with Token auth.""" + header = req.get_header('Authorization', False) + auth = header.split(' ') + + if auth is None or auth[0].lower != 'Token': + return None + + if len(auth) != 2: + raise HTTPBadRequest( + "Invalid Authorization header", + "The Authorization header for Token auth should be in form:\n" + "Authorization: Token " + ) class XForwardedFor(BaseAuthenticationMiddleware): """Authenticate user with ``X-Forwarded-For`` header or remote address. + This authentication middleware is usually used with the + :any:`IPWhitelistStorage` e.g: + + .. code-block:: python + + from iptools import IPRangeList + import falcon + + from graceful import authentication + + IP_WHITELIST = IpRangeList( + '127.0.0.1', + # ... + ) + + auth_middleware = authentication.XForwardedFor( + user_storage=authentication.IPWhitelistStorage( + IP_WHITELIST, user={"username": "internal" + ) + ) + + api = application = falcon.API(middleware=[auth_middleware]) + .. note:: Using this middleware class is **highly unrecommended** if you are not able to ensure that contents of ``X-Forwarded-For`` header @@ -322,7 +485,7 @@ class XForwardedFor(BaseAuthenticationMiddleware): """ challenge = None - storage_required = False + only_with_storage = False @staticmethod def _get_client_address(req): @@ -354,13 +517,16 @@ class Anonymous(BaseAuthenticationMiddleware): """Dummy authentication middleware that authenticates every request. It makes every every request authenticated with default value of - anonymous user. + anonymous user. This authentication middleware may be used in order + to simplify custom authorization code since it will ensure that + every request context will have the ``'user'`` variable defined. .. note:: This middleware will always add the default user to the request context if no other previous authentication middleware resolved. So if this middleware is used it makes no sense to: - * Use the :any:`is_authenticated` decorator. + + * Use the :any:`authentication_required` decorator. * Define any other authentication middleware after this one. Args: @@ -368,7 +534,7 @@ class Anonymous(BaseAuthenticationMiddleware): """ challenge = None - storage_required = True + only_with_storage = True def __init__(self, user): """Initialize anonymous authentication middleware.""" diff --git a/src/graceful/authorization.py b/src/graceful/authorization.py index 1db4766..fb65ad1 100644 --- a/src/graceful/authorization.py +++ b/src/graceful/authorization.py @@ -1,11 +1,26 @@ # -*- coding: utf-8 -*- +from functools import wraps from falcon import hooks, HTTPUnauthorized -@hooks.before -def is_authenticated(req, resp, resource, uri_kwargs): +def _before(hook): + """Metadata preserving version of ``falcon.hooks.before``. + + Args: + hook: actual hook version. + + Returns: falcon hook decorator. + """ + return wraps(hook)(hooks.before(hook)) + + +@_before +def authentication_required(req, resp, resource, uri_kwargs): """Ensure that user is authenticated otherwise return ``401 Unauthorized``. + If request fails to authenticate this authorization hook will also + include list of ``WWW-Athenticate`` challenges. + Args: req (falcon.Request): the request object. resp (falcon.Response): the response object. From c4201564fa7621ebabdcbfdf05bfd9539b8712d5 Mon Sep 17 00:00:00 2001 From: mjaworski Date: Mon, 21 Nov 2016 16:59:13 +0100 Subject: [PATCH 03/16] docs: fix pep257 violation --- src/graceful/authentication.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/graceful/authentication.py b/src/graceful/authentication.py index 075a73c..24b5a87 100644 --- a/src/graceful/authentication.py +++ b/src/graceful/authentication.py @@ -329,6 +329,7 @@ class name. REALM_RE = re.compile(r"^[\w ]+$") def __init__(self, user_storage=None, name=None, realm="api"): + """Initialize middleware and validate realm string.""" if not self.REALM_RE.match(realm): raise ValueError( "realm argument should match '{}' regular expression" From bc451645354a50249d9763fa5aed2e2483a94a94 Mon Sep 17 00:00:00 2001 From: mjaworski Date: Mon, 21 Nov 2016 17:07:02 +0100 Subject: [PATCH 04/16] docs: add versionadded markup to new public API --- src/graceful/authentication.py | 21 ++++++++++++++++++++- src/graceful/authorization.py | 1 + 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/graceful/authentication.py b/src/graceful/authentication.py index 24b5a87..9dcf0a8 100644 --- a/src/graceful/authentication.py +++ b/src/graceful/authentication.py @@ -14,6 +14,8 @@ class BaseUserStorage: to have compatible API. Custom authentication middlewares do not need to use storages and even they use any they do not need to have compatible interfaces. + + .. versionadded:: 0.3.0 """ def get_user( @@ -47,6 +49,8 @@ class DummyUserStorage(BaseUserStorage): Args: user: user to return. Defaults to ``None`` (will never authenticate). + + .. versionadded:: 0.3.0 """ def __init__(self, user=None): @@ -72,6 +76,8 @@ class IPWhitelistStorage(BaseUserStorage): ip_range: any object that supports ``in`` operator in order to check if identifier falls into specified whitelist. Tip: use ``iptools``. user: default user to return on successful authentication. + + .. versionadded:: 0.3.0 """ def __init__(self, ip_range, user): @@ -112,6 +118,8 @@ class RedisUserStorage(BaseUserStorage): key_prefix: key prefix used to store client identities. serialization: serialization object/module that uses the ``dumps()``/``loads()`` protocol. Defaults to ``json``. + + .. versionadded:: 0.3.0 """ def __init__(self, redis, key_prefix='users', serialization=None): @@ -184,6 +192,8 @@ class BaseAuthenticationMiddleware: name (str): custom name of the authentication middleware useful for handling custom user storage backends. Defaults to middleware class name. + + .. versionadded:: 0.3.0 """ #: challenge returned in WWW-Authenticate header on non authorized @@ -310,7 +320,6 @@ class Basic(BaseAuthenticationMiddleware): username, _, password = identifier.partition(":") - Args: realm (str): name of the protected realm. This can be only alphanumeric string with spaces (see: the ``REALM_RE`` pattern). @@ -320,6 +329,8 @@ class Basic(BaseAuthenticationMiddleware): for handling custom user storage backends. Defaults to middleware class name. + .. versionadded:: 0.3.0 + .. _RFC-7617: https://tools.ietf.org/html/rfc7616 """ @@ -397,6 +408,8 @@ class XAPIKey(BaseAuthenticationMiddleware): This middleware **must** be configured with ``user_storage`` that provides access to database of client API keys and their identities. + + .. versionadded:: 0.3.0 """ challenge = 'X-Api-Key' @@ -430,6 +443,8 @@ class Token(BaseAuthenticationMiddleware): This middleware **must** be configured with ``user_storage`` that provides access to database of client tokens and their identities. + + .. versionadded:: 0.3.0 """ challenge = 'Token' @@ -483,6 +498,8 @@ class XForwardedFor(BaseAuthenticationMiddleware): can be trusted. This requires proper reverse proxy and network configuration. It is also recommended to at least use the static :any:`IPWhitelistStorage` as the user storage. + + .. versionadded:: 0.3.0 """ challenge = None @@ -532,6 +549,8 @@ class Anonymous(BaseAuthenticationMiddleware): Args: user: default anonymous user object. + + .. versionadded:: 0.3.0 """ challenge = None diff --git a/src/graceful/authorization.py b/src/graceful/authorization.py index fb65ad1..d8b84c3 100644 --- a/src/graceful/authorization.py +++ b/src/graceful/authorization.py @@ -27,6 +27,7 @@ def authentication_required(req, resp, resource, uri_kwargs): resource (object): the resource object. uri_kwargs (dict): keyword arguments from the URI template. + .. versionadded:: 0.3.0 """ if 'user' not in req.context: raise HTTPUnauthorized( From 9d97f99cb43ef05427f74863654791bce60bef04 Mon Sep 17 00:00:00 2001 From: mjaworski Date: Tue, 22 Nov 2016 17:38:11 +0100 Subject: [PATCH 05/16] auth: add some test and fix minor issues --- src/graceful/authentication.py | 9 ++- tests/test_auth.py | 113 +++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 tests/test_auth.py diff --git a/src/graceful/authentication.py b/src/graceful/authentication.py index 9dcf0a8..41188c3 100644 --- a/src/graceful/authentication.py +++ b/src/graceful/authentication.py @@ -269,9 +269,12 @@ def try_storage(self, identifier, req, resp, resource, uri_kwargs): Returns: user object """ + if identifier is None: + user = None + # note: if user_storage is defined, always use it in order to # authenticate user. - if self.user_storage is not None: + elif self.user_storage is not None: user = self.user_storage.get_user( self, identifier, req, resp, resource, uri_kwargs ) @@ -354,7 +357,7 @@ def identify(self, req, resp, resource, uri_kwargs): """Identify user using Authenticate header with Basic auth.""" header = req.get_header("Authorization", False) - auth = header.split(" ") + auth = header.split(" ") if header else None if auth is None or auth[0].lower != 'basic': return None @@ -453,7 +456,7 @@ class Token(BaseAuthenticationMiddleware): def identify(self, req, resp, resource, uri_kwargs): """Identify user using Authenticate header with Token auth.""" header = req.get_header('Authorization', False) - auth = header.split(' ') + auth = header.split(' ') if header else None if auth is None or auth[0].lower != 'Token': return None diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..4a14333 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +import base64 +import pytest + +from falcon import testing, API, HTTPUnauthorized +from falcon import status_codes + +from graceful.resources.base import BaseResource +from graceful import authentication +from graceful import authorization + + +@authorization.authentication_required +class ExampleResource(BaseResource, with_context=True): + def on_get(self, req, resp, **kwargs): + assert 'user' in req.context + + +class ExampleStorage(authentication.BaseUserStorage): + def __init__(self, password_or_key, user): + self.password_or_key = password_or_key + self.user = user + + def get_user( + self, identified_with, identifier, req, resp, resource, uri_kwargs + ): + if isinstance(identified_with, authentication.Basic): + import ipdb; ipdb.set_trace() + *_, password_or_key = identifier.partition(":") + else: + password_or_key = identifier + + if password_or_key == self.password_or_key: + return self.user + + +@pytest.fixture(scope='module') +def testing_user(): + return { + "username": "foo", + "password": "bar", + } + + +@pytest.fixture(scope='module') +def auth_anonymous_client(testing_user): + route = '/foo/' + app = API(middleware=authentication.Anonymous(user=testing_user)) + app.add_route(route, ExampleResource()) + + test_client = testing.TestClient(app) + test_client.user = testing_user + test_client.route = route + return test_client + + +@pytest.fixture(scope='module') +def auth_basic_client(testing_user): + route = '/foo/' + + password = "secretP4ssw0rd" + username = "foo_bar" + + app = API( + middleware=authentication.Basic( + ExampleStorage(password, testing_user) + ) + ) + app.add_route(route, ExampleResource()) + + test_client = testing.TestClient(app) + + test_client.user = testing_user + test_client.route = route + test_client.password = password + test_client.username = username + + return test_client + + +def test_authentication_required_unauthorized(req, resp): + resource = ExampleResource() + + with pytest.raises(HTTPUnauthorized): + resource.on_get(req, resp) + + +def test_authentication_required_authorized(req, resp, testing_user): + req.context['user'] = testing_user + + resource = ExampleResource() + resource.on_get(req, resp) + + +def test_anonymous_auth(auth_anonymous_client): + result = auth_anonymous_client.simulate_get(auth_anonymous_client.route) + assert result.status == status_codes.HTTP_OK + + +def test_basic_auth(auth_basic_client): + result = auth_basic_client.simulate_get(auth_basic_client.route) + assert result.status == status_codes.HTTP_UNAUTHORIZED + + result = auth_basic_client.simulate_get( + auth_basic_client.route, + headers={"Authorization": "Basic " + base64.b64encode( + ":".join( + [auth_basic_client.username, auth_basic_client.password] + ).encode() + ).decode()} + + ) + assert result.status == status_codes.HTTP_UNAUTHORIZED From 3c01987e69984951de35603ccbfa19701c455f69 Mon Sep 17 00:00:00 2001 From: mjaworski Date: Wed, 11 Jan 2017 12:47:09 +0100 Subject: [PATCH 06/16] tests: update tox ini configuration --- tox.ini | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 836df28..e46f9ed 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{33,34,35}-falcon{0.3,1.0} + py{33,34,35}-falcon{0.3,1.0,1.1} pep8 pep257 coverage-dev @@ -18,11 +18,13 @@ sitepackages = False deps = -r{toxinidir}/requirements-tests.txt + falcon1.1: falcon>=1.1,<1.2 falcon1.0: falcon>=1.0,<1.1 falcon0.3: falcon>=0.3,<1.0 coverage: coverage==4.0.3 coverage: coveralls==1.1 - coverage: falcon>=1.0,<1.1 + coverage: falcon>=1.1,<1.2 + basepython = py35: python3.5 From cf5ea11582d99f92238fb906e6c3f99412d98f01 Mon Sep 17 00:00:00 2001 From: mjaworski Date: Wed, 22 Mar 2017 16:50:25 +0100 Subject: [PATCH 07/16] auth: fix broken tests --- src/graceful/authentication.py | 5 ++- src/graceful/authorization.py | 16 ++++++--- tests/test_auth.py | 66 +++++++++++++++++----------------- 3 files changed, 45 insertions(+), 42 deletions(-) diff --git a/src/graceful/authentication.py b/src/graceful/authentication.py index 41188c3..d92360e 100644 --- a/src/graceful/authentication.py +++ b/src/graceful/authentication.py @@ -356,10 +356,9 @@ def __init__(self, user_storage=None, name=None, realm="api"): def identify(self, req, resp, resource, uri_kwargs): """Identify user using Authenticate header with Basic auth.""" header = req.get_header("Authorization", False) - auth = header.split(" ") if header else None - if auth is None or auth[0].lower != 'basic': + if auth is None or auth[0].lower() != 'basic': return None if len(auth) != 2: @@ -458,7 +457,7 @@ def identify(self, req, resp, resource, uri_kwargs): header = req.get_header('Authorization', False) auth = header.split(' ') if header else None - if auth is None or auth[0].lower != 'Token': + if auth is None or auth[0].lower() != 'token': return None if len(auth) != 2: diff --git a/src/graceful/authorization.py b/src/graceful/authorization.py index d8b84c3..cd20c48 100644 --- a/src/graceful/authorization.py +++ b/src/graceful/authorization.py @@ -1,6 +1,10 @@ # -*- coding: utf-8 -*- from functools import wraps from falcon import hooks, HTTPUnauthorized +import falcon.version + +# todo: consider moving to `compat` module if we have to use more compat code +FALCON_VERSION = tuple(map(int, falcon.version.__version__.split('.'))) def _before(hook): @@ -30,8 +34,10 @@ def authentication_required(req, resp, resource, uri_kwargs): .. versionadded:: 0.3.0 """ if 'user' not in req.context: - raise HTTPUnauthorized( - "Unauthorized", - "This resource requires authentication", - req.context.get('challenges', []) - ) + args = ["Unauthorized", "This resource requires authentication"] + + # compat: falcon >= 1.0.0 requires the list of challenges + if FALCON_VERSION >= (1, 0, 0): + args.append(req.context.get('challenges', [])) + + raise HTTPUnauthorized(*args) diff --git a/tests/test_auth.py b/tests/test_auth.py index 4a14333..6bfdba9 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- import base64 +from falcon.testing import StartResponseMock, create_environ import pytest -from falcon import testing, API, HTTPUnauthorized +from falcon import API, HTTPUnauthorized from falcon import status_codes from graceful.resources.base import BaseResource @@ -25,8 +26,7 @@ def get_user( self, identified_with, identifier, req, resp, resource, uri_kwargs ): if isinstance(identified_with, authentication.Basic): - import ipdb; ipdb.set_trace() - *_, password_or_key = identifier.partition(":") + *_, password_or_key = identifier else: password_or_key = identifier @@ -34,48 +34,42 @@ def get_user( return self.user +def simulate_request(api, path, **kwargs): + srmock = StartResponseMock() + result = api(create_environ(path=path, **kwargs), srmock) + return result, srmock + + @pytest.fixture(scope='module') def testing_user(): return { "username": "foo", - "password": "bar", + "details": "bar", + "password": "secretP4ssw0rd" } @pytest.fixture(scope='module') -def auth_anonymous_client(testing_user): +def auth_anonymous_app_route(testing_user): route = '/foo/' app = API(middleware=authentication.Anonymous(user=testing_user)) app.add_route(route, ExampleResource()) - test_client = testing.TestClient(app) - test_client.user = testing_user - test_client.route = route - return test_client + return app, route @pytest.fixture(scope='module') -def auth_basic_client(testing_user): +def auth_basic_app_route(testing_user): route = '/foo/' - password = "secretP4ssw0rd" - username = "foo_bar" - app = API( middleware=authentication.Basic( - ExampleStorage(password, testing_user) + ExampleStorage(testing_user['password'], testing_user) ) ) app.add_route(route, ExampleResource()) - test_client = testing.TestClient(app) - - test_client.user = testing_user - test_client.route = route - test_client.password = password - test_client.username = username - - return test_client + return app, route def test_authentication_required_unauthorized(req, resp): @@ -92,22 +86,26 @@ def test_authentication_required_authorized(req, resp, testing_user): resource.on_get(req, resp) -def test_anonymous_auth(auth_anonymous_client): - result = auth_anonymous_client.simulate_get(auth_anonymous_client.route) - assert result.status == status_codes.HTTP_OK +def test_anonymous_auth(auth_anonymous_app_route): + app, route = auth_anonymous_app_route + + result, srmock = simulate_request(app, route, method='GET') + assert srmock.status == status_codes.HTTP_OK -def test_basic_auth(auth_basic_client): - result = auth_basic_client.simulate_get(auth_basic_client.route) - assert result.status == status_codes.HTTP_UNAUTHORIZED +def test_basic_auth(auth_basic_app_route, testing_user): + app, route = auth_basic_app_route - result = auth_basic_client.simulate_get( - auth_basic_client.route, + result, srmock = simulate_request(app, route, method='GET') + assert srmock.status == status_codes.HTTP_UNAUTHORIZED + + result, srmock = simulate_request( + app, route, headers={"Authorization": "Basic " + base64.b64encode( ":".join( - [auth_basic_client.username, auth_basic_client.password] + [testing_user['username'], testing_user['password']] ).encode() - ).decode()} - + ).decode()}, + method='GET', ) - assert result.status == status_codes.HTTP_UNAUTHORIZED + assert srmock.status == status_codes.HTTP_OK From df6b5907ede4f770973ec95ae42ce6d94b44f60f Mon Sep 17 00:00:00 2001 From: mjaworski Date: Wed, 22 Mar 2017 17:50:27 +0100 Subject: [PATCH 08/16] Fix XAPIKey auth class and improve code coverage --- src/graceful/authentication.py | 6 +- tests/test_auth.py | 179 ++++++++++++++++++++++----------- 2 files changed, 125 insertions(+), 60 deletions(-) diff --git a/src/graceful/authentication.py b/src/graceful/authentication.py index d92360e..b4c5b95 100644 --- a/src/graceful/authentication.py +++ b/src/graceful/authentication.py @@ -420,7 +420,7 @@ class XAPIKey(BaseAuthenticationMiddleware): def identify(self, req, resp, resource, uri_kwargs): """Initialize X-Api-Key authentication middleware.""" try: - return req.get_header('X-Api-Key', False) + return req.get_header('X-Api-Key', True) except (KeyError, HTTPMissingHeader): pass @@ -467,6 +467,8 @@ def identify(self, req, resp, resource, uri_kwargs): "Authorization: Token " ) + return auth[1] + class XForwardedFor(BaseAuthenticationMiddleware): """Authenticate user with ``X-Forwarded-For`` header or remote address. @@ -488,7 +490,7 @@ class XForwardedFor(BaseAuthenticationMiddleware): auth_middleware = authentication.XForwardedFor( user_storage=authentication.IPWhitelistStorage( - IP_WHITELIST, user={"username": "internal" + IP_WHITELIST, user={"username": "internal"} ) ) diff --git a/tests/test_auth.py b/tests/test_auth.py index 6bfdba9..7dbb54d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- import base64 -from falcon.testing import StartResponseMock, create_environ +from falcon.testing import StartResponseMock, create_environ, TestBase import pytest from falcon import API, HTTPUnauthorized @@ -34,78 +34,141 @@ def get_user( return self.user -def simulate_request(api, path, **kwargs): - srmock = StartResponseMock() - result = api(create_environ(path=path, **kwargs), srmock) - return result, srmock +def test_invalid_basic_auth_realm(): + with pytest.raises(ValueError): + authentication.Basic(realm="Impro=per realm%%% &") -@pytest.fixture(scope='module') -def testing_user(): - return { +class AuthTestsMixin: + """ Test mixin that defines common routine for testing auth classes. + """ + + class SkipTest(Exception): + """Raised when given tests is marked to be skipped""" + + route = '/foo/' + user = { "username": "foo", "details": "bar", "password": "secretP4ssw0rd" } - - -@pytest.fixture(scope='module') -def auth_anonymous_app_route(testing_user): - route = '/foo/' - app = API(middleware=authentication.Anonymous(user=testing_user)) - app.add_route(route, ExampleResource()) - - return app, route - - -@pytest.fixture(scope='module') -def auth_basic_app_route(testing_user): - route = '/foo/' - - app = API( - middleware=authentication.Basic( - ExampleStorage(testing_user['password'], testing_user) + auth_storage = ExampleStorage(user['password'], user) + auth_middleware = authentication.Anonymous(user) + + def get_authorized_headers(self): + raise NotImplementedError + + def get_invalid_headers(self): + raise NotImplementedError + + def get_unauthorized_headers(self): + return {} + + def setUp(self): + super().setUp() + self.api = API(middleware=self.auth_middleware) + self.api.add_route(self.route, ExampleResource()) + + def test_unauthorized(self): + try: + self.simulate_request( + self.route, decode='utf-8', method='GET', + headers=self.get_unauthorized_headers() + ) + assert self.srmock.status == status_codes.HTTP_UNAUTHORIZED + except self.SkipTest: + pass + + def test_authorized(self): + try: + self.simulate_request( + self.route, decode='utf-8', method='GET', + headers=self.get_authorized_headers() + ) + assert self.srmock.status == status_codes.HTTP_OK + except self.SkipTest: + pass + + def test_bad_request(self): + try: + maybe_multiple_headers_sets = self.get_invalid_headers() + + if isinstance(maybe_multiple_headers_sets, tuple): + header_sets = maybe_multiple_headers_sets + + else: + header_sets = (maybe_multiple_headers_sets,) + + for headers in header_sets: + self.simulate_request( + self.route, decode='utf-8', method='GET', + headers=headers + ) + assert self.srmock.status == status_codes.HTTP_BAD_REQUEST + except self.SkipTest: + pass + + +class AnonymousAuthTestCase(AuthTestsMixin, TestBase): + auth_middleware = authentication.Anonymous(...) + + def get_authorized_headers(self): + return {} + + def get_unauthorized_headers(self): + raise self.SkipTest + + def get_invalid_headers(self): + raise self.SkipTest + + +class BasicAuthTestCase(AuthTestsMixin, TestBase): + auth_middleware = authentication.Basic(AuthTestsMixin.auth_storage) + + def get_authorized_headers(self): + return { + "Authorization": + "Basic " + base64.b64encode( + ":".join( + [self.user['username'], self.user['password']] + ).encode() + ).decode() + } + + def get_invalid_headers(self): + return ( + # to many header tokens + {"Authorization": "Basic Basic Basic"}, + # non base64 decoded + {"Authorization": "Basic nonbase64decoded"} ) - ) - app.add_route(route, ExampleResource()) - - return app, route - - -def test_authentication_required_unauthorized(req, resp): - resource = ExampleResource() - with pytest.raises(HTTPUnauthorized): - resource.on_get(req, resp) +class TokenAuthTestCase(AuthTestsMixin, TestBase): + auth_middleware = authentication.Token(AuthTestsMixin.auth_storage) + def get_authorized_headers(self): + return {"Authorization": "Token " + self.user['password']} -def test_authentication_required_authorized(req, resp, testing_user): - req.context['user'] = testing_user + def get_invalid_headers(self): + return {"Authorization": "Token Token Token"} - resource = ExampleResource() - resource.on_get(req, resp) +class XAPIKeyAuthTestCase(AuthTestsMixin, TestBase): + auth_middleware = authentication.XAPIKey(AuthTestsMixin.auth_storage) -def test_anonymous_auth(auth_anonymous_app_route): - app, route = auth_anonymous_app_route + def get_authorized_headers(self): + return {"X-Api-Key": self.user['password']} - result, srmock = simulate_request(app, route, method='GET') - assert srmock.status == status_codes.HTTP_OK + def get_invalid_headers(self): + raise self.SkipTest -def test_basic_auth(auth_basic_app_route, testing_user): - app, route = auth_basic_app_route +class XForwardedForAuthTestCase(AuthTestsMixin, TestBase): + auth_storage = authentication.IPWhitelistStorage(["127.100.100.1"], ...) + auth_middleware = authentication.XForwardedFor(auth_storage) - result, srmock = simulate_request(app, route, method='GET') - assert srmock.status == status_codes.HTTP_UNAUTHORIZED + def get_authorized_headers(self): + return {"X-Forwarded-For": "127.100.100.1"} - result, srmock = simulate_request( - app, route, - headers={"Authorization": "Basic " + base64.b64encode( - ":".join( - [testing_user['username'], testing_user['password']] - ).encode() - ).decode()}, - method='GET', - ) - assert srmock.status == status_codes.HTTP_OK + def get_invalid_headers(self): + raise self.SkipTest From a68a536c2d22fbaacba427e90f1d287b702d5885 Mon Sep 17 00:00:00 2001 From: mjaworski Date: Wed, 22 Mar 2017 18:33:08 +0100 Subject: [PATCH 09/16] auth: more API improvements and test coverage --- src/graceful/authentication.py | 27 +++++++++--- tests/test_auth.py | 75 ++++++++++++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 9 deletions(-) diff --git a/src/graceful/authentication.py b/src/graceful/authentication.py index b4c5b95..72db59f 100644 --- a/src/graceful/authentication.py +++ b/src/graceful/authentication.py @@ -289,7 +289,9 @@ def try_storage(self, identifier, req, resp, resource, uri_kwargs): 'identifier': identifier } - else: + else: # pragma: nocover + # note: this should not happen if the base class is properly + # initialized. Still, user can skip super().__init__() call. user = None return user @@ -473,9 +475,16 @@ def identify(self, req, resp, resource, uri_kwargs): class XForwardedFor(BaseAuthenticationMiddleware): """Authenticate user with ``X-Forwarded-For`` header or remote address. + Args: + remote_address_fallback (bool): fallback to ``REMOTE_ADDR`` value from + WSGI environment dictionary if ``X-Forwarded-For`` header is not + available. Defaults to False. + + This authentication middleware is usually used with the :any:`IPWhitelistStorage` e.g: + .. code-block:: python from iptools import IPRangeList @@ -509,8 +518,12 @@ class XForwardedFor(BaseAuthenticationMiddleware): challenge = None only_with_storage = False - @staticmethod - def _get_client_address(req): + def __init__(self, user_storage=None, remote_address_fallback=False): + """Initialize middleware and set default arguments.""" + super().__init__(user_storage) + self.remote_address_fallback = remote_address_fallback + + def _get_client_address(self, req): """Get address from ``X-Forwarded-For`` header or use remote address. Remote address is used if the ``X-Forwarded-For`` header is not @@ -524,11 +537,13 @@ def _get_client_address(req): str: client address. """ try: - # in case our worker is behind reverse proxy forwarded_for = req.get_header('X-Forwarded-For', True) return forwarded_for.split(',')[0].strip() - except (KeyError, HTTPMissingHeader): # pragma: nocover - return req.env.get('REMOTE_ADDR') + except (KeyError, HTTPMissingHeader): + return ( + req.env.get('REMOTE_ADDR') if self.remote_address_fallback + else None + ) def identify(self, req, resp, resource, uri_kwargs): """Identify client using his address.""" diff --git a/tests/test_auth.py b/tests/test_auth.py index 7dbb54d..4a55044 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- import base64 -from falcon.testing import StartResponseMock, create_environ, TestBase + import pytest -from falcon import API, HTTPUnauthorized +from falcon.testing import TestBase +from falcon import API from falcon import status_codes from graceful.resources.base import BaseResource @@ -39,12 +40,29 @@ def test_invalid_basic_auth_realm(): authentication.Basic(realm="Impro=per realm%%% &") +@pytest.mark.parametrize( + "auth_class", [ + authentication.Basic, + authentication.Token, + authentication.XAPIKey, + ] +) +def test_auth_requires_storage(auth_class): + with pytest.raises(ValueError): + auth_class() + + class AuthTestsMixin: """ Test mixin that defines common routine for testing auth classes. """ class SkipTest(Exception): - """Raised when given tests is marked to be skipped""" + """Raised when given tests is marked to be skipped + + Note: we use this exception instead of self.skipTest() method because + this has slightly different semantics. We simply don't want to report + these tests as skipped. + """ route = '/foo/' user = { @@ -116,9 +134,11 @@ def get_authorized_headers(self): return {} def get_unauthorized_headers(self): + # note: Anonymous always authenticates the user. raise self.SkipTest def get_invalid_headers(self): + # note: it is not possible to have invalid header for this auth. raise self.SkipTest @@ -143,6 +163,7 @@ def get_invalid_headers(self): {"Authorization": "Basic nonbase64decoded"} ) + class TokenAuthTestCase(AuthTestsMixin, TestBase): auth_middleware = authentication.Token(AuthTestsMixin.auth_storage) @@ -160,6 +181,7 @@ def get_authorized_headers(self): return {"X-Api-Key": self.user['password']} def get_invalid_headers(self): + # note: it is not possible to have invalid header for this auth. raise self.SkipTest @@ -171,4 +193,51 @@ def get_authorized_headers(self): return {"X-Forwarded-For": "127.100.100.1"} def get_invalid_headers(self): + # note: it is not possible to have invalid header for this auth. + raise self.SkipTest + + +class XForwardedForWithoutStorageAuthTestCase(AuthTestsMixin, TestBase): + auth_middleware = authentication.XForwardedFor() + + def get_authorized_headers(self): + return {"X-Forwarded-For": "127.0.0.1"} + + def get_invalid_headers(self): + # note: it is not possible to have invalid header for this auth. + raise self.SkipTest + + +class XForwardedForWithFallbackAuthTestCase(AuthTestsMixin, TestBase): + auth_middleware = authentication.XForwardedFor( + remote_address_fallback=True + ) + + def get_authorized_headers(self): + return {} + + def get_unauthorized_headers(self): + raise self.SkipTest + + def get_invalid_headers(self): + # note: it is not possible to have invalid header for this auth. raise self.SkipTest + + +class MultipleAuthTestCase(AuthTestsMixin, TestBase): + auth_middleware = [ + authentication.Token(AuthTestsMixin.auth_storage), + authentication.Anonymous(...), + authentication.Basic(AuthTestsMixin.auth_storage), + ] + + def get_unauthorized_headers(self): + # note: Anonymous will always authenticate the user as a fallback auth + raise self.SkipTest + + def get_invalid_headers(self): + # this is invalid header for basic authentication + return {"Authorization": "Token Basic Basic"} + + def get_authorized_headers(self): + return {"Authorization": "Token " + self.user['password']} From ff9a40b2abaae8eee3355abcabbe031b8c5fe6b1 Mon Sep 17 00:00:00 2001 From: mjaworski Date: Thu, 23 Mar 2017 17:24:01 +0100 Subject: [PATCH 10/16] generic KV User storage, single dispatch and full coverage --- demo/auth_app.py | 39 +++++ docs/guide/auth.rst | 260 +++++++++++++++++++++++++++++---- setup.py | 3 +- src/graceful/authentication.py | 105 +++++++++---- tests/test_auth.py | 127 ++++++++++++---- 5 files changed, 442 insertions(+), 92 deletions(-) create mode 100644 demo/auth_app.py diff --git a/demo/auth_app.py b/demo/auth_app.py new file mode 100644 index 0000000..ef0e02c --- /dev/null +++ b/demo/auth_app.py @@ -0,0 +1,39 @@ +import hashlib + +from redis import StrictRedis as Redis +import falcon + +from graceful.resources.generic import Resource +from graceful.authentication import KeyValueUserStorage, Token, Basic +from graceful.authorization import authentication_required + + +@authentication_required +class Me(Resource, with_context=True): + def retrieve(self, params, meta, context): + return context.get('user') + + +auth_storage = KeyValueUserStorage(Redis()) + + +@auth_storage.hash_identifier.register(Basic) +def _(identified_with, identifier): + return ":".join(( + identifier[0], + hashlib.sha1(identifier[1].encode()).hexdigest() + )) + + +@auth_storage.hash_identifier.register(Token) +def _(identified_with, identifier): + return hashlib.sha1(identifier[1].encode()).hexdigest() + +api = application = falcon.API( + middleware=[ + Token(auth_storage), + Basic(auth_storage), + ] + ) + +api.add_route('/me/', Me()) diff --git a/docs/guide/auth.rst b/docs/guide/auth.rst index 641223a..1ef5653 100644 --- a/docs/guide/auth.rst +++ b/docs/guide/auth.rst @@ -41,7 +41,7 @@ falcon application to use them. For example: api = application = falcon.API(middleware=[ authentication.XForwardedFor(), - authentication.Anonymous({"user": "anonymous"}) + authentication.Anonymous(), ]) @@ -51,7 +51,7 @@ in request context under ``req.context['user']`` key. If this context variable exists it is a clear sign that request was succesfully authenticated. If you use multiple different middleware classes only the first middleware -that succedded to identify the user will be resolved. This allows for having +that succeeded to identify the user will be resolved. This allows for having fallback authentication mechanism like anonymous users or users identified by remote address. @@ -60,8 +60,8 @@ User objects and working with user storages ``````````````````````````````````````````` Most of authentication middleware classes provided in graceful require -``user_storage`` as one of initializations argument. This is the object -that abstracts access to the authentication database and should implement +``user_storage`` initializations argument. This is the object +that abstracts access to the authentication database. It should implement at least the ``get_user()`` method: .. code-block:: python @@ -78,18 +78,19 @@ at least the ``get_user()`` method: Accepted ``get_user()`` method arguments are: -* **identified_with** *(str)*: instance of the authentication middleware that - provided the ``identifier`` value. It allows to distinguish different types - of user credentials. -* **identifier** *(str)*: string that identifies the user (it is specific - for every authentication middleware implementation). +* **identified_with** *(object)*: instance of the authentication middleware + that provided the ``identifier`` value. It allows to distinguish different + types of user credentials. +* **identifier** *(object)*: object that identifies the user. It is specific + for every authentication middleware implementation. For some middlewares + it can be a raw string value (e.g. token or API key). * **req** *(falcon.Request)*: the request object. * **resp** *(falcon.Response)*: the response object. resource (object): the resource object. * **uri_kwargs** *(dict)*: keyword arguments from the URI template. -If user exists in the storage (user can be identified) the method should -return user object. This object is usually just a simple Python dictionary. +If user entry exists in the storage (user can be identified) the method should +return user object. This object usually is just a simple Python dictionary. This object will be later included in the request context as ``req.context['user']`` variable. If user cannot be found in the storage it means that his identifier is either fake or invalid. In such case this @@ -99,26 +100,27 @@ method should always return ``None``. Note that at this stage you should not verify any user permissions. If you can identify user but it is unpriviledged client you should still return - the user object. Actual permission checking belongs to authorization layer. - You should definitely inlcude all user metadata data that will be later - required in the authorization process. + the user object. Actual permission checking is a responsibility of the + authorization layer. You should inlcude all user metadata that will be + later required in the authorization process. Graceful inlcudes a few useful concrete user storage implementations: -* :any:`RedisUserStorage`: simple implementetion of user storage using Redis - as a storage backend. +* :any:`KeyValueUserStorage`: simple implementation of user storage using any + key-value database client as a storage backend. * :any:`DummyUserStorage`: a dummy user storage that will always return - the configured default user. It is useful only for testing purposed. -* :any:`IPWhitelistStorage`: an user storage with IP whitelist intended to be - used exclusively with the :any:`XForwardedFor` authentication middleware. + the configured default user. It is useful only for testing purposes. +* :any:`IPRangeWhitelistStorage`: user storage with IP range whitelist intended + to be used exclusively with the :any:`XForwardedFor` authentication + middleware. Implictit authentication without user storages `````````````````````````````````````````````` Some built-in authentication implementations for graceful do not require -any user storage to be defined in order to work. These authentication methods -are: +any user storage to be defined in order to work. These authentication +schemes are provided in form of following middlewares: * :any:`authentication.XForwardedFor`: the ``user_storage`` argument is completely optional. @@ -130,14 +132,15 @@ identify **every** request. The resulting request object will be syntetic user dictionary in following form:: { - 'identified_with': , + 'identified_with': , 'identifier': } -Where ```` will be the configured name of authentication -middleware (here defaults to ``XForwardedFor``) and the ``indentity`` will be -client's address (either value of ``X-Forwarded-For`` header or remote address -directly from WSGI enviroment dictionary). +Where ```` is the authentication middleware instance (here +defaults to ``XForwardedFor``) and the ``indentity`` will be +client's address. Client address is either value of ``X-Forwarded-For`` header +or remote address taken directly from WSGI enviroment dictionary (only if +middleware is configured with ``remote_address_fallback=True``). In case of :any:`Anonymous` the resulting user context variable will be always the same as the value of middleware's ``user`` initialization argument. @@ -271,5 +274,206 @@ authentication schemes. This allows for easier implementation but may not cover every use case possible. If you need to restrict some authentication methods to specific resources -(e.g. some custom auth only for internal use) the best way to handle that -is through separate application deployments. +(e.g. some custom auth only for internal use) the best way is to handle this +through separate application deployments. + + +Practical example -- authentication with redis backend +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Let's assume we want to build simple REST API application supporting two +authentication schemes: + +* :any:`Token` access authentication with ``Authorization: Token`` HTTP header +* :any:`Basic` access authentication with ``Authorization: Basic`` HTTP header + as specified by `RFC 7617`_. + +As a user database we will use :any:`KeyValueUserStorage` storage class which is +compatible with any key-value database client that provides two simple methods: + +* ``set(key, value)``: set key value in the storage. Both key and value should + be strings. +* ``get(key)``: get key value from the storage. Both key and return value + should be string. + + +First step is to create a key-value store client user storage intance that +will be used by both authentication middlewares. With redis and +:any:`KeyValueUserStorage` this is very simple: + +.. code-block:: python + + from redis import StrictRedis as Redis + from graceful.authentication import KeyValueUserStorage + + auth_storage = KeyValueUserStorage(Redis()) + +This storage can be used by many different authentication middlewares at the +same time. It will properly prefix every Redis key with middleware name to make +sure different types of user entries do not collide with each other. + +The only problem is that default implementation of +``KeyValueUserStorage.hash_identifier(identified_with, identifier)`` method expects +that ``identifier`` argument is a single string argument. The :any:`Basic` +authentication middleware generates identifiers in form of +``(username, password)`` two-tuples. Fortunately you don't need to use +subclassing in order to override this method behavior. The +:any:`hash_identifier` method is a `single-dispatch generic function`_ so you +can easily create custom handlers for specific authentication middleware types. + +We definitely don't want to store user passwords in plain text. Let's register +simple ``hash_identifier`` handler for :any:`Basic` access authentication that +will properly prepare password hash using SHA1 algorithm: + +.. code-block:: python + + from hashlib import sha1 + + from graceful.authentication import Basic + + + @auth_storage.hash_identifier.register(Basic) + def _(identified_with, identifier): + return ":".join(( + identifier[0], + hashlib.sha1(identifier[1].encode()).hexdigest() + )) + +Default ``hash_identifier`` leaves single-string identifiers untouched so it +may be a good idea to hash token identifiers in similar fashion too: + +.. code-block:: python + + @auth_storage.hash_identifier.register(Token) + def _(identified_with, identifier): + return hashlib.sha1(identifier[1].encode()).hexdigest() + +.. note:: + Really secure `password verification`_ mechanism would require proper + time-consuming hashing algorithm that would prevent application from + brute-force and timing attacks. Anyway, for real end-user applications you + would probably use a session cookie for authentication rather than basic + access authentication. For such case simple SHA1 hashing may not be the + best solution. Still, **basic access authentication** is a simple + alternative to custom authentication headers and/or GET parameters when + communicating in **server-to-server fashion** over the **secure channel**. + +Our authentication setup is almost finished. The last things to do is to +initialize authentication middlewares and setup a very basic authorization +to API resources. Following is the code for a very small application that +protects its resources with :any:`Token` and :any:`Basic` authentication +middlewares: + +.. code-block:: python + + import hashlib + + from redis import StrictRedis as Redis + import falcon + + from graceful.resources.generic import Resource + from graceful.authentication import KeyValueUserStorage, Token, Basic + from graceful.authorization import authentication_required + + @authentication_required + class Me(Resource, with_context=True): + def retrieve(self, params, meta, context): + return context.get('user') + + + auth_storage = KeyValueUserStorage(Redis()) + + + @auth_storage.hash_identifier.register(Basic) + def _(identified_with, identifier): + return ":".join(( + identifier[0], + hashlib.sha1(identifier[1].encode()).hexdigest() + )) + + + @auth_storage.hash_identifier.register(Token) + def _(identified_with, identifier): + return hashlib.sha1(identifier[1].encode()).hexdigest() + + api = application = falcon.API( + middleware=[ + Token(auth_storage), + Basic(auth_storage), + ] + ) + + api.add_route('/me/', Me()) + +Now you can easily create new user entries using Pyhton console:: + + >>> from auth_app import auth_storage, Token, Basic + >>> auth_storage.register(Token(auth_storage), 'mytoken', {"user": "me with token"}) + >>> auth_storage.register(Basic(auth_storage), ['myusername', 'mysecretpassword'], {"user": "me with password"}) + +... check if they are successfully saved in Redis:: + + $ redis-cli keys '*' + 1) "users:Token:95cb0bfd2977c761298d9624e4b4d4c72a39974a" + 2) "users:Basic:myusername:08cd923367890009657eab812753379bdb321eeb" + + +... and verify authentication using HTTP client (here with ``httpie``):: + + $ http localhost:8000/me + HTTP/1.1 401 Unauthorized + Connection: close + Date: Thu, 23 Mar 2017 16:09:55 GMT + Server: gunicorn/19.6.0 + content-length: 91 + content-type: application/json + vary: Accept + www-authenticate: Token, Basic realm=api + + { + "description": "This resource requires authentication", + "title": "Unauthorized" + } + + $ http localhost:8000/me --auth myusername:mysecretpassword + HTTP/1.1 200 OK + Connection: close + Date: Thu, 23 Mar 2017 16:08:53 GMT + Server: gunicorn/19.6.0 + content-length: 76 + content-type: application/json + + { + "content": { + "user": "me with password" + }, + "meta": { + "params": { + "indent": 0 + } + } + } + + $ http localhost:8000/me 'Authorization:Token mytoken' + HTTP/1.1 200 OK + Connection: close + Date: Thu, 23 Mar 2017 16:09:39 GMT + Server: gunicorn/19.6.0 + content-length: 73 + content-type: application/json + + { + "content": { + "user": "me with token" + }, + "meta": { + "params": { + "indent": 0 + } + } + } + +.. _RFC 7617: https://tools.ietf.org/html/rfc7616 +.. _single-dispatch generic function: https://docs.python.org/3/library/functools.html#functools.singledispatch +.. _password verification: https://en.wikipedia.org/wiki/Cryptographic_hash_function#Password_verification + diff --git a/setup.py b/setup.py index a549756..971771a 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,8 @@ def get_version(version_tuple): VERSION = get_version(eval(version_line.split('=')[-1])) INSTALL_REQUIRES = [ - 'falcon' + 'falcon', + 'singledispatch', ] try: diff --git a/src/graceful/authentication.py b/src/graceful/authentication.py index 72db59f..812431d 100644 --- a/src/graceful/authentication.py +++ b/src/graceful/authentication.py @@ -4,16 +4,23 @@ import binascii import re +try: + from functools import singledispatch +except ImportError: # pragma: nocover + # future: remove when dropping support for Python 3.3 + # compat: backport of singledispatch module introduced in Python 3.4 + from singledispatch import singledispatch + from falcon import HTTPMissingHeader, HTTPBadRequest class BaseUserStorage: """Base user storage class that defines required API for user storages. - All built in graceful authentication middleware classes expect user storage + All built-in graceful authentication middleware classes expect user storage to have compatible API. Custom authentication middlewares do not need - to use storages and even they use any they do not need to have compatible - interfaces. + to use storages and even if they use any they do not need to have + compatible interfaces. .. versionadded:: 0.3.0 """ @@ -37,7 +44,7 @@ def get_user( the deserialized user object. Preferably a ``dict`` but it is application-specific. """ - raise NotImplementedError + raise NotImplementedError # pragma: nocover class DummyUserStorage(BaseUserStorage): @@ -64,13 +71,15 @@ def get_user( return self.user -class IPWhitelistStorage(BaseUserStorage): +class IPRangeWhitelistStorage(BaseUserStorage): """Simple storage dedicated for :any:`XForwardedFor` authentication. This storage expects that is used with authentication middleware that returns client address from its ``identify()`` method. For example usage - see :any:`XForwardedFor`. - + see :any:`XForwardedFor`. Because it is IP range whitelist this storage + it cannot distinguish different users' IP and always returns default + user object. If you want to identify different users by their IP see + :any:`KVUserStorage`. Args: ip_range: any object that supports ``in`` operator in order to check @@ -97,10 +106,10 @@ def get_user( return self.user -class RedisUserStorage(BaseUserStorage): - """Basic user storage using Redis as authentication backend. +class KeyValueUserStorage(BaseUserStorage): + """Basic user storage using any key-value store as authentication backend. - Client identities are stored as string under keys mathing following + Client identities are stored as string under keys matching following template:: :: @@ -108,13 +117,16 @@ class RedisUserStorage(BaseUserStorage): Where: * ```` is the configured key prefix (same as the initialization - argument, + argument), * ```` is the name of authentication middleware that provided user identifier, * ```` is the string that identifies the user. Args: - redis: Redis client instance + kv_store: Key-value store client instance (e.g. Redis client object). + The ``kv_store`` must provide at least two methods: ``get(key)`` + and ``set(key, value)``. Arguments and return values of these + methods must be strings. key_prefix: key prefix used to store client identities. serialization: serialization object/module that uses the ``dumps()``/``loads()`` protocol. Defaults to ``json``. @@ -122,14 +134,38 @@ class RedisUserStorage(BaseUserStorage): .. versionadded:: 0.3.0 """ - def __init__(self, redis, key_prefix='users', serialization=None): - """Initialize redis user storage.""" - self.redis = redis + def __init__(self, kv_store, key_prefix='users', serialization=None): + """Initialize kv_store user storage.""" + self.kv_store = kv_store self.key_prefix = key_prefix self.serialization = serialization or json def _get_storage_key(self, identified_with, identifier): - """Consistently get Redis key name of identifier string for api key. + return ':'.join(( + self.key_prefix, identified_with.name, + self.hash_identifier(identified_with, identifier), + )) + + @staticmethod + @singledispatch + def hash_identifier(identified_with, identifier): + """Create hash from identifier to be used as a part of user lookup. + + This method is a ``singledispatch`` function. It allows to register + new implementations for specific authentication middleware classes: + + .. code-block:: python + + from hashlib import sha1 + + from graceful.authentication import KeyValueUserStorage, Basic + + @KeyValueUserStorage.hash_identifier.register(Basic) + def _(identified_with, identifier): + return ":".join(( + identifier[0], + sha1(identifier[1].encode()).hexdigest(), + )) Args: identified_with (str): name of the authentication middleware used @@ -137,9 +173,14 @@ def _get_storage_key(self, identified_with, identifier): identifier (str): user identifier string Return: - str: user object key name + str: hashed identifier string """ - return ':'.join((self.key_prefix, identified_with, identifier)) + if isinstance(identifier, str): + return identifier + else: + raise TypeError( + "User storage does not support this kind of identifier" + ) def get_user( self, identified_with, identifier, req, resp, resource, uri_kwargs @@ -147,15 +188,15 @@ def get_user( """Get user object for given identifier. Args: - identified_with (str): name of the authentication middleware used + identified_with (object): authentication middleware used to identify the user. - identifier: middleware specifix user identifier (string in case of - all built in authentication middleware classes). + identifier: middleware specifix user identifier (string or tuple + in case of all built in authentication middleware classes). Returns: dict: user object stored in Redis if it exists, otherwise ``None`` """ - stored_value = self.redis.get( + stored_value = self.kv_store.get( self._get_storage_key(identified_with, identifier) ) if stored_value is not None: @@ -172,13 +213,13 @@ def register(self, identified_with, identifier, user): user objects for client identities (keys, tokens, addresses etc.). Args: - identified_with (str): name of the authentication middleware used + identified_with (object): authentication middleware used to identify the user. identifier (str): user identifier. user (str): user object to be stored in the backend. """ - self.redis.set( - self._get_storage_key(identified_with.name, identifier), + self.kv_store.set( + self._get_storage_key(identified_with, identifier), self.serialization.dumps(user).encode(), ) @@ -258,13 +299,13 @@ def identify(self, req, resp, resource, uri_kwargs): Returns: object: a user object (preferably a dictionary). """ - raise NotImplementedError + raise NotImplementedError # pragma: nocover def try_storage(self, identifier, req, resp, resource, uri_kwargs): """Try to find user in configured user storage object. Args: - identifier (str): user identifier. + identifier: user identifier. Returns: user object @@ -285,7 +326,7 @@ def try_storage(self, identifier, req, resp, resource, uri_kwargs): # if there is a valid indentity. elif self.user_storage is None and not self.only_with_storage: user = { - 'identified_with': self.name, + 'identified_with': self, 'identifier': identifier } @@ -298,7 +339,7 @@ def try_storage(self, identifier, req, resp, resource, uri_kwargs): class Basic(BaseAuthenticationMiddleware): - """Authenticate user with Basic auth as specified by `RFC-7617`_. + """Authenticate user with Basic auth as specified by `RFC 7617`_. Token authentication takes form of ``Authorization`` header in the following form:: @@ -336,7 +377,7 @@ class name. .. versionadded:: 0.3.0 - .. _RFC-7617: https://tools.ietf.org/html/rfc7616 + .. _RFC 7617: https://tools.ietf.org/html/rfc7616 """ only_with_storage = True @@ -518,9 +559,9 @@ class XForwardedFor(BaseAuthenticationMiddleware): challenge = None only_with_storage = False - def __init__(self, user_storage=None, remote_address_fallback=False): + def __init__(self, user_storage=None, name=None, remote_address_fallback=False): """Initialize middleware and set default arguments.""" - super().__init__(user_storage) + super().__init__(user_storage, name) self.remote_address_fallback = remote_address_fallback def _get_client_address(self, req): diff --git a/tests/test_auth.py b/tests/test_auth.py index 4a55044..f7fbd39 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import base64 - import pytest +import hashlib from falcon.testing import TestBase from falcon import API @@ -18,21 +18,29 @@ def on_get(self, req, resp, **kwargs): assert 'user' in req.context -class ExampleStorage(authentication.BaseUserStorage): - def __init__(self, password_or_key, user): - self.password_or_key = password_or_key - self.user = user +class ExampleKVUserStorage(authentication.KeyValueUserStorage): + class SimpleKVStore(dict): + def set(self, key, value): + self[key] = value + + def __init__(self, data=None): + super().__init__(self.SimpleKVStore(data or {})) + + def clear(self): + self.kv_store.clear() + + +@ExampleKVUserStorage.hash_identifier.register(authentication.Basic) +def _(identified_with, identifier): + return ":".join([ + identifier[0], + hashlib.sha1(identifier[1].encode()).hexdigest() + ]) - def get_user( - self, identified_with, identifier, req, resp, resource, uri_kwargs - ): - if isinstance(identified_with, authentication.Basic): - *_, password_or_key = identifier - else: - password_or_key = identifier - if password_or_key == self.password_or_key: - return self.user +def test_default_kv_hashes_only_strings(): + with pytest.raises(TypeError): + ExampleKVUserStorage.hash_identifier(None, [1, 2, 3, 4]) def test_invalid_basic_auth_realm(): @@ -68,10 +76,15 @@ class SkipTest(Exception): user = { "username": "foo", "details": "bar", - "password": "secretP4ssw0rd" + "password": "secretP4ssw0rd", + "allowed_ip": "127.100.100.1", + "allowed_remote": "127.0.0.1", + "token": "s3cr3t70ken", + 'allowed_ip_range': ['127.100.100.1'], } - auth_storage = ExampleStorage(user['password'], user) - auth_middleware = authentication.Anonymous(user) + ident_keys = ['password'] + auth_storage = ExampleKVUserStorage() + auth_middleware = [authentication.Anonymous(user)] def get_authorized_headers(self): raise NotImplementedError @@ -87,6 +100,16 @@ def setUp(self): self.api = API(middleware=self.auth_middleware) self.api.add_route(self.route, ExampleResource()) + self.auth_storage.clear() + + identity = [self.user[key] for key in self.ident_keys] + + self.auth_storage.register( + self.auth_middleware[0], + identity[0] if len(identity) == 1 else identity, + self.user + ) + def test_unauthorized(self): try: self.simulate_request( @@ -103,6 +126,7 @@ def test_authorized(self): self.route, decode='utf-8', method='GET', headers=self.get_authorized_headers() ) + assert self.srmock.status == status_codes.HTTP_OK except self.SkipTest: pass @@ -128,7 +152,7 @@ def test_bad_request(self): class AnonymousAuthTestCase(AuthTestsMixin, TestBase): - auth_middleware = authentication.Anonymous(...) + auth_middleware = [authentication.Anonymous(...)] def get_authorized_headers(self): return {} @@ -143,7 +167,8 @@ def get_invalid_headers(self): class BasicAuthTestCase(AuthTestsMixin, TestBase): - auth_middleware = authentication.Basic(AuthTestsMixin.auth_storage) + auth_middleware = [authentication.Basic(AuthTestsMixin.auth_storage)] + ident_keys = ['username', 'password'] def get_authorized_headers(self): return { @@ -165,20 +190,22 @@ def get_invalid_headers(self): class TokenAuthTestCase(AuthTestsMixin, TestBase): - auth_middleware = authentication.Token(AuthTestsMixin.auth_storage) + auth_middleware = [authentication.Token(AuthTestsMixin.auth_storage)] + ident_keys = ['token'] def get_authorized_headers(self): - return {"Authorization": "Token " + self.user['password']} + return {"Authorization": "Token " + self.user['token']} def get_invalid_headers(self): return {"Authorization": "Token Token Token"} class XAPIKeyAuthTestCase(AuthTestsMixin, TestBase): - auth_middleware = authentication.XAPIKey(AuthTestsMixin.auth_storage) + auth_middleware = [authentication.XAPIKey(AuthTestsMixin.auth_storage)] + ident_keys = ['token'] def get_authorized_headers(self): - return {"X-Api-Key": self.user['password']} + return {"X-Api-Key": self.user['token']} def get_invalid_headers(self): # note: it is not possible to have invalid header for this auth. @@ -186,11 +213,13 @@ def get_invalid_headers(self): class XForwardedForAuthTestCase(AuthTestsMixin, TestBase): - auth_storage = authentication.IPWhitelistStorage(["127.100.100.1"], ...) - auth_middleware = authentication.XForwardedFor(auth_storage) + auth_middleware = [ + authentication.XForwardedFor(AuthTestsMixin.auth_storage) + ] + ident_keys = ['allowed_ip'] def get_authorized_headers(self): - return {"X-Forwarded-For": "127.100.100.1"} + return {"X-Forwarded-For": self.user['allowed_ip']} def get_invalid_headers(self): # note: it is not possible to have invalid header for this auth. @@ -198,10 +227,11 @@ def get_invalid_headers(self): class XForwardedForWithoutStorageAuthTestCase(AuthTestsMixin, TestBase): - auth_middleware = authentication.XForwardedFor() + auth_middleware = [authentication.XForwardedFor()] + ident_keys = ['allowed_ip'] def get_authorized_headers(self): - return {"X-Forwarded-For": "127.0.0.1"} + return {"X-Forwarded-For": self.user['allowed_ip']} def get_invalid_headers(self): # note: it is not possible to have invalid header for this auth. @@ -209,9 +239,10 @@ def get_invalid_headers(self): class XForwardedForWithFallbackAuthTestCase(AuthTestsMixin, TestBase): - auth_middleware = authentication.XForwardedFor( - remote_address_fallback=True - ) + auth_middleware = [ + authentication.XForwardedFor(remote_address_fallback=True) + ] + ident_keys = ['allowed_remote'] def get_authorized_headers(self): return {} @@ -224,12 +255,46 @@ def get_invalid_headers(self): raise self.SkipTest +class IPRangeXForwardedForAuthTestCase(AuthTestsMixin, TestBase): + class IPRangeWhitelistStorage(authentication.IPRangeWhitelistStorage): + """Test compatible implementation of IPRangeWhitelistStorage. + + This implementation simply extends the base class with + tests-compatible ``register()`` and ``clear()`` methods. + """ + + def register(self, identified_with, identity, user): + self.ip_range = identity + self.user = user + + def clear(self): + self.ip_range = [] + self.user = None + + auth_storage = IPRangeWhitelistStorage([], None) + auth_middleware = [ + authentication.XForwardedFor(auth_storage) + ] + ident_keys = ['allowed_ip_range'] + + def get_authorized_headers(self): + return {'X-Forwarded-For': self.user['allowed_ip_range'][0]} + + def get_unauthorized_headers(self): + raise self.SkipTest + + def get_invalid_headers(self): + # note: it is not possible to have invalid header for this auth. + raise self.SkipTest + + class MultipleAuthTestCase(AuthTestsMixin, TestBase): auth_middleware = [ authentication.Token(AuthTestsMixin.auth_storage), authentication.Anonymous(...), authentication.Basic(AuthTestsMixin.auth_storage), ] + ident_keys = ["token"] def get_unauthorized_headers(self): # note: Anonymous will always authenticate the user as a fallback auth From c7ffe199417b8439ba198bbe894b79dd4860da1b Mon Sep 17 00:00:00 2001 From: mjaworski Date: Thu, 23 Mar 2017 17:28:36 +0100 Subject: [PATCH 11/16] fix minor pep8 violation --- src/graceful/authentication.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/graceful/authentication.py b/src/graceful/authentication.py index 812431d..7b140d8 100644 --- a/src/graceful/authentication.py +++ b/src/graceful/authentication.py @@ -559,7 +559,9 @@ class XForwardedFor(BaseAuthenticationMiddleware): challenge = None only_with_storage = False - def __init__(self, user_storage=None, name=None, remote_address_fallback=False): + def __init__( + self, user_storage=None, name=None, remote_address_fallback=False + ): """Initialize middleware and set default arguments.""" super().__init__(user_storage, name) self.remote_address_fallback = remote_address_fallback From af2ed4387285d9b232dc4354b69617532aa51ec0 Mon Sep 17 00:00:00 2001 From: mjaworski Date: Thu, 23 Mar 2017 17:44:17 +0100 Subject: [PATCH 12/16] demo: resolve pep8 violations in auth_app demo --- demo/auth_app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/demo/auth_app.py b/demo/auth_app.py index ef0e02c..e5d54e4 100644 --- a/demo/auth_app.py +++ b/demo/auth_app.py @@ -18,7 +18,7 @@ def retrieve(self, params, meta, context): @auth_storage.hash_identifier.register(Basic) -def _(identified_with, identifier): +def _basic(identified_with, identifier): return ":".join(( identifier[0], hashlib.sha1(identifier[1].encode()).hexdigest() @@ -26,7 +26,7 @@ def _(identified_with, identifier): @auth_storage.hash_identifier.register(Token) -def _(identified_with, identifier): +def _token(identified_with, identifier): return hashlib.sha1(identifier[1].encode()).hexdigest() api = application = falcon.API( From 2dd82bef2a9b6e26b9bfe4f34d9cf2a5a213c768 Mon Sep 17 00:00:00 2001 From: mjaworski Date: Mon, 27 Mar 2017 12:16:18 +0200 Subject: [PATCH 13/16] auth: fix some docstrings and use ABC metaclass to verify storage interfaces --- docs/guide/auth.rst | 2 + src/graceful/authentication.py | 110 ++++++++++++++++++++------------- src/graceful/authorization.py | 2 +- 3 files changed, 71 insertions(+), 43 deletions(-) diff --git a/docs/guide/auth.rst b/docs/guide/auth.rst index 1ef5653..e7db062 100644 --- a/docs/guide/auth.rst +++ b/docs/guide/auth.rst @@ -278,6 +278,8 @@ If you need to restrict some authentication methods to specific resources through separate application deployments. +.. _auth-practical-example: + Practical example -- authentication with redis backend ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/graceful/authentication.py b/src/graceful/authentication.py index 7b140d8..057e63c 100644 --- a/src/graceful/authentication.py +++ b/src/graceful/authentication.py @@ -3,6 +3,7 @@ import base64 import binascii import re +import abc try: from functools import singledispatch @@ -14,17 +15,17 @@ from falcon import HTTPMissingHeader, HTTPBadRequest -class BaseUserStorage: +class BaseUserStorage(metaclass=abc.ABCMeta): """Base user storage class that defines required API for user storages. All built-in graceful authentication middleware classes expect user storage to have compatible API. Custom authentication middlewares do not need - to use storages and even if they use any they do not need to have - compatible interfaces. + to use storages. - .. versionadded:: 0.3.0 + .. versionadded:: 0.4.0 """ + @abc.abstractmethod def get_user( self, identified_with, identifier, req, resp, resource, uri_kwargs ): @@ -46,18 +47,27 @@ def get_user( """ raise NotImplementedError # pragma: nocover + @classmethod + def __subclasshook__(cls, klass): + """Verify implicit class interface.""" + if cls is BaseUserStorage: + if any("get_user" in B.__dict__ for B in klass.__mro__): + return True + return NotImplemented + class DummyUserStorage(BaseUserStorage): - """A dummy storage that always returns no users or specified default. + """A dummy storage that never returns users or returns specified default. - This storage is part of :any:`Anonymous` authentication middleware - but also may be useful for testing or disabling specific authentication - middlewares through app configuration. + This storage is part of :any:`Anonymous` authentication middleware. + It may also be useful for testing purposes or to disable specific + authentication middlewares through app configuration. Args: - user: user to return. Defaults to ``None`` (will never authenticate). + user: User object to return. Defaults to ``None`` (will never + authenticate). - .. versionadded:: 0.3.0 + .. versionadded:: 0.4.0 """ def __init__(self, user=None): @@ -74,19 +84,20 @@ def get_user( class IPRangeWhitelistStorage(BaseUserStorage): """Simple storage dedicated for :any:`XForwardedFor` authentication. - This storage expects that is used with authentication middleware that - returns client address from its ``identify()`` method. For example usage - see :any:`XForwardedFor`. Because it is IP range whitelist this storage - it cannot distinguish different users' IP and always returns default - user object. If you want to identify different users by their IP see - :any:`KVUserStorage`. + This storage expects that authentication middleware return client address + from its ``identify()`` method. For example usage see :any:`XForwardedFor`. + Because it is IP range whitelist this storage it cannot distinguish + different users' IP and always returns default user object. If you want to + identify different users by their IP see :any:`KeyValueUserStorage`. Args: - ip_range: any object that supports ``in`` operator in order to check - if identifier falls into specified whitelist. Tip: use ``iptools``. - user: default user to return on successful authentication. + ip_range: Any object that supports ``in`` operator (i.e. implements the + ``__cointains__`` method). The ``__contains__`` method should + return ``True`` if identifier falls into specified whitelist. + Tip: use ``iptools``. + user: Default user object to return on successful authentication. - .. versionadded:: 0.3.0 + .. versionadded:: 0.4.0 """ def __init__(self, ip_range, user): @@ -120,18 +131,30 @@ class KeyValueUserStorage(BaseUserStorage): argument), * ```` is the name of authentication middleware that provided user identifier, - * ```` is the string that identifies the user. + * ```` is the identifier object that identifies the user. + + Note that this key scheme will work only for middlewares that return + identifiers as single string objects. Also the ```` part + of key template is a plain text value of without any hashing algorithm + applied. It may not be secure enough to store user secrets that way. + + If you want to use this storage with middleware that uses more complex + identifier format/objects (e.g. the :any:`Basic` class) you will have + to register own identifier format in the :any:`hash_identifier` method. + For details see the :any:`hash_identifier` method docstring or the + :ref:`practical example ` section of the + documentation. Args: kv_store: Key-value store client instance (e.g. Redis client object). The ``kv_store`` must provide at least two methods: ``get(key)`` - and ``set(key, value)``. Arguments and return values of these + and ``set(key, value)``. The arguments and return values of these methods must be strings. key_prefix: key prefix used to store client identities. serialization: serialization object/module that uses the ``dumps()``/``loads()`` protocol. Defaults to ``json``. - .. versionadded:: 0.3.0 + .. versionadded:: 0.4.0 """ def __init__(self, kv_store, key_prefix='users', serialization=None): @@ -141,6 +164,7 @@ def __init__(self, kv_store, key_prefix='users', serialization=None): self.serialization = serialization or json def _get_storage_key(self, identified_with, identifier): + """Get key string for given user identifier in consistent manner.""" return ':'.join(( self.key_prefix, identified_with.name, self.hash_identifier(identified_with, identifier), @@ -234,7 +258,7 @@ class BaseAuthenticationMiddleware: for handling custom user storage backends. Defaults to middleware class name. - .. versionadded:: 0.3.0 + .. versionadded:: 0.4.0 """ #: challenge returned in WWW-Authenticate header on non authorized @@ -252,10 +276,13 @@ def __init__(self, user_storage=None, name=None): name if name else self.__class__.__name__ ) - if self.only_with_storage and self.user_storage is None: + if ( + self.only_with_storage and + not isinstance(self.user_storage, BaseUserStorage) + ): raise ValueError( - "{} authentication middleware requires valid storage" - "".format(self.__class__.__name__) + "{} authentication middleware requires valid storage. Got {}." + "".format(self.__class__.__name__, self.user_storage) ) def process_resource(self, req, resp, resource, uri_kwargs=None): @@ -305,10 +332,10 @@ def try_storage(self, identifier, req, resp, resource, uri_kwargs): """Try to find user in configured user storage object. Args: - identifier: user identifier. + identifier: User identifier. Returns: - user object + user object. """ if identifier is None: user = None @@ -375,7 +402,7 @@ class Basic(BaseAuthenticationMiddleware): for handling custom user storage backends. Defaults to middleware class name. - .. versionadded:: 0.3.0 + .. versionadded:: 0.4.0 .. _RFC 7617: https://tools.ietf.org/html/rfc7616 """ @@ -454,7 +481,7 @@ class XAPIKey(BaseAuthenticationMiddleware): This middleware **must** be configured with ``user_storage`` that provides access to database of client API keys and their identities. - .. versionadded:: 0.3.0 + .. versionadded:: 0.4.0 """ challenge = 'X-Api-Key' @@ -485,11 +512,10 @@ class Token(BaseAuthenticationMiddleware): WWW-Authenticate: Token - This middleware **must** be configured with ``user_storage`` that provides access to database of client tokens and their identities. - .. versionadded:: 0.3.0 + .. versionadded:: 0.4.0 """ challenge = 'Token' @@ -517,13 +543,13 @@ class XForwardedFor(BaseAuthenticationMiddleware): """Authenticate user with ``X-Forwarded-For`` header or remote address. Args: - remote_address_fallback (bool): fallback to ``REMOTE_ADDR`` value from - WSGI environment dictionary if ``X-Forwarded-For`` header is not - available. Defaults to False. + remote_address_fallback (bool): Use fallback to ``REMOTE_ADDR`` value + from WSGI environment dictionary if ``X-Forwarded-For`` header is + not available. Defaults to ``False``. This authentication middleware is usually used with the - :any:`IPWhitelistStorage` e.g: + :any:`IPRangeWhitelistStorage` e.g: .. code-block:: python @@ -539,7 +565,7 @@ class XForwardedFor(BaseAuthenticationMiddleware): ) auth_middleware = authentication.XForwardedFor( - user_storage=authentication.IPWhitelistStorage( + user_storage=authentication.IPWRangehitelistStorage( IP_WHITELIST, user={"username": "internal"} ) ) @@ -551,9 +577,9 @@ class XForwardedFor(BaseAuthenticationMiddleware): are not able to ensure that contents of ``X-Forwarded-For`` header can be trusted. This requires proper reverse proxy and network configuration. It is also recommended to at least use the static - :any:`IPWhitelistStorage` as the user storage. + :any:`IPRangeWhitelistStorage` as the user storage. - .. versionadded:: 0.3.0 + .. versionadded:: 0.4.0 """ challenge = None @@ -610,9 +636,9 @@ class Anonymous(BaseAuthenticationMiddleware): * Define any other authentication middleware after this one. Args: - user: default anonymous user object. + user: Default anonymous user object. - .. versionadded:: 0.3.0 + .. versionadded:: 0.4.0 """ challenge = None diff --git a/src/graceful/authorization.py b/src/graceful/authorization.py index cd20c48..d4f0cc9 100644 --- a/src/graceful/authorization.py +++ b/src/graceful/authorization.py @@ -31,7 +31,7 @@ def authentication_required(req, resp, resource, uri_kwargs): resource (object): the resource object. uri_kwargs (dict): keyword arguments from the URI template. - .. versionadded:: 0.3.0 + .. versionadded:: 0.4.0 """ if 'user' not in req.context: args = ["Unauthorized", "This resource requires authentication"] From 2bbc5fa116171b671b39469e1ae27660c715a5ed Mon Sep 17 00:00:00 2001 From: mjaworski Date: Mon, 27 Mar 2017 12:36:07 +0200 Subject: [PATCH 14/16] tests: use wider testing matrix --- .travis.yml | 11 ++++++++++- README.md | 4 ++-- tox.ini | 3 ++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 03fcd3e..382cc88 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ language: python python: - - 3.5 + - 3.6 install: pip install tox env: @@ -9,11 +9,20 @@ env: - TOX_ENV=py33-falcon0.3 - TOX_ENV=py34-falcon0.3 - TOX_ENV=py35-falcon0.3 + - TOX_ENV=py36-falcon0.3 + # falcon 1.0.x test matrix - TOX_ENV=py33-falcon1.0 - TOX_ENV=py34-falcon1.0 - TOX_ENV=py35-falcon1.0 + - TOX_ENV=py36-falcon1.0 + + # falcon 1.1.x test matrix + - TOX_ENV=py33-falcon1.0 + - TOX_ENV=py34-falcon1.0 + - TOX_ENV=py35-falcon1.0 + - TOX_ENV=py36-falcon1.0 # linters and coverage - TOX_ENV=pep8 diff --git a/README.md b/README.md index f50d74a..346b4b9 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ Features: through `OPTIONS` requests * painless validation * 100% tests coverage -* falcon>=0.3.0 (tested up to 1.0.x) -* python3 exclusive (tested from 3.3 to 3.5) +* falcon>=0.3.0 (tested up to 1.1.x) +* python3 exclusive (tested from 3.3 to 3.6) Community behind graceful is starting to grow but we don't have any mailing list yet. There was one on [Librelist](http://librelist.com/browser/graceful) diff --git a/tox.ini b/tox.ini index e46f9ed..98f59b9 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{33,34,35}-falcon{0.3,1.0,1.1} + py{33,34,35,36}-falcon{0.3,1.0,1.1} pep8 pep257 coverage-dev @@ -27,6 +27,7 @@ deps = basepython = + py36: python3.6 py35: python3.5 py34: python3.4 py33: python3.3 From eb7825940b4ef6d37f1b1e676c850920bfe5234c Mon Sep 17 00:00:00 2001 From: mjaworski Date: Mon, 27 Mar 2017 13:50:18 +0200 Subject: [PATCH 15/16] CI: try to omit specific Python version on travis --- .travis.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 382cc88..c7bb11c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,5 @@ language: python -python: - - 3.6 - install: pip install tox env: # falcon 0.3.x test matrix From 94079aaaed07517a9f16c0a34e674bad4617020a Mon Sep 17 00:00:00 2001 From: mjaworski Date: Mon, 27 Mar 2017 14:03:18 +0200 Subject: [PATCH 16/16] CI: different approach to environment grouping --- .travis.yml | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/.travis.yml b/.travis.yml index c7bb11c..3edc672 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,31 +1,29 @@ language: python -install: pip install tox -env: - # falcon 0.3.x test matrix - - TOX_ENV=py33-falcon0.3 - - TOX_ENV=py34-falcon0.3 - - TOX_ENV=py35-falcon0.3 - - TOX_ENV=py36-falcon0.3 - +python: 3.6 - # falcon 1.0.x test matrix - - TOX_ENV=py33-falcon1.0 - - TOX_ENV=py34-falcon1.0 - - TOX_ENV=py35-falcon1.0 - - TOX_ENV=py36-falcon1.0 - - # falcon 1.1.x test matrix - - TOX_ENV=py33-falcon1.0 - - TOX_ENV=py34-falcon1.0 - - TOX_ENV=py35-falcon1.0 - - TOX_ENV=py36-falcon1.0 +install: pip install tox +env: # linters and coverage - TOX_ENV=pep8 - TOX_ENV=coverage - TOX_ENV=pep257 +matrix: + include: + - python: 3.6 + env: TOX_ENV=py36-falcon0.3,py36-falcon1.0,py36-falcon1.1 + + - python: 3.5 + env: TOX_ENV=py35-falcon0.3,py35-falcon1.0,py35-falcon1.1 + + - python: 3.4 + env: TOX_ENV=py34-falcon0.3,py34-falcon1.0,py34-falcon1.1 + + - python: 3.3 + env: TOX_ENV=py33-falcon0.3,py33-falcon1.0,py33-falcon1.1 + script: - tox -e $TOX_ENV