diff --git a/api_v2/blueprints/cred_and_data/blueprint.py b/api_v2/blueprints/cred_and_data/blueprint.py index f473c2f..43bf91b 100644 --- a/api_v2/blueprints/cred_and_data/blueprint.py +++ b/api_v2/blueprints/cred_and_data/blueprint.py @@ -45,6 +45,9 @@ sys.path.append("..") # For using Quart: from quart import Blueprint, current_app, make_response +# Common: +from shared import constants + # My utils: from utils_v2.string import json from utils_v2.date_time import date_time @@ -54,6 +57,7 @@ from utils_v2.api.async_quart import ( set_api_version, read_input, log_request_to_mongo, + log_chain_to_mongo, should_not_be_under_maintenance, only_whitelisted_ips, limit_rate, @@ -128,13 +132,15 @@ def init(blueprint_setup_state): @set_api_version(api_version = "2.2.0") @read_input(sanitize_headers = True, sanitize_data = True) @log_request_to_mongo( - attr_name = "mongo", - project = "internal", + attr_name = "logs_mongo", + project = constants.PROJECT_NAME, log_type = "credData", operation = "set", log_input = False, - log_output = True + log_output = True, + sensitive_keys = None ) +@log_chain_to_mongo(attr_name = "logs_mongo") @should_not_be_under_maintenance(attr_name = "is_under_maintenance") @only_whitelisted_ips(attr_name = "whitelisted_ips") @validate_input(header_validator = lambda x: CredAndDataSetRequestHeaders(**x).model_dump()) @@ -280,13 +286,15 @@ async def get_data( @set_api_version(api_version = "2.1.0") @read_input(sanitize_headers = True, sanitize_data = True) @log_request_to_mongo( - attr_name = "mongo", - project = "internal", + attr_name = "logs_mongo", + project = constants.PROJECT_NAME, log_type = "credData", operation = "update", log_input = False, - log_output = True + log_output = True, + sensitive_keys = None ) +@log_chain_to_mongo(attr_name = "logs_mongo") @should_not_be_under_maintenance(attr_name = "is_under_maintenance") @only_whitelisted_ips(attr_name = "whitelisted_ips") @validate_input( @@ -353,13 +361,15 @@ async def update_cred( @set_api_version(api_version = "2.1.0") @read_input(sanitize_headers = True, sanitize_data = True) @log_request_to_mongo( - attr_name = "mongo", - project = "internal", + attr_name = "logs_mongo", + project = constants.PROJECT_NAME, log_type = "credData", operation = "delete", log_input = True, - log_output = True + log_output = True, + sensitive_keys = None ) +@log_chain_to_mongo(attr_name = "logs_mongo") @should_not_be_under_maintenance(attr_name = "is_under_maintenance") @only_whitelisted_ips(attr_name = "whitelisted_ips") @validate_input(header_validator = lambda x: CredAndDataDeleteRequestHeaders(**x).model_dump()) @@ -408,13 +418,15 @@ async def delete_data( @set_api_version(api_version = "2.1.0") @read_input(sanitize_headers = True, sanitize_data = True) @log_request_to_mongo( - attr_name = "mongo", - project = "internal", + attr_name = "logs_mongo", + project = constants.PROJECT_NAME, log_type = "credData", operation = "listIds", log_input = True, - log_output = True + log_output = True, + sensitive_keys = None ) +@log_chain_to_mongo(attr_name = "logs_mongo") @should_not_be_under_maintenance(attr_name = "is_under_maintenance") @only_whitelisted_ips(attr_name = "whitelisted_ips") @validate_input(header_validator = None) diff --git a/api_v2/blueprints/otp/__init__.py b/api_v2/blueprints/otp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api_v2/blueprints/otp/blueprint.py b/api_v2/blueprints/otp/blueprint.py new file mode 100644 index 0000000..b909523 --- /dev/null +++ b/api_v2/blueprints/otp/blueprint.py @@ -0,0 +1,288 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Wednesday, 19th Jun., 2025 + + OBJECTIVE: + + To generate and verify OTPs that expire. + + 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 + +# Common: +from shared import constants + +# My utils: +from utils_v2.string import json +from utils_v2.date_time import date_time +from utils_v2.security.otp import HashedOTP +from utils_v2.api.response import ResponseModel +from utils_v2.api.codes import StatusCodes, HttpCodes +from utils_v2.api.async_quart import ( + log_request_to_mongo, + only_whitelisted_ips, + should_not_be_under_maintenance, + read_input, + limit_rate, + validate_input, + set_api_version, + log_chain_to_mongo, + handle_cancelled_request +) + +# Data models: +from models.otp.timed_otp import GenerateTimedOTP, VerifyTimedOTP + +# For asynchronous activities: +import asyncio + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# Related to Quart: +otp_bp = Blueprint("otp", __name__) + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +@otp_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 + + +# --------------------------------------------------------------------------------------------------------------------- + + +@otp_bp.route("/timed/generate", methods = ["POST", "GET"]) +@set_api_version(api_version = "1.0.0") +@read_input(sanitize_headers = True, sanitize_data = True) +@log_request_to_mongo( + attr_name = "logs_mongo", + project = constants.PROJECT_NAME, + log_type = "timedOTP", + operation = "generate", + log_input = True, + log_output = True, + sensitive_keys = None +) +@log_chain_to_mongo(attr_name = "logs_mongo") +@should_not_be_under_maintenance(attr_name = "is_under_maintenance") +# @only_whitelisted_ips(attr_name = "whitelisted_ips") +@validate_input(data_validator = lambda x: GenerateTimedOTP(**x)) +# @limit_rate( +# attr_name = "otp_redis", +# rate_limit = 1, +# seconds = 2, +# data_keys = ["id"], +# allow_if_exception = False +# ) +@handle_cancelled_request() +async def generate_otp( + inbound_headers: dict = None, + inbound_data: dict | GenerateTimedOTP = None, + inbound_files: dict = None, + log_id: str = None, + **kwargs +): + + """ + Generates one OTP for the amount of time specified in the request. + :param inbound_headers: auto-extracted by the decorators from 'async_quart.py'. + :param inbound_data: auto-extracted by the decorators from 'async_quart.py'. + :param inbound_files: auto-extracted by the decorators from 'async_quart.py'. + :param log_id: An identifier for the logs (if logging is enabled). + :return: A standard response structure from the function in 'async_quart.py'. + """ + + # Generate the OTP: + otp_key = current_app.otp_redis.make_key(str(inbound_data.id)) + otp_client = HashedOTP(secret = HashedOTP.generate_secret()) + otp = otp_client.generate_otp(count = 0) + + # Delete any existing OTP with the same identifiers: + await current_app.otp_redis.delete(key = otp_key) + + # Store the OTP in Redis: + otp_stored = await current_app.otp_redis.set( + key = otp_key, + value = { + "otp": otp, + "att": inbound_data.attempts, + "iat": date_time.get_current_utc_date_time().timestamp(), + "sec": inbound_data.seconds + }, + expiry = inbound_data.seconds + ) + + # Done here: + return ResponseModel( + status_code = StatusCodes.OK if otp_stored else StatusCodes.FAILED, + data = {"otp": otp} if otp_stored else None + ) + + +# --------------------------------------------------------------------------------------------------------------------- + + +@otp_bp.route("/timed/verify", methods = ["POST", "GET"]) +@set_api_version(api_version = "1.0.0") +@read_input(sanitize_headers = True, sanitize_data = True) +@log_request_to_mongo( + attr_name = "logs_mongo", + project = constants.PROJECT_NAME, + log_type = "timedOTP", + operation = "verify", + log_input = True, + log_output = True, + sensitive_keys = None +) +@log_chain_to_mongo(attr_name = "logs_mongo") +@should_not_be_under_maintenance(attr_name = "is_under_maintenance") +# @only_whitelisted_ips(attr_name = "whitelisted_ips") +@validate_input(data_validator = lambda x: VerifyTimedOTP(**x)) +# @limit_rate( +# attr_name = "otp_redis", +# rate_limit = 1, +# seconds = 2, +# data_keys = ["id"], +# allow_if_exception = False, +# count_for_http_codes = [200] +# ) +@handle_cancelled_request() +async def verify_otp( + inbound_headers: dict = None, + inbound_data: dict | VerifyTimedOTP = None, + inbound_files: dict = None, + log_id: str = None, + **kwargs +): + + """ + Verifies the claimed OTP against the stored OTP. + :param inbound_headers: auto-extracted by the decorators from 'async_quart.py'. + :param inbound_data: auto-extracted by the decorators from 'async_quart.py'. + :param inbound_files: auto-extracted by the decorators from 'async_quart.py'. + :param log_id: An identifier for the logs (if logging is enabled). + :return: A standard response structure from the function in 'async_quart.py'. + """ + + # Start by assuming failure: + is_valid = False + attempts_left = None + + # Fetch the OTP from Redis: + otp_key = current_app.otp_redis.make_key(str(inbound_data.id)) + stored_otp = await current_app.otp_redis.get(key = otp_key) + + # Test the validity of the OTP: + if stored_otp is not None: + + # Delete the record from the cache.: + await current_app.otp_redis.delete(key = otp_key) + + # Make note of the attempts left. + # We reduce the count by one straightaway: + attempts_left = stored_otp["att"] - 1 + + # If the stored OTP matches the claimed OTP, + # we note down the acceptance and delete the record from the cache: + if inbound_data.otp == stored_otp["otp"]: + is_valid = True + attempts_left = 0 + + # If the stored OTP and the claimed OTP don't match, + # we reduce the attempt count and : + elif attempts_left > 0: + + # Update the OTP info in the cache: + otp_updated = await current_app.otp_redis.set( + key = otp_key, + value = { + "otp": stored_otp["otp"], + "att": attempts_left, + "iat": stored_otp["iat"], + "sec": stored_otp["sec"] + }, + expiry = (stored_otp["iat"] + stored_otp["sec"]) - date_time.get_current_utc_date_time().timestamp() + ) + + # If the update failed: + if not otp_updated: attempts_left = None + + # Done here: + return ResponseModel( + status_code = StatusCodes.OK if is_valid else StatusCodes.FAILED, + data = { + "isValid": is_valid, + "attemptsLeft": attempts_left + }, + http_code = HttpCodes.UNAUTHORIZED + ) + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass diff --git a/api_v2/main.py b/api_v2/main.py index f5004d4..06be6ae 100644 --- a/api_v2/main.py +++ b/api_v2/main.py @@ -53,6 +53,7 @@ from utils_v2.string import json from utils_v2.api import async_quart from utils_v2.date_time import date_time from utils_v2.database.async_mongo_v2 import AsyncMongo +from utils_v2.cache.async_redis_cache_v3 import AsyncRedisCache from utils_v2.api.async_quart import ( set_api_version, read_input, @@ -73,6 +74,7 @@ from controllers.servers.server import CoreServerController # All the blueprints: from api_v2.blueprints.cred_and_data.blueprint import cred_and_data_bp from api_v2.blueprints.logs.blueprint import logs_bp +from api_v2.blueprints.otp.blueprint import otp_bp from api_v2.blueprints.servers.blueprint import servers_bp @@ -100,6 +102,7 @@ app = Quart(__name__) app = cors(app) app.register_blueprint(cred_and_data_bp, url_prefix = f"/{MODULE_BASE}") app.register_blueprint(logs_bp, url_prefix = f"/{MODULE_BASE}/logs") +app.register_blueprint(otp_bp, url_prefix = f"/{MODULE_BASE}/otp") app.register_blueprint(servers_bp, url_prefix = f"/{MODULE_BASE}/servers") @@ -136,9 +139,21 @@ async def app_startup(**kwargs): # Safe-halt mechanism for upgrades (for a single-worker run): current_app.is_under_maintenance = False + # ┳┓ ┓ • + # ┃┃┏┓┣┓┓┏┏┓┏┓┓┏┓┏┓ + # ┻┛┗ ┗┛┗┻┗┫┗┫┗┛┗┗┫ + # ┛ ┛ ┛ + # Debugging: + enable_debugging = True if os.environ["DEBUG"].strip().lower() == "true" else False current_app.printer = IceCreamDebugger(prefix = f"{MODULE_BASE} (Q) | ", includeContext = True) - if os.environ["DEBUG"] == "True": current_app.printer.disable() + if not enable_debugging: current_app.printer.disable() + current_app.printer("initializing worker.") + + # ┳┳┓ ┳┓┳┓ + # ┃┃┃┏┓┏┓┏┓┏┓┃┃┣┫ + # ┛ ┗┗┛┛┗┗┫┗┛┻┛┻┛ + # ┛ # To connect to Mongo: current_app.mongo = AsyncMongo( @@ -150,19 +165,47 @@ async def app_startup(**kwargs): ) await current_app.mongo.connect() + current_app.logs_mongo = current_app.mongo + + current_app.printer("MongoDB ready.") + + # ┏┓ ┓ ┓ ┳┓ + # ┃ ┏┓┏┓┏┫ ┏┓┏┓┏┫ ┃┃┏┓╋┏┓ + # ┗┛┛ ┗ ┗┻ ┗┻┛┗┗┻ ┻┛┗┻┗┗┻ + # Get the script data: script_id = os.environ["SCRIPT_ID"] + script_cred = (await current_app.mongo.find_one( + collection = "_scriptCred", + filter = {"scriptId": script_id} + ))["content"] current_app.script_data = (await current_app.mongo.find_one( collection = "_scriptData", filter = {"scriptId": script_id} ))["content"] + current_app.printer("Cred and Data ready.") + + # ┳┓ ┓• ┏┓ ┓ + # ┣┫┏┓┏┫┓┏ ┃ ┏┓┏┣┓┏┓ + # ┛┗┗ ┗┻┗┛ ┗┛┗┻┗┛┗┗ + + current_app.otp_redis = AsyncRedisCache( + connection_string = script_cred["redisCache"]["general"]["sentinelJson"], + debug = enable_debugging, + debug_prefix = "OTP Cache | " + ) + + current_app.printer("Redis ready.") + # ┏┓ ┓┓ # ┃ ┏┓┏┓╋┏┓┏┓┃┃┏┓┏┓┏ # ┗┛┗┛┛┗┗┛ ┗┛┗┗┗ ┛ ┛ current_app.server_controller = CoreServerController() + current_app.printer("Controllers ready.") + # ┳┳┓• # ┃┃┃┓┏┏ # ┛ ┗┗┛┗• diff --git a/models/otp/__init__.py b/models/otp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models/otp/timed_otp.py b/models/otp/timed_otp.py new file mode 100644 index 0000000..d79e78b --- /dev/null +++ b/models/otp/timed_otp.py @@ -0,0 +1,121 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Tuesday, 10th Sept., 2024. + + OBJECTIVE: + + To provide a data structure for the JSON received in the API calls to generate and verify timed OTPs. + + 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 models: +from pydantic import BaseModel, Field, field_validator, Extra +from typing import Optional, Any, Dict, List +from typing_extensions import Annotated + +# My utils: +from utils_v2.string import regex + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +class GenerateTimedOTP(BaseModel): + + id: str | Dict | List + attempts: Optional[int] = 3 + seconds: Optional[int | float] = 30.0 + + class Config: + extra = "forbid" + + def get(self, key: str, default = None): + return getattr(self, key, default) + + @field_validator("attempts") + def validate_attempts(cls, value): + if not 1 <= value <= 10: raise ValueError("attempts must be at least 1 and at most 10") + return value + + @field_validator("seconds") + def validate_expiry(cls, value): + if not 30 <= value <= 300: raise ValueError("expiry must be at least 30s and at most 300s") + return value + + +# --------------------------------------------------------------------------------------------------------------------- + + +class VerifyTimedOTP(BaseModel): + + id: str | Dict | List + otp: str + + class Config: + extra = "forbid" + + def get(self, key: str, default = None): + return getattr(self, key, default) + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass diff --git a/shared/constants.py b/shared/constants.py index 81e3661..4d1176a 100644 --- a/shared/constants.py +++ b/shared/constants.py @@ -49,6 +49,10 @@ from utils_v2.system import files # ***************************************************************************************************************** +APP_VERSION = "1.0.0" +PROJECT_NAME = "internal" +MODULE_NAME = "internal" + # Directories: PROJECT_DIRECTORY = files.get_parent_directory(files.get_file_directory(), depth = 1) CREDENTIALS_DIRECTORY = os.path.join(PROJECT_DIRECTORY, "cred")