You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

283 lines
10 KiB

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))