Skip to content

Commit 1a41ed7

Browse files
author
EliuX
committed
feat: Ack JWT claims for authentication #94
1 parent 7f64c83 commit 1a41ed7

File tree

7 files changed

+194
-30
lines changed

7 files changed

+194
-30
lines changed

requirements/time_tracker_api/prod.txt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,11 @@ Flask-Script==2.0.6
2626
#Semantic versioning
2727
python-semantic-release==5.2.0
2828

29-
# The Debug Toolbar
29+
#The Debug Toolbar
3030
Flask-DebugToolbar==0.11.0
3131

3232
#CORS
33-
flask-cors==3.0.8
33+
flask-cors==3.0.8
34+
35+
#JWT
36+
PyJWT==1.7.1

tests/conftest.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,50 @@
1+
from datetime import datetime, timedelta
2+
3+
import jwt
14
import pytest
25
from faker import Faker
3-
from flask import Flask
6+
from flask import Flask, url_for
47
from flask.testing import FlaskClient
58

69
from commons.data_access_layer.cosmos_db import CosmosDBRepository, datetime_str, current_datetime
710
from time_tracker_api import create_app
11+
from time_tracker_api.security import get_or_generate_dev_secret_key
812
from time_tracker_api.time_entries.time_entries_model import TimeEntryCosmosDBRepository
913

1014
fake = Faker()
1115
Faker.seed()
1216

17+
TEST_USER = {
18+
"name": "[email protected]",
19+
"password": "secret"
20+
}
21+
22+
23+
class User:
24+
def __init__(self, username, password):
25+
self.username = username
26+
self.password = password
27+
28+
29+
class AuthActions:
30+
"""Auth actions container in tests"""
31+
32+
def __init__(self, app, client):
33+
self._app = app
34+
self._client = client
35+
36+
# def login(self, username=TEST_USER["name"],
37+
# password=TEST_USER["password"]):
38+
# login_url = url_for("security.login", self._app)
39+
# return open_with_basic_auth(self._client,
40+
# login_url,
41+
# username,
42+
# password)
43+
#
44+
# def logout(self):
45+
# return self._client.get(url_for("security.logout", self._app),
46+
# follow_redirects=True)
47+
1348

1449
@pytest.fixture(scope='session')
1550
def app() -> Flask:
@@ -148,3 +183,18 @@ def running_time_entry(time_entry_repository: TimeEntryCosmosDBRepository,
148183

149184
time_entry_repository.delete(id=created_time_entry.id,
150185
partition_key_value=tenant_id)
186+
187+
188+
@pytest.fixture(scope="session")
189+
def valid_jwt(app: Flask) -> str:
190+
expiration_time = datetime.utcnow() + timedelta(seconds=3600)
191+
return jwt.encode({
192+
"iss": "https://securityioet.b2clogin.com/%s/v2.0/" % fake.uuid4(),
193+
"oid": fake.uuid4(),
194+
'exp': expiration_time
195+
}, key=get_or_generate_dev_secret_key()).decode("UTF-8")
196+
197+
198+
@pytest.fixture(scope="session")
199+
def valid_header(valid_jwt: str) -> dict:
200+
return {'Authorization': "Bearer %s" % valid_jwt}

tests/time_tracker_api/activities/activities_namespace_test.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,18 @@
1919
}).update(valid_activity_data)
2020

2121

22-
def test_create_activity_should_succeed_with_valid_request(client: FlaskClient, mocker: MockFixture):
22+
def test_create_activity_should_succeed_with_valid_request(client: FlaskClient,
23+
mocker: MockFixture,
24+
valid_header: dict):
2325
from time_tracker_api.activities.activities_namespace import activity_dao
2426
repository_create_mock = mocker.patch.object(activity_dao.repository,
2527
'create',
2628
return_value=fake_activity)
2729

28-
response = client.post("/activities", json=valid_activity_data, follow_redirects=True)
30+
response = client.post("/activities",
31+
headers=valid_header,
32+
json=valid_activity_data,
33+
follow_redirects=True)
2934

3035
assert HTTPStatus.CREATED == response.status_code
3136
repository_create_mock.assert_called_once()
@@ -57,7 +62,9 @@ def test_list_all_activities(client: FlaskClient, mocker: MockFixture):
5762
repository_find_all_mock.assert_called_once()
5863

5964

