diff --git a/.travis.yml b/.travis.yml index 1a0e8d2..7cf43b8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,8 +16,8 @@ deploy: - LICENSE - kwikapi/api.py - kwikapi/__init__.py - name: kwikapi-0.3.6 - tag_name: 0.3.6 + name: kwikapi-0.3.7 + tag_name: 0.3.7 on: repo: deep-compute/kwikapi - provider: pypi diff --git a/README.md b/README.md index e9d41bd..cb42921 100644 --- a/README.md +++ b/README.md @@ -604,6 +604,113 @@ print(c.namespace.add(a=10, b=10)) print(c(version='v2', prtocol='pickle').namespace.add(a=10, b=10)) ``` +### Authentication +KwikAPI supports `Basic` and `Bearer` authentication. + +**Basic authentication:** + +Setting up authentication at server side + +```python +import tornado.ioloop +import tornado.web + +from kwikapi.tornado import RequestHandler +from kwikapi import API, Request, BasicServerAuthenticator + +class Calc(object): + def add(self, req: Request, a: int, b: int) -> int: + if not req.auth.is_authenticated: + raise Exception("No auth") # Or your logic + return a + b + +user_store = dict(johndoe=dict(password='password')) +auth = BasicServerAuthenticator(user_store=user_store) +api = API(auth=auth) +api.register(Calc(), 'v1') + +def make_app(): + return tornado.web.Application([ + (r'^/api/.*', RequestHandler, dict(api=api)), + ]) +if __name__ == "__main__": + app = make_app() + app.listen(8818) + tornado.ioloop.IOLoop.current().start() +``` + +Using authentication at client side. +```python +from kwikapi import Client, BasicClientAuthenticator + +auth = BasicClientAuthenticator(username='johndoe', password='password') + +c = Client('http://localhost:8818/api/', version='v1', auth=auth, protocol='json') +print(c.add(a=10, b=10)) +``` + +**Bearer Authentication** + +Setting up authentication at server side + +```python +import tornado.ioloop +import tornado.web + +from kwikapi.tornado import RequestHandler +from kwikapi import API, BearerServerAuthenticator, Request + +class Calc(object): + def add(self, req: Request, a: int, b: int) -> int: + if not req.auth.is_authenticated: + raise Exception("No auth") + return a + b + +tokstore = dict( + key1 = dict(user='blah'), + key2 = dict(user='blah1'), +) +auth = BearerServerAuthenticator(token_store=tokstore) + +api = API(auth=auth) +api.register(Calc(), 'v1') + +def make_app(): + return tornado.web.Application([ + (r'^/api/.*', RequestHandler, dict(api=api)), + ]) +if __name__ == "__main__": + app = make_app() + app.listen(8818) + tornado.ioloop.IOLoop.current().start() +``` + +Using authentication at client side. +```python +from kwikapi import Client, BearerClientAuthenticator + +auth = BearerClientAuthenticator('key2') + +c = Client('http://localhost:8818/api/', version='v1', auth=auth, protocol='json') +print(c.add(a=10, b=10)) +``` + +If you don't want to use KwikAPI client then you have to pass the authentication deatails through headers. + +- Example for passing authentication details in Base Authentication. +```python +key = b'%s:%s' % (username, password) +key = base64.b64encode(key) +auth = b'Basic %s' % key +headers['Authorization'] = auth +``` + +- Example for passing authentication details in Bearer Authentication. +```python +auth = b'Bearer %s' % b'key' +headers['Authorization'] = auth +``` + ## Run test cases ```bash $ python3 -m doctest -v README.md diff --git a/kwikapi/__init__.py b/kwikapi/__init__.py index eec55d3..d95a7af 100644 --- a/kwikapi/__init__.py +++ b/kwikapi/__init__.py @@ -5,3 +5,14 @@ from .client import Client, DNSCache from . import exception + +from .auth import BaseServerAuthenticator, \ + AuthInfo, \ + BasicServerAuthenticator, \ + BearerServerAuthenticator, \ + HMACServerAuthenticator, \ + BaseClientAuthenticator, \ + BasicClientAuthenticator, \ + BearerClientAuthenticator, \ + HMACClientAuthenticator, \ + BaseAuthAPI diff --git a/kwikapi/api.py b/kwikapi/api.py index fe878e0..6f1ed86 100644 --- a/kwikapi/api.py +++ b/kwikapi/api.py @@ -54,6 +54,7 @@ def __init__(self): self.protocol = None self.metrics = {} self._id = generate_random_string(length=5).decode('utf8') + self.auth = None @property def id(self): @@ -187,9 +188,10 @@ class API(object): def __init__(self, default_version=None, id='', threadpool=None, threadpool_size=THREADPOOL_SIZE, - log=DUMMY_LOG): + auth=None, log=DUMMY_LOG): self._api_funcs = {} + self._auth = auth self.log = log.bind(api_id=id) self._id = id self.default_version = default_version @@ -450,6 +452,9 @@ def _find_request_protocol(self, request): return self.PROTOCOLS[protocol] def handle_request(self, request): + if self.api._auth: + request.auth = self.api._auth.authenticate(request) + protocol = self._find_request_protocol(request) request.protocol = protocol.get_name() response = request.response diff --git a/kwikapi/auth.py b/kwikapi/auth.py new file mode 100644 index 0000000..b3f5fa7 --- /dev/null +++ b/kwikapi/auth.py @@ -0,0 +1,125 @@ +import abc +from abc import abstractmethod +import base64 + +from deeputil import xcode, AttrDict + +from .api import Request + +class BaseServerAuthenticator: + '''Helps in authenticating a request on the server''' + __metaclass__ = abc.ABCMeta + + TYPE = 'base' + + def _read_auth(self, req): + auth = req.headers.get('Authorization') + _type, info = auth.split(' ', 1) + if _type.lower() != self.TYPE: + raise Exception('Invalid auth type: %s' % _type) # FIXME: raise exc hierarchy + + auth = AuthInfo(type=self.TYPE) + auth.header_info = info + return auth + + def authenticate(self, req): + return self._read_auth(req) + +class AuthInfo(AttrDict): + '''Represents authentication information (post auth)''' + __metaclass__ = abc.ABCMeta + + def __init__(self, type=None): + self.type = type + self.is_authenticated = False + +class BasicServerAuthenticator(BaseServerAuthenticator): + TYPE = 'basic' + + def __init__(self, user_store=None): + self.user_store = user_store or {} + + def authenticate(self, req): + auth = super().authenticate(req) + auth.username, auth.password = \ + base64.b64decode(xcode(auth.header_info)).decode('utf-8').split(':') + + auth_info = self.user_store.get(auth.username, None) + if not auth_info: + return auth + + if auth_info.get('password', None) != auth.password: + return auth + + auth.is_authenticated = True + auth.update(auth_info) + + return auth + +class BearerServerAuthenticator(BaseServerAuthenticator): + TYPE = 'bearer' + + def __init__(self, token_store=None): + self.token_store = token_store or {} + + def authenticate(self, req): + auth = super().authenticate(req) + auth.token = auth.header_info + + auth_info = self.token_store.get(auth.token, None) + if auth_info: + auth.is_authenticated = True + auth.update(auth_info) + + return auth + +class HMACServerAuthenticator(BaseServerAuthenticator): + pass + +class BaseClientAuthenticator: + '''Helps in signing a request with auth info''' + __metaclass__ = abc.ABCMeta + + def __init__(self): + self.type = None + + @abstractmethod + def sign(self, url, headers, body): + pass + +class BasicClientAuthenticator(BaseClientAuthenticator): + def __init__(self, username, password): + self.username = xcode(username) + self.password = xcode(password) + self.encoded_key = b'%s:%s' % (self.username, self.password) + self.encoded_key = base64.b64encode(self.encoded_key) + + def sign(self, url, headers, body): + headers['Authorization'] = b'Basic %s' % self.encoded_key + +class BearerClientAuthenticator(BaseClientAuthenticator): + def __init__(self, bearer_token): + self.bearer_token = xcode(bearer_token) + + def sign(self, url, headers, body): + headers['Authorization'] = b'Bearer %s' % self.bearer_token + +class HMACClientAuthenticator(BaseClientAuthenticator): + pass + +class BaseAuthAPI: + __metaclass__ = abc.ABCMeta + + @abstractmethod + def login(self, req: Request, username: str, password: str) -> str: + pass + + @abstractmethod + def logout(self, req: Request) -> None: + pass + + @abstractmethod + def signup(self, req: Request, + username: str, password: str, + email: str) -> str: + pass diff --git a/kwikapi/client.py b/kwikapi/client.py index d680ed8..1aad18d 100644 --- a/kwikapi/client.py +++ b/kwikapi/client.py @@ -46,7 +46,7 @@ class Client: def __init__(self, url, version=None, protocol=DEFAULT_PROTOCOL, path=None, request='', timeout=None, dnscache=None, - headers=None, log=DUMMY_LOG): + headers=None, auth=None, log=DUMMY_LOG): headers = headers or {} @@ -59,6 +59,7 @@ def __init__(self, url, version=None, protocol=DEFAULT_PROTOCOL, self._timeout = timeout self._dnscache = dnscache self._headers = CaseInsensitiveDict(headers) + self._auth = auth self._log = log if not self._dnscache: @@ -69,7 +70,7 @@ def _get_state(self): protocol=self._protocol, path=self._path, request=self._request, timeout=self._timeout, dnscache=self._dnscache, headers=self._headers, - log=self._log) + auth=self._auth, log=self._log) def _copy(self, **kwargs): _kwargs = self._get_state() @@ -97,6 +98,9 @@ def _prepare_request(self, post_body, get_params=None): url = '{}?{}'.format(url, urlencode(get_params)) url = self._dnscache.map_url(url) + if self._auth: + self._auth.sign(url, headers, post_body) + return url, post_body, headers @staticmethod diff --git a/setup.py b/setup.py index 364f70a..3252937 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from setuptools import setup, find_packages -version = '0.3.6' +version = '0.3.7' setup( name="kwikapi", version=version, @@ -21,9 +21,9 @@ 'requests==2.18.4', ], extras_require={ - 'django': ['kwikapi-django==0.2.2'], + 'django': ['kwikapi-django==0.2.3'], 'tornado': ['kwikapi-tornado==0.3'], - 'all': ['kwikapi-django==0.2.2', 'kwikapi-tornado==0.3'] + 'all': ['kwikapi-django==0.2.3', 'kwikapi-tornado==0.3'] }, classifiers=[ 'Environment :: Web Environment',