From 76874068d5f15773d46c357cd8279bd21d8b2cdf Mon Sep 17 00:00:00 2001 From: Promise Fru Date: Wed, 12 Jun 2024 22:18:39 +0100 Subject: [PATCH 1/5] feat(api): add StoreEntityToken gRPC function --- protos/v1/publisher.proto | 71 ++++++++++++++ protos/v1/vault.proto | 24 +++++ src/grpc_entity_service.py | 177 ++++++++++++++++++++++++++--------- src/grpc_publisher_client.py | 162 ++++++++++++++++++++++++++++++++ src/long_lived_token.py | 4 +- src/utils.py | 16 +++- 6 files changed, 405 insertions(+), 49 deletions(-) create mode 100644 protos/v1/publisher.proto create mode 100644 src/grpc_publisher_client.py diff --git a/protos/v1/publisher.proto b/protos/v1/publisher.proto new file mode 100644 index 0000000..da7518d --- /dev/null +++ b/protos/v1/publisher.proto @@ -0,0 +1,71 @@ +syntax = "proto3"; + +package publisher.v1; + +// Request message for getting the authorization URL +message GetAuthorizationUrlRequest { + // List of scopes for the authorization + repeated string scope = 1; + // Redirect URI for the authorization response + string redirect_uri = 2; + // Client ID of the application + string client_id = 3; + // URL of the authorization server's authorization endpoint. + string authorization_endpoint = 4; + // Optional state parameter to prevent CSRF attacks. If not given, it will be generated for you + string state = 5; + // Optional code verifier for PKCE (Proof Key for Code Exchange) + string code_verifier = 6; + // Flag to indicate if the code verifier should be auto-generated + bool autogenerate_code_verifier = 7; + // Optional access type parameter (online or offline) + string access_type = 8; + // Optional prompt parameter (none, consent, select_account) + string prompt = 9; +} + +// Response message for the GetAuthorizationUrl RPC +message GetAuthorizationUrlResponse { + // The authorization URL + string authorization_url = 1; + // The state parameter for preventing CSRF attacks + string state = 2; + // The code verifier used for PKCE + string code_verifier = 3; + // Optional message providing additional information + string message = 4; +} + +// Request message for exchanging the OAuth2 authorization code for a token +message ExchangeOAuth2CodeRequest { + // The authorization code received from the authorization server + string authorization_code = 1; + // The redirect URI used in the authorization request + string redirect_uri = 2; + // Client ID of the application + string client_id = 3; + // Client secret of the application + string client_secret = 4; + // Optional code verifier for PKCE (Proof Key for Code Exchange) + string code_verifier = 5; + // URL of the authorization server's token endpoint. + string token_endpoint = 6; +} + +// Response message for the ExchangeOAuth2Code RPC +message ExchangeOAuth2CodeResponse { + // Token information (JSON string) + string token = 1; + // Profile information (JSON string) + string profile = 2; + // Optional message providing additional information + string message = 3; +} + +// Service definition for Publisher +service Publisher { + // RPC for getting the authorization URL + rpc GetAuthorizationUrl(GetAuthorizationUrlRequest) returns (GetAuthorizationUrlResponse); + // RPC for exchanging OAuth2 authorization code for a token + rpc ExchangeOAuth2Code(ExchangeOAuth2CodeRequest) returns (ExchangeOAuth2CodeResponse); +} diff --git a/protos/v1/vault.proto b/protos/v1/vault.proto index 9c13d75..2f384bd 100644 --- a/protos/v1/vault.proto +++ b/protos/v1/vault.proto @@ -86,6 +86,28 @@ message Token { string account_identifier = 2; } +// Request message for storing an entity's token. +message StoreEntityTokenRequest { + // The long-lived token of the authenticated entity. + string long_lived_token = 1; + // The authorization code for the token. + string authorization_code = 2; + // The code verifier for the token. + string code_verifier = 3; + // The platform associated with the token. + string platform = 4; + // The protocol used for the request. + string protocol = 5; +} + +// Response message for storing an entity's token. +message StoreEntityTokenResponse { + // A response message. + string message = 1; + // Indicates whether the operation was successful. + bool success = 2; +} + // Service for managing entities. service Entity { // Creates an entity. @@ -94,4 +116,6 @@ service Entity { rpc AuthenticateEntity (AuthenticateEntityRequest) returns (AuthenticateEntityResponse); // Lists stored tokens for an entity. rpc ListEntityStoredTokens (ListEntityStoredTokenRequest) returns (ListEntityStoredTokenResponse); + // Stores a token for an entity. + rpc StoreEntityToken (StoreEntityTokenRequest) returns (StoreEntityTokenResponse); } diff --git a/src/grpc_entity_service.py b/src/grpc_entity_service.py index eb28950..4965144 100644 --- a/src/grpc_entity_service.py +++ b/src/grpc_entity_service.py @@ -10,7 +10,7 @@ import vault_pb2_grpc from src.entity import create_entity, find_entity -from src.tokens import fetch_entity_tokens +from src.tokens import fetch_entity_tokens, create_entity_token from src.crypto import generate_hmac, verify_hmac from src.otp_service import send_otp, verify_otp from src.utils import ( @@ -27,10 +27,14 @@ ) from src.long_lived_token import generate_llt, verify_llt from src.device_id import compute_device_id +from src.grpc_publisher_client import exchange_oauth2_code HASHING_KEY = load_key(get_configs("HASHING_SALT"), 32) KEYSTORE_PATH = get_configs("KEYSTORE_PATH") +logging.basicConfig( + level=logging.INFO, format=("%(asctime)s - %(name)s - %(levelname)s - %(message)s") +) logger = logging.getLogger(__name__) @@ -52,11 +56,6 @@ def error_response(context, response, sys_msg, status_code, user_msg=None, _type if not user_msg: user_msg = sys_msg - if isinstance(user_msg, tuple): - user_msg = "".join(user_msg) - if isinstance(sys_msg, tuple): - sys_msg = "".join(sys_msg) - if _type == "UNKNOWN": logger.exception(sys_msg, exc_info=True) else: @@ -161,6 +160,75 @@ def handle_pow_initialization(context, request, response): return success, (message, expires) +def verify_long_lived_token(request, context, response): + """ + Verifies the long-lived token from the request. + + Args: + context: gRPC context. + request: gRPC request object. + response: gRPC response object. + + Returns: + tuple: Tuple containing entity object, and error response. + """ + eid, llt = request.long_lived_token.split(":", 1) + + entity_obj = find_entity(eid=eid) + if not entity_obj: + return None, error_response( + context, + response, + f"Possible token tampering detected. Entity not found with eid: {eid}", + grpc.StatusCode.UNAUTHENTICATED, + user_msg=( + "Your session has expired or the token is invalid. " + "Please log in again to generate a new token." + ), + _type="UNKNOWN", + ) + + entity_crypto_metadata = load_crypto_metadata( + decrypt_and_decode(entity_obj.server_crypto_metadata) + ) + entity_device_id_keypair = entity_crypto_metadata.device_id_keypair + entity_device_id_shared_key = get_shared_key( + os.path.join(KEYSTORE_PATH, f"{entity_obj.eid.hex}_device_id.db"), + entity_device_id_keypair.pnt_keystore, + entity_device_id_keypair.secret_key, + base64.b64decode(entity_obj.client_device_id_pub_key), + ) + + llt_payload, llt_error = verify_llt(llt, entity_device_id_shared_key) + + if not llt_payload: + return None, error_response( + context, + response, + llt_error, + grpc.StatusCode.UNAUTHENTICATED, + user_msg=( + "Your session has expired or the token is invalid. " + "Please log in again to generate a new token." + ), + ) + + if llt_payload["eid"] != eid: + return None, error_response( + context, + response, + f"Possible token tampering detected. EID mismatch: {eid}", + grpc.StatusCode.UNAUTHENTICATED, + user_msg=( + "Your session has expired or the token is invalid. " + "Please log in again to generate a new token." + ), + _type="UNKNOWN", + ) + + return entity_obj, None + + class EntityService(vault_pb2_grpc.EntityServicer): """Entity Service Descriptor""" @@ -477,39 +545,11 @@ def ListEntityStoredTokens(self, request, context): if invalid_fields_response: return invalid_fields_response - eid, llt = request.long_lived_token.split(":", 1) - - entity_obj = find_entity(eid=eid) - if not entity_obj: - raise ValueError( - f"Possible token tampering detected. Invalid token for eid: {eid}" - ) - - entity_crypto_metadata = load_crypto_metadata( - decrypt_and_decode(entity_obj.server_crypto_metadata) + entity_obj, llt_error_response = verify_long_lived_token( + request, context, response ) - entity_device_id_keypair = entity_crypto_metadata.device_id_keypair - entity_device_id_shared_key = get_shared_key( - os.path.join(KEYSTORE_PATH, f"{entity_obj.eid.hex}_device_id.db"), - entity_device_id_keypair.pnt_keystore, - entity_device_id_keypair.secret_key, - base64.b64decode(entity_obj.client_device_id_pub_key), - ) - - llt_payload, llt_error = verify_llt(llt, entity_device_id_shared_key) - - if not llt_payload: - return error_response( - context, - response, - llt_error, - grpc.StatusCode.UNAUTHENTICATED, - ) - - if llt_payload["eid"] != eid: - raise ValueError( - f"Possible token tampering detected. Invalid token for eid: {eid}" - ) + if llt_error_response: + return llt_error_response tokens = fetch_entity_tokens( entity_obj, True, ["account_identifier", "platform"] @@ -525,19 +565,68 @@ def ListEntityStoredTokens(self, request, context): stored_tokens=tokens, message="Tokens retrieved successfully." ) - except ValueError as e: + except Exception as e: return error_response( context, response, e, - grpc.StatusCode.UNAUTHENTICATED, - user_msg=( - "Your session has expired or the token is invalid. ", - "Please log in again to generate a new token.", - ), + grpc.StatusCode.INTERNAL, + user_msg="Oops! Something went wrong. Please try again later.", _type="UNKNOWN", ) + def StoreEntityToken(self, request, context): + """Handles storing tokens for an entiry""" + + response = vault_pb2.StoreEntityTokenResponse + + try: + invalid_fields_response = validate_request_fields( + context, + request, + response, + ["long_lived_token", "authorization_code", "platform", "protocol"], + ) + if invalid_fields_response: + return invalid_fields_response + + entity_obj, llt_error_response = verify_long_lived_token( + request, context, response + ) + if llt_error_response: + return llt_error_response + + if request.protocol.lower() == "oauth2": + oauth2_response, oauth2_error = exchange_oauth2_code( + request.platform, + request.authorization_code, + getattr(request, "code_verifier"), + ) + + if oauth2_error: + return response(message=oauth2_error, success=False) + + new_token = { + "entity": entity_obj, + "platform": request.platform, + "account_identifier_hash": "id", + "account_identifier": "id", + "account_tokens": encrypt_and_encode(oauth2_response.token), + } + create_entity_token(**new_token) + + logger.info("Successfully stored tokens for %s", entity_obj.eid) + + return response(message="Token stored successfully.", success=True) + + except NotImplementedError as e: + return error_response( + context, + response, + str(e), + grpc.StatusCode.UNIMPLEMENTED, + ) + except Exception as e: return error_response( context, diff --git a/src/grpc_publisher_client.py b/src/grpc_publisher_client.py new file mode 100644 index 0000000..67e44ea --- /dev/null +++ b/src/grpc_publisher_client.py @@ -0,0 +1,162 @@ +"""gRPC Publisher Client""" + +import logging +import json +import grpc + +import publisher_pb2 +import publisher_pb2_grpc + +from src.utils import get_configs + +logging.basicConfig( + level=logging.INFO, format=("%(asctime)s - %(name)s - %(levelname)s - %(message)s") +) +logger = logging.getLogger(__name__) + + +def get_channel(): + """Get the appropriate gRPC channel based on the mode. + + Returns: + grpc.Channel: The gRPC channel. + """ + mode = get_configs("MODE", False, "development") + hostname = get_configs("PUBLISHER_GRPC_HOST") + port = get_configs("PUBLISHER_GRPC_PORT") + secure_port = get_configs("PUBLISHER_GRPC_SSL_PORT") + server_certificate = get_configs("SSL_CERTIFICATE") + private_key = get_configs("SSL_KEY") + + if mode == "production": + with open(server_certificate, "rb") as cert_file, open( + private_key, "rb" + ) as key_file: + credentials = grpc.ssl_channel_credentials( + root_certificates=cert_file.read(), private_key=key_file.read() + ) + return grpc.secure_channel(f"{hostname}:{secure_port}", credentials) + + return grpc.insecure_channel(f"{hostname}:{port}") + + +def get_platform_creds(platform): + """Get platform credentials based on platform name. + + Args: + platform (str): The platform name. + Returns: + dict: Platform credentials. + """ + creds_path = get_configs(f"{platform.upper()}_CREDENTIALS") + + if creds_path is None: + raise NotImplementedError( + f"The platform '{platform}' is currently not supported. " + "Please contact the developers for more information on when " + "this platform will be implemented." + ) + + oauth2_credentials = load_oauth2_credentials(creds_path) + + redirect_uris = oauth2_credentials.get("redirect_uris", []) + redirect_uri = oauth2_credentials.get("redirect_uri", "") + + if redirect_uris: + redirect_uri = redirect_uris[0] + + return { + "auth_uri": oauth2_credentials.get("auth_uri", ""), + "token_uri": oauth2_credentials.get("token_uri", ""), + "client_id": oauth2_credentials.get("client_id", ""), + "client_secret": oauth2_credentials.get("client_secret", ""), + "redirect_uri": redirect_uri, + } + + +def load_oauth2_credentials(file_path): + """ + Load OAuth2 credentials from a JSON file. + + Args: + file_path (str): The path to the JSON file containing OAuth2 credentials. + + Returns: + dict: OAuth2 credentials or an empty dictionary if not found. + """ + + def find_credentials_recursive(data): + """ + Recursively search for OAuth2 credentials in a nested dictionary. + + Args: + data (dict): The nested dictionary to search. + + Returns: + dict: OAuth2 credentials if found, otherwise an empty dictionary. + """ + if isinstance(data, dict): + if "client_id" in data and "client_secret" in data: + return data + for value in data.values(): + credentials = find_credentials_recursive(value) + if credentials: + return credentials + return {} + + try: + with open(file_path, "r", encoding="utf-8") as file: + data = json.load(file) + credentials = find_credentials_recursive(data) + if credentials: + return credentials + + logger.warning("OAuth2 credentials not found in '%s'", file_path) + return {} + except FileNotFoundError: + logger.error("OAuth2 credentials file not found: '%s'", file_path) + except json.JSONDecodeError: + logger.error( + "Error decoding JSON from OAuth2 credentials file: '%s'", file_path + ) + except Exception as error: + logger.error("Error decoding JSON from OAuth2 credentials file: '%s'", error) + return {} + + +def exchange_oauth2_code(platform, authorization_code, code_verifier=None): + """ + Exchange OAuth2 authorization code for access token and profile information. + + Args: + platform (str): The platform name. + authorization_code (str): The OAuth2 authorization code. + code_verifier (str, optional): The OAuth2 code verifier. Defaults to None. + Returns: + tuple: A tuple containing the access token and profile information. + """ + try: + channel = get_channel() + stub = publisher_pb2_grpc.PublisherStub(channel) + + platform_creds = get_platform_creds(platform) + + request = publisher_pb2.ExchangeOAuth2CodeRequest( + authorization_code=authorization_code, + code_verifier=code_verifier, + redirect_uri=platform_creds["redirect_uri"], + client_id=platform_creds["client_id"], + client_secret=platform_creds["client_secret"], + token_endpoint=platform_creds["token_uri"], + ) + + response = stub.ExchangeOAuth2Code(request) + return (response, None) + except grpc.RpcError as e: + if e.code() == grpc.StatusCode.INVALID_ARGUMENT: + logger.error(e) + details = e.details() + return (None, details) + raise e + except Exception as e: + raise e diff --git a/src/long_lived_token.py b/src/long_lived_token.py index 0eb9dd5..d852ecd 100644 --- a/src/long_lived_token.py +++ b/src/long_lived_token.py @@ -13,7 +13,9 @@ from src.crypto import encrypt_fernet from src.utils import convert_to_fernet_key -logging.basicConfig(level=logging.INFO) +logging.basicConfig( + level=logging.INFO, format=("%(asctime)s - %(name)s - %(levelname)s - %(message)s") +) logger = logging.getLogger(__name__) diff --git a/src/utils.py b/src/utils.py index da1032c..3b3ce5b 100644 --- a/src/utils.py +++ b/src/utils.py @@ -130,23 +130,31 @@ def wrapper(*args, **kwargs): return decorator -def get_configs(config_name: str, strict: bool = False) -> str: +def get_configs(config_name, strict=False, default_value=None): """ Retrieves the value of a configuration from the environment variables. Args: config_name (str): The name of the configuration to retrieve. - strict (bool): If True, raises an error if the configuration is not found. Default is False. + strict (bool): If True, raises an error if the configuration + is not found. Default is False. + default_value (str): The default value to return if the configuration + is not found and strict is False. Default is None. Returns: - str: The value of the configuration, or None if not found and strict is False. + str: The value of the configuration, or default_value if not found and s + trict is False. Raises: KeyError: If the configuration is not found and strict is True. ValueError: If the configuration value is empty and strict is True. """ try: - value = os.environ[config_name] if strict else os.environ.get(config_name) + value = ( + os.environ[config_name] + if strict + else os.environ.get(config_name) or default_value + ) if strict and (value is None or value.strip() == ""): raise ValueError(f"Configuration '{config_name}' is missing or empty.") return value From 60575d3136076f986b697a0c956d618d7a8c7a0c Mon Sep 17 00:00:00 2001 From: Promise Fru Date: Thu, 13 Jun 2024 00:56:17 +0100 Subject: [PATCH 2/5] feat: add account_identifier extraction and storage --- .gitignore | 6 +++ Makefile | 9 +++++ grpc_server.py | 10 +++-- protos/v1/publisher.proto | 71 ------------------------------------ src/db.py | 3 ++ src/grpc_entity_service.py | 15 ++++++-- src/grpc_publisher_client.py | 14 +++++-- src/long_lived_token.py | 2 + src/otp_service.py | 6 ++- src/utils.py | 4 +- 10 files changed, 55 insertions(+), 85 deletions(-) delete mode 100644 protos/v1/publisher.proto diff --git a/.gitignore b/.gitignore index dbeeeb2..4957e51 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,9 @@ dmypy.json *.sqlite *.sqlite3 *.db + +# Ignore all .proto files +*.proto + +# Except for vault.proto +!vault.proto \ No newline at end of file diff --git a/Makefile b/Makefile index e079d67..91c1c2e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,7 @@ python=python3 +PROTO_URL=https://raw.githubusercontent.com/smswithoutborders/SMSWithoutBorders-Publisher/feature/grpc-api/protos/v1/publisher.proto +PROTO_DIR=protos/v1 +PROTO_FILE=$(PROTO_DIR)/publisher.proto define log_message @echo "[$(shell date +'%Y-%m-%d %H:%M:%S')] - $1" @@ -61,3 +64,9 @@ grpc-compile: --grpc_python_out=. \ ./protos/v1/*.proto $(call log_message,INFO - gRPC Compilation complete!) + +download-publisher-proto: + $(call log_message,INFO - Downloading publisher.proto ...) + @mkdir -p $(PROTO_DIR) + @curl -o $(PROTO_FILE) -L $(PROTO_URL) + $(call log_message,INFO - Publisher.proto downloaded successfully!) diff --git a/grpc_server.py b/grpc_server.py index 0e55259..31a245b 100644 --- a/grpc_server.py +++ b/grpc_server.py @@ -11,7 +11,10 @@ from src.utils import get_configs from src.grpc_entity_service import EntityService -logger = logging.getLogger("[GRPC SERVER]") +logging.basicConfig( + level=logging.INFO, format=("%(asctime)s - %(name)s - %(levelname)s - %(message)s") +) +logger = logging.getLogger("[Vault gRPC Server]") def serve(): @@ -74,7 +77,9 @@ def serve(): raise else: server.add_insecure_port(f"{hostname}:{port}") - logger.info("The server is running in insecure mode at %s:%s", hostname, port) + logger.warning( + "The server is running in insecure mode at %s:%s", hostname, port + ) server.start() @@ -87,5 +92,4 @@ def serve(): if __name__ == "__main__": - logging.basicConfig(level=logging.INFO) serve() diff --git a/protos/v1/publisher.proto b/protos/v1/publisher.proto deleted file mode 100644 index da7518d..0000000 --- a/protos/v1/publisher.proto +++ /dev/null @@ -1,71 +0,0 @@ -syntax = "proto3"; - -package publisher.v1; - -// Request message for getting the authorization URL -message GetAuthorizationUrlRequest { - // List of scopes for the authorization - repeated string scope = 1; - // Redirect URI for the authorization response - string redirect_uri = 2; - // Client ID of the application - string client_id = 3; - // URL of the authorization server's authorization endpoint. - string authorization_endpoint = 4; - // Optional state parameter to prevent CSRF attacks. If not given, it will be generated for you - string state = 5; - // Optional code verifier for PKCE (Proof Key for Code Exchange) - string code_verifier = 6; - // Flag to indicate if the code verifier should be auto-generated - bool autogenerate_code_verifier = 7; - // Optional access type parameter (online or offline) - string access_type = 8; - // Optional prompt parameter (none, consent, select_account) - string prompt = 9; -} - -// Response message for the GetAuthorizationUrl RPC -message GetAuthorizationUrlResponse { - // The authorization URL - string authorization_url = 1; - // The state parameter for preventing CSRF attacks - string state = 2; - // The code verifier used for PKCE - string code_verifier = 3; - // Optional message providing additional information - string message = 4; -} - -// Request message for exchanging the OAuth2 authorization code for a token -message ExchangeOAuth2CodeRequest { - // The authorization code received from the authorization server - string authorization_code = 1; - // The redirect URI used in the authorization request - string redirect_uri = 2; - // Client ID of the application - string client_id = 3; - // Client secret of the application - string client_secret = 4; - // Optional code verifier for PKCE (Proof Key for Code Exchange) - string code_verifier = 5; - // URL of the authorization server's token endpoint. - string token_endpoint = 6; -} - -// Response message for the ExchangeOAuth2Code RPC -message ExchangeOAuth2CodeResponse { - // Token information (JSON string) - string token = 1; - // Profile information (JSON string) - string profile = 2; - // Optional message providing additional information - string message = 3; -} - -// Service definition for Publisher -service Publisher { - // RPC for getting the authorization URL - rpc GetAuthorizationUrl(GetAuthorizationUrlRequest) returns (GetAuthorizationUrlResponse); - // RPC for exchanging OAuth2 authorization code for a token - rpc ExchangeOAuth2Code(ExchangeOAuth2CodeRequest) returns (ExchangeOAuth2CodeResponse); -} diff --git a/src/db.py b/src/db.py index bfff54c..8ae9bf1 100644 --- a/src/db.py +++ b/src/db.py @@ -6,6 +6,9 @@ from src.utils import ensure_database_exists, get_configs from settings import Configurations +logging.basicConfig( + level=logging.INFO, format=("%(asctime)s - %(name)s - %(levelname)s - %(message)s") +) logger = logging.getLogger(__name__) DATABASE_CONFIGS = { diff --git a/src/grpc_entity_service.py b/src/grpc_entity_service.py index 4965144..ae78c45 100644 --- a/src/grpc_entity_service.py +++ b/src/grpc_entity_service.py @@ -1,6 +1,7 @@ -"""GRPC Entity Service""" +"""gRPC Entity Service""" import os +import json import logging import base64 @@ -35,7 +36,7 @@ logging.basicConfig( level=logging.INFO, format=("%(asctime)s - %(name)s - %(levelname)s - %(message)s") ) -logger = logging.getLogger(__name__) +logger = logging.getLogger("[gRPC Entity Service]") def error_response(context, response, sys_msg, status_code, user_msg=None, _type=None): @@ -606,11 +607,17 @@ def StoreEntityToken(self, request, context): if oauth2_error: return response(message=oauth2_error, success=False) + profile_data = json.loads(oauth2_response.profile) + account_identifier = profile_data.get("email") or profile_data.get( + "username" + ) new_token = { "entity": entity_obj, "platform": request.platform, - "account_identifier_hash": "id", - "account_identifier": "id", + "account_identifier_hash": generate_hmac( + HASHING_KEY, account_identifier + ), + "account_identifier": encrypt_and_encode(account_identifier), "account_tokens": encrypt_and_encode(oauth2_response.token), } create_entity_token(**new_token) diff --git a/src/grpc_publisher_client.py b/src/grpc_publisher_client.py index 67e44ea..c5520b2 100644 --- a/src/grpc_publisher_client.py +++ b/src/grpc_publisher_client.py @@ -1,4 +1,4 @@ -"""gRPC Publisher Client""" +"""Publisher gRPC Client""" import logging import json @@ -12,7 +12,7 @@ logging.basicConfig( level=logging.INFO, format=("%(asctime)s - %(name)s - %(levelname)s - %(message)s") ) -logger = logging.getLogger(__name__) +logger = logging.getLogger("[Publisher gRPC Client]") def get_channel(): @@ -28,6 +28,8 @@ def get_channel(): server_certificate = get_configs("SSL_CERTIFICATE") private_key = get_configs("SSL_KEY") + logger.info("Connecting to publisher gRPC server at %s:%s", hostname, port) + if mode == "production": with open(server_certificate, "rb") as cert_file, open( private_key, "rb" @@ -37,6 +39,7 @@ def get_channel(): ) return grpc.secure_channel(f"{hostname}:{secure_port}", credentials) + logger.warning("Using insecure channel for gRPC communication") return grpc.insecure_channel(f"{hostname}:{port}") @@ -68,6 +71,7 @@ def get_platform_creds(platform): return { "auth_uri": oauth2_credentials.get("auth_uri", ""), "token_uri": oauth2_credentials.get("token_uri", ""), + "userinfo_uri": oauth2_credentials.get("userinfo_uri", ""), "client_id": oauth2_credentials.get("client_id", ""), "client_secret": oauth2_credentials.get("client_secret", ""), "redirect_uri": redirect_uri, @@ -109,6 +113,7 @@ def find_credentials_recursive(data): data = json.load(file) credentials = find_credentials_recursive(data) if credentials: + logger.info("Loaded OAuth2 credentials from %s", file_path) return credentials logger.warning("OAuth2 credentials not found in '%s'", file_path) @@ -119,8 +124,6 @@ def find_credentials_recursive(data): logger.error( "Error decoding JSON from OAuth2 credentials file: '%s'", file_path ) - except Exception as error: - logger.error("Error decoding JSON from OAuth2 credentials file: '%s'", error) return {} @@ -148,9 +151,12 @@ def exchange_oauth2_code(platform, authorization_code, code_verifier=None): client_id=platform_creds["client_id"], client_secret=platform_creds["client_secret"], token_endpoint=platform_creds["token_uri"], + userinfo_endpoint=platform_creds["userinfo_uri"], ) + logger.debug("Exchanging OAuth2 code for platform '%s'", platform) response = stub.ExchangeOAuth2Code(request) + logger.info("OAuth2 code exchanged successfully for platform '%s'", platform) return (response, None) except grpc.RpcError as e: if e.code() == grpc.StatusCode.INVALID_ARGUMENT: diff --git a/src/long_lived_token.py b/src/long_lived_token.py index d852ecd..960f878 100644 --- a/src/long_lived_token.py +++ b/src/long_lived_token.py @@ -47,6 +47,7 @@ def generate_llt(eid, key): llt_ciphertext = encrypt_fernet(convert_to_fernet_key(key), f"{eid}:{llt}") + logger.info("Successfully generated long-lived token for %s", eid) return base64.b64encode(llt_ciphertext).decode("utf-8") @@ -69,6 +70,7 @@ def verify_llt(llt, key): {"kty": "oct", "k": base64.urlsafe_b64encode(key).decode("utf-8")} ) payload = token_obj.decode(llt, signing_key, algorithms=["HS256"]) + logger.info("Successfully verified long-lived token: %s", llt) return payload, None except JWTDecodeError as error: diff --git a/src/otp_service.py b/src/otp_service.py index 760f572..b1f6604 100644 --- a/src/otp_service.py +++ b/src/otp_service.py @@ -7,8 +7,10 @@ from src.db_models import OTPRateLimit from src.utils import get_configs -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) +logging.basicConfig( + level=logging.INFO, format=("%(asctime)s - %(name)s - %(levelname)s - %(message)s") +) +logger = logging.getLogger("[OTP Service]") TWILIO_ACCOUNT_SID = get_configs("TWILIO_ACCOUNT_SID") TWILIO_AUTH_TOKEN = get_configs("TWILIO_AUTH_TOKEN") diff --git a/src/utils.py b/src/utils.py index 3b3ce5b..232fbf1 100644 --- a/src/utils.py +++ b/src/utils.py @@ -14,7 +14,9 @@ from src.crypto import encrypt_aes, decrypt_aes -logging.basicConfig(level=logging.INFO) +logging.basicConfig( + level=logging.INFO, format=("%(asctime)s - %(name)s - %(levelname)s - %(message)s") +) logger = logging.getLogger(__name__) From 33ed070ea707152a4086804b3eed2930817685cf Mon Sep 17 00:00:00 2001 From: Promise Fru Date: Thu, 13 Jun 2024 01:11:26 +0100 Subject: [PATCH 3/5] docs(grpc): add reference for StoreEntityToken function --- docs/grpc.md | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/docs/grpc.md b/docs/grpc.md index 86fba5a..1e6956e 100644 --- a/docs/grpc.md +++ b/docs/grpc.md @@ -13,6 +13,7 @@ - [Initiate Authentication](#initiate-authentication) - [Complete Authentication](#complete-authentication) - [List an Entity's Stored Tokens](#list-an-entitys-stored-tokens) + - [Store an Entity's Token](#store-an-entitys-token) ## Download Protocol Buffer File @@ -509,3 +510,91 @@ localhost:6000 vault.v1.Entity/ListEntityStoredTokens "message": "Tokens retrieved successfully." } ``` + +### Store an Entity's Token + +This step involves storing tokens securely for the authenticated entity. + +--- + +> `request` **StoreEntityTokenRequest** + +> [!IMPORTANT] +> +> The table lists only the required fields for this step. Other fields will be +> ignored. + +| Field | Type | Description | +| ------------------ | ------ | ------------------------------------------------------------------- | +| long_lived_token | string | The long-lived token for the authenticated session. | +| authorization_code | string | The authorization code obtained from the OAuth2 flow. | +| platform | string | The platform from which the token is being issued. (e.g., "gmail"). | +| protocol | string | The protocol used for authentication (e.g., "oauth2"). | +| code_verifier | string | Optional. The code verifier used in OAuth2 PKCE flows. | + +--- + +> `response` **StoreEntityTokenResponse** + +> [!IMPORTANT] +> +> The table lists only the fields that are populated for this step. Other fields +> may be empty, omitted, or false. + +| Field | Type | Description | +| ------- | ------- | ------------------------------------------ | +| message | string | A response message from the server. | +| success | boolean | Indicates if the operation was successful. | + +--- + +> `method` **StoreEntityToken** + +> [!TIP] +> +> The examples below use +> [grpcurl](https://github.com/fullstorydev/grpcurl#grpcurl). + +> [!NOTE] +> +> Here is what a successful response from the server looks like. +> +> The server would return a status code of `0 OK` if the API transaction goes +> through without any friction. Otherwise, it will return any other code out of +> the +> [17 codes supported by gRPC](https://grpc.github.io/grpc/core/md_doc_statuscodes.html). + +--- + +**Sample request** + +```bash +grpcurl -plaintext \ + -d @ \ + -proto protos/v1/vault.proto \ +localhost:6000 vault.v1.Entity/StoreEntityToken +``` + +--- + +**Sample payload.json** + +```json +{ + "long_lived_token": "long_lived_token", + "authorization_code": "oauth2_code", + "platform": "gmail", + "protocol": "oauth2" +} +``` + +--- + +**Sample response** + +```json +{ + "message": "Token stored successfully.", + "success": true +} +``` From da020328c7799cafd1dfe7b20e4339d389c25d59 Mon Sep 17 00:00:00 2001 From: Promise Fru Date: Thu, 13 Jun 2024 11:29:07 +0100 Subject: [PATCH 4/5] docs: update StoreEntityToken function docs --- docs/grpc.md | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/docs/grpc.md b/docs/grpc.md index 1e6956e..8fb1204 100644 --- a/docs/grpc.md +++ b/docs/grpc.md @@ -517,6 +517,29 @@ This step involves storing tokens securely for the authenticated entity. --- +> [!NOTE] +> +> Ensure you have generated your authorization URL before using this function. +> For Gmail and Twitter offline access, use the following recommended +> parameters: +> +> **Gmail:** +> +> - **scope:** > +> `openid https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email` +> - **access_type:** `offline` +> - **prompt:** `consent` +> +> **Twitter:** +> +> - **scope:** `tweet.read tweet.write users.read offline.access` +> - **prompt:** `consent` +> +> You can use the publisher's [Get Authorization URL](#) function to help +> generate the URL for you, or use other tools that can construct the URL. + +--- + > `request` **StoreEntityTokenRequest** > [!IMPORTANT] @@ -530,7 +553,12 @@ This step involves storing tokens securely for the authenticated entity. | authorization_code | string | The authorization code obtained from the OAuth2 flow. | | platform | string | The platform from which the token is being issued. (e.g., "gmail"). | | protocol | string | The protocol used for authentication (e.g., "oauth2"). | -| code_verifier | string | Optional. The code verifier used in OAuth2 PKCE flows. | + +Optional fields: + +| Field | Type | Description | +| ------------- | ------ | ---------------------------------------------------- | +| code_verifier | string | A cryptographic random string used in the PKCE flow. | --- @@ -572,7 +600,7 @@ This step involves storing tokens securely for the authenticated entity. grpcurl -plaintext \ -d @ \ -proto protos/v1/vault.proto \ -localhost:6000 vault.v1.Entity/StoreEntityToken +localhost:6000 vault.v1.Entity/StoreEntityToken Date: Thu, 13 Jun 2024 12:09:03 +0100 Subject: [PATCH 5/5] build(script): update supervisord grpc-server unit --- supervisord.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supervisord.conf b/supervisord.conf index 3df1606..da67afc 100644 --- a/supervisord.conf +++ b/supervisord.conf @@ -14,7 +14,7 @@ stdout_logfile_maxbytes=0 redirect_stderr=true [program:grpc_server] -command=/bin/sh -c "make grpc-compile && python3 -u grpc_server.py" +command=/bin/sh -c "make download-publisher-proto && make grpc-compile && python3 -u grpc_server.py" autostart=true autorestart=true stdout_logfile=/dev/fd/1