From 4566185cb21ff3f500a0312aab5d05457cc77e8a Mon Sep 17 00:00:00 2001 From: Vamsi Date: Tue, 17 Apr 2018 00:56:15 +0530 Subject: [PATCH 1/3] Initial work against JWT auth middleware --- configrc | 1 + etc/retailstore/auth-paste.ini | 10 +++ ...etailstore-paste.ini => no-auth-paste.ini} | 2 +- requirements-direct.txt | 4 +- retailstore/conf/config.py | 19 ++++++ retailstore/control/auth.py | 16 +++++ retailstore/middleware/__init__.py | 0 retailstore/middleware/jwt.py | 67 +++++++++++++++++++ retailstore/util.py | 35 +++++++++- 9 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 etc/retailstore/auth-paste.ini rename etc/retailstore/{retailstore-paste.ini => no-auth-paste.ini} (66%) create mode 100644 retailstore/control/auth.py create mode 100644 retailstore/middleware/__init__.py create mode 100644 retailstore/middleware/jwt.py diff --git a/configrc b/configrc index d60e03c..0797d0d 100644 --- a/configrc +++ b/configrc @@ -7,3 +7,4 @@ export DB_ADMIN_USER=vamsis export ADMIN_DB=postgres export DB_SERVICE_USER=root export DB_SERVICE_PASSWORD=root +export JWT_SECRET=thisisaverylongsecret \ No newline at end of file diff --git a/etc/retailstore/auth-paste.ini b/etc/retailstore/auth-paste.ini new file mode 100644 index 0000000..61ffedd --- /dev/null +++ b/etc/retailstore/auth-paste.ini @@ -0,0 +1,10 @@ +[app:store-api] +paste.app_factory = retailstore.server:api_app_factory + +[filter:auth] +exempted_routes = ['tokens', 'health'] +paste.filter_factory = retailstore.util:jwt_auth_filter_factory +; paste.filter_factory = keystonemiddleware.auth_token:filter_factory + +[pipeline:main] +pipeline = auth store-api diff --git a/etc/retailstore/retailstore-paste.ini b/etc/retailstore/no-auth-paste.ini similarity index 66% rename from etc/retailstore/retailstore-paste.ini rename to etc/retailstore/no-auth-paste.ini index b50a9d8..78cf616 100644 --- a/etc/retailstore/retailstore-paste.ini +++ b/etc/retailstore/no-auth-paste.ini @@ -2,7 +2,7 @@ paste.app_factory = retailstore.server:api_app_factory [filter:auth] -paste.filter_factory = retailstore.util:auth_filter_factory +paste.filter_factory = retailstore.util:basic_auth_filter_factory [pipeline:main] pipeline = auth store-api diff --git a/requirements-direct.txt b/requirements-direct.txt index 7cf3d54..166fa77 100644 --- a/requirements-direct.txt +++ b/requirements-direct.txt @@ -10,4 +10,6 @@ oslo.db==4.35.0 uWSGI==2.0.17 falcon-marshmallow==0.2.0 marshmallow==2.15.0 -docopt==0.6.2 \ No newline at end of file +docopt==0.6.2 +PyJWT==1.6.1 +Werkzeug==0.14.1 # ideally this is for dev \ No newline at end of file diff --git a/retailstore/conf/config.py b/retailstore/conf/config.py index 4c02e07..a06f274 100644 --- a/retailstore/conf/config.py +++ b/retailstore/conf/config.py @@ -35,12 +35,30 @@ class AppConfig(object): help='The URI database connect string.'), ] + # JWT options + jwt_options = [ + cfg.StrOpt( + 'secret', + help='Secret used for jwt authentication.'), + cfg.StrOpt( + 'algorithm', + default='HS256', + help='Algorithm for jwt', + ), + cfg.StrOpt( + 'token_expiration_seconds', + default='3600', + help='Jwt token expiration time', + ), + ] + def __init__(self): self.conf = cfg.CONF def register_options(self): self.conf.register_opts(AppConfig.options) self.conf.register_opts(AppConfig.database_options, group='database') + self.conf.register_opts(AppConfig.jwt_options, group='jwt') config_mgr = AppConfig() @@ -51,6 +69,7 @@ def list_opts(): opts = { 'DEFAULT': AppConfig.options, 'database': AppConfig.database_options, + 'jwt': AppConfig.jwt_options, } return _tupleize(opts) diff --git a/retailstore/control/auth.py b/retailstore/control/auth.py new file mode 100644 index 0000000..942bd26 --- /dev/null +++ b/retailstore/control/auth.py @@ -0,0 +1,16 @@ +import falcon + +from retailstore.control.base import BaseResource +from retailstore.control.auth import AuthService + + +class TokenResource(BaseResource): + + def on_post(self, req, resp): + auth_service = AuthService(req.context['json']) + + if auth_service.valid(): + resp.body = auth_service.token_info.to_json() + resp.status = falcon.HTTP_201 + else: + resp.status = falcon.HTTP_401 diff --git a/retailstore/middleware/__init__.py b/retailstore/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/retailstore/middleware/jwt.py b/retailstore/middleware/jwt.py new file mode 100644 index 0000000..5741f92 --- /dev/null +++ b/retailstore/middleware/jwt.py @@ -0,0 +1,67 @@ +from datetime import datetime, timedelta +import jwt +import os +import webob.dec +import webob.exc + +from retailstore.errors import ConfigMissingError + + +class JwtAuthentication: + + def __init__(self, token, secret=None): + self.token = token + + self.secret = secret or os.environ.get('JWT_SECRET') + if not self.secret: + raise ConfigMissingError() + + def is_valid(self): + return True + + options = {'verify_exp': True} + user_identifier = 'vamsi.skrishna@gmail.com' + token_expiration_seconds = 3600 + encode_data = { + 'user_identifier': user_identifier, + 'exp': datetime.utcnow() + timedelta(seconds=token_expiration_seconds) + } + token = jwt.encode( + encode_data, + self.secret, + algorithm='HS256' + ).decode("utf-8") + try: + jwt.decode( + token, + self.secret, + verify='True', + algorithms=['HS256'], + options=options + ) + + return True + except jwt.DecodeError: + return True + + +class JwtAuthFilter(object): + """PasteDeploy filter for Jwt Auth.""" + + def __init__(self, app): + self.app = app[0] + + @webob.dec.wsgify + def __call__(self, req): + environ = req.environ + # token = 1 + token = environ.get('AUTHORIZATION', '').partition('Bearer ')[2] + + jwt_auth = JwtAuthentication(token) + if jwt_auth.is_valid(): + response = req.get_response(self.app) + # return self.app(environ, start_response) + else: + response = webob.exc.HTTPUnauthorized() + + return response diff --git a/retailstore/util.py b/retailstore/util.py index 8b49df7..7b86558 100644 --- a/retailstore/util.py +++ b/retailstore/util.py @@ -1,5 +1,20 @@ import falcon +from retailstore.middleware.jwt import JwtAuthFilter +from retailstore import errors + + +class ErrorHandler(object): + + def __init__(self, app): + self.app = app + + def __call__(self, environ, start_response): + import pdb; pdb.set_trace() # breakpoint d4b1fbc9 // + + self.app.add_error_handler( + Exception, errors.default_exception_handler) + return self.app(environ, start_response) class BasicAuthFilter(object): """PasteDeploy filter for Basic Http Auth.""" @@ -11,14 +26,30 @@ def __call__(self, environ, start_response): basic_auth_token = 'open-sesame' header_auth_token = environ.get('HTTP_X_AUTH_TOKEN', basic_auth_token) if header_auth_token == basic_auth_token: - return self.app(environ, start_response) + return [self.app(environ, start_response)] else: raise falcon.HTTPUnauthorized() -def auth_filter_factory(global_config, **local_config): +def basic_auth_filter_factory(global_config, **local_config): """Paste Auth filter factory.""" def auth_filter(app): return BasicAuthFilter(app) return auth_filter + + +def jwt_auth_filter_factory(global_config, exempted_routes=[]): + """Paste Jwt Auth filter factory.""" + def auth_filter(app): + return JwtAuthFilter(app) + + return auth_filter + + +def error_handler_filter_factory(global_config, exempted_routes=[]): + """Paste Jwt Auth filter factory.""" + def error_filter(app): + return ErrorHandler(app) + + return error_filter From 677290e83604c2f66f642a5ec31d16b7b2fe024e Mon Sep 17 00:00:00 2001 From: Vamsi Date: Wed, 18 Apr 2018 01:15:55 +0530 Subject: [PATCH 2/3] finish jwt middleware and tokens resource --- .dockerignore | 3 +- etc/retailstore/auth-paste.ini | 2 +- requirements-direct.txt | 3 +- retailstore/control/auth.py | 16 -------- retailstore/control/auth_token.py | 64 +++++++++++++++++++++++++++++++ retailstore/errors.py | 7 ++++ retailstore/middleware/jwt.py | 36 ++++++----------- retailstore/server.py | 2 + retailstore/util.py | 26 +------------ 9 files changed, 92 insertions(+), 67 deletions(-) delete mode 100644 retailstore/control/auth.py create mode 100644 retailstore/control/auth_token.py diff --git a/.dockerignore b/.dockerignore index 2d009dd..99e74d4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,4 +2,5 @@ .pytest_cache __pycache__ results -*.egg-info \ No newline at end of file +*.egg-info +htmlcov diff --git a/etc/retailstore/auth-paste.ini b/etc/retailstore/auth-paste.ini index 61ffedd..009ed91 100644 --- a/etc/retailstore/auth-paste.ini +++ b/etc/retailstore/auth-paste.ini @@ -2,7 +2,7 @@ paste.app_factory = retailstore.server:api_app_factory [filter:auth] -exempted_routes = ['tokens', 'health'] +exempted_routes = tokens health paste.filter_factory = retailstore.util:jwt_auth_filter_factory ; paste.filter_factory = keystonemiddleware.auth_token:filter_factory diff --git a/requirements-direct.txt b/requirements-direct.txt index 166fa77..baeb405 100644 --- a/requirements-direct.txt +++ b/requirements-direct.txt @@ -12,4 +12,5 @@ falcon-marshmallow==0.2.0 marshmallow==2.15.0 docopt==0.6.2 PyJWT==1.6.1 -Werkzeug==0.14.1 # ideally this is for dev \ No newline at end of file +Werkzeug==0.14.1 # ideally this is for dev +passlib==1.7.1 \ No newline at end of file diff --git a/retailstore/control/auth.py b/retailstore/control/auth.py deleted file mode 100644 index 942bd26..0000000 --- a/retailstore/control/auth.py +++ /dev/null @@ -1,16 +0,0 @@ -import falcon - -from retailstore.control.base import BaseResource -from retailstore.control.auth import AuthService - - -class TokenResource(BaseResource): - - def on_post(self, req, resp): - auth_service = AuthService(req.context['json']) - - if auth_service.valid(): - resp.body = auth_service.token_info.to_json() - resp.status = falcon.HTTP_201 - else: - resp.status = falcon.HTTP_401 diff --git a/retailstore/control/auth_token.py b/retailstore/control/auth_token.py new file mode 100644 index 0000000..8797d0b --- /dev/null +++ b/retailstore/control/auth_token.py @@ -0,0 +1,64 @@ +from datetime import datetime, timedelta +import falcon +import jwt +import os +from passlib.hash import sha256_crypt + +from retailstore.control.base import BaseResource + + +class AuthService: + DATA_STORE = { + 'email': 'vamsi.skrishna@gmail.com', + 'encrypted_password': sha256_crypt.encrypt('s3cr3t') + } + + def __init__( + self, + data, + secret=None, + token_expiration_seconds=3600, + algorithm = 'HS256', + ): + self.email = data['email'] + self.password = data['password'] + self.secret = secret or os.environ.get('JWT_SECRET') + self.token_expiration_seconds = token_expiration_seconds + self.algorithm = algorithm + + def _verify_email(self): + return self.email == self.DATA_STORE['email'] + + def _verify_password(self): + return sha256_crypt.verify( + self.password, self.DATA_STORE['encrypted_password']) + + @property + def _token_expires_at(self): + return datetime.utcnow() + timedelta( + seconds=self.token_expiration_seconds) + + def is_valid(self): + return self._verify_email() and self._verify_password() + + def generate_jwt_token(self): + encode_data = { + 'user_identifier': self.email, + 'exp': self._token_expires_at + } + return jwt.encode( + encode_data, + self.secret, + algorithm=self.algorithm).decode("utf-8") + + +class CollectionResource(BaseResource): + + def on_post(self, req, resp): + auth_service = AuthService(req.context['json']) + + if auth_service.is_valid(): + resp.body = auth_service.generate_jwt_token() + resp.status = falcon.HTTP_201 + else: + resp.status = falcon.HTTP_401 diff --git a/retailstore/errors.py b/retailstore/errors.py index a65e933..a0bb2b1 100644 --- a/retailstore/errors.py +++ b/retailstore/errors.py @@ -144,3 +144,10 @@ class DuplicationResource(RetailStoreException): msg_fmt = "Unable to save Resource=%(table)s data" \ " because of invalid data." code = falcon.HTTP_400 + + +class ConfigMissingError(object): + """docstring for ConfigMissingError""" + + msg_fmt = "Missing Configuration, please setup properly" + code = falcon.HTTP_500 diff --git a/retailstore/middleware/jwt.py b/retailstore/middleware/jwt.py index 5741f92..702a7dd 100644 --- a/retailstore/middleware/jwt.py +++ b/retailstore/middleware/jwt.py @@ -1,4 +1,3 @@ -from datetime import datetime, timedelta import jwt import os import webob.dec @@ -11,56 +10,45 @@ class JwtAuthentication: def __init__(self, token, secret=None): self.token = token - self.secret = secret or os.environ.get('JWT_SECRET') + if not self.secret: raise ConfigMissingError() def is_valid(self): - return True - options = {'verify_exp': True} - user_identifier = 'vamsi.skrishna@gmail.com' - token_expiration_seconds = 3600 - encode_data = { - 'user_identifier': user_identifier, - 'exp': datetime.utcnow() + timedelta(seconds=token_expiration_seconds) - } - token = jwt.encode( - encode_data, - self.secret, - algorithm='HS256' - ).decode("utf-8") try: jwt.decode( - token, + self.token, self.secret, verify='True', algorithms=['HS256'], options=options ) - return True except jwt.DecodeError: - return True + return False -class JwtAuthFilter(object): +class JwtAuthFilter: """PasteDeploy filter for Jwt Auth.""" - def __init__(self, app): + def __init__(self, app, exempted_routes): self.app = app[0] + self.exempted_routes = [ + ('/api/v1.0/%s' % route) + for route in exempted_routes + ] @webob.dec.wsgify def __call__(self, req): - environ = req.environ - # token = 1 - token = environ.get('AUTHORIZATION', '').partition('Bearer ')[2] + if req.path in self.exempted_routes: + return req.get_response(self.app) + token = req.environ.get('HTTP_AUTHORIZATION').partition('Bearer ')[2] jwt_auth = JwtAuthentication(token) if jwt_auth.is_valid(): response = req.get_response(self.app) - # return self.app(environ, start_response) else: response = webob.exc.HTTPUnauthorized() diff --git a/retailstore/server.py b/retailstore/server.py index 4c0947f..6a15373 100644 --- a/retailstore/server.py +++ b/retailstore/server.py @@ -10,12 +10,14 @@ hiera, categories, sub_categories, + auth_token, ) from retailstore import errors def configure_app(app, version=''): v1_0_routes = [ + ('tokens', auth_token.CollectionResource()), ('health', health.HealthResource()), ('locations', locations.CollectionResource()), ('locations/{location_id}', locations.ItemResource()), diff --git a/retailstore/util.py b/retailstore/util.py index 7b86558..d3d8458 100644 --- a/retailstore/util.py +++ b/retailstore/util.py @@ -1,20 +1,6 @@ import falcon from retailstore.middleware.jwt import JwtAuthFilter -from retailstore import errors - - -class ErrorHandler(object): - - def __init__(self, app): - self.app = app - - def __call__(self, environ, start_response): - import pdb; pdb.set_trace() # breakpoint d4b1fbc9 // - - self.app.add_error_handler( - Exception, errors.default_exception_handler) - return self.app(environ, start_response) class BasicAuthFilter(object): """PasteDeploy filter for Basic Http Auth.""" @@ -39,17 +25,9 @@ def auth_filter(app): return auth_filter -def jwt_auth_filter_factory(global_config, exempted_routes=[]): +def jwt_auth_filter_factory(global_config, exempted_routes=''): """Paste Jwt Auth filter factory.""" def auth_filter(app): - return JwtAuthFilter(app) + return JwtAuthFilter(app, exempted_routes.split()) return auth_filter - - -def error_handler_filter_factory(global_config, exempted_routes=[]): - """Paste Jwt Auth filter factory.""" - def error_filter(app): - return ErrorHandler(app) - - return error_filter From c79f58fd38edf821030df2f28fac95f996eab636 Mon Sep 17 00:00:00 2001 From: Vamsi Date: Wed, 18 Apr 2018 01:29:46 +0530 Subject: [PATCH 3/3] use python alpine image --- images/retailstore/Dockerfile | 54 ++++++++++++----------------------- 1 file changed, 19 insertions(+), 35 deletions(-) diff --git a/images/retailstore/Dockerfile b/images/retailstore/Dockerfile index b76255e..62ed0b7 100644 --- a/images/retailstore/Dockerfile +++ b/images/retailstore/Dockerfile @@ -1,41 +1,13 @@ -FROM ubuntu:16.04 +FROM python:3.5-alpine ENV PORT 9000 +ENV PBR_VERSION 1.0.2 + # Expose port 9000 for application EXPOSE $PORT -RUN set -x && \ - apt-get -qq update && \ - apt-get -y install \ - git \ - curl \ - netcat \ - netbase \ - python3 \ - python3-setuptools \ - python3-pip \ - python3-dev \ - python3-dateutil \ - ca-certificates \ - gcc \ - g++ \ - make \ - libffi-dev \ - libssl-dev \ - libpq-dev \ - --no-install-recommends \ - && python3 -m pip install -U pip \ - && apt-get clean \ - && rm -rf \ - /var/lib/apt/lists/* \ - /tmp/* \ - /var/tmp/* \ - /usr/share/man \ - /usr/share/doc \ - /usr/share/doc-base - -RUN useradd -ms /bin/bash retailstore +RUN adduser -S retailstore COPY . /home/retailstore/ @@ -43,10 +15,22 @@ RUN chown -R retailstore: /home/retailstore \ && chmod +x /home/retailstore/entrypoint.sh WORKDIR /home/retailstore -RUN pip3 install -r requirements-direct.txt -RUN python3 setup.py install + +RUN set -e; \ + apk add --no-cache --virtual .build-deps \ + gcc \ + libc-dev \ + linux-headers \ + musl-dev \ + postgresql-dev \ + python3-dev \ + ; \ + pip install -r requirements-direct.txt; \ + apk del .build-deps; + +RUN python setup.py install USER retailstore # Execute entrypoint -ENTRYPOINT ["/home/retailstore/entrypoint.sh"] \ No newline at end of file +ENTRYPOINT ["/home/retailstore/entrypoint.sh"]