From 4c258b110a06fc9bf4f072e4730de32d546ef94a Mon Sep 17 00:00:00 2001 From: khushal Date: Fri, 13 Dec 2024 17:01:32 +0530 Subject: [PATCH] (20241213) Started accepting "auth" for software/theCaOfficeAi... I don't know how these details count as "auth", though. --- api/blueprints/mail/oauth/request.py | 2 +- api/blueprints/mail/retrieve/list.py | 18 ++- api/blueprints/sms/auth.py | 2 +- api/blueprints/software/__init__.py | 0 api/blueprints/software/auth.py | 225 +++++++++++++++++++++++++++ api/helpers/user/token_check.py | 165 ++++++++++++++++++++ api/main.py | 2 + controllers/core/auth_token.py | 31 +++- models/api/software/__init__.py | 0 models/api/software/auth.py | 159 +++++++++++++++++++ models/core/auth_token.py | 18 ++- 11 files changed, 604 insertions(+), 18 deletions(-) create mode 100644 api/blueprints/software/__init__.py create mode 100644 api/blueprints/software/auth.py create mode 100644 api/helpers/user/token_check.py create mode 100644 models/api/software/__init__.py create mode 100644 models/api/software/auth.py diff --git a/api/blueprints/mail/oauth/request.py b/api/blueprints/mail/oauth/request.py index fc8a80f..632881f 100644 --- a/api/blueprints/mail/oauth/request.py +++ b/api/blueprints/mail/oauth/request.py @@ -223,7 +223,7 @@ async def request_oauth_authorization_url( status_code = StatusCodes.OK if auth_url else StatusCodes.FAILED, http_code = HttpCodes.SUCCESS if auth_url else HttpCodes.INTERNAL_SERVER_ERROR, data = { - "mailClient": inbound_data.mailClient, + "client": inbound_data.mailClient, "authorizationUrl": auth_url } ) diff --git a/api/blueprints/mail/retrieve/list.py b/api/blueprints/mail/retrieve/list.py index 7f6bb86..6501f20 100644 --- a/api/blueprints/mail/retrieve/list.py +++ b/api/blueprints/mail/retrieve/list.py @@ -81,6 +81,9 @@ import asyncio # To work with date and time: import datetime +# Helpers: +from api.helpers.user import token_check + # ***************************************************************************************************************** # ***** **** @@ -161,17 +164,20 @@ async def list_mails( """ # If the session token is invalid/expired: - if kwargs.get("session_info") is None: - return ResponseModel( - status_code = StatusCodes.FAILED, - http_code = HttpCodes.UNAUTHORIZED - ) + if await token_check.is_not_authorized( + mongo_conn = current_app.data_mongo, + user_info = kwargs.get("session_info"), + token_ids = inbound_data.tokenIds + ): return ResponseModel( + status_code = StatusCodes.FAILED, + http_code = HttpCodes.UNAUTHORIZED, + message = "user not authorized to use this token" + ) # Build the additional filter: additional_filter = {} if inbound_data.tags: additional_filter["tags"] = {"$in": inbound_data.tags} additional_filter = additional_filter or None - print("AddFil:", additional_filter) # Get the mails: mails_list = await current_app.mail_controller.list_mails( diff --git a/api/blueprints/sms/auth.py b/api/blueprints/sms/auth.py index 7aca6ff..6508f25 100644 --- a/api/blueprints/sms/auth.py +++ b/api/blueprints/sms/auth.py @@ -243,7 +243,7 @@ async def authorize_sms_client( status_code = StatusCodes.OK if token_id else StatusCodes.FAILED, http_code = HttpCodes.SUCCESS if token_id else HttpCodes.INTERNAL_SERVER_ERROR, data = { - "smsClient": inbound_data.smsClient, + "client": inbound_data.smsClient, "authorized": True } ) diff --git a/api/blueprints/software/__init__.py b/api/blueprints/software/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/blueprints/software/auth.py b/api/blueprints/software/auth.py new file mode 100644 index 0000000..8df6e13 --- /dev/null +++ b/api/blueprints/software/auth.py @@ -0,0 +1,225 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Friday, 13th Dec., 2024 + + OBJECTIVE: + + To receive auth details for various software. + + REFERENCES: + + N/A + + DOWNLOADS: + + N/A + + NOTES: + + N/A + +""" + + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +sys.path.append("..") + +# For using Quart: +from quart import Blueprint, current_app, request + +# My utils: +from utils_v2.string import json +from utils_v2.api.codes import StatusCodes, HttpCodes +from utils_v2.api.response import ResponseModel +from utils_v2.api.async_quart import ( + set_api_version, + read_input, + get_session_info, + log_request_to_mongo, + log_chain_to_mongo, + should_not_be_under_maintenance, + only_whitelisted_ips, + limit_rate, + validate_input, + handle_cancelled_request +) + +# Common: +from shared import constants + +# To work with MongoDB: +from bson.objectid import ObjectId + +# Data Models: +from models.api.software.auth import SoftwareAuthRequestHeaders, SoftwareAuthRequestData +from models.core.auth_token import CoreAuthTokenModel + +# For asynchronous activities: +import asyncio + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# Related to Quart: +sw_auth_bp = Blueprint("sw_auth", __name__) + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +@sw_auth_bp.record_once +def init(blueprint_setup_state): + + # This gets called when the blueprint is registered. + # Consider this to be a one-time setup for the whole blueprint: + pass + + +# --------------------------------------------------------------------------------------------------------------------- + + +@sw_auth_bp.route("/auth", methods = ["POST"]) +@set_api_version(api_version = "1.0.0") +@read_input(sanitize_headers = False, sanitize_data = False) +@get_session_info(key = "X-Session-Token", session_coro = "get_session") +@log_request_to_mongo( + attr_name = "logs_mongo", + project = constants.PROJECT_NAME, + log_type = constants.MODULE_NAME, + operation = "smsAuthApi", + log_input = True, + log_output = True, + sensitive_keys = ["sessionToken", "X-Session-Token"] +) +@log_chain_to_mongo(attr_name = "logs_mongo") +@should_not_be_under_maintenance(attr_name = "is_under_maintenance") +@validate_input( + header_validator = lambda x: SoftwareAuthRequestHeaders(**x).model_dump(), + data_validator = lambda x: SoftwareAuthRequestData(**x) +) +@handle_cancelled_request() +async def authorize_software_client( + inbound_headers: dict | SoftwareAuthRequestHeaders = None, + inbound_data: dict | SoftwareAuthRequestData = None, + inbound_files: dict = None, + **kwargs +): + + """ + God knows. Don't ask. + :param inbound_headers: auto-extracted by the decorators. + :param inbound_data: auto-extracted by the decorators. + :param inbound_files: auto-extracted by the decorators. + :param kwargs: Any number of extra inputs supplied by the decorators. + :return: A standard response structure. + """ + + # ┏┓ + # ┃┃┏┓┏┓┏┓┏┓┏┓┏┏┓┏┏ + # ┣┛┛ ┗ ┣┛┛ ┗┛┗┗ ┛┛ + # ┛ + + # If the session token is invalid/expired: + if kwargs.get("session_info") is None: + return ResponseModel( + status_code = StatusCodes.FAILED, + http_code = HttpCodes.UNAUTHORIZED + ) + + # Start by assuming failure: + success = False + + # ┏┳┓┓ ┏┓┏┓ ┏┓┏┏• ┏┓┳ + # ┃ ┣┓┏┓ ┃ ┣┫ ┃┃╋╋┓┏┏┓ ┣┫┃ + # ┻ ┛┗┗ ┗┛┛┗ ┗┛┛┛┗┗┗ ┛┗┻ + + if inbound_data.softwareClient == "theCaOfficeAi": + + auth_token = CoreAuthTokenModel( + serviceType = "software", + client = inbound_data.softwareClient, + authType = "auth", + auth = inbound_data.auth.model_dump(), + user = kwargs.get("session_info"), + clientUserId = {}, + status = "active", + syncFreq = 60 + ) + + token_id = await current_app.core_auth_token_controller.get_token_id( + db_conn = current_app.sql_writer, + mongo_conn = current_app.data_mongo, + auth_token = auth_token, + token_notes = {}, + session_token = inbound_headers["X-Session-Token"] + ) + + success = await current_app.core_auth_token_controller.set_token( + db_conn = current_app.sql_writer, + mongo_conn = current_app.data_mongo, + token_id = token_id, + auth_token = auth_token, + token_notes = {}, + session_token = inbound_headers["X-Session-Token"] + ) + + # ┳┓ + # ┣┫┏┓┏┏┓┏┓┏┓┏┏┓ + # ┛┗┗ ┛┣┛┗┛┛┗┛┗ + # ┛ + + # Done here: + return ResponseModel( + status_code = StatusCodes.OK if success else StatusCodes.FAILED, + http_code = HttpCodes.SUCCESS if success else HttpCodes.INTERNAL_SERVER_ERROR, + data = { + "client": inbound_data.softwareClient, + "authorized": True if success else False + } + ) + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass diff --git a/api/helpers/user/token_check.py b/api/helpers/user/token_check.py new file mode 100644 index 0000000..005237f --- /dev/null +++ b/api/helpers/user/token_check.py @@ -0,0 +1,165 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Friday, 13th Dec., 2024 + + OBJECTIVE: + + To validate if a token id belongs to a user based on the session token sent by the user. + + REFERENCES: + + N/A + + DOWNLOADS: + + N/A + +""" + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +sys.path.append("..") + +# To use Quart: +from quart import current_app + +# My utils: +from utils_v2.database.async_mongo_v2 import AsyncMongo + +# To work with MongoDB: +from bson.objectid import ObjectId + +# To work with datatypes: +from typing import List + +# The data model: +from models.core.user import CoreUserInfoModel + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** CLASSES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +async def is_authorized( + mongo_conn: AsyncMongo, + user_info: CoreUserInfoModel | dict, + token_ids: ObjectId | str | List[ObjectId | str] +) -> bool: + + """ + To verify if the token id is owned by the user (who will be initially identified from his session token). + :param mongo_conn: The instance of the MongoDB connector to use to perform this action. + :param user_info: The user trying to access some service. + :param token_ids: One or more token ids that the user is claiming to own. + :return: True if the user is authorized to use this token, else False. + """ + + # If the user info was never available: + if user_info is None: return False + + # Start by assuming the access is authorized, + # and pre-process the inputs: + authorized = True + if not isinstance(token_ids, list): token_ids = [token_ids] + if isinstance(user_info, dict): user_info = CoreUserInfoModel(**user_info) + + # Fetch the auth tokens from the database: + # Retrieve the document of the token id: + auth_tokens = await current_app.core_auth_token_controller.get_tokens( + mongo_conn = mongo_conn, + token_ids = [ObjectId(t) for t in token_ids] + ) + + # Check all the token ids: + for auth_token in auth_tokens: + if user_info.billingAccountId != auth_token.user.billingAccountId: + authorized = False + break + + # Done here: + return authorized + + +# --------------------------------------------------------------------------------------------------------------------- + + +async def is_not_authorized( + mongo_conn: AsyncMongo, + user_info: CoreUserInfoModel, + token_ids: ObjectId | str | List[ObjectId | str] +) -> bool: + + """ + Just a wrapper around the above function to improve readability. + :param mongo_conn: The instance of the MongoDB connector to use to perform this action. + :param user_info: The user trying to access some service. + :param token_ids: One or more token ids that the user is claiming to own. + :return: True if the user is authorized to use this token, else False. + """ + + authorized = await is_authorized( + mongo_conn = mongo_conn, + user_info = user_info, + token_ids = token_ids + ) + + return not authorized + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass diff --git a/api/main.py b/api/main.py index 05a1b67..915b42e 100644 --- a/api/main.py +++ b/api/main.py @@ -94,6 +94,7 @@ from api.blueprints.mail.tags.update import mail_tags_update_bp # from api.blueprints.sms.send import sms_send_bp # from api.blueprints.chat.auth import chat_auth_bp # from api.blueprints.chat.webhook import chat_webhook_bp +from api.blueprints.software.auth import sw_auth_bp from api.blueprints.tech.chat_alerts import tech_chat_alert_bp from api.blueprints.test.callback import test_callback_bp from api.blueprints.ai.llm.invoke import llm_invoke_bp @@ -134,6 +135,7 @@ app.register_blueprint(mail_tags_update_bp, url_prefix = f"/{MODULE_BASE}/mail") # app.register_blueprint(sms_send_bp, url_prefix = f"/{MODULE_BASE}/sms") # app.register_blueprint(chat_auth_bp, url_prefix = f"/{MODULE_BASE}/chat") # app.register_blueprint(chat_webhook_bp, url_prefix = f"/{MODULE_BASE}/chat") +app.register_blueprint(sw_auth_bp, url_prefix = f"/{MODULE_BASE}/software") app.register_blueprint(tech_chat_alert_bp, url_prefix = f"/{MODULE_BASE}/tech/alert") app.register_blueprint(test_callback_bp, url_prefix = f"/{MODULE_BASE}/test") app.register_blueprint(llm_invoke_bp, url_prefix = f"/{MODULE_BASE}/ai") diff --git a/controllers/core/auth_token.py b/controllers/core/auth_token.py index a7a3064..9114a08 100644 --- a/controllers/core/auth_token.py +++ b/controllers/core/auth_token.py @@ -34,9 +34,6 @@ import sys sys.path.append(".") sys.path.append("..") -# For Quart: -from quart import current_app - # My async utils: from utils_v2.string import json from utils_v2.date_time import date_time @@ -49,6 +46,9 @@ from controllers.base import BaseModel # Data models: from models.core.auth_token import CoreAuthTokenModel +# To work with datatypes: +from typing import List + # To work with MongoDB: from bson import ObjectId @@ -275,7 +275,7 @@ class AuthTokenController(BaseModel): ) -> CoreAuthTokenModel | None: """ - To retrieve stored tokens from the database. + To retrieve stored tokens from the database. One token at a time. :param mongo_conn: The database connection (MongoDB) to use to perform the action. :param token_id: The identifier granted by the 'get_token_id' method. :return: The retrieved record that has the token, and information about the service and client if found, else @@ -291,6 +291,29 @@ class AuthTokenController(BaseModel): # Done here: return CoreAuthTokenModel(**token) if token else None + async def get_tokens( + self, + mongo_conn: AsyncMongo, + token_ids: List[ObjectId | str] = None, + ) -> List[CoreAuthTokenModel]: + + """ + To retrieve stored tokens from the database. Multiple tokens at a time. + :param mongo_conn: The database connection (MongoDB) to use to perform the action. + :param token_ids: the identifiers granted by the 'get_token_id' method. + :return: The retrieved record that has the token, and information about the service and client if found, else + None when there is no matching record. + """ + + # If there is some filtering possible, we fetch the token: + tokens = await mongo_conn.find_many( + collection = self.AUTH_COLLECTION, + filter = {"_id": {"$in": [ObjectId(t) for t in token_ids]}} + ) + + # Done here: + return [CoreAuthTokenModel(**token) for token in tokens] + # ***************************************************************************************************************** # ***** **** diff --git a/models/api/software/__init__.py b/models/api/software/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models/api/software/auth.py b/models/api/software/auth.py new file mode 100644 index 0000000..0f1966f --- /dev/null +++ b/models/api/software/auth.py @@ -0,0 +1,159 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Friday, 13th Dec., 2024. + + OBJECTIVE: + + To provide a structure to receive auth details of various software. + + REFERENCES: + + N/A + + DOWNLOADS: + + N/A + +""" + + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +sys.path.append("..") + +# For making data behaviour_models: +from pydantic import BaseModel, Field, field_validator, PastDatetime +from typing import Optional, Literal, Union + +# My utils: +from utils_v2.string import regex +from utils_v2.date_time import date_time + +# To work with date and time: +import datetime + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# RegEx Patterns: +REGEX_SESSION_TOKEN = r"^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$" + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +class TheCAOfficeAIAuth(BaseModel): + + authorizeSoftware: bool = Field( + description = "god knows; don't ask", + frozen = True + ) + + authorizeMailMessaging: bool = Field( + description = "god knows; don't ask", + frozen = True + ) + + authorizeCompliancePortal: bool = Field( + description = "god knows; don't ask", + frozen = True + ) + + authorizeFinancialInstitution: bool = Field( + description = "god knows; don't ask", + frozen = True + ) + + # ┏┓ ┏• + # ┃ ┏┓┏┓╋┓┏┓ + # ┗┛┗┛┛┗┛┗┗┫ + # ┛ + + class Config: + extra = "forbid" + + +# --------------------------------------------------------------------------------------------------------------------- + + +class SoftwareAuthRequestHeaders(BaseModel): + + sessionToken: str = Field( + description = "the session token of the user who is requesting the service", + pattern = REGEX_SESSION_TOKEN, + frozen = True, + alias = "X-Session-Token" + ) + + # ┏┓ ┏• + # ┃ ┏┓┏┓╋┓┏┓ + # ┗┛┗┛┛┗┛┗┗┫ + # ┛ + + class Config: + extra = "allow" + + def model_dump(self, *args, **kwargs): + return super().model_dump(*args, by_alias = True, **kwargs) + + +# --------------------------------------------------------------------------------------------------------------------- + + +class SoftwareAuthRequestData(BaseModel): + + softwareClient: Literal["theCaOfficeAi"] = Field(alias = "client") + auth: Union[TheCAOfficeAIAuth] + + # ┏┓ ┏• + # ┃ ┏┓┏┓╋┓┏┓ + # ┗┛┗┛┛┗┛┗┗┫ + # ┛ + + class Config: + extra = "forbid" + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass diff --git a/models/core/auth_token.py b/models/core/auth_token.py index a97e0db..7541d49 100644 --- a/models/core/auth_token.py +++ b/models/core/auth_token.py @@ -89,7 +89,7 @@ class CoreAuthTokenModel(BaseModel): alias = "_id" ) - serviceType: Literal["email", "sms", "chat"] = Field( + serviceType: Literal["software", "email", "sms", "chat"] = Field( description = "the kind of service this message was sent/received from", frozen = True ) @@ -98,7 +98,8 @@ class CoreAuthTokenModel(BaseModel): "gmail", "outlook", # ...................... Mail Clients "telegram", "whatsapp", # .................. Chat Clients "nimbusSmsIndia", "savvyBulkSmsKenya", # ... SMS Clients - "razorpay", "safaricomMPesaExpress" # ...... Payment Gateways + "razorpay", "safaricomMPesaExpress", # ..... Payment Gateways + "theCaOfficeAi" # .......................... God knows, don't ask. ] = Field( description = "the third-part client that was used", frozen = True @@ -133,16 +134,16 @@ class CoreAuthTokenModel(BaseModel): default = None ) - auth: dict | None = Field( + auth: dict = Field( description = "any direct auth details like api keys or passwords; will differ for each client", frozen = False, - default = None + default = {} ) - token: dict | None = Field( + token: dict = Field( description = "the actual auth tokens of that client; will differ for each client", frozen = False, - default = None + default = {} ) user: CoreUserInfoModel = Field( @@ -191,6 +192,11 @@ class CoreAuthTokenModel(BaseModel): def parse_date_time(cls, value): return date_time.parse_date_time(input_value = value, timezone = date_time.TIMEZONE_UTC) + @field_validator("auth", "token", mode = "before") + def handle_null_creds(cls, value): + if value is None: value = {} + return value + # ***************************************************************************************************************** # ***** ****