Skip to content
This repository has been archived by the owner on Feb 9, 2024. It is now read-only.

Disallow cross-user access. #12

Merged
merged 3 commits into from
Nov 27, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions tk/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,31 @@ def __init__(self, secret_key, ttl):
self._algorithm = 'HS512'
self._ttl = ttl

def grant_access_token(self):
return jwt.generate_jwt({}, self._jwt_key, self._algorithm,
def grant_access_token(self, user_name):
"""
Grants an access token to the given user.
:param user_name: The name of the user to grant the access token to.
:return: str
"""
claims = {
'user': user_name,
}
return jwt.generate_jwt(claims, self._jwt_key, self._algorithm,
datetime.timedelta(seconds=self._ttl))

def verify_access_token(self, access_token):
"""
Verifies an access token.
:param access_token:
:return: The name of the authenticated user.
"""
try:
jwt.verify_jwt(access_token, self._jwt_key,
[self._algorithm])
jwt_header, jwt_claims = jwt.verify_jwt(access_token, self._jwt_key,
[self._algorithm])
# This is an overly broad exception clause, because:
# 1) the JWT library does not raise exceptions of a single type.
# 2) calling code will convert this to an appropriate HTTP response.
except Exception as e:
return False
return None

return True
return jwt_claims['user']
18 changes: 12 additions & 6 deletions tk/flask/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,10 @@ def checker(*route_method_args, **route_method_kwargs):
if 'access_token' not in request.args:
raise Unauthorized()
access_token = request.args.get('access_token')
if not self.auth.verify_access_token(access_token):
user_name = self.auth.verify_access_token(access_token)
if user_name is None:
raise Forbidden()
request._tk_auth_user_name = user_name
return route_method(*route_method_args, **route_method_kwargs)

return checker
Expand All @@ -93,6 +95,7 @@ def _get_user_password(actual_name):
"""
for name, password in self.config['USERS']:
if name == actual_name:
request._tk_auth_user_name = name
return password
return None

Expand All @@ -101,7 +104,7 @@ def _get_user_password(actual_name):
@request_content_type('')
@response_content_type('text/plain')
def access_token():
return self.auth.grant_access_token()
return self.auth.grant_access_token(request._tk_auth_user_name)

@self.route('/submit', methods=['POST'])
@self.request_access_token
Expand All @@ -111,15 +114,18 @@ def submit():
document = request.get_data()
if not document:
raise BadRequest()
process_id = self.process.submit(document)
process_id = self.process.submit(
request._tk_auth_user_name, document)
return Response(process_id, 200, mimetype='text/plain')

@self.route('/retrieve/<process_id>')
@self.request_access_token
@request_content_type('')
@response_content_type('text/xml')
def retrieve(process_id):
result = self.process.retrieve(process_id)
if result is None:
process = self.process.retrieve(process_id)
if process is None:
raise NotFound()
return Response(result, 200, mimetype='text/xml')
if request._tk_auth_user_name != process[0]:
raise Forbidden()
return Response(process[1], 200, mimetype='text/xml')
14 changes: 10 additions & 4 deletions tk/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@


class Process:

PROGRESS = 'PROGRESS'

def __init__(self, session, sourcebox_url, sourcebox_account_name, sourcebox_user_name, sourcebox_password):
# Values are 2-tuples (user_name: str, result: str).
self._processes = {}
self._process_queue = Queue()
self._session = session
Expand All @@ -21,12 +25,13 @@ def __init__(self, session, sourcebox_url, sourcebox_account_name, sourcebox_use
def _process_queue_worker(self, queue):
while True:
process_id, profile = queue.get()
self._processes[process_id] = profile
self._processes[process_id] = (
self._processes[process_id][0], profile)
queue.task_done()

def submit(self, document):
def submit(self, user_name, document):
process_id = str(uuid.uuid4())
self._processes[process_id] = 'PROGRESS'
self._processes[process_id] = (user_name, self.PROGRESS)
self._session.post(self._sourcebox_url, data={
'account': self._sourcebox_account_name,
'username': self._sourcebox_user_name,
Expand All @@ -50,5 +55,6 @@ def retrieve(self, process_id):
if process_id not in self._processes:
return None
process = self._processes[process_id]
del self._processes[process_id]
if self.PROGRESS != process[1]:
del self._processes[process_id]
return process
17 changes: 11 additions & 6 deletions tk/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,26 @@
class AuthTest(TestCase):
def testGrantShouldProduceAccessToken(self):
auth = Auth('foo', 9)
self.assertRegex(auth.grant_access_token(),
user_name = 'User Foo'
self.assertRegex(auth.grant_access_token(user_name),
'^[^.]+\.[^.]+\.[^.]+$')

def testVerification(self):
auth = Auth('foo', 9)
self.assertTrue(auth.verify_access_token(auth.grant_access_token()))
user_name = 'User Foo'
self.assertEqual(auth.verify_access_token(
auth.grant_access_token(user_name)), user_name)

def testVerifyExpiredToken(self):
auth = Auth('foo', 1)
token = auth.grant_access_token()
user_name = 'User Foo'
token = auth.grant_access_token(user_name)
sleep(2)
self.assertFalse(auth.verify_access_token(token))
self.assertIsNone(auth.verify_access_token(token))

def testVerifyInvalidSignature(self):
auth_a = Auth('foo', 9)
auth_b = Auth('bar', 9)
self.assertFalse(auth_b.verify_access_token(
auth_a.grant_access_token()))
user_name = 'User Foo'
self.assertIsNone(auth_b.verify_access_token(
auth_a.grant_access_token(user_name)))
47 changes: 32 additions & 15 deletions tk/tests/test_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,15 +136,15 @@ def testWithDisallowedMethodShould405(self, method):

def testWithUnsupportedMediaTypeShould415(self):
response = self._flask_app_client.post('/submit', query_string={
'access_token': self._flask_app.auth.grant_access_token(),
'access_token': self._flask_app.auth.grant_access_token('User Foo'),
})
self.assertEquals(415, response.status_code)

def testWithNotAcceptableShould406(self):
response = self._flask_app_client.post('/submit', headers={
'Content-Type': 'application/octet-stream'
}, query_string={
'access_token': self._flask_app.auth.grant_access_token(),
'access_token': self._flask_app.auth.grant_access_token('User Foo'),
})
self.assertEquals(406, response.status_code)

Expand All @@ -153,7 +153,7 @@ def testWithMissingDocumentShould400(self):
'Accept': 'text/plain',
'Content-Type': 'application/octet-stream'
}, query_string={
'access_token': self._flask_app.auth.grant_access_token(),
'access_token': self._flask_app.auth.grant_access_token('User Foo'),
})
self.assertEquals(400, response.status_code)

Expand All @@ -180,7 +180,7 @@ def testSuccess(self, m):
'Accept': 'text/plain',
'Content-Type': 'application/octet-stream'
}, data=b'I am an excellent CV, mind you.', query_string={
'access_token': self._flask_app.auth.grant_access_token(),
'access_token': self._flask_app.auth.grant_access_token('User Foo'),
})
self.assertEquals(200, response.status_code)
# Assert the response contains a plain-text process UUID.
Expand All @@ -199,21 +199,21 @@ def testWithUnsupportedMediaTypeShould415(self):
response = self._flask_app_client.get('/retrieve/foo', headers={
'Content-Type': 'text/plain',
}, query_string={
'access_token': self._flask_app.auth.grant_access_token(),
'access_token': self._flask_app.auth.grant_access_token('User Foo'),
})
self.assertEquals(response.status_code, 415)

def testWithNotAcceptableShould406(self):
response = self._flask_app_client.get('/retrieve/foo', query_string={
'access_token': self._flask_app.auth.grant_access_token(),
'access_token': self._flask_app.auth.grant_access_token('User Foo'),
})
self.assertEquals(response.status_code, 406)

def testWithUnknownProcessIdShould404(self):
response = self._flask_app_client.get('/retrieve/foo', headers={
'Accept': 'text/xml',
}, query_string={
'access_token': self._flask_app.auth.grant_access_token(),
'access_token': self._flask_app.auth.grant_access_token('User Foo'),
})
self.assertEquals(response.status_code, 404)

Expand All @@ -236,21 +236,25 @@ def testWithForbiddenShould403(self):

@requests_mock.mock()
def testSuccessWithUnprocessedDocument(self, m):
user_name = 'User Foo'
m.post(self._flask_app.config['SOURCEBOX_URL'], text=delayed_profile)
process_id = self._flask_app.process.submit(b'I am an excellent CV, mind you.')
process_id = self._flask_app.process.submit(
user_name, b'I am an excellent CV, mind you.')
response = self._flask_app_client.get('/retrieve/%s' % process_id,
headers={
'Accept': 'text/xml',
}, query_string={
'access_token': self._flask_app.auth.grant_access_token(),
'access_token': self._flask_app.auth.grant_access_token(user_name),
})
self.assertEquals(response.status_code, 200)
self.assertEquals(response.get_data(as_text=True), 'PROGRESS')

@requests_mock.mock()
def testSuccessWithProcessedDocument(self, m):
user_name = 'User Foo'
m.post(self._flask_app.config['SOURCEBOX_URL'], text=PROFILE)
process_id = self._flask_app.process.submit(b'I am an excellent CV too, mind you.')
process_id = self._flask_app.process.submit(
user_name, b'I am an excellent CV too, mind you.')
# Sleep to allow asynchronous processing of the document. This is not
# ideal as it could lead to random test failures, but it is
# unavoidable without additional tools.
Expand All @@ -259,14 +263,27 @@ def testSuccessWithProcessedDocument(self, m):
'Accept': 'text/xml',
}
query = {
'access_token': self._flask_app.auth.grant_access_token(),
'access_token': self._flask_app.auth.grant_access_token(user_name),
}
response = self._flask_app_client.get('/retrieve/%s' % process_id,
headers=headers, query_string=query)
response = self._flask_app_client.get('/retrieve/%s' % process_id, headers=headers, query_string=query)
self.assertEquals(response.status_code, 200)
self.assertEquals(response.get_data(as_text=True), PROFILE)

# Confirm the results are no longer available, in order to keep memory consumption reasonable.
response = self._flask_app_client.get('/retrieve/%s' % process_id,
headers=headers, query_string=query)
response = self._flask_app_client.get('/retrieve/%s' % process_id, headers=headers, query_string=query)
self.assertEquals(response.status_code, 404)

@requests_mock.mock()
def testSuccessWithSomeoneElsesDocument(self, m):
my_user_name = 'User Foo'
someone_elses_user_name = 'User Bar'
m.post(self._flask_app.config['SOURCEBOX_URL'], text=delayed_profile)
process_id = self._flask_app.process.submit(
someone_elses_user_name, b'I am an excellent CV, mind you.')
response = self._flask_app_client.get('/retrieve/%s' % process_id,
headers={
'Accept': 'text/xml',
}, query_string={
'access_token': self._flask_app.auth.grant_access_token(my_user_name),
})
self.assertEquals(response.status_code, 403)