60-
def test_get_activity_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture):
65+
def test_get_activity_should_succeed_with_valid_id(client: FlaskClient,
66+
mocker: MockFixture,
67+
valid_header: dict):
6168
from time_tracker_api.activities.activities_namespace import activity_dao
6269

6370
valid_id = fake.random_int(1, 9999)
@@ -66,15 +73,19 @@ def test_get_activity_should_succeed_with_valid_id(client: FlaskClient, mocker:
6673
'find',
6774
return_value=fake_activity)
6875

69-
response = client.get("/activities/%s" % valid_id, follow_redirects=True)
76+
response = client.get("/activities/%s" % valid_id,
77+
headers=valid_header,
78+
follow_redirects=True)
7079

7180
assert HTTPStatus.OK == response.status_code
7281
fake_activity == json.loads(response.data)
7382
repository_find_mock.assert_called_once_with(str(valid_id),
7483
partition_key_value=current_user_tenant_id())
7584

7685

77-
def test_get_activity_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture):
86+
def test_get_activity_should_return_not_found_with_invalid_id(client: FlaskClient,
87+
mocker: MockFixture,
88+
valid_header: dict):
7889
from time_tracker_api.activities.activities_namespace import activity_dao
7990
from werkzeug.exceptions import NotFound
8091

@@ -84,7 +95,9 @@ def test_get_activity_should_return_not_found_with_invalid_id(client: FlaskClien
8495
'find',
8596
side_effect=NotFound)
8697

87-
response = client.get("/activities/%s" % invalid_id, follow_redirects=True)
98+
response = client.get("/activities/%s" % invalid_id,
99+
headers=valid_header,
100+
follow_redirects=True)
88101

