Skip to content

Commit

Permalink
Merge pull request #117 from jbernal0019/master
Browse files Browse the repository at this point in the history
Implement JWT-based authentication
  • Loading branch information
jbernal0019 authored Jan 25, 2022
2 parents 33da207 + eed16d3 commit 06a709c
Show file tree
Hide file tree
Showing 10 changed files with 115 additions and 41 deletions.
5 changes: 3 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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}"]

Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions kubernetes/prod/base/secrets/.pfcon.env
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# Compose supports declaring default environment variables in an environment file

SECRET_KEY=
PFCON_USER=
PFCON_PASSWORD=
17 changes: 12 additions & 5 deletions pfcon/app.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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, '/<string:job_id>/', endpoint='api.job')
api.add_resource(JobFile, '/<string:job_id>/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/<string:job_id>/', endpoint='api.job')
api.add_resource(JobFile, '/jobs/<string:job_id>/file/', endpoint='api.jobfile')

return app
8 changes: 7 additions & 1 deletion pfcon/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/'

Expand Down Expand Up @@ -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')
97 changes: 72 additions & 25 deletions pfcon/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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
Expand All @@ -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__()

Expand All @@ -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':
Expand Down Expand Up @@ -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'
}
2 changes: 1 addition & 1 deletion pfcon/wsgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@

os.environ.setdefault("APPLICATION_MODE", "production")

application = create_app()
application = create_app()
3 changes: 2 additions & 1 deletion requirements/base.txt
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions swarm/prod/secrets/.pfcon.env
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# Compose supports declaring default environment variables in an environment file

SECRET_KEY=
PFCON_USER=
PFCON_PASSWORD=
18 changes: 13 additions & 5 deletions tests/test_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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')
Expand All @@ -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)


Expand All @@ -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:
Expand Down

0 comments on commit 06a709c

Please sign in to comment.