(20250619) Shifted the timed OTP generation code to this project.

This commit is contained in:
2025-06-19 16:41:51 +05:30
parent c754bb6a97
commit 265705a7da
7 changed files with 481 additions and 13 deletions
+24 -12
View File
@@ -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)
View File
+288
View File
@@ -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
+44 -1
View File
@@ -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.")
# ┳┳┓•
# ┃┃┃┓┏┏
# ┛ ┗┗┛┗•
View File
+121
View File
@@ -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
+4
View File
@@ -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")