Skip to content

Commit 258bcfa

Browse files
perf: Implement mechanism for caching token on login (#146)
* Implement mechanism for caching token on login * Add missing dependency * Add missing cryptography * Add appdirs stubs * Add missing pyfakefs for testing * remove redundant code check * return UNAUTHORIZED error handling * minor imports refactoring * minor refactoring * allow multiple tokens, add global fs mock for tests * add token expiration handlin * fix code checks * patch fs for each test Co-authored-by: Stepan Burlakov <[email protected]>
1 parent 4497348 commit 258bcfa

File tree

9 files changed

+317
-18
lines changed

9 files changed

+317
-18
lines changed

setup.cfg

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ project_urls =
2424
packages = find:
2525
install_requires =
2626
aiorwlock==1.1.0
27+
appdirs>=1.4.4
28+
appdirs-stubs>=0.1.0
2729
async-property==0.2.1
30+
cryptography>=36.0.1
2831
httpx[http2]==0.21.3
2932
pydantic[dotenv]==1.8.2
3033
readerwriterlock==1.0.9
@@ -44,6 +47,7 @@ dev =
4447
devtools==0.7.0
4548
mypy==0.910
4649
pre-commit==2.15.0
50+
pyfakefs>=4.5.3
4751
pytest==6.2.5
4852
pytest-asyncio
4953
pytest-cov==3.0.0

src/firebolt/client/auth.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from firebolt.client.constants import _REQUEST_ERRORS, DEFAULT_API_URL
88
from firebolt.common.exception import AuthenticationError
9+
from firebolt.common.token_storage import TokenSecureStorage
910
from firebolt.common.urls import AUTH_URL
1011
from firebolt.common.util import fix_url_schema
1112

@@ -34,13 +35,18 @@ def from_token(token: str) -> "Auth":
3435
return a
3536

3637
def __init__(
37-
self, username: str, password: str, api_endpoint: str = DEFAULT_API_URL
38+
self,
39+
username: str,
40+
password: str,
41+
api_endpoint: str = DEFAULT_API_URL,
3842
):
3943
self.username = username
4044
self.password = password
45+
self._token_storage = TokenSecureStorage(username=username, password=password)
46+
4147
# Add schema to url if it's missing
4248
self._api_endpoint = fix_url_schema(api_endpoint)
43-
self._token: Optional[str] = None
49+
self._token: Optional[str] = self._token_storage.get_cached_token()
4450
self._expires: Optional[int] = None
4551

4652
def copy(self) -> "Auth":
@@ -56,7 +62,6 @@ def expired(self) -> Optional[int]:
5662

5763
def get_new_token_generator(self) -> Generator[Request, Response, None]:
5864
"""Get new token using username and password"""
59-
6065
try:
6166
response = yield Request(
6267
"POST",
@@ -74,6 +79,9 @@ def get_new_token_generator(self) -> Generator[Request, Response, None]:
7479

7580
self._token = parsed["access_token"]
7681
self._expires = int(time()) + int(parsed["expires_in"])
82+
83+
self._token_storage.cache_token(parsed["access_token"], self._expires)
84+
7785
except _REQUEST_ERRORS as e:
7886
raise AuthenticationError(repr(e), self._api_endpoint)
7987

@@ -83,8 +91,11 @@ def auth_flow(self, request: Request) -> Generator[Request, Response, None]:
8391

8492
if not self.token or self.expired:
8593
yield from self.get_new_token_generator()
94+
8695
request.headers["Authorization"] = f"Bearer {self.token}"
96+
8797
response = yield request
98+
8899
if response.status_code == codes.UNAUTHORIZED:
89100
yield from self.get_new_token_generator()
90101
request.headers["Authorization"] = f"Bearer {self.token}"
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
from base64 import b64decode, b64encode, urlsafe_b64encode
2+
from hashlib import sha256
3+
from json import JSONDecodeError
4+
from json import dump as json_dump
5+
from json import load as json_load
6+
from os import makedirs, path, urandom
7+
from time import time
8+
from typing import Optional
9+
10+
from appdirs import user_data_dir
11+
from cryptography.fernet import Fernet, InvalidToken
12+
from cryptography.hazmat.primitives import hashes
13+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
14+
15+
APPNAME = "firebolt"
16+
17+
18+
def generate_salt() -> str:
19+
return b64encode(urandom(16)).decode("ascii")
20+
21+
22+
def generate_file_name(username: str, password: str) -> str:
23+
username_hash = sha256(username.encode("utf-8")).hexdigest()[:32]
24+
password_hash = sha256(password.encode("utf-8")).hexdigest()[:32]
25+
26+
return f"{username_hash}{password_hash}.json"
27+
28+
29+
class TokenSecureStorage:
30+
def __init__(self, username: str, password: str):
31+
"""
32+
Class for permanent storage of token in the filesystem in encrypted way
33+
34+
:param username: username used for toke encryption
35+
:param password: password used for toke encryption
36+
"""
37+
self._data_dir = user_data_dir(appname=APPNAME)
38+
makedirs(self._data_dir, exist_ok=True)
39+
40+
self._token_file = path.join(
41+
self._data_dir, generate_file_name(username, password)
42+
)
43+
44+
self.salt = self._get_salt()
45+
self.encrypter = FernetEncrypter(self.salt, username, password)
46+
47+
def _get_salt(self) -> str:
48+
"""
49+
Get salt from the file if exists, or generate a new one
50+
51+
:return: salt
52+
"""
53+
res = self._read_data_json()
54+
return res.get("salt", generate_salt())
55+
56+
def _read_data_json(self) -> dict:
57+
"""
58+
Read json token file
59+
60+
:return: json object as dict
61+
"""
62+
if not path.exists(self._token_file):
63+
return {}
64+
65+
with open(self._token_file) as f:
66+
try:
67+
return json_load(f)
68+
except JSONDecodeError:
69+
return {}
70+
71+
def get_cached_token(self) -> Optional[str]:
72+
"""
73+
Get decrypted token using username and password
74+
If the token not found or token cannot be decrypted using username, password
75+
None will be returned
76+
77+
:return: token or None
78+
"""
79+
res = self._read_data_json()
80+
if "token" not in res:
81+
return None
82+
83+
# Ignore expired tokens
84+
if "expiration" in res and res["expiration"] <= int(time()):
85+
return None
86+
87+
return self.encrypter.decrypt(res["token"])
88+
89+
def cache_token(self, token: str, expiration_ts: int) -> None:
90+
"""
91+
92+
:param token:
93+
:return:
94+
"""
95+
token = self.encrypter.encrypt(token)
96+
97+
with open(self._token_file, "w") as f:
98+
json_dump(
99+
{"token": token, "salt": self.salt, "expiration": expiration_ts}, f
100+
)
101+
102+
103+
class FernetEncrypter:
104+
def __init__(self, salt: str, username: str, password: str):
105+
"""
106+
107+
:param salt:
108+
:param username:
109+
:param password:
110+
"""
111+
112+
kdf = PBKDF2HMAC(
113+
algorithm=hashes.SHA256(),
114+
salt=b64decode(salt),
115+
length=32,
116+
iterations=39000,
117+
)
118+
self.fernet = Fernet(
119+
urlsafe_b64encode(
120+
kdf.derive(bytes(f"{username}{password}", encoding="utf-8"))
121+
)
122+
)
123+
124+
def encrypt(self, data: str) -> str:
125+
return self.fernet.encrypt(bytes(data, encoding="utf-8")).decode("utf-8")
126+
127+
def decrypt(self, data: str) -> Optional[str]:
128+
try:
129+
return self.fernet.decrypt(bytes(data, encoding="utf-8")).decode("utf-8")
130+
except InvalidToken:
131+
return None

tests/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from pyfakefs.fake_filesystem_unittest import Patcher
2+
from pytest import fixture
3+
4+
5+
@fixture(autouse=True)
6+
def global_fake_fs() -> None:
7+
with Patcher():
8+
yield

tests/unit/client/test_auth.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import pytest
44
from httpx import Client, Request, StreamError, codes
5+
from pyfakefs.fake_filesystem import FakeFilesystem
56
from pytest_httpx import HTTPXMock
67
from pytest_mock import MockerFixture
78

@@ -30,9 +31,7 @@ def test_auth_basic(
3031

3132

3233
def test_auth_refresh_on_expiration(
33-
httpx_mock: HTTPXMock,
34-
test_token: str,
35-
test_token2: str,
34+
httpx_mock: HTTPXMock, test_token: str, test_token2: str, fs: FakeFilesystem
3635
):
3736
"""Auth refreshes the token on expiration."""
3837

@@ -56,9 +55,7 @@ def test_auth_refresh_on_expiration(
5655

5756

5857
def test_auth_uses_same_token_if_valid(
59-
httpx_mock: HTTPXMock,
60-
test_token: str,
61-
test_token2: str,
58+
httpx_mock: HTTPXMock, test_token: str, test_token2: str, fs: FakeFilesystem
6259
):
6360
"""Auth refreshes the token on expiration"""
6461

@@ -92,7 +89,7 @@ def test_auth_uses_same_token_if_valid(
9289
httpx_mock.reset(False)
9390

9491

95-
def test_auth_error_handling(httpx_mock: HTTPXMock):
92+
def test_auth_error_handling(httpx_mock: HTTPXMock, fs: FakeFilesystem):
9693
"""Auth handles various errors properly."""
9794

9895
for api_endpoint in ("https://host", "host"):

tests/unit/client/test_auth_async.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from httpx import AsyncClient, Request, codes
2+
from pyfakefs.fake_filesystem import FakeFilesystem
23
from pytest import mark
34
from pytest_httpx import HTTPXMock
45

@@ -8,9 +9,7 @@
89

910
@mark.asyncio
1011
async def test_auth_refresh_on_expiration(
11-
httpx_mock: HTTPXMock,
12-
test_token: str,
13-
test_token2: str,
12+
httpx_mock: HTTPXMock, test_token: str, test_token2: str, fs: FakeFilesystem
1413
):
1514
"""Auth refreshes the token on expiration."""
1615

@@ -39,9 +38,7 @@ async def test_auth_refresh_on_expiration(
3938

4039
@mark.asyncio
4140
async def test_auth_uses_same_token_if_valid(
42-
httpx_mock: HTTPXMock,
43-
test_token: str,
44-
test_token2: str,
41+
httpx_mock: HTTPXMock, test_token: str, test_token2: str, fs: FakeFilesystem
4542
):
4643
"""Auth refreshes the token on expiration"""
4744

@@ -81,8 +78,7 @@ async def test_auth_uses_same_token_if_valid(
8178

8279
@mark.asyncio
8380
async def test_auth_adds_header(
84-
httpx_mock: HTTPXMock,
85-
test_token: str,
81+
httpx_mock: HTTPXMock, test_token: str, fs: FakeFilesystem
8682
):
8783
"""Auth adds required authentication headers to httpx.Request."""
8884
httpx_mock.add_response(

tests/unit/client/test_client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from typing import Callable
22

33
from httpx import codes
4+
from pyfakefs.fake_filesystem import FakeFilesystem
45
from pytest import raises
56
from pytest_httpx import HTTPXMock
67

@@ -15,6 +16,7 @@ def test_client_retry(
1516
test_username: str,
1617
test_password: str,
1718
test_token: str,
19+
fs: FakeFilesystem,
1820
):
1921
"""
2022
Client retries with new auth token
@@ -56,6 +58,7 @@ def test_client_different_auths(
5658
test_username: str,
5759
test_password: str,
5860
test_token: str,
61+
fs: FakeFilesystem,
5962
):
6063
"""
6164
Client properly handles such auth types:

tests/unit/client/test_client_async.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from typing import Callable
22

33
from httpx import codes
4+
from pyfakefs.fake_filesystem import FakeFilesystem
45
from pytest import mark, raises
56
from pytest_httpx import HTTPXMock
67

@@ -16,6 +17,7 @@ async def test_client_retry(
1617
test_username: str,
1718
test_password: str,
1819
test_token: str,
20+
fs: FakeFilesystem,
1921
):
2022
"""
2123
Client retries with new auth token
@@ -58,6 +60,7 @@ async def test_client_different_auths(
5860
test_username: str,
5961
test_password: str,
6062
test_token: str,
63+
fs: FakeFilesystem,
6164
):
6265
"""
6366
Client properly handles such auth types:

0 commit comments

Comments
 (0)