import json import hashlib import random import secrets import logging from datetime import datetime, timedelta from typing import Optional from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse from redis.exceptions import RedisError from django.conf import settings from config.redis_config import RedisConfig from utils.exceptions import ServiceUnavailableException, NotFoundException logger = logging.getLogger(__name__) class RedisManager(RedisConfig): def __serialize(self, code, fullname, password): return f'{code},{fullname},{password}' def add_to_redis(self, code, **kwargs) -> bool: try: password = kwargs.get('password') payload = { 'code': str(code), 'fullname': kwargs.get('fullname'), 'password': password, } for k, v in kwargs.items(): if k not in payload: payload[k] = v self.redis.set(kwargs["email"], json.dumps(payload), ex=timedelta(minutes=20)) return kwargs["email"] except RedisError as exp: raise ServiceUnavailableException() def __deserialize( self, value: str, key: list = ['code', 'fullname', 'password'] ): values = value.split(',') # Check if lengths of keys and values are not equal if len(key) != len(values): raise ValueError("The number of keys does not match the number of values.") result = {} for k, v in zip(key, values): if not k or not v or v == 'None': result[k] = None else: result[k] = v return result def get_by_redis(self, key: str): try: data = self.redis.get(key) if not data: return None raw = data.decode() if hasattr(data, 'decode') else data if raw.startswith('{'): return json.loads(raw) return self.__deserialize(raw) except RedisError as exp: raise ServiceUnavailableException() except (TypeError, ValueError, AttributeError): raise NotFoundException() def check_exists_redis(self, email: str) -> bool: """ check exists key in redis """ try: exists = self.redis.exists(email) return exists except RedisError as exp: raise CustomException("Service temporarily unavailable") @staticmethod def generate_otp_code() -> int: random_code = random.randint(10000, 99999) return random_code @staticmethod def generate_2fa_otp_code() -> str: """Generate a 6-digit numeric OTP code for 2FA email verification.""" return f"{secrets.randbelow(900000) + 100000:06d}" # ─── Two-Factor Authentication (2FA) Stateless Redis Store ──────────────── _memory_fallback: dict = {} def _set_json(self, key: str, data: dict, ttl_seconds: int = 300) -> None: raw = json.dumps(data) try: self.redis.set(key, raw, ex=ttl_seconds) except Exception: self._memory_fallback[key] = { "value": raw, "expires_at": datetime.utcnow().timestamp() + ttl_seconds, } def _get_json(self, key: str) -> Optional[dict]: try: data = self.redis.get(key) if data: raw = data.decode() if hasattr(data, "decode") else data return json.loads(raw) except Exception: pass entry = self._memory_fallback.get(key) if not entry: return None if datetime.utcnow().timestamp() > entry["expires_at"]: self._memory_fallback.pop(key, None) return None try: return json.loads(entry["value"]) except Exception: return None def _delete_key(self, key: str) -> None: try: self.redis.delete(key) except Exception: pass self._memory_fallback.pop(key, None) def create_2fa_challenge(self, user_id: int, method: str, ttl: int = 300) -> str: temp_token = secrets.token_urlsafe(32) key = f"auth:2fa:challenge:{temp_token}" payload = { "user_id": user_id, "method": method, "attempts": 0, "created_at": datetime.utcnow().isoformat() + "Z", } self._set_json(key, payload, ttl_seconds=ttl) return temp_token def get_2fa_challenge(self, temp_token: str) -> Optional[dict]: if not temp_token: return None return self._get_json(f"auth:2fa:challenge:{temp_token}") def increment_2fa_attempt(self, temp_token: str, max_attempts: int = 5) -> tuple: """ Increments the failed attempt counter for a 2FA challenge. Returns (is_still_valid, attempts_count). Invalidates challenge if max_attempts is reached. """ key = f"auth:2fa:challenge:{temp_token}" challenge = self._get_json(key) if not challenge: return False, max_attempts attempts = int(challenge.get("attempts", 0)) + 1 challenge["attempts"] = attempts if attempts >= max_attempts: self.delete_2fa_challenge(temp_token) return False, attempts self._set_json(key, challenge, ttl_seconds=300) return True, attempts def set_2fa_email_otp(self, temp_token: str, otp_code: str, ttl: int = 300) -> None: key = f"auth:2fa:email_otp:{temp_token}" self._set_json(key, {"code": str(otp_code)}, ttl_seconds=ttl) def get_2fa_email_otp(self, temp_token: str) -> Optional[str]: if not temp_token: return None key = f"auth:2fa:email_otp:{temp_token}" data = self._get_json(key) return str(data.get("code", "")).strip() if data else None def verify_2fa_email_otp(self, temp_token: str, otp_code: str) -> bool: if not temp_token or not otp_code: return False key = f"auth:2fa:email_otp:{temp_token}" data = self._get_json(key) if not data: return False expected = str(data.get("code", "")).strip() provided = str(otp_code).strip() if expected and secrets.compare_digest(expected, provided): self._delete_key(key) return True return False def delete_2fa_challenge(self, temp_token: str) -> None: if not temp_token: return self._delete_key(f"auth:2fa:challenge:{temp_token}") self._delete_key(f"auth:2fa:email_otp:{temp_token}") def set_pending_2fa_setup(self, user_id: int, method: str, secret_or_code: str, ttl: int = 600) -> None: key = f"auth:2fa:setup:{user_id}:{method}" self._set_json(key, {"value": secret_or_code, "method": method}, ttl_seconds=ttl) def get_pending_2fa_setup(self, user_id: int, method: str) -> Optional[str]: key = f"auth:2fa:setup:{user_id}:{method}" data = self._get_json(key) return data.get("value") if data else None def delete_pending_2fa_setup(self, user_id: int, method: str) -> None: self._delete_key(f"auth:2fa:setup:{user_id}:{method}") class OnlineClassTokenManager(RedisConfig): """Manage temporary tokens used for joining online classes.""" KEY_PREFIX = "online_class_token:" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.ttl = getattr(settings, "ONLINE_CLASS_TOKEN_TTL", 300) def _build_key(self, token: str) -> str: return f"{self.KEY_PREFIX}{token}" def generate_token(self, course_id: int, user_identifier: str) -> str: seed = f"{course_id}:{user_identifier}:{secrets.token_urlsafe(16)}" token = hashlib.sha256(seed.encode()).hexdigest() logger.info(f"[OnlineClassToken] Token generated - course_id={course_id} user={user_identifier} token={token[:16]}...") return token def store_token(self, token: str, payload: dict, ttl: Optional[int] = None) -> None: data = { **payload, "generated_at": datetime.utcnow().isoformat() + "Z", } key = self._build_key(token) ttl_value = ttl or self.ttl logger.info(f"[OnlineClassToken] Storing token - key={key} ttl={ttl_value}s payload={payload}") try: self.redis.set(key, json.dumps(data), ex=ttl_value) logger.info(f"[OnlineClassToken] Token stored successfully - key={key}") except RedisError as e: logger.error(f"[OnlineClassToken] Failed to store token - key={key} error={str(e)}") raise def get_payload(self, token: str) -> dict: key = self._build_key(token) logger.info(f"[OnlineClassToken] Retrieving token - key={key} token={token[:16]}...") try: stored = self.redis.get(key) if not stored: logger.warning(f"[OnlineClassToken] Token not found or expired - key={key}") raise NotFoundException("Token not found or has expired.") payload = json.loads(stored) logger.info(f"[OnlineClassToken] Token retrieved successfully - key={key} payload={payload}") return payload except RedisError as e: logger.error(f"[OnlineClassToken] Redis error retrieving token - key={key} error={str(e)}") raise except json.JSONDecodeError as e: logger.error(f"[OnlineClassToken] Invalid JSON in stored token - key={key} error={str(e)}") raise NotFoundException("Invalid token data.") def delete_token(self, token: str) -> None: key = self._build_key(token) logger.info(f"[OnlineClassToken] Deleting token - key={key}") try: result = self.redis.delete(key) logger.info(f"[OnlineClassToken] Token deleted - key={key} deleted={result}") except RedisError as e: logger.error(f"[OnlineClassToken] Failed to delete token - key={key} error={str(e)}") raise @staticmethod def build_entry_url(token: str, base_url: Optional[str] = None) -> str: base = base_url or getattr(settings, "ONLINE_CLASS_FRONTEND_DOMAIN", getattr(settings, "SITE_DOMAIN", "")) if not base: return f"?token={token}" parsed = urlparse(base) query_params = dict(parse_qsl(parsed.query)) query_params["token"] = token new_query = urlencode(query_params) return urlunparse(parsed._replace(query=new_query))