diff --git a/alice/auth/auth.py b/alice/auth/auth.py index 2f5787e..db7f849 100644 --- a/alice/auth/auth.py +++ b/alice/auth/auth.py @@ -1,14 +1,14 @@ -import json from typing import Optional, Union import requests from meiga import Failure, Result, Success -from requests import Response, Session +from requests import Session from alice.config import Config from .auth_client import AuthClient from .auth_errors import AuthError +from .token_tools import get_token_from_response DEFAULT_URL = "https://apis.alicebiometrics.com/onboarding" @@ -42,12 +42,13 @@ def __init__( self.url = url self.verbose = verbose - def create_backend_token( - self, user_id: Union[str, None] = None, verbose: Optional[bool] = False + def create_user_token( + self, user_id: str, verbose: Optional[bool] = False ) -> Result[str, AuthError]: """ - Returns a BACKEND_TOKEN or BACKEND_TOKEN_WITH_USER depending of user_id given parameter. - Both BACKEND_TOKEN and BACKEND_TOKEN_WITH_USER are used to secure global requests. + Returns a USER_TOKEN. + The USER_TOKEN is used to secure requests made by the users on their mobile devices or web clients. + Parameters ---------- @@ -56,31 +57,30 @@ def create_backend_token( verbose Used for print service response as well as the time elapsed + Returns ------- - A Result where if the operation is successful it returns BACKEND_TOKEN or BACKEND_TOKEN_WITH_USER. + A Result where if the operation is successful it returns USER_TOKEN. Otherwise, it returns an OnboardingError. """ verbose = self.verbose or verbose - response = self._auth_client.create_backend_token(user_id, verbose=verbose) + response = self._auth_client.create_user_token(user_id, verbose=verbose) if response.status_code == 200: - return Success(self.__get_token_from_response(response)) + return Success(get_token_from_response(response)) else: - suffix = " (with user)" if user_id else "" return Failure( AuthError.from_response( - operation=f"create_backend_token{suffix}", response=response + operation="create_user_token", response=response ) ) - def create_user_token( - self, user_id: str, verbose: Optional[bool] = False + def create_backend_token( + self, user_id: Union[str, None] = None, verbose: Optional[bool] = False ) -> Result[str, AuthError]: """ - Returns a USER_TOKEN. - The USER_TOKEN is used to secure requests made by the users on their mobile devices or web clients. - + Returns a BACKEND_TOKEN or BACKEND_TOKEN_WITH_USER depending of user_id given parameter. + Both BACKEND_TOKEN and BACKEND_TOKEN_WITH_USER are used to secure global requests. Parameters ---------- @@ -89,26 +89,20 @@ def create_user_token( verbose Used for print service response as well as the time elapsed - Returns ------- - A Result where if the operation is successful it returns USER_TOKEN. + A Result where if the operation is successful it returns BACKEND_TOKEN or BACKEND_TOKEN_WITH_USER. Otherwise, it returns an OnboardingError. """ verbose = self.verbose or verbose - response = self._auth_client.create_user_token(user_id, verbose=verbose) + response = self._auth_client.create_backend_token(user_id, verbose=verbose) if response.status_code == 200: - return Success(self.__get_token_from_response(response)) + return Success(get_token_from_response(response)) else: + suffix = " (with user)" if user_id else "" return Failure( AuthError.from_response( - operation="create_user_token", response=response + operation=f"create_backend_token{suffix}", response=response ) ) - - @staticmethod - def __get_token_from_response(response: Response) -> str: - response_json = json.loads(response.content) - token: str = response_json["token"] - return token diff --git a/alice/auth/auth_client.py b/alice/auth/auth_client.py index d55ed1d..771715e 100644 --- a/alice/auth/auth_client.py +++ b/alice/auth/auth_client.py @@ -1,15 +1,27 @@ -import json -import time from typing import Optional, Union from unittest.mock import Mock -import jwt import requests +from meiga import Failure, Result, Success from requests import Response, Session +from alice.auth.cached_token_stack import CachedTokenStack +from alice.auth.token_tools import ( + get_reponse_from_token, + get_token_from_response, + is_valid_token, +) from alice.onboarding.tools import print_intro, print_response, timeit +def get_response_timeout() -> Response: + response = Mock(spec=Response) + response.json.return_value = {"message": "Request timed out"} + response.text.return_value = "Request timed out" + response.status_code = 408 + return response + + class AuthClient: def __init__( self, @@ -20,74 +32,122 @@ def __init__( ): self.url = url self._api_key = api_key - self._login_token: Union[str, None] = None + self._cached_login_token: Union[str, None] = None + self._cached_backend_token: Union[str, None] = None + self._cached_backend_token_stack = CachedTokenStack() + self._cached_user_token_stack = CachedTokenStack() self.session = session self.timeout = timeout @timeit - def create_backend_token( - self, user_id: Union[str, None] = None, verbose: Optional[bool] = False + def create_user_token( + self, user_id: str, verbose: Optional[bool] = False ) -> Response: - suffix = " (with user)" if user_id else "" - print_intro(f"create_backend_token{suffix}", verbose=verbose) + print_intro("create_user_token", verbose=verbose) - if not self._is_valid_token(self._login_token): - response = self._create_login_token() + token = self._cached_user_token_stack.get(user_id) + if token: + return get_reponse_from_token(token) + + result = self._get_login_token() + if result.is_failure: + return result.value # type: ignore + login_token = result.unwrap() + + url = f"{self.url}/user_token/{user_id}" + headers = {"Authorization": f"Bearer {login_token}"} + try: + response = self.session.get(url, headers=headers, timeout=self.timeout) if response.status_code == 200: - self._login_token = self._get_token_from_response(response) - else: - return response + self._cached_user_token_stack.add( + user_id, get_token_from_response(response) + ) + except requests.exceptions.Timeout: + response = get_response_timeout() + + print_response(response=response, verbose=verbose) + + return response - final_url = f"{self.url}/backend_token" + @timeit + def create_backend_token( + self, user_id: Union[str, None] = None, verbose: Optional[bool] = False + ) -> Response: if user_id: - final_url += f"/{user_id}" + return self._create_backend_token_with_user_id(user_id, verbose) + else: + return self._create_backend_token(verbose) + + def _create_backend_token(self, verbose: Optional[bool] = False) -> Response: + print_intro("create_backend_token", verbose=verbose) - headers = {"Authorization": f"Bearer {self._login_token}"} + token = self._get_cached_backend_token() + if token: + return get_reponse_from_token(token) + + result = self._get_login_token() + if result.is_failure: + return result.value # type: ignore + login_token = result.unwrap() + + url = f"{self.url}/backend_token" + headers = {"Authorization": f"Bearer {login_token}"} try: - response = self.session.get( - final_url, headers=headers, timeout=self.timeout - ) + response = self.session.get(url, headers=headers, timeout=self.timeout) + if response.status_code == 200: + self._cached_backend_token = get_token_from_response(response) except requests.exceptions.Timeout: - response = Mock(spec=Response) - response.json.return_value = {"message": "Request timed out"} - response.text.return_value = "Request timed out" - response.status_code = 408 + response = get_response_timeout() print_response(response=response, verbose=verbose) return response - @timeit - def create_user_token( + def _get_cached_backend_token(self) -> Union[str, None]: + if not is_valid_token(self._cached_backend_token): + return None + return self._cached_backend_token + + def _create_backend_token_with_user_id( self, user_id: str, verbose: Optional[bool] = False ) -> Response: + print_intro("create_backend_token (with user)", verbose=verbose) - print_intro("create_user_token", verbose=verbose) + token = self._cached_backend_token_stack.get(user_id) + if token: + return get_reponse_from_token(token) - if not self._is_valid_token(self._login_token): - response = self._create_login_token() - if response.status_code == 200: - self._login_token = self._get_token_from_response(response) - else: - return response + result = self._get_login_token() + if result.is_failure: + return result.value # type: ignore + login_token = result.unwrap() - final_url = f"{self.url}/user_token/{user_id}" - headers = {"Authorization": f"Bearer {self._login_token}"} + url = f"{self.url}/backend_token/{user_id}" + headers = {"Authorization": f"Bearer {login_token}"} try: - response = self.session.get( - final_url, headers=headers, timeout=self.timeout - ) + response = self.session.get(url, headers=headers, timeout=self.timeout) + if response.status_code == 200: + self._cached_backend_token_stack.add( + user_id, get_token_from_response(response) + ) except requests.exceptions.Timeout: - response = Mock(spec=Response) - response.json.return_value = {"message": "Request timed out"} - response.text.return_value = "Request timed out" - response.status_code = 408 + response = get_response_timeout() print_response(response=response, verbose=verbose) return response + def _get_login_token(self) -> Result[str, Response]: + if not is_valid_token(self._cached_login_token): + response = self._create_login_token() + if response.status_code == 200: + self._cached_login_token = get_token_from_response(response) + return Success(self._cached_login_token) + else: + return Failure(response) + return Success(self._cached_login_token) # type: ignore + def _create_login_token(self) -> Response: final_url = f"{self.url}/login_token" headers = {"apikey": self._api_key} @@ -96,22 +156,6 @@ def _create_login_token(self) -> Response: final_url, headers=headers, timeout=self.timeout ) except requests.exceptions.Timeout: - response = Mock(spec=Response) - response.json.return_value = {"message": "Request timed out"} - response.text.return_value = "Request timed out" - response.status_code = 408 + response = get_response_timeout() return response - - @staticmethod - def _is_valid_token(token: Union[str, None], margin_seconds: int = 60) -> bool: - if not token: - return False - decoded_token = jwt.decode(token, options={"verify_signature": False}) - return bool(decoded_token["exp"] > time.time() - margin_seconds) - - @staticmethod - def _get_token_from_response(response: Response) -> str: - response_json = json.loads(response.content) - token: str = response_json["token"] - return token diff --git a/alice/auth/cached_token_stack.py b/alice/auth/cached_token_stack.py new file mode 100644 index 0000000..01d143d --- /dev/null +++ b/alice/auth/cached_token_stack.py @@ -0,0 +1,76 @@ +import sys +from collections import OrderedDict +from typing import Union + +from alice.auth.token_tools import is_valid_token +from alice.onboarding.tools import timeit + + +class CachedTokenStack: + _data: OrderedDict # type: ignore + + def __init__(self, max_size: int = 5000): + self._data = OrderedDict() + self._max_size = max_size + + def __repr__(self) -> str: + size = len(self._data) + memory = sys.getsizeof(self._data) + return f"CachedTokenStack: [size={size} tokens | memory = {memory} bytes]" + + def add(self, user_id: str, token: str) -> None: + self._data[user_id] = token + + def get(self, user_id: str) -> Union[str, None]: + token = self._data.get(user_id) + + if token: + # If token exists take advantage of the saved time and clear expired tokens and keep max size. + self._clear_expired_tokens() + self._clear_if_max_size_has_been_exceeded() + return token # type: ignore + + def __len__(self) -> int: + return len(self._data) + + def show(self) -> None: + print(self.__repr__()) + print( + "----------------------------------- CachedTokenStack -----------------------------------------" + ) + for user_id, token in self._data.items(): + print(f"{user_id} (valid={is_valid_token(token)}): {token} ") + print( + "-------------------------------------------------------------------------------------------------------" + ) + + @timeit + def _clear_expired_tokens(self) -> None: + + num_data = len(self._data) + + if num_data > 0: + + latest_expired_token = None + for i, token in enumerate(reversed(list(self._data.values()))): + if not is_valid_token(token): + latest_expired_token = token + break + + if latest_expired_token: + exist_expired_tokens = True + + while exist_expired_tokens: + _, token = self._data.popitem(last=False) + if token == latest_expired_token: + exist_expired_tokens = False + + def _clear_if_max_size_has_been_exceeded(self) -> None: + num_items = len(self._data) + + if num_items > self._max_size: + num_to_delete = num_items - self._max_size + for _ in range(num_to_delete): + self._data.popitem(last=False) + + num_items = len(self._data) diff --git a/alice/auth/token_tools.py b/alice/auth/token_tools.py new file mode 100644 index 0000000..68cd652 --- /dev/null +++ b/alice/auth/token_tools.py @@ -0,0 +1,24 @@ +import time +from typing import Union +from unittest.mock import Mock + +import jwt +from requests import Response + + +def is_valid_token(token: Union[str, None], margin_seconds: int = 60) -> bool: + if not token: + return False + decoded_token = jwt.decode(token, options={"verify_signature": False}) + return bool(decoded_token["exp"] > time.time() - margin_seconds) + + +def get_token_from_response(response: Response) -> str: + return response.json().get("token") # type: ignore + + +def get_reponse_from_token(token: str) -> Response: + response = Mock(spec=Response) + response.json.return_value = {"token": token} + response.status_code = 200 + return response diff --git a/alice/config.py b/alice/config.py index 4e845aa..e397abd 100644 --- a/alice/config.py +++ b/alice/config.py @@ -16,7 +16,7 @@ class Config: api_key: Union[str, None] = Field(default=None) sandbox_token: Union[str, None] = Field(default=None) timeout: Union[float, None] = Field( - default=None, description="Timeout for every request in seconds", ge=1, le=100 + default=None, description="Timeout for every request in seconds", ge=0, le=100 ) send_agent: bool = Field(default=True) verbose: bool = Field(default=False) diff --git a/examples/dummy_cached_token_stack.py b/examples/dummy_cached_token_stack.py new file mode 100644 index 0000000..6202dfa --- /dev/null +++ b/examples/dummy_cached_token_stack.py @@ -0,0 +1,36 @@ +from uuid import uuid4 + +from alice.auth.cached_token_stack import CachedTokenStack +from tests.test_unit_cached_token_stack import generate_dummy_token + +stack = CachedTokenStack(max_size=100) + +print(stack) + +number_of_expired_tokens = 100 +print(f"Adding {number_of_expired_tokens} expired tokens...") +for i in range(number_of_expired_tokens): + stack.add( + str(uuid4()), + generate_dummy_token(payload_value=f"payload_value_{str(i)}", expired=True), + ) +print("Done") + +print(stack) + +number_valid_tokens = 5000 +print(f"Adding {number_valid_tokens} valid tokens...") +for i in range(number_of_expired_tokens, number_valid_tokens): + stack.add( + str(uuid4()), generate_dummy_token(payload_value=f"payload_value_{str(i)}") + ) +print("Done") + +user_id = str(uuid4()) +stack.add(user_id, generate_dummy_token(payload_value=f"payload_value_{str(i+1)}")) + +print(stack) + +stack.get(user_id) + +print(stack) diff --git a/tests/test_integration_onboarding.py b/tests/test_integration_onboarding.py index da89b18..523c25c 100644 --- a/tests/test_integration_onboarding.py +++ b/tests/test_integration_onboarding.py @@ -2,12 +2,12 @@ from meiga import Error, Result, Success, early_return from alice import Config, DeviceInfo, Onboarding, UserInfo +from alice.auth.auth_errors import AuthError from alice.onboarding.enums.document_side import DocumentSide from alice.onboarding.enums.document_source import DocumentSource from alice.onboarding.enums.document_type import DocumentType from alice.onboarding.enums.version import Version from alice.onboarding.models.report.report import Report -from alice.onboarding.onboarding_errors import OnboardingError @pytest.mark.unit @@ -25,22 +25,15 @@ def test_should_return_an_error_when_the_api_key_is_not_configured(): def test_should_timeout_when_time_exceeded( given_valid_api_key, given_any_selfie_image_media_data ): - config = Config(api_key=given_valid_api_key, timeout=1) + config = Config(api_key=given_valid_api_key, timeout=0.1) onboarding = Onboarding.from_config(config) - user_id = onboarding.create_user( + result = onboarding.create_user( user_info=UserInfo(first_name="Alice", last_name="Biometrics"), device_info=DeviceInfo(device_platform="Android"), - ).unwrap_or_throw() - - result = onboarding.add_selfie( - user_id=user_id, media_data=given_any_selfie_image_media_data - ) - result.assert_failure( - value_is_equal_to=OnboardingError( - operation="add_selfie", code=408, message={"message": "Request timed out"} - ) ) + result.assert_failure(value_is_instance_of=AuthError) + assert result.value.code == 408 @pytest.mark.unit diff --git a/tests/test_integration_webhooks.py b/tests/test_integration_webhooks.py index 4311364..c1224f0 100644 --- a/tests/test_integration_webhooks.py +++ b/tests/test_integration_webhooks.py @@ -77,6 +77,8 @@ def test_should_execute_all_webhook_lifecycle(given_valid_api_key): result = webhooks_client.get_webhooks() assert_success(result, value_is_instance_of=list) + sleep(2.0) + # Retrieve las webhook result of an specific webhook result = webhooks_client.get_last_webhook_result(webhook_id) assert_success(result, value_is_instance_of=dict) diff --git a/tests/test_unit_cached_token_stack.py b/tests/test_unit_cached_token_stack.py new file mode 100644 index 0000000..026fd3c --- /dev/null +++ b/tests/test_unit_cached_token_stack.py @@ -0,0 +1,71 @@ +from datetime import datetime, timedelta, timezone +from uuid import uuid4 + +import jwt +import pytest + +from alice.auth.cached_token_stack import CachedTokenStack + + +def generate_dummy_token( + expired: bool = False, payload_value: str = "payload_value" +) -> str: + if expired: + exp = (datetime.now(timezone.utc) - timedelta(minutes=60)).timestamp() + else: + exp = (datetime.now(timezone.utc) + timedelta(minutes=60)).timestamp() + + encoded_jwt = jwt.encode( + {"id": payload_value, "exp": exp}, "secret", algorithm="HS256" + ) + return encoded_jwt + + +@pytest.mark.unit +class TestCachedTokenStack: + def setup_method(self): + self.user_id = str(uuid4()) + + def should_add_and_get_a_token(self): + stack = CachedTokenStack() + + token = generate_dummy_token() + stack.add("key", token) + retrieved_token = stack.get("key") + + assert token == retrieved_token + + def should_keep_max_size(self): + stack = CachedTokenStack(max_size=3) + + for i in range(6): + stack.add( + str(i), generate_dummy_token(payload_value=f"payload_value_{str(i)}") + ) + + token = stack.get("1") # this forces clear + + assert len(stack) == 3 + + def should_remove_expired_tokens(self): + stack = CachedTokenStack() + + for i in range(4): + stack._data[str(i)] = generate_dummy_token( + payload_value=f"payload_value_{str(i)}", expired=True + ) + + assert len(stack) == 4 + + for i in range(6, 12): + stack.add( + str(i), generate_dummy_token(payload_value=f"payload_value_{str(i)}") + ) + + token = stack.get( + "not_available_user_id" + ) # this will not forces clear since there is no cached token + assert len(stack) == 10 + + token = stack.get(str(i)) # this forces clear + assert len(stack) == 6