diff --git a/apps/auth/__init__.py b/apps/auth/__init__.py index ec9487c..e69de29 100644 --- a/apps/auth/__init__.py +++ b/apps/auth/__init__.py @@ -1 +0,0 @@ -default_app_config = "apps.auth.apps.OAuthConfig" diff --git a/apps/auth/services/__init__.py b/apps/auth/services/__init__.py new file mode 100644 index 0000000..ba3e958 --- /dev/null +++ b/apps/auth/services/__init__.py @@ -0,0 +1,43 @@ +from apps.auth.services.flows import ( + complete_signup, + initiate_auth, + login_with_password, + reset_password, + verify_callback, + verify_token_pwd, +) +from apps.auth.services.passwords import ( + rate_password_strength, + validate_password_strength, +) +from apps.auth.services.questionnaire import ( + get_latest_answer, + get_survey_details, +) +from apps.auth.services.tokens import TEMP_TOKEN_TIMEOUT +from apps.auth.services.types import ( + AuthIntent, + CallbackVerification, + PasswordVerification, + ServiceError, +) +from apps.auth.services.users import create_user_session + +__all__ = [ + "AuthIntent", + "CallbackVerification", + "PasswordVerification", + "ServiceError", + "TEMP_TOKEN_TIMEOUT", + "complete_signup", + "create_user_session", + "get_latest_answer", + "get_survey_details", + "initiate_auth", + "login_with_password", + "rate_password_strength", + "reset_password", + "validate_password_strength", + "verify_callback", + "verify_token_pwd", +] diff --git a/apps/auth/services/captcha.py b/apps/auth/services/captcha.py new file mode 100644 index 0000000..744196a --- /dev/null +++ b/apps/auth/services/captcha.py @@ -0,0 +1,57 @@ +import logging + +import httpx + +from django.conf import settings + +from apps.auth.services.types import ServiceError + +logger = logging.getLogger(__name__) + +AUTH_SETTINGS = settings.AUTH +OTP_TIMEOUT = AUTH_SETTINGS["OTP_TIMEOUT"] + + +async def verify_turnstile_token( + turnstile_token, + client_ip, +) -> tuple[bool, ServiceError | None]: + """Helper function to verify Turnstile token with Cloudflare's API""" + + try: + async with httpx.AsyncClient(timeout=OTP_TIMEOUT) as client: + response = await client.post( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + data={ + "secret": settings.TURNSTILE_SECRET_KEY, + "response": turnstile_token, + "remoteip": client_ip, + }, + ) + + response_data = response.json() + if not response_data.get("success"): + logger.warning("Turnstile verification failed: %s", response_data) + + return False, ServiceError( + "Turnstile verification failed", + status=403, + ) + + return True, None + + except httpx.TimeoutException: + logger.error("Turnstile verification timed out") + + return False, ServiceError( + "Turnstile verification timed out", + status=504, + ) + + except Exception: + logger.exception("Turnstile verification error") + + return False, ServiceError( + "Turnstile verification error", + status=500, + ) diff --git a/apps/auth/services/flows.py b/apps/auth/services/flows.py new file mode 100644 index 0000000..69119e1 --- /dev/null +++ b/apps/auth/services/flows.py @@ -0,0 +1,682 @@ +import asyncio +import json +import logging +import time + +import dateutil.parser + +from django.conf import settings +from django.contrib.auth import authenticate, get_user_model, login + +from apps.auth.services.captcha import verify_turnstile_token +from apps.auth.services.passwords import validate_password_strength +from apps.auth.services.questionnaire import get_latest_answer, get_survey_details +from apps.auth.services.tokens import ( + OTP_TIMEOUT, + TEMP_TOKEN_TIMEOUT, + cleanup_existing_temp_token, + generate_otp, + generate_temp_token, + get_auth_redis, + get_client_ip, + get_state_key, + get_token_hash, + store_auth_intent, +) +from apps.auth.services.types import ( + AuthIntent, + CallbackVerification, + PasswordVerification, + ServiceError, +) +from apps.auth.services.users import create_user_session +from apps.web.models import Student + +logger = logging.getLogger(__name__) + +AUTH_SETTINGS = settings.AUTH +ACTION_LIST = AUTH_SETTINGS["ACTION_LIST"] +TOKEN_RATE_LIMIT = AUTH_SETTINGS["TOKEN_RATE_LIMIT"] +TOKEN_RATE_LIMIT_TIME = AUTH_SETTINGS["TOKEN_RATE_LIMIT_TIME"] + + +def initiate_auth(request) -> tuple[AuthIntent | None, ServiceError | None]: + # Get required fields from request data + action = request.data.get("action") + turnstile_token = request.data.get("turnstile_token") + + if not action or not turnstile_token: + logger.warning("Missing action or turnstile_token in auth_initiate_api") + + return None, ServiceError( + "Missing action or turnstile_token", + status=400, + ) + + if action not in ACTION_LIST: + logger.warning("Invalid action '%s' in auth_initiate_api", action) + + return None, ServiceError("Invalid action", status=400) + + client_ip = get_client_ip(request) + + # Verify Turnstile token + success, error = asyncio.run( + verify_turnstile_token(turnstile_token, client_ip), + ) + if not success: + logger.warning( + "verify_turnstile_token failed in auth_initiate_api:%s", + error.as_payload() if error else None, + ) + + return None, error or ServiceError( + "Turnstile verification failed", + status=502, + ) + + details = get_survey_details(action) + if not details: + logger.error("Invalid action '%s' when fetching survey details", action) + + return None, ServiceError("Invalid action", status=400) + + survey_url = details.get("url") + if not survey_url: + logger.error("Survey URL missing for %s", action) + + return None, ServiceError( + "Something went wrong when fetching the survey URL", + status=500, + ) + + # Generate cryptographically secure OTP and temp_token + otp = generate_otp() + temp_token = generate_temp_token() + + # Create Redis storage and clean up existing tokens + try: + redis_client = get_auth_redis() + cleanup_existing_temp_token(redis_client, request.COOKIES.get("temp_token")) + store_auth_intent(redis_client, otp, temp_token, action) + except Exception: + logger.exception("Failed to create auth intent") + + return None, ServiceError( + "Failed to create auth intent", + status=500, + ) + + logger.info("Created auth intent for action %s with OTP and temp_token", action) + + return AuthIntent( + otp=otp, + temp_token=temp_token, + redirect_url=survey_url, + ), None + + +def _load_pending_temp_token_state( + redis_client, + temp_token: str, + action: str, +) -> tuple[str | None, dict | None, ServiceError | None]: + # Step 1: Look up temp_token state record + state_key = get_state_key(temp_token) + state_data_raw = redis_client.get(state_key) + + if not state_data_raw: + logger.warning("Temp token state not found or expired in verify_callback_api") + + return ( + None, + None, + ServiceError( + "Temp token state not found or expired", + status=401, + ), + ) + + try: + state_data = json.loads(state_data_raw) + except json.JSONDecodeError: + logger.error("Invalid temp token state data in verify_callback_api") + + return ( + None, + None, + ServiceError( + "Invalid temp token state data", + status=401, + ), + ) + + if not isinstance(state_data, dict): + logger.error("Invalid temp token state data in verify_callback_api") + + return ( + None, + None, + ServiceError( + "Invalid temp token state data", + status=401, + ), + ) + + # Verify status is pending and action matches + if state_data.get("status") != "pending": + logger.warning("Temp token state not pending in verify_callback_api") + + return ( + None, + None, + ServiceError( + "Invalid temp token state", + status=401, + ), + ) + + if state_data.get("action") != action: + logger.warning("Action mismatch in verify_callback_api") + + return None, None, ServiceError("Action mismatch", status=403) + + return state_key, state_data, None + + +def _apply_verification_rate_limit( + redis_client, + temp_token: str, +) -> tuple[str | None, ServiceError | None]: + # Step 2: Apply rate limiting per temp_token to prevent brute-force attempts + rate_limit_key = f"verify_attempts:{get_token_hash(temp_token)}" + + attempts = redis_client.incr(rate_limit_key) + + if attempts == 1: + redis_client.expire(rate_limit_key, TOKEN_RATE_LIMIT_TIME) + + if attempts > TOKEN_RATE_LIMIT: + logger.warning("Too many verification attempts in verify_callback_api") + + return None, ServiceError( + "Too many verification attempts", + status=429, + ) + + return rate_limit_key, None + + +def _get_and_validate_latest_answer( + action: str, + account: str, + answer_id, +) -> tuple[dict | None, ServiceError | None]: + # Step 3: Query questionnaire API for latest submission of the specific questionnaire of the action + latest_answer, error = asyncio.run( + get_latest_answer(action=action, account=account), + ) + if error: + return None, error + + if latest_answer is None: + logger.warning("No questionnaire submission found in verify_callback_api") + + return None, ServiceError( + "No questionnaire submission found", + status=404, + ) + + # Check if this is the submission we're looking for + if str(latest_answer.get("id")) != str(answer_id): + logger.warning("Answer ID mismatch in verify_callback_api") + + return None, ServiceError("Answer ID mismatch", status=403) + + return latest_answer, None + + +def _consume_and_validate_otp( + redis_client, + submitted_otp, + temp_token: str, +) -> tuple[float | None, ServiceError | None]: + # Atomically get and delete OTP record to prevent reuse + otp_key = f"otp:{submitted_otp}" + otp_data_raw = redis_client.getdel(otp_key) + + if not otp_data_raw: + logger.warning("Invalid or expired OTP in verify_callback_api") + + return None, ServiceError( + "Invalid or expired OTP", + status=401, + ) + + try: + otp_data = json.loads(otp_data_raw.decode("utf-8")) + expected_temp_token = otp_data.get("temp_token") + initiated_at = otp_data.get("initiated_at") + except json.JSONDecodeError, AttributeError, TypeError: + logger.error("Invalid OTP data format in verify_callback_api") + + return None, ServiceError( + "Invalid OTP data format", + status=401, + ) + + if not expected_temp_token or not initiated_at: + logger.warning("Incomplete OTP data in verify_callback_api") + + return None, ServiceError( + "Incomplete OTP data", + status=401, + ) + + # Step 5: StepVerify temp_token matches + if expected_temp_token != temp_token: + logger.warning("Invalid temp_token in verify_callback_api") + + return None, ServiceError( + "Invalid temp_token", + status=401, + ) + + return float(initiated_at), None + + +def _validate_submission_timestamp( + latest_answer: dict, + initiated_at: float, +) -> ServiceError | None: + # Step 6: Validate submission timestamp after OTP extraction + try: + submitted_at_str = latest_answer.get("submitted_at") + if submitted_at_str is None: + return ServiceError( + "Missing submission timestamp", + status=400, + ) + + submitted_at = dateutil.parser.parse(submitted_at_str).timestamp() + + # Additional validation: check submission is after initiation and within window + if submitted_at < initiated_at or (submitted_at - initiated_at) > OTP_TIMEOUT: + return ServiceError( + "Submission timestamp outside validity window", + status=401, + ) + + except ValueError, TypeError: + logger.error("Error parsing submission timestamp") + + return ServiceError( + "Invalid submission timestamp", + status=401, + ) + + return None + + +def _mark_temp_token_verified( + redis_client, + state_key: str, + state_data: dict, + account: str, +) -> int: + # Step 7: Update state to verified and add user details + state_data.update( + { + "status": "verified", + "account": account, + }, + ) + + # Update temp_token_state in Redis with refreshed TTL + redis_client.setex(state_key, TEMP_TOKEN_TIMEOUT, json.dumps(state_data)) + + return int(time.time() + TEMP_TOKEN_TIMEOUT) + + +def _login_verified_user( + request, + redis_client, + state_key: str, + account: str, +) -> tuple[bool, ServiceError | None]: + user, error = create_user_session(request, account) + if user is None: + if error: + logger.error( + "Failed to create session for login: %s", + error.as_payload().get("error", "Unknown error"), + ) + + return False, error + + logger.error("Failed to create user session in verify_callback_api") + + return False, ServiceError( + "Failed to create user session", + status=500, + ) + + if not user.is_active: + logger.warning("Inactive user attempted OAuth login: %s", account) + + return False, ServiceError( + "User account is inactive", + status=403, + ) + + try: + # Create Django session + login(request, user) + + # Delete temp_token_state after successful login + redis_client.delete(state_key) + except Exception: + logger.exception( + "Error during login session creation or cleanup for user %s", + account, + ) + + return False, ServiceError( + "Failed to finalize login process", + status=500, + ) + + return True, None + + +def verify_callback(request) -> tuple[CallbackVerification | None, ServiceError | None]: + logger.info( + "verify_callback_api called for account=%s, action=%s", + request.data.get("account"), + request.data.get("action"), + ) + + # Get required parameters from request + account = request.data.get("account") + answer_id = request.data.get("answer_id") + action = request.data.get("action") + + if not account or not answer_id or not action: + logger.warning("Missing account, answer_id, or action in verify_callback_api") + + return None, ServiceError( + "Missing account, answer_id, or action", + status=400, + ) + + if action not in ACTION_LIST: + logger.warning("Invalid action '%s' in verify_callback_api", action) + + return None, ServiceError("Invalid action", status=400) + + # Get temp_token from HttpOnly cookie + temp_token = request.COOKIES.get("temp_token") + if not temp_token: + logger.warning("No temp_token found in verify_callback_api") + + return None, ServiceError("No temp_token found", status=401) + + redis_client = get_auth_redis() + + state_key, state_data, error = _load_pending_temp_token_state( + redis_client, + temp_token, + action, + ) + if error: + return None, error + + rate_limit_key, error = _apply_verification_rate_limit(redis_client, temp_token) + if error: + return None, error + + latest_answer, error = _get_and_validate_latest_answer( + action, + account, + answer_id, + ) + if error: + return None, error + + # Extract OTP and quest_id from submission + submitted_otp = latest_answer.get("otp") + + initiated_at, error = _consume_and_validate_otp( + redis_client, + submitted_otp, + temp_token, + ) + if error: + return None, error + + error = _validate_submission_timestamp(latest_answer, initiated_at) + if error: + return None, error + + expires_at = _mark_temp_token_verified( + redis_client, + state_key, + state_data, + account, + ) + + # Clear rate limiting on success + redis_client.delete(rate_limit_key) + + logger.info( + "Successfully verified temp_token for user %s with action %s", + account, + action, + ) + + # For login action, handle immediate session creation and cleanup + is_logged_in = False + if action == "login": + is_logged_in, error = _login_verified_user( + request, + redis_client, + state_key, + account, + ) + if error: + return None, error + + return CallbackVerification( + action=action, + expires_at=expires_at, + is_logged_in=is_logged_in, + ), None + + +def verify_token_pwd( + request, + action: str, +) -> tuple[PasswordVerification | None, ServiceError | None]: + # Get temp_token from HttpOnly cookie + temp_token = request.COOKIES.get("temp_token") + if not temp_token: + return None, ServiceError("No temp_token found", status=401) + + redis_client = get_auth_redis() + + # Look up temp_token state record + state_key = get_state_key(temp_token) + state_data_raw = redis_client.get(state_key) + + if not state_data_raw: + return None, ServiceError( + "Temp token state not found or expired", + status=401, + ) + + try: + state_data = json.loads(state_data_raw) + except json.JSONDecodeError: + return None, ServiceError( + "Invalid temp token state data", + status=401, + ) + + if not isinstance(state_data, dict): + return None, ServiceError( + "Invalid temp token state data", + status=401, + ) + + # Verify status is verified and action is signup + if state_data.get("status") != "verified" or state_data.get("action") != action: + return None, ServiceError( + "Invalid temp token state", + status=403, + ) + + # Get password from request data + password = request.data.get("password") + if not password: + return None, ServiceError("Missing password", status=400) + + # Validate password strength + is_valid, password_error = validate_password_strength(password) + if not is_valid: + return None, ServiceError( + "Invalid password", + status=400, + payload=password_error, + ) + + # Get account from verified state + account = state_data.get("account") + if not account: + return None, ServiceError( + "No account in verified state", + status=401, + ) + + return PasswordVerification( + account=account, + password=password, + state_key=state_key, + ), None + + +def complete_signup(request) -> tuple[dict | None, ServiceError | None]: + try: + verification_data, error = verify_token_pwd(request, action="signup") + if verification_data is None: + return None, error or ServiceError("Verification failed", status=400) + + # Create user session + user, error = create_user_session(request, verification_data.account) + if user is None: + return None, error or ServiceError( + "Failed to create user session", + status=500, + ) + + if user.password: + return None, ServiceError( + "User already exists with password.", + status=409, + ) + + user.is_active = True + + # Set password + user.set_password(verification_data.password) + user.save() + + login(request, user) + + # Cleanup: Delete temp_token_state and clear cookie + redis_client = get_auth_redis() + redis_client.delete(verification_data.state_key) + + return {"success": True, "username": user.username}, None + + except Exception: + logger.exception("Error in auth_signup_api") + + return None, ServiceError( + "Failed to complete signup", + status=500, + ) + + +def reset_password(request) -> tuple[dict | None, ServiceError | None]: + try: + verification_data, error = verify_token_pwd( + request, + action="reset_password", + ) + if verification_data is None: + return None, error or ServiceError("Verification failed", status=400) + + # Get the user object and update password + user_model = get_user_model() + try: + user = user_model.objects.get(username=verification_data.account) + user.set_password(verification_data.password) + user.save() + except user_model.DoesNotExist: + return None, ServiceError( + "User does not exist", + status=404, + ) + + # Cleanup: Delete temp_token_state and clear cookie + redis_client = get_auth_redis() + redis_client.delete(verification_data.state_key) + + return {"success": True, "username": user.username}, None + + except Exception: + logger.exception("Error in auth_reset_password_api") + + return None, ServiceError( + "Failed to reset password", + status=500, + ) + + +def login_with_password(request) -> tuple[dict | None, ServiceError | None]: + account = request.data.get("account", "").strip() + password = request.data.get("password", "") + turnstile_token = request.data.get("turnstile_token", "") + + if not account or not password or not turnstile_token: + logger.warning( + "Account, password, and Turnstile token are missing in auth_login_api", + ) + + return None, ServiceError( + "Account, password, and Turnstile token are missing", + status=400, + ) + + client_ip = get_client_ip(request) + + success, error = asyncio.run( + verify_turnstile_token(turnstile_token, client_ip), + ) + if not success: + return None, error or ServiceError( + "Turnstile verification failed", + status=502, + ) + + user = authenticate(request, username=account, password=password) + if user is None or not user.is_active: + return None, ServiceError( + "Invalid account or password", + status=401, + ) + + login(request, user) + Student.objects.get_or_create(user=user) + + return {"message": "Login successfully"}, None diff --git a/apps/auth/services/passwords.py b/apps/auth/services/passwords.py new file mode 100644 index 0000000..b18a8cc --- /dev/null +++ b/apps/auth/services/passwords.py @@ -0,0 +1,63 @@ +import re + +from django.conf import settings +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import ValidationError + +AUTH_SETTINGS = settings.AUTH +PASSWORD_LENGTH_MIN = AUTH_SETTINGS["PASSWORD_LENGTH_MIN"] +PASSWORD_LENGTH_MAX = AUTH_SETTINGS["PASSWORD_LENGTH_MAX"] + + +def rate_password_strength(password: str) -> int: + """Helper function to rate password strength""" + + if len(password) < PASSWORD_LENGTH_MIN or len(password) > PASSWORD_LENGTH_MAX: + return 0 + + score = 1 + + if re.search(r"[a-z]", password): + score += 1 + if re.search(r"[A-Z]", password): + score += 1 + if re.search(r"\d", password): + score += 1 + if re.search(r"[^a-zA-Z0-9\s]", password): + score += 1 + + length_range = max(1, PASSWORD_LENGTH_MAX - PASSWORD_LENGTH_MIN) + length_step = max(1, length_range // 10) + + score += (len(password) - PASSWORD_LENGTH_MIN) // length_step + + return min(score, 5) + + +def validate_password_strength(password: str) -> tuple[bool, dict | None]: + """Helper function to validate password complexity and strength. + + Returns: A tuple of (is_valid, error_response). + `is_valid` is True if the password is valid, otherwise False. + `error_response` is a dict with a detailed error message if invalid, otherwise None. + """ + + score = rate_password_strength(password) + + if score == 0: + return False, { + "error": "Password is too short or too long.", + } + + if score < 3: + return False, { + "error": "Password is too weak.", + } + + # Use Django's built-in validators for additional checks + try: + validate_password(password) + except ValidationError as e: + return False, {"error": list(e.messages)} + + return True, None diff --git a/apps/auth/services/questionnaire.py b/apps/auth/services/questionnaire.py new file mode 100644 index 0000000..73c4323 --- /dev/null +++ b/apps/auth/services/questionnaire.py @@ -0,0 +1,168 @@ +import json +import logging + +from typing import Any + +import httpx + +from django.conf import settings + +from apps.auth.services.types import ServiceError + +logger = logging.getLogger(__name__) + +AUTH_SETTINGS = settings.AUTH +OTP_TIMEOUT = AUTH_SETTINGS["OTP_TIMEOUT"] + +QUEST_SETTINGS = settings.QUEST +QUEST_BASE_URL = QUEST_SETTINGS["BASE_URL"] + + +def get_survey_details(action: str) -> dict[str, Any] | None: + """ + A single, clean function to get all survey details for a given action. + Valid actions: "signup", "login", "reset_password". + """ + + action_details = QUEST_SETTINGS.get(action.upper()) + + if not action_details: + logger.error("Invalid quest action requested: %s", action) + + return None + + try: + question_id = int(action_details.get("QUESTIONID")) + except ValueError, TypeError: + logger.error( + "Could not parse 'QUESTIONID' for action '%s'. Check your settings.", + action, + ) + + return None + + return { + "url": action_details.get("URL"), + "api_key": action_details.get("API_KEY"), + "question_id": question_id, + } + + +async def get_latest_answer( + action: str, + account: str, +) -> tuple[dict | None, ServiceError | None]: + """Fetch the latest questionnaire answer for a given account from the WJ API(specific api for actions).""" + + details = get_survey_details(action) + if not details: + return None, ServiceError("Invalid action", status=400) + + quest_api = details.get("api_key") + if not quest_api: + return None, ServiceError("Invalid action", status=400) + + # Get the target question ID for the verification code + question_id = details.get("question_id") + if not question_id: + return None, ServiceError( + "Configuration error: question ID not found for action", + status=500, + ) + + # Build the 'params' and 'sort' dictionaries + params_dict = { + "account": account, + "current": 1, + "pageSize": 1, + } + sort_dict = {"id": "desc"} + + params_json_str = json.dumps(params_dict, ensure_ascii=False) + sort_json_str = json.dumps(sort_dict) + + # Prepare the final query parameters + final_query_params = {"params": params_json_str, "sort": sort_json_str} + + # Combine to form the full URL path + full_url_path = f"{QUEST_BASE_URL}/{quest_api}/json" + + try: + async with httpx.AsyncClient(timeout=OTP_TIMEOUT) as client: + response = await client.get( + full_url_path, + params=final_query_params, + ) + response.raise_for_status() # Raise an exception for bad status codes + full_data = response.json() + + except httpx.TimeoutException: + logger.error("Questionnaire API query timed out") + + return None, ServiceError( + "Questionnaire API query timed out", + status=504, + ) + + except httpx.RequestError, httpx.HTTPStatusError: + logger.error("Error querying questionnaire API") + + return None, ServiceError( + "Failed to query questionnaire API", + status=500, + ) + + except Exception: + logger.exception("An unexpected error occurred") + + return None, ServiceError( + "An unexpected error occurred", + status=500, + ) + + # Filter and return only the required fields from the first row + if ( + full_data.get("success") + and full_data.get("data") + and full_data["data"].get("rows") + and len(full_data["data"]["rows"]) > 0 + ): + # Get the first (latest) row + latest_answer = full_data["data"]["rows"][0] + + # Find the otp by matching the question ID + otp = None + answers = latest_answer.get("answers", []) + for ans in answers: + if str(ans.get("question", {}).get("id")) == str(question_id): + otp = ans.get("answer") + break + + # Extract only the required fields from this row + filtered_data = { + "id": latest_answer.get("id"), + "submitted_at": latest_answer.get("submitted_at"), + "account": latest_answer.get("user", {}).get("account") + if latest_answer.get("user") + else None, + "otp": otp, + } + + # Check if all required fields are present + if not all( + key in filtered_data and filtered_data[key] is not None + for key in ["id", "submitted_at", "account", "otp"] + ): + logger.warning("Missing required field(s) in questionnaire response") + + return None, ServiceError( + "Missing required field(s) in questionnaire response", + status=400, + ) + + return filtered_data, None + + return None, ServiceError( + "No questionnaire submission found or submission invalid", + status=403, + ) diff --git a/apps/auth/services/tokens.py b/apps/auth/services/tokens.py new file mode 100644 index 0000000..1d1436a --- /dev/null +++ b/apps/auth/services/tokens.py @@ -0,0 +1,84 @@ +import hashlib +import json +import logging +import secrets +import time + +from django.conf import settings +from django_redis import get_redis_connection + +logger = logging.getLogger(__name__) + +AUTH_SETTINGS = settings.AUTH +OTP_TIMEOUT = AUTH_SETTINGS["OTP_TIMEOUT"] +TEMP_TOKEN_TIMEOUT = AUTH_SETTINGS["TEMP_TOKEN_TIMEOUT"] + + +def get_auth_redis(): + return get_redis_connection("default") + + +def get_client_ip(request) -> str | None: + return ( + request.META.get("HTTP_CF_CONNECTING_IP") + or request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")[0].strip() + or request.META.get("REMOTE_ADDR") + ) + + +def get_token_hash(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +def get_state_key(temp_token: str) -> str: + return f"temp_token_state:{get_token_hash(temp_token)}" + + +def generate_otp(length: int = 8) -> str: + return "".join(str(secrets.randbelow(10)) for _ in range(length)) + + +def generate_temp_token() -> str: + return secrets.token_urlsafe(32) + + +def cleanup_existing_temp_token(redis_client, temp_token: str | None) -> None: + # Clean up any existing temp_token for this client to prevent memory leaks + if not temp_token: + return + + try: + existing_state_key = get_state_key(temp_token) + existing_state_data = redis_client.get(existing_state_key) + + if existing_state_data: + existing_state = json.loads(existing_state_data) + action = ( + existing_state.get("action", "unknown") + if isinstance(existing_state, dict) + else "unknown" + ) + + redis_client.delete(existing_state_key) + logger.info( + "Cleaned up existing temp_token_state for action %s", + action, + ) + + except Exception: + logger.warning("Error cleaning up existing temp_token") + + +def store_auth_intent(redis_client, otp: str, temp_token: str, action: str) -> None: + # Store OTP -> temp_token mapping with initiated_at timestamp + current_time = time.time() + otp_data = {"temp_token": temp_token, "initiated_at": current_time} + redis_client.setex(f"otp:{otp}", OTP_TIMEOUT, json.dumps(otp_data)) + + # Store temp_token with SHA256 hash as key, and status of pending as well as action + temp_token_state = {"status": "pending", "action": action} + redis_client.setex( + get_state_key(temp_token), + TEMP_TOKEN_TIMEOUT, + json.dumps(temp_token_state), + ) diff --git a/apps/auth/services/types.py b/apps/auth/services/types.py new file mode 100644 index 0000000..dbb3403 --- /dev/null +++ b/apps/auth/services/types.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass +from typing import Any + + +@dataclass(slots=True) +class ServiceError: + message: str + status: int = 400 + payload: dict[str, Any] | None = None + + def as_payload(self) -> dict[str, Any]: + if self.payload is not None: + return self.payload + + return {"error": self.message} + + +@dataclass(slots=True) +class AuthIntent: + otp: str + temp_token: str + redirect_url: str + + +@dataclass(slots=True) +class CallbackVerification: + action: str + expires_at: int + is_logged_in: bool = False + + +@dataclass(slots=True) +class PasswordVerification: + account: str + password: str + state_key: str diff --git a/apps/auth/services/users.py b/apps/auth/services/users.py new file mode 100644 index 0000000..1e4477e --- /dev/null +++ b/apps/auth/services/users.py @@ -0,0 +1,55 @@ +import logging + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AbstractUser + +from apps.auth.services.types import ServiceError +from apps.web.models import Student + +logger = logging.getLogger(__name__) + +AUTH_SETTINGS = settings.AUTH +EMAIL_DOMAIN_NAME = AUTH_SETTINGS["EMAIL_DOMAIN_NAME"] + + +def create_user_session( + request, + account, +) -> tuple[AbstractUser | None, ServiceError | None]: + """Helper function includes session management, user creation and Student model integration.""" + + try: + # Ensure session exists - create one if it doesn't exist + if not request.session.session_key: + request.session.create() + + # Get or create user + user_model = get_user_model() + + user, _ = user_model.objects.get_or_create( + username=account, + defaults={"email": f"{account}@{EMAIL_DOMAIN_NAME}"}, + ) + + if not user: + return None, ServiceError( + "Failed to retrieve or create user", + status=500, + ) + + # Handle Student model integration + Student.objects.get_or_create(user=user) + + # Update session to use authenticated username + request.session["user_id"] = user.username + + return user, None + + except Exception: + logger.exception("Failed to create user session") + + return None, ServiceError( + "Failed to create user session", + status=500, + ) diff --git a/apps/auth/utils.py b/apps/auth/utils.py index 3e63695..eb3ecfb 100644 --- a/apps/auth/utils.py +++ b/apps/auth/utils.py @@ -1,295 +1,8 @@ -import json -import logging -import re -from typing import Any - -import httpx -from django.conf import settings -from django.contrib.auth import get_user_model -from django.contrib.auth.models import AbstractUser -from django.contrib.auth.password_validation import validate_password -from django.core.exceptions import ValidationError from rest_framework.authentication import SessionAuthentication -from rest_framework.response import Response - -from apps.web.models import Student - -logger = logging.getLogger(__name__) - -AUTH_SETTINGS = settings.AUTH -PASSWORD_LENGTH_MIN = AUTH_SETTINGS["PASSWORD_LENGTH_MIN"] -PASSWORD_LENGTH_MAX = AUTH_SETTINGS["PASSWORD_LENGTH_MAX"] -OTP_TIMEOUT = AUTH_SETTINGS["OTP_TIMEOUT"] -EMAIL_DOMAIN_NAME = AUTH_SETTINGS["EMAIL_DOMAIN_NAME"] - -QUEST_SETTINGS = settings.QUEST -QUEST_BASE_URL = QUEST_SETTINGS["BASE_URL"] class CSRFCheckSessionAuthentication(SessionAuthentication): def authenticate(self, request): super().enforce_csrf(request) - return super().authenticate(request) - - -def get_survey_details(action: str) -> dict[str, Any] | None: - """ - A single, clean function to get all survey details for a given action. - Valid actions: "signup", "login", "reset_password". - """ - - action_details = QUEST_SETTINGS.get(action.upper()) - - if not action_details: - logger.error("Invalid quest action requested: %s", action) - return None - - try: - question_id = int(action_details.get("QUESTIONID")) - except ValueError, TypeError: - logger.error( - "Could not parse 'QUESTIONID' for action '%s'. Check your settings.", action - ) - return None - - return { - "url": action_details.get("URL"), - "api_key": action_details.get("API_KEY"), - "question_id": question_id, - } - - -async def verify_turnstile_token( - turnstile_token, client_ip -) -> tuple[bool, Response | None]: - """Helper function to verify Turnstile token with Cloudflare's API""" - - try: - async with httpx.AsyncClient(timeout=OTP_TIMEOUT) as client: - response = await client.post( - "https://challenges.cloudflare.com/turnstile/v0/siteverify", - data={ - "secret": settings.TURNSTILE_SECRET_KEY, - "response": turnstile_token, - "remoteip": client_ip, - }, - ) - if not response.json().get("success"): - logger.warning("Turnstile verification failed: %s", response.json()) - return False, Response( - {"error": "Turnstile verification failed"}, status=403 - ) - return True, None - except httpx.TimeoutException: - logger.error("Turnstile verification timed out") - return False, Response( - {"error": "Turnstile verification timed out"}, status=504 - ) - except Exception: - logger.error("Turnstile verification error") - return False, Response({"error": "Turnstile verification error"}, status=500) - - -async def get_latest_answer( - action: str, - account: str, -) -> tuple[dict | None, Response | None]: - """Fetch the latest questionnaire answer for a given account from the WJ API(specific api for actions). - Returns a tuple of (filtered_data, error_response). - `filtered_data` contains: id, submitted_at, user.account, and otp. - `error_response` is a DRF Response object if an error occurs, otherwise None. - """ - - details = get_survey_details(action) - if not details: - return None, Response({"error": "Invalid action"}, status=400) - quest_api = details.get("api_key") - if not quest_api: - return None, Response({"error": "Invalid action"}, status=400) - - # Get the target question ID for the verification code - question_id = details.get("question_id") - if not question_id: - return None, Response( - {"error": "Configuration error: question ID not found for action"}, - status=500, - ) - - # Build the 'params' and 'sort' dictionaries - params_dict = { - "account": account, - "current": 1, - "pageSize": 1, - } - sort_dict = {"id": "desc"} - - params_json_str = json.dumps(params_dict, ensure_ascii=False) - sort_json_str = json.dumps(sort_dict) - - # Prepare the final query parameters - final_query_params = {"params": params_json_str, "sort": sort_json_str} - - # Combine to form the full URL path - full_url_path = f"{QUEST_BASE_URL}/{quest_api}/json" - - try: - async with httpx.AsyncClient(timeout=OTP_TIMEOUT) as client: - response = await client.get( - full_url_path, - params=final_query_params, - ) - response.raise_for_status() # Raise an exception for bad status codes - full_data = response.json() - except httpx.TimeoutException: - logger.error("Questionnaire API query timed out") - return None, Response( - {"error": "Questionnaire API query timed out"}, - status=504, - ) - except httpx.RequestError: - logger.error("Error querying questionnaire API") - return None, Response( - {"error": "Failed to query questionnaire API"}, - status=500, - ) - except Exception: - logger.error("An unexpected error occurred") - return None, Response({"error": "An unexpected error occurred"}, status=500) - - # Filter and return only the required fields from the first row - if ( - full_data.get("success") - and full_data.get("data") - and full_data["data"].get("rows") - and len(full_data["data"]["rows"]) > 0 - ): - # Get the first (latest) row - latest_answer = full_data["data"]["rows"][0] - - # Find the otp by matching the question ID - otp = None - answers = latest_answer.get("answers", []) - for ans in answers: - if str(ans.get("question", {}).get("id")) == str(question_id): - otp = ans.get("answer") - break - - # Extract only the required fields from this row - filtered_data = { - "id": latest_answer.get("id"), - "submitted_at": latest_answer.get("submitted_at"), - "account": latest_answer.get("user", {}).get("account") - if latest_answer.get("user") - else None, - "otp": otp, - } - # Check if all required fields are present - if not all( - key in filtered_data and filtered_data[key] is not None - for key in ["id", "submitted_at", "account", "otp"] - ): - logger.warning("Missing required field(s) in questionnaire response") - return None, Response( - {"error": "Missing required field(s) in questionnaire response"}, - status=400, - ) - - return filtered_data, None - - return None, Response( - {"error": "No questionnaire submission found or submission invalid"}, - status=403, - ) - - -def rate_password_strength(password: str) -> int: - """Helper function to rate password strength""" - - if len(password) < PASSWORD_LENGTH_MIN or len(password) > PASSWORD_LENGTH_MAX: - return 0 - - score = 1 - - if re.search(r"[a-z]", password): - score += 1 - if re.search(r"[A-Z]", password): - score += 1 - if re.search(r"\d", password): - score += 1 - if re.search(r"[^a-zA-Z0-9\s]", password): - score += 1 - - length_range = max(1, PASSWORD_LENGTH_MAX - PASSWORD_LENGTH_MIN) - length_step = max(1, length_range // 10) - - score += (len(password) - PASSWORD_LENGTH_MIN) // length_step - - return min(score, 5) - - -def validate_password_strength(password: str) -> tuple[bool, dict | None]: - """Helper function to validate password complexity and strength. - - Returns: A tuple of (is_valid, error_response). - `is_valid` is True if the password is valid, otherwise False. - `error_response` is a dict with a detailed error message if invalid, otherwise None. - """ - - score = rate_password_strength(password) - - if score == 0: - return False, { - "error": "Password is too short or too long.", - } - - if score < 3: - return False, { - "error": "Password is too weak.", - } - - # Use Django's built-in validators for additional checks - try: - validate_password(password) - return True, None - except ValidationError as e: - return False, {"error": list(e.messages)} - - -def create_user_session( - request, - account, -) -> tuple[AbstractUser | None, Response | None]: - """Helper function includes session management, user creation and Student model integration. - Returns a tuple of (user, error_response). - `user` is the user object on success, otherwise None. - `error_response` is a DRF Response object if an error occurs, otherwise None. - """ - - try: - # Ensure session exists - create one if it doesn't exist - if not request.session.session_key: - request.session.create() - - # Get or create user - user_model = get_user_model() - - user, _ = user_model.objects.get_or_create( - username=account, - defaults={"email": f"{account}@{EMAIL_DOMAIN_NAME}"}, - ) - - if not user: - return None, Response( - {"error": "Failed to retrieve or create user"}, status=500 - ) - - # Handle Student model integration - Student.objects.get_or_create(user=user) - - # Update session to use authenticated username - request.session["user_id"] = user.username - return user, None - - except Exception: - return None, Response({"error": "Failed to create user session"}, status=500) + return super().authenticate(request) diff --git a/apps/auth/views.py b/apps/auth/views.py index 01aafb0..da8537b 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -1,15 +1,8 @@ -import asyncio -import hashlib -import json import logging -import secrets -import time -import dateutil.parser from django.conf import settings -from django.contrib.auth import authenticate, get_user_model, login, logout +from django.contrib.auth import logout from django.views.decorators.csrf import ensure_csrf_cookie -from django_redis import get_redis_connection from rest_framework.decorators import ( api_view, authentication_classes, @@ -18,18 +11,13 @@ from rest_framework.permissions import AllowAny from rest_framework.response import Response -from apps.auth import utils -from apps.web.models import Student +from apps.auth import services, utils logger = logging.getLogger(__name__) -AUTH_SETTINGS = settings.AUTH -OTP_TIMEOUT = AUTH_SETTINGS["OTP_TIMEOUT"] -TEMP_TOKEN_TIMEOUT = AUTH_SETTINGS["TEMP_TOKEN_TIMEOUT"] -ACTION_LIST = AUTH_SETTINGS["ACTION_LIST"] -TOKEN_RATE_LIMIT = AUTH_SETTINGS["TOKEN_RATE_LIMIT"] -TOKEN_RATE_LIMIT_TIME = AUTH_SETTINGS["TOKEN_RATE_LIMIT_TIME"] +def _service_error_response(error: services.ServiceError) -> Response: + return Response(error.as_payload(), status=error.status) @api_view(["POST"]) @@ -43,97 +31,27 @@ def auth_initiate_api(request): 4. Stores OTP->temp_token mapping and temp_token state in Redis 5. Sets temp_token as HttpOnly cookie and returns OTP and redirect_url """ - # Get required fields from request data - action = request.data.get("action") - turnstile_token = request.data.get("turnstile_token") - - if not action or not turnstile_token: - logger.warning("Missing action or turnstile_token in auth_initiate_api") - return Response({"error": "Missing action or turnstile_token"}, status=400) - - if action not in ACTION_LIST: - logger.warning("Invalid action '%s' in auth_initiate_api", action) - return Response({"error": "Invalid action"}, status=400) - - client_ip = ( - request.META.get("HTTP_CF_CONNECTING_IP") - or request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")[0].strip() - or request.META.get("REMOTE_ADDR") - ) - - # Verify Turnstile token - success, error_response = asyncio.run( - utils.verify_turnstile_token(turnstile_token, client_ip) - ) - if not success: - logger.warning( - "verify_turnstile_token failed in auth_initiate_api:%s", - error_response.data, - ) - return error_response - - # Generate cryptographically secure OTP and temp_token - otp = "".join([str(secrets.randbelow(10)) for _ in range(8)]) - temp_token = secrets.token_urlsafe(32) - - # Create Redis storage and clean up existing tokens - r = get_redis_connection("default") - - # Clean up any existing temp_token for this client to prevent memory leaks - existing_temp_token = request.COOKIES.get("temp_token") - if existing_temp_token: - try: - existing_hash = hashlib.sha256(existing_temp_token.encode()).hexdigest() - existing_state_key = f"temp_token_state:{existing_hash}" - existing_state_data = r.get(existing_state_key) - if existing_state_data: - existing_state = json.loads(existing_state_data) - r.delete(existing_state_key) - logger.info( - "Cleaned up existing temp_token_state for action %s", - existing_state.get("action", "unknown"), - ) - except Exception: - logger.warning("Error cleaning up existing temp_token") - - # Store OTP -> temp_token mapping with initiated_at timestamp - current_time = time.time() - otp_data = {"temp_token": temp_token, "initiated_at": current_time} - r.setex(f"otp:{otp}", OTP_TIMEOUT, json.dumps(otp_data)) - - # Store temp_token with SHA256 hash as key, and status of pending as well as action - temp_token_hash = hashlib.sha256(temp_token.encode()).hexdigest() - temp_token_state = {"status": "pending", "action": action} - r.setex( - f"temp_token_state:{temp_token_hash}", - TEMP_TOKEN_TIMEOUT, - json.dumps(temp_token_state), - ) - logger.info("Created auth intent for action %s with OTP and temp_token", action) - - details = utils.get_survey_details(action) - if not details: - logger.error("Invalid action '%s' when fetching survey details", action) - return Response({"error": "Invalid action"}, status=400) - survey_url = details.get("url") - if not survey_url: - logger.error("Survey URL missing for %s", action) - return Response( - {"error": "Something went wrong when fetching the survey URL"}, - status=500, + intent, error = services.initiate_auth(request) + if intent is None: + return _service_error_response( + error or services.ServiceError("Failed to initiate authentication", 500), ) # Create response and set temp_token as HttpOnly cookie - response = Response({"otp": otp, "redirect_url": survey_url}, status=200) + response = Response( + {"otp": intent.otp, "redirect_url": intent.redirect_url}, + status=200, + ) response.set_cookie( "temp_token", - temp_token, - max_age=TEMP_TOKEN_TIMEOUT, + intent.temp_token, + max_age=services.TEMP_TOKEN_TIMEOUT, httponly=True, secure=getattr(settings, "SECURE_COOKIES", True), samesite="Lax", ) + return response @@ -145,240 +63,30 @@ def verify_callback_api(request): request data includes account, answer_id, action Handles the verification of questionnaire callback using temp_token from cookie. """ - logger.info( - "verify_callback_api called for account=%s, action=%s", - request.data.get("account"), - request.data.get("action"), - ) - # Get required parameters from request - account = request.data.get("account") - answer_id = request.data.get("answer_id") - action = request.data.get("action") - - if not account or not answer_id or not action: - logger.warning("Missing account, answer_id, or action in verify_callback_api") - return Response({"error": "Missing account, answer_id, or action"}, status=400) - - if action not in ACTION_LIST: - logger.warning("Invalid action '%s' in verify_callback_api", action) - return Response({"error": "Invalid action"}, status=400) - - # Get temp_token from HttpOnly cookie - temp_token = request.COOKIES.get("temp_token") - if not temp_token: - logger.warning("No temp_token found in verify_callback_api") - return Response({"error": "No temp_token found"}, status=401) - - r = get_redis_connection("default") - - # Step 1: Look up temp_token state record - temp_token_hash = hashlib.sha256(temp_token.encode()).hexdigest() - state_key = f"temp_token_state:{temp_token_hash}" - state_data = r.get(state_key) - - if not state_data: - logger.warning("Temp token state not found or expired in verify_callback_api") - return Response({"error": "Temp token state not found or expired"}, status=401) - - try: - state_data = json.loads(state_data) - except json.JSONDecodeError: - logger.error("Invalid temp token state data in verify_callback_api") - return Response({"error": "Invalid temp token state data"}, status=401) - - # Verify status is pending and action matches - if state_data.get("status") != "pending": - logger.warning("Temp token state not pending in verify_callback_api") - return Response({"error": "Invalid temp token state"}, status=401) - - if state_data.get("action") != action: - logger.warning("Action mismatch in verify_callback_api") - return Response({"error": "Action mismatch"}, status=403) - - # Step 2: Apply rate limiting per temp_token to prevent brute-force attempts - rate_limit_key = ( - f"verify_attempts:{hashlib.sha256(temp_token.encode()).hexdigest()}" - ) - - attempts = r.incr(rate_limit_key) - - if attempts == 1: - r.expire(rate_limit_key, TOKEN_RATE_LIMIT_TIME) - - if attempts > TOKEN_RATE_LIMIT: - logger.warning("Too many verification attempts in verify_callback_api") - return Response({"error": "Too many verification attempts"}, status=429) - # Step 3: Query questionnaire API for latest submission of the specific questionnaire of the action - latest_answer, error_response = asyncio.run( - utils.get_latest_answer(action=action, account=account), - ) - if error_response: - return error_response - - if latest_answer is None: - logger.warning("No questionnaire submission found in verify_callback_api") - return Response({"error": "No questionnaire submission found"}, status=404) - - # Check if this is the submission we're looking for - if str(latest_answer.get("id")) != str(answer_id): - logger.warning("Answer ID mismatch in verify_callback_api") - return Response({"error": "Answer ID mismatch"}, status=403) - - # Extract OTP and quest_id from submission - submitted_otp = latest_answer.get("otp") - - # Atomically get and delete OTP record to prevent reuse - otp_key = f"otp:{submitted_otp}" - otp_data_raw = r.getdel(otp_key) - - if not otp_data_raw: - logger.warning("Invalid or expired OTP in verify_callback_api") - return Response({"error": "Invalid or expired OTP"}, status=401) - - try: - otp_data = json.loads(otp_data_raw.decode("utf-8")) - expected_temp_token = otp_data.get("temp_token") - initiated_at = otp_data.get("initiated_at") - except json.JSONDecodeError, AttributeError: - logger.error("Invalid OTP data format in verify_callback_api") - return Response({"error": "Invalid OTP data format"}, status=401) - - if not expected_temp_token or not initiated_at: - logger.warning("Incomplete OTP data in verify_callback_api") - return Response({"error": "Incomplete OTP data"}, status=401) - - # Step 5: StepVerify temp_token matches - if expected_temp_token != temp_token: - logger.warning("Invalid temp_token in verify_callback_api") - return Response({"error": "Invalid temp_token"}, status=401) - - # Step 6: Validate submission timestamp after OTP extraction - try: - submitted_at_str = latest_answer.get("submitted_at") - if submitted_at_str is None: - return Response({"error": "Missing submission timestamp"}, status=400) - - submitted_at = dateutil.parser.parse(submitted_at_str).timestamp() - - # Additional validation: check submission is after initiation and within window - if submitted_at < initiated_at or (submitted_at - initiated_at) > OTP_TIMEOUT: - return Response( - {"error": "Submission timestamp outside validity window"}, - status=401, - ) - - except ValueError, TypeError: - logger.error("Error parsing submission timestamp") - return Response({"error": "Invalid submission timestamp"}, status=401) - - # Step 7: Update state to verified and add user details - state_data.update( - { - "status": "verified", - "account": account, - }, - ) - - # Update temp_token_state in Redis with refreshed TTL - r.setex(state_key, TEMP_TOKEN_TIMEOUT, json.dumps(state_data)) - expires_at = int(time.time() + TEMP_TOKEN_TIMEOUT) - - # Clear rate limiting on success - r.delete(rate_limit_key) - - logger.info( - "Successfully verified temp_token for user %s with action %s", - account, - action, - ) - - # For login action, handle immediate session creation and cleanup - is_logged_in = False - if action == "login": - user, error_response = utils.create_user_session(request, account) - if user is None: - if error_response: - logger.error( - "Failed to create session for login: %s", - getattr(error_response, "data", {}).get("error", "Unknown error"), - ) - return error_response - else: - logger.error("Failed to create user session in verify_callback_api") - return Response({"error": "Failed to create user session"}, status=500) - if not user.is_active: - logger.warning("Inactive user attempted OAuth login: %s", account) - return Response({"error": "User account is inactive"}, status=403) - try: - # Create Django session - login(request, user) - is_logged_in = True - # Delete temp_token_state after successful login - r.delete(state_key) - except Exception: - logger.exception( - "Error during login session creation or cleanup for user %s", account - ) - return Response({"error": "Failed to finalize login process"}, status=500) + verification, error = services.verify_callback(request) + if verification is None: + return _service_error_response( + error or services.ServiceError("Verification failed", 400), + ) # Create response response = Response( - {"action": action, "expires_at": expires_at, "is_logged_in": is_logged_in}, + { + "action": verification.action, + "expires_at": verification.expires_at, + "is_logged_in": verification.is_logged_in, + }, status=200, ) # Clear temp_token cookie if login succeeded - if is_logged_in: + if verification.is_logged_in: response.delete_cookie("temp_token") return response -def verify_token_pwd(request, action: str) -> tuple[dict | None, Response | None]: - # Get temp_token from HttpOnly cookie - temp_token = request.COOKIES.get("temp_token") - if not temp_token: - return None, Response({"error": "No temp_token found"}, status=401) - - r = get_redis_connection("default") - - # Look up temp_token state record - temp_token_hash = hashlib.sha256(temp_token.encode()).hexdigest() - state_key = f"temp_token_state:{temp_token_hash}" - state_data = r.get(state_key) - - if not state_data: - return None, Response( - {"error": "Temp token state not found or expired"}, - status=401, - ) - - try: - state_data = json.loads(state_data) - except json.JSONDecodeError: - return None, Response({"error": "Invalid temp token state data"}, status=401) - - # Verify status is verified and action is signup - if state_data.get("status") != "verified" or state_data.get("action") != action: - return None, Response({"error": "Invalid temp token state"}, status=403) - - # Get password from request data - password = request.data.get("password") - if not password: - return None, Response({"error": "Missing password"}, status=400) - - # Validate password strength - is_valid, error_response = utils.validate_password_strength(password) - if not is_valid: - return None, Response(error_response, status=400) - # Get account from verified state - account = state_data.get("account") - if not account: - return None, Response({"error": "No account in verified state"}, status=401) - return {"account": account, "password": password, "state_key": state_key}, None - - @api_view(["POST"]) @authentication_classes([utils.CSRFCheckSessionAuthentication]) @permission_classes([AllowAny]) @@ -387,43 +95,17 @@ def auth_signup_api(request) -> Response: Handles user signup using verified temp_token. """ - try: - verification_data, error_response = verify_token_pwd(request, action="signup") - if verification_data is None: - return error_response or Response( - {"error": "Verification failed"}, status=400 - ) - - account = verification_data.get("account") - password = verification_data.get("password") - state_key = verification_data.get("state_key") - - # Create user session - user, error_response = utils.create_user_session(request, account) - if user is None: - return error_response or Response( - {"error": "Failed to create user session"}, status=500 - ) - if user.password: - return Response({"error": "User already exists with password."}, status=409) - - user.is_active = True - # Set password - user.set_password(password) - user.save() - - login(request, user) - - # Cleanup: Delete temp_token_state and clear cookie - r = get_redis_connection("default") - r.delete(state_key) - response = Response({"success": True, "username": user.username}, status=200) - response.delete_cookie("temp_token") - return response - except Exception: - logger.error("Error in auth_signup_api") - return Response({"error": "Failed to complete signup"}, status=500) + data, error = services.complete_signup(request) + if data is None: + return _service_error_response( + error or services.ServiceError("Failed to complete signup", 500), + ) + + response = Response(data, status=200) + response.delete_cookie("temp_token") + + return response @api_view(["POST"]) @@ -434,87 +116,41 @@ def auth_reset_password_api(request) -> Response: Handles password reset using verified temp_token. """ - try: - verification_data, error_response = verify_token_pwd( - request, - action="reset_password", + + data, error = services.reset_password(request) + if data is None: + return _service_error_response( + error or services.ServiceError("Failed to reset password", 500), ) - if verification_data is None: - return error_response or Response( - {"error": "Verification failed"}, status=400 - ) - account = verification_data.get("account") - password = verification_data.get("password") - state_key = verification_data.get("state_key") - - # Get the user object and update password - user_model = get_user_model() - try: - user = user_model.objects.get(username=account) - user.set_password(password) - user.save() - except user_model.DoesNotExist: - return Response({"error": "User does not exist"}, status=404) - - # Cleanup: Delete temp_token_state and clear cookie - r = get_redis_connection("default") - r.delete(state_key) - response = Response({"success": True, "username": user.username}, status=200) - response.delete_cookie("temp_token") - return response - except Exception: - logger.error("Error in auth_reset_password_api") - return Response({"error": "Failed to reset password"}, status=500) + response = Response(data, status=200) + response.delete_cookie("temp_token") + + return response @api_view(["POST"]) @permission_classes([AllowAny]) def auth_login_api(request) -> Response: - account = request.data.get("account", "").strip() - password = request.data.get("password", "") - turnstile_token = request.data.get("turnstile_token", "") - - if not account or not password or not turnstile_token: - logger.warning( - "Account, password, and Turnstile token are missing in auth_login_api" - ) - return Response( - {"error": "Account, password, and Turnstile token are missing"}, status=400 + data, error = services.login_with_password(request) + if data is None: + return _service_error_response( + error or services.ServiceError("Failed to login", 500), ) - client_ip = ( - request.META.get("HTTP_CF_CONNECTING_IP") - or request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")[0].strip() - or request.META.get("REMOTE_ADDR") - ) - - success, error_response = asyncio.run( - utils.verify_turnstile_token(turnstile_token, client_ip) - ) - if not success: - return error_response or Response( - {"error": "Turnstile verification failed"}, status=502 - ) - - user = authenticate(username=account, password=password) - if user is None or not user.is_active: - return Response({"error": "Invalid account or password"}, status=401) - - login(request, user) - Student.objects.get_or_create(user=user) - - return Response({"message": "Login successfully"}, status=200) + return Response(data, status=200) @api_view(["POST"]) @authentication_classes([utils.CSRFCheckSessionAuthentication]) @permission_classes([AllowAny]) def auth_logout_api(request) -> Response: + """Logout a user.""" + logger.info( "auth_logout_api called for user=%s", getattr(request.user, "username", None), ) - """Logout a user.""" logout(request) + return Response({"message": "Logged out successfully"}, status=200)