89102
assert HTTPStatus.NOT_FOUND == response.status_code
90103
repository_find_mock.assert_called_once_with(str(invalid_id),
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from time_tracker_api.security import parse_jwt, parse_tenant_id_from_iss_claim
2+
3+
4+
def test_parse_jwt_with_valid_input(valid_jwt: str):
5+
result = parse_jwt("Bearer %s" % valid_jwt)
6+
7+
assert result is not None
8+
assert type(result) is dict
9+
10+
11+
def test_parse_jwt_with_invalid_input():
12+
result = parse_jwt("whetever")
13+
14+
assert result is None
15+
16+
17+
def test_parse_tenant_id_from_iss_claim_with_valid_input():
18+
valid_iss_claim = "https://securityioet.b2clogin.com/b21c4e98-c4bf-420f-9d76-e51c2515c7a4/v2.0/"
19+
20+
result = parse_tenant_id_from_iss_claim(valid_iss_claim)
21+
22+
assert result is not None
23+
assert type(result) is str
24+
assert result == "b21c4e98-c4bf-420f-9d76-e51c2515c7a4"
25+
26+
27+
def test_parse_tenant_id_from_iss_claim_with_invalid_input():
28+
invalid_iss_claim1 = "https://securityioet.b2clogin.com/whatever/v2.0/"
29+
invalid_iss_claim2 = ""
30+
31+
result1 = parse_tenant_id_from_iss_claim(invalid_iss_claim1)
32+
result2 = parse_tenant_id_from_iss_claim(invalid_iss_claim2)
33+
34+
assert result1 == result2 == None

time_tracker_api/api.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@
55
from flask_restplus._http import HTTPStatus
66

77
from commons.data_access_layer.cosmos_db import CustomError
8+
from time_tracker_api import security
89
from time_tracker_api.version import __version__
910

1011
faker = Faker()
1112

1213
api = Api(
1314
version=__version__,
1415
title="TimeTracker API",
15-
description="API for the TimeTracker project"
16+
description="API for the TimeTracker project",
17+
authorizations=security.authorizations,
18+
security="TimeTracker JWT",
1619
)
1720

1821
# For matching UUIDs

time_tracker_api/config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import os
22

3-
from time_tracker_api.security import generate_dev_secret_key
3+
from time_tracker_api.security import get_or_generate_dev_secret_key
44

55
DISABLE_STR_VALUES = ("false", "0", "disabled")
66

77

88
class Config:
9-
SECRET_KEY = generate_dev_secret_key()
9+
SECRET_KEY = get_or_generate_dev_secret_key()
1010
SQL_DATABASE_URI = os.environ.get('SQL_DATABASE_URI')
1111
PROPAGATE_EXCEPTIONS = True
1212
RESTPLUS_VALIDATE = True

time_tracker_api/security.py

Lines changed: 79 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,97 @@
22
This is where we handle everything regarding to authorization
33
and authentication. Also stores helper functions related to it.
44
"""
5+
import re
6+
7+
import jwt
58
from faker import Faker
9+
from flask import request
10+
from flask_restplus import abort
11+
from flask_restplus._http import HTTPStatus
12+
from jwt import DecodeError, ExpiredSignatureError
613

714
fake = Faker()
815

916
dev_secret_key: str = None
1017

18+
authorizations = {
19+
"TimeTracker JWT": {
20+
'type': 'apiKey',
21+
'in': 'header',
22+
'name': 'Authorization',
23+
'description': "Specify in the value **'Bearer <JWT>'**, where JWT is the token",
24+
}
25+
}
26+
27+
iss_claim_pattern = re.compile(
28+
r"securityioet.b2clogin.com/(?P<tenant_id>[0-9a-f]{8}\-[0-9a-f]{4}\-4[0-9a-f]{3}\-[89ab][0-9a-f]{3}\-[0-9a-f]{12})")
29+
1130

1231
def current_user_id() -> str:
13-
"""
14-
Returns the id of the authenticated user in
15-
Azure Active Directory
16-
"""
17-
return 'anonymous'
32+
oid_claim = get_token_json().get("oid")
33+
if oid_claim is None:
34+
abort(message='The claim "oid" is missing in the JWT', code=HTTPStatus.UNAUTHORIZED)
35+
36+
return oid_claim
1837

1938

2039
def current_user_tenant_id() -> str:
21-
# TODO Get this from the JWT
22-
return "ioet"
40+
iss_claim = get_token_json().get("iss")
41+
if iss_claim is None:
42+
abort(message='The claim "iss" is missing in the JWT', code=HTTPStatus.UNAUTHORIZED)
43+
44+
tenant_id = parse_tenant_id_from_iss_claim(iss_claim)
45+
if tenant_id is None:
46+
abort(message='The format of the claim "iss" cannot be understood. '
47+
'Please contact the development team.',
48+
code=HTTPStatus.UNAUTHORIZED)
2349

50+
return tenant_id
2451

25-
def generate_dev_secret_key():
26-
from time_tracker_api import flask_app as app
27-
"""
28-
Generates a security key for development purposes
29-
:return: str
30-
"""
52+
53+
def get_or_generate_dev_secret_key():
3154
global dev_secret_key
32-
dev_secret_key = fake.password(length=16, special_chars=True, digits=True, upper_case=True, lower_case=True)
33-
if app.config.get("FLASK_DEBUG", False): # pragma: no cover
34-
print('*********************************************************')
35-
print("The generated secret is \"%s\"" % dev_secret_key)
36-
print('*********************************************************')
55+
if dev_secret_key is None:
56+
from time_tracker_api import flask_app as app
57+
"""
58+
Generates a security key for development purposes
59+
:return: str
60+
"""
61+
dev_secret_key = fake.password(length=16, special_chars=True, digits=True, upper_case=True, lower_case=True)
62+
if app.config.get("FLASK_DEBUG", False): # pragma: no cover
63+
print('*********************************************************')
64+
print("The generated secret is \"%s\"" % dev_secret_key)
65+
print('*********************************************************')
3766
return dev_secret_key
67+
68+
69+
def parse_jwt(authentication_header_content):
70+
if authentication_header_content is not None:
71+
parsed_content = authentication_header_content.split("Bearer ")
72+
73+
if len(parsed_content) > 1:
74+
return jwt.decode(parsed_content[1], verify=False)
75+
76+
return None
77+
78+
79+
def get_authorization_jwt():
80+
auth_header = request.headers.get('Authorization')
81+
return parse_jwt(auth_header)
82+
83+
84+
def get_token_json():
85+
try:
86+
return get_authorization_jwt()
87+
except DecodeError:
88+
abort(message='Malformed token', code=HTTPStatus.UNAUTHORIZED)
89+
except ExpiredSignatureError:
90+
abort(message='Expired token', code=HTTPStatus.UNAUTHORIZED)
91+
92+
93+
def parse_tenant_id_from_iss_claim(iss_claim: str) -> str:
94+
m = iss_claim_pattern.search(iss_claim)
95+
if m is not None:
96+
return m.group('tenant_id')
97+
98+
return None

0 commit comments

Comments
 (0)