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