""" AUTHOR: Khushal P Soonderji DATE: Thursday, 19th Dec., 2024 OBJECTIVE: To handle all SMS related behaviour from one place. REFERENCES: N/A DOWNLOADS: N/A """ # ***************************************************************************************************************** # ***** **** # *** IMPORT *** # ***** **** # ***************************************************************************************************************** # To make sibling directories accessible for imports: import sys sys.path.append(".") sys.path.append("..") # My async utils: from utils_v2.date_time import date_time from utils_v2.security.otp import HashedOTP from utils_v2.database.async_mongo_v2 import AsyncMongo from utils_v2.cache.async_redis_cache_v2 import AsyncRedisCache # Controllers: from controllers_v2.message.sms.base import SMSController # Models: from models.core.auth_token import CoreAuthTokenModel from models.common.function_response import FunctionCallResponse from models.common.otp.timed_otp import GenerateTimedOTP, VerifyTimedOTP from models.message.sms.send import NimbusSMSIndiaMessage, SavvyBulkSMSKenyaMessage # To work with datatypes: from typing import List, Any # For debugging: from icecream import IceCreamDebugger # ***************************************************************************************************************** # ***** **** # *** MACROS / ONE-TIME INIT *** # ***** **** # ***************************************************************************************************************** # --- Nothing Yet # ***************************************************************************************************************** # ***** **** # *** VARIABLES *** # ***** **** # ***************************************************************************************************************** # --- Nothing Yet # ***************************************************************************************************************** # ***** **** # *** FUNCTIONS *** # ***** **** # ***************************************************************************************************************** # --- Nothing Yet # ***************************************************************************************************************** # ***** **** # *** CLASSES *** # ***** **** # ***************************************************************************************************************** class TimedOTPController: # ┏┓┓ ┓┏ # ┃ ┃┏┓┏┏ ┃┃┏┓┏┓┏ # ┗┛┗┗┻┛┛ ┗┛┗┻┛ ┛ KEY_PREFIX = "otp_" # ┏┓ # ┃ ┏┓┏┓┏╋┏┓┓┏┏╋┏┓┏┓ # ┗┛┗┛┛┗┛┗┛ ┗┻┗┗┗┛┛ def __init__( self, debug: bool = True, debug_prefix: str = "TOTP (C) | ", debug_only_errors: bool = True ): """ This is a simple controller that will allow us to generate and verify timed OTPs. :param debug: Whether, or not, you would like to print debugging messages: :param debug_prefix: The prefix to print with the debugging messages. :param debug_only_errors: Whether you would like to print only error messages or all messages. :return: None. """ # For debugging: self._printer = IceCreamDebugger(prefix = debug_prefix, includeContext = True) if not debug: self._printer.disable() self._debug_only_errors = debug_only_errors def enable_terminal_print(self): self._printer.enable() def disable_terminal_print(self): self._printer.disable() def debug_only_errors(self): self._debug_only_errors = True def debug_everything(self): self._debug_only_errors = False # ┏┓┏┳┓┏┓ ┏┓ • # ┃┃ ┃ ┃┃ ┃┓┏┓┏┓┏┓┏┓┏┓╋┓┏┓┏┓ # ┗┛ ┻ ┣┛ ┗┛┗ ┛┗┗ ┛ ┗┻┗┗┗┛┛┗ async def generate( self, redis_cache: AsyncRedisCache, inbound_data: GenerateTimedOTP ) -> FunctionCallResponse: """ To generate one OTP and store it in cache. :param redis_cache: The instance of the cache client to use to hold the OTP. :param inbound_data: The data from which the OTP will be generated. :return: A structured response to describe what happened in the process. """ # Start by assuming failure: response = FunctionCallResponse() # Generate the OTP: otp_key = self.KEY_PREFIX + redis_cache.make_key(str(inbound_data.id)) print("GENERATE KEY:", otp_key) otp_client = HashedOTP(secret = HashedOTP.generate_secret()) otp = otp_client.generate_otp(count = 0) # Store the OTP in Redis: otp_stored = await redis_cache.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 ) # Analyze the response: if otp_stored: response.success = True response.message = "OTP generated successfully." response.data = otp else: response.message = "Failed to generate an OTP." # Done here: return response # ┏┓┏┳┓┏┓ ┓┏ •┏• • # ┃┃ ┃ ┃┃ ┃┃┏┓┏┓┓╋┓┏┏┓╋┓┏┓┏┓ # ┗┛ ┻ ┣┛ ┗┛┗ ┛ ┗┛┗┗┗┻┗┗┗┛┛┗ async def verify( self, redis_cache: AsyncRedisCache, inbound_data: VerifyTimedOTP ) -> FunctionCallResponse: """ To verify if an OTP is valid, or not. :param redis_cache: The instance of the cache client to use to hold the OTP. :param inbound_data: The data from which the OTP will be verified. :return: A structured response to describe what happened in the process. """ # Start by assuming failure: response = FunctionCallResponse() # Fetch the OTP from Redis: otp_key = self.KEY_PREFIX + redis_cache.make_key(str(inbound_data.id)) print("VERIFY KEY:", otp_key) stored_otp = await redis_cache.get(key = otp_key) if not stored_otp: response.message = "No OTP found for this request. It may have expired." return response # Delete the record from the cache.: await redis_cache.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"]: response.success = True response.message = "OTP verified successfully." return response # If the stored OTP and the claimed OTP don't match, # we reduce the attempt count and store the updated payload in cache: elif attempts_left > 0: otp_updated = await redis_cache.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 not otp_updated: attempts_left = 0 # Done here: response.message = f"Invalid OTP. {attempts_left} attempts left." return response # ***************************************************************************************************************** # ***** **** # *** MAIN PROGRAM *** # ***** **** # ***************************************************************************************************************** if __name__ == "__main__": pass