diff --git a/Dockerfile b/Dockerfile index 6fbaa54..3b8d837 100755 --- a/Dockerfile +++ b/Dockerfile @@ -48,7 +48,8 @@ RUN apt-get update && locale-gen en_US.UTF-8 \ && dpkg-reconfigure locales \ && apt-get install -y gunicorn \ - && useradd -u $UID -ms /bin/bash localuser + && useradd -u $UID -ms /bin/bash localuser \ + && apt-get clean && rm -rf /var/lib/apt/lists/* COPY ["./requirements", "${REQPATH}"] @@ -58,7 +59,7 @@ COPY --chown=localuser ./pfcon ${APPROOT}/pfcon COPY --chown=localuser ./setup.cfg ./setup.py README.rst ${APPROOT}/ RUN pip install --upgrade pip \ - && pip install -r ${REQPATH}/${ENVIRONMENT}.txt + && pip install --no-cache-dir -r ${REQPATH}/${ENVIRONMENT}.txt # Start as user localuser #USER localuser diff --git a/kubernetes/prod/base/secrets/.pfcon.env b/kubernetes/prod/base/secrets/.pfcon.env index 3ecb9f9..690fad7 100755 --- a/kubernetes/prod/base/secrets/.pfcon.env +++ b/kubernetes/prod/base/secrets/.pfcon.env @@ -1,3 +1,5 @@ # Compose supports declaring default environment variables in an environment file SECRET_KEY= +PFCON_USER= +PFCON_PASSWORD= diff --git a/pfcon/app.py b/pfcon/app.py index c615549..84c123f 100755 --- a/pfcon/app.py +++ b/pfcon/app.py @@ -1,11 +1,11 @@ import os -from flask import Flask +from flask import Flask, request from flask_restful import Api from .config import DevConfig, ProdConfig -from pfcon.resources import JobList, Job, JobFile +from pfcon.resources import HealthCheck, JobList, Job, JobFile, Auth def create_app(config_dict=None): @@ -19,11 +19,18 @@ def create_app(config_dict=None): app.config.from_object(config_obj) app.config.update(config_dict or {}) + @app.before_request + def hook(): + if request.endpoint and request.endpoint not in ('api.auth', 'api.healthcheck'): + Auth.check_token() + api = Api(app, prefix='/api/v1/') # url mappings - api.add_resource(JobList, '/', endpoint='api.joblist') - api.add_resource(Job, '//', endpoint='api.job') - api.add_resource(JobFile, '//file/', endpoint='api.jobfile') + api.add_resource(HealthCheck, '/health/', endpoint='api.healthcheck') + api.add_resource(Auth, '/auth-token/', endpoint='api.auth') + api.add_resource(JobList, '/jobs/', endpoint='api.joblist') + api.add_resource(Job, '/jobs//', endpoint='api.job') + api.add_resource(JobFile, '/jobs//file/', endpoint='api.jobfile') return app diff --git a/pfcon/config.py b/pfcon/config.py index 4478036..faa5fbb 100755 --- a/pfcon/config.py +++ b/pfcon/config.py @@ -10,7 +10,7 @@ class Config: STATIC_FOLDER = 'static' DEBUG = False TESTING = False - SERVER_VERSION = "3.4.0" + SERVER_VERSION = "4.0.0" def __init__(self): # Environment variables @@ -72,6 +72,10 @@ def __init__(self): } }) + self.SECRET_KEY = 'a2oxu^l=@pnsf!5piqz6!!5kdcdpo79y6jebbp+5712yjm*#+q' + self.PFCON_USER = 'pfcon' + self.PFCON_PASSWORD = 'pfcon1234' + # EXTERNAL SERVICES self.COMPUTE_SERVICE_URL = 'http://pman:5010/api/v1/' @@ -125,6 +129,8 @@ def __init__(self): # SECURITY WARNING: keep the secret key used in production secret! env = self.env self.SECRET_KEY = env('SECRET_KEY') + self.PFCON_USER = env('PFCON_USER') + self.PFCON_PASSWORD = env('PFCON_PASSWORD') # EXTERNAL SERVICES self.COMPUTE_SERVICE_URL = env('COMPUTE_SERVICE_URL') diff --git a/pfcon/resources.py b/pfcon/resources.py index 9e40948..c5c0f5a 100755 --- a/pfcon/resources.py +++ b/pfcon/resources.py @@ -2,6 +2,8 @@ import os import logging import zipfile +from datetime import datetime, timedelta +import jwt from flask import request, Response, current_app as app from flask_restful import reqparse, abort, Resource @@ -33,6 +35,10 @@ location='form') parser.add_argument('data_file', dest='data_file', required=True, location='files') +parser_auth = reqparse.RequestParser(bundle_errors=True) +parser_auth.add_argument('pfcon_user', dest='pfcon_user', required=True) +parser_auth.add_argument('pfcon_password', dest='pfcon_password', required=True) + class JobList(Resource): """ @@ -70,11 +76,11 @@ def post(self): logger.error(f'Error while decompressing and storing job {job_id} data, ' f'detail: {str(e)}') abort(400, message='data_file: Bad zip file') - + if self.store_env == 'swift': swift = SwiftStore(app.config) d_info = swift.storeData(job_id, 'incoming', request.files['data_file']) - + logger.info(f'Successfully stored job {job_id} input data') # process compute @@ -98,15 +104,16 @@ def post(self): except ServiceException as e: abort(e.code, message=str(e)) return { - 'data': d_info, - 'compute': d_compute_response - }, 201 + 'data': d_info, + 'compute': d_compute_response + }, 201 class Job(Resource): """ Resource representing a single job running on the compute. """ + def __init__(self): super(Job, self).__init__() @@ -121,24 +128,6 @@ def get(self, job_id): return { 'compute': d_compute_response } - - def delete(self, job_id): - if self.store_env == 'mount': - storebase = app.config.get('STORE_BASE') - job_dir = os.path.join(storebase, 'key-' + job_id) - if not os.path.isdir(job_dir): - abort(404) - mdir = MountDir() - logger.info(f'Deleting job {job_id} data from store') - mdir.delete_data(job_dir) - logger.info(f'Successfully removed job {job_id} data from store') - pman = PmanService.get_service_obj() - try: - pman.delete_job(job_id) - except ServiceException as e: - abort(e.code, message=str(e)) - logger.info(f'Successfully removed job {job_id} from remote compute') - return '', 204 def delete(self, job_id): if self.store_env == 'mount': @@ -181,9 +170,67 @@ def get(self, job_id): logger.info(f'Retrieving job {job_id} output data') content = mdir.get_data(job_id, outgoing_dir) logger.info(f'Successfully retrieved job {job_id} output data') - + if self.store_env == 'swift': swift = SwiftStore(app.config) content = swift.getData(job_id) - + return Response(content, mimetype='application/zip') + + +class Auth(Resource): + """ + Authorization resource. + """ + + def __init__(self): + super(Auth, self).__init__() + + self.pfcon_user = app.config.get('PFCON_USER') + self.pfcon_password = app.config.get('PFCON_PASSWORD') + self.secret_key = app.config.get('SECRET_KEY') + + def post(self): + args = parser_auth.parse_args() + if not self.check_credentials(args.pfcon_user, args.pfcon_password): + abort(400, message='Unable to log in with provided credentials.') + return { + 'token': self.create_token() + } + + def check_credentials(self, user, password): + return user == self.pfcon_user and password == self.pfcon_password + + def create_token(self): + dt = datetime.now() + timedelta(days=2) + return jwt.encode({'pfcon_user': self.pfcon_user, 'exp': dt}, + self.secret_key, + algorithm='HS256') + + @staticmethod + def check_token(): + bearer_token = request.headers.get('Authorization') + if not bearer_token: + abort(401, message='Missing authorization header.') + if not bearer_token.startswith('Bearer '): + abort(401, message='Invalid authorization header.') + token = bearer_token.split(' ')[1] + try: + jwt.decode(token, app.config.get('SECRET_KEY'), algorithms=['HS256']) + except jwt.ExpiredSignatureError: + logger.info(f'Authorization Token {token} expired') + abort(401, message='Expired authorization token.') + except jwt.InvalidTokenError: + logger.info(f'Invalid authorization token {token}') + abort(401, message='Invalid authorization token.') + + +class HealthCheck(Resource): + """ + Health check resource. + """ + + def get(self): + return { + 'health': 'OK' + } diff --git a/pfcon/wsgi.py b/pfcon/wsgi.py index e07cfd2..dae1a44 100755 --- a/pfcon/wsgi.py +++ b/pfcon/wsgi.py @@ -6,4 +6,4 @@ os.environ.setdefault("APPLICATION_MODE", "production") -application = create_app() \ No newline at end of file +application = create_app() diff --git a/requirements/base.txt b/requirements/base.txt index 4cade01..3f1f896 100755 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,8 +1,9 @@ Flask==1.1.2 Flask-RESTful==0.3.8 -requests==2.25.1 +requests==2.27.1 keystoneauth1==4.3.1 python-keystoneclient==4.2.0 python-swiftclient==3.11.1 pfmisc==2.2.4 +PyJWT==2.3.0 environs==9.3.2 diff --git a/setup.py b/setup.py index 19d141b..adbd69f 100755 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ setup( name = 'pfcon', - version = '3.4.1', + version = '4.0.0', description = '(Python) Process and File Controller', long_description = readme, author = 'FNNDSC Developers', diff --git a/swarm/prod/secrets/.pfcon.env b/swarm/prod/secrets/.pfcon.env index 3ecb9f9..690fad7 100755 --- a/swarm/prod/secrets/.pfcon.env +++ b/swarm/prod/secrets/.pfcon.env @@ -1,3 +1,5 @@ # Compose supports declaring default environment variables in an environment file SECRET_KEY= +PFCON_USER= +PFCON_PASSWORD= diff --git a/tests/test_resources.py b/tests/test_resources.py index c1395e3..cc04e96 100755 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -25,6 +25,14 @@ def setUp(self): self.app = create_app() self.client = self.app.test_client() + with self.app.test_request_context(): + # create a header with authorization token + pfcon_user = self.app.config.get('PFCON_USER') + pfcon_password = self.app.config.get('PFCON_PASSWORD') + url = url_for('api.auth') + response = self.client.post(url, data={'pfcon_user': pfcon_user, + 'pfcon_password': pfcon_password}) + self.headers = {'Authorization': 'Bearer ' + response.json['token']} def tearDown(self): # re-enable logging @@ -48,7 +56,7 @@ def tearDown(self): super().tearDown() def test_get(self): - response = self.client.get(self.url) + response = self.client.get(self.url, headers=self.headers) self.assertEqual(response.status_code, 200) self.assertTrue('server_version' in response.json) @@ -77,7 +85,7 @@ def test_post(self): 'data_file': (memory_zip_file, 'data.txt.zip') } # make the POST request - response = self.client.post(self.url, data=data, + response = self.client.post(self.url, data=data, headers=self.headers, content_type='multipart/form-data') self.assertEqual(response.status_code, 201) self.assertIn('compute', response.json) @@ -143,7 +151,7 @@ def test_get(self): # make the GET requests for _ in range(10): time.sleep(3) - response = self.client.get(url) + response = self.client.get(url, headers=self.headers) if response.json['compute']['status'] == 'finishedSuccessfully': break self.assertEqual(response.status_code, 200) self.assertEqual(response.json['compute']['status'], 'finishedSuccessfully') @@ -169,7 +177,7 @@ def test_delete(self): # make the DELETE request time.sleep(3) - response = self.client.delete(url) + response = self.client.delete(url, headers=self.headers) self.assertEqual(response.status_code, 204) @@ -196,7 +204,7 @@ def test_get(self): Path(outgoing).mkdir(parents=True, exist_ok=True) with open(os.path.join(outgoing, 'test.txt'), 'w') as f: f.write('job input test file') - response = self.client.get(url) + response = self.client.get(url, headers=self.headers) self.assertEqual(response.status_code, 200) memory_zip_file = io.BytesIO(response.data) with zipfile.ZipFile(memory_zip_file, 'r', zipfile.ZIP_DEFLATED) as job_zip: