diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e0f5cdd --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# PostgreSQL +DB_USER=admin +DB_PASSWORD=test +DB_HOST=127.0.0.1 +DB_PORT=5432 +REDIS_URL=redis://localhost:6379/0 +SECRET_KEY=02247f40-a769-4c49-9178-4c038048e7ad +DEBUG=True +OFFERINGS_THRESHOLD_FOR_TERM_UPDATE=100 + +# Frontend +FRONTEND_URL=http://localhost:5173 + +# wj platform +SIGNUP_QUEST_API_KEY= +SIGNUP_QUEST_URL= +SIGNUP_QUEST_QUESTIONID= +LOGIN_QUEST_API_KEY= +LOGIN_QUEST_URL= +LOGIN_QUEST_QUESTIONID= +RESET_QUEST_API_KEY= +RESET_QUEST_URL= +RESET_QUEST_QUESTIONID= + +# Turnstile +TURNSTILE_SECRET_KEY= \ No newline at end of file diff --git a/Makefile b/Makefile index 48d26fb..ad8f5f9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: run clean collect format format-backend format-frontend makemigrations migrate shell createsuperuser dev-frontend help +.PHONY: run dev-frontend clean collect install-frontend format format-backend format-frontend lint lint-backend lint-frontend makemigrations migrate shell createsuperuser help # Default target when 'make' is run without arguments .DEFAULT_GOAL := help @@ -9,9 +9,13 @@ help: @echo " dev-frontend - Starts the frontend development server (formats frontend code first)" @echo " clean - Clears Django session data" @echo " collect - Collects Django static files" + @echo " install-frontend - Installs frontend dependencies using bun" @echo " format - Formats both backend (Python) and frontend (JS/TS/CSS) code" @echo " format-backend - Formats Python code using isort and black" @echo " format-frontend - Formats frontend code using prettier" + @echo " lint - Lints both backend (Python) and frontend (JS/TS/CSS) code" + @echo " lint-backend - Lints Python code using ruff" + @echo " lint-frontend - Lints frontend code using eslint" @echo " makemigrations - Creates new Django model migrations" @echo " migrate - Applies Django database migrations" @echo " shell - Opens a Django shell" @@ -33,6 +37,10 @@ collect: @echo "Collecting Django static files (confirming 'yes')..." echo 'yes' | uv run manage.py collectstatic +install-frontend: + @echo "Installing frontend dependencies with bun..." + cd frontend && bun install + format: format-backend format-frontend @echo "All code formatted successfully!" @@ -42,7 +50,18 @@ format-backend: format-frontend: @echo "Formatting frontend code with prettier..." - cd frontend && bunx prettier . -w + cd frontend && bun run format | grep -v -F '(unchanged)' || true + +lint: lint-backend lint-frontend + @echo "All code linted successfully!" + +lint-backend: format-backend + @echo "Linting backend (Python) code with ruff..." + uvx ruff check + +lint-frontend: format-frontend + @echo "Linting frontend code with eslint..." + cd frontend && bun run lint makemigrations: @echo "Creating Django database migrations..." diff --git a/apps/auth/__init__.py b/apps/auth/__init__.py new file mode 100644 index 0000000..ec9487c --- /dev/null +++ b/apps/auth/__init__.py @@ -0,0 +1 @@ +default_app_config = "apps.auth.apps.OAuthConfig" diff --git a/apps/auth/admin.py b/apps/auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/apps/auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/auth/apps.py b/apps/auth/apps.py new file mode 100644 index 0000000..f2da6af --- /dev/null +++ b/apps/auth/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class OAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.auth" + label = "oauth" # Unique label to avoid conflict with django.contrib.auth diff --git a/apps/auth/migrations/__init__.py b/apps/auth/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/auth/models.py b/apps/auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/apps/auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/apps/auth/tests.py b/apps/auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/auth/utils.py b/apps/auth/utils.py new file mode 100644 index 0000000..0ca4a8b --- /dev/null +++ b/apps/auth/utils.py @@ -0,0 +1,288 @@ +import json +import logging +import re + +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.response import Response + +from apps.web.models import Student + +PASSWORD_LENGTH_MIN = settings.AUTH["PASSWORD_LENGTH_MIN"] +PASSWORD_LENGTH_MAX = settings.AUTH["PASSWORD_LENGTH_MAX"] +OTP_TIME_OUT = settings.AUTH["OTP_TIME_OUT"] +QUEST_BASE_URL = settings.AUTH["QUEST_BASE_URL"] +EMAIL_DOMAIN_NAME = settings.AUTH["EMAIL_DOMAIN_NAME"] + + +def get_survey_url(action: str) -> str | None: + """Helper function to get the survey URL based on action type""" + if action == "signup": + return settings.SIGNUP_QUEST_URL + if action == "login": + return settings.LOGIN_QUEST_URL + if action == "reset_password": + return settings.RESET_QUEST_URL + return None + + +def get_survey_api_key(action: str) -> str | None: + """Helper function to get the survey API key based on action type""" + if action == "signup": + return settings.SIGNUP_QUEST_API_KEY + if action == "login": + return settings.LOGIN_QUEST_API_KEY + if action == "reset_password": + return settings.RESET_QUEST_API_KEY + return None + + +def get_survey_questionid(action: str) -> int | None: + """Helper function to get the survey question ID for the verification code based on action type""" + question_id_str = None + if action == "signup": + question_id_str = settings.SIGNUP_QUEST_QUESTIONID + elif action == "login": + question_id_str = settings.LOGIN_QUEST_QUESTIONID + elif action == "reset_password": + question_id_str = settings.RESET_QUEST_QUESTIONID + + if question_id_str: + try: + return int(question_id_str) + except (ValueError, TypeError): + return None + return None + + +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_TIME_OUT) 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"): + logging.warning(f"Turnstile verification failed: {response.json()}") + return False, Response( + {"error": "Turnstile verification failed"}, status=403 + ) + return True, None + except httpx.TimeoutException: + logging.error("Turnstile verification timed out") + return False, Response( + {"error": "Turnstile verification timed out"}, status=504 + ) + except Exception as e: + logging.error(f"Error verifying Turnstile token: {e}") + 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. + """ + quest_api = get_survey_api_key(action) + if not quest_api: + return None, Response({"error": "Invalid action"}, status=400) + + # Get the target question ID for the verification code + question_id = get_survey_questionid(action) + 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_TIME_OUT) 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: + logging.exception("Questionnaire API query timed out") + return None, Response( + {"error": "Questionnaire API query timed out"}, + status=504, + ) + except httpx.RequestError as e: + logging.exception(f"Error querying questionnaire API: {e}") + return None, Response( + {"error": "Failed to query questionnaire API"}, + status=500, + ) + except Exception as e: + logging.exception(f"An unexpected error occurred: {e}") + 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 + ): + latest_answer = full_data["data"]["rows"][0] # Get the first (latest) row + + # 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"] + ): + logging.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_step = (PASSWORD_LENGTH_MAX - PASSWORD_LENGTH_MIN) // 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) diff --git a/apps/auth/views.py b/apps/auth/views.py new file mode 100644 index 0000000..cf20b0f --- /dev/null +++ b/apps/auth/views.py @@ -0,0 +1,476 @@ +import asyncio +import base64 +import hashlib +import json +import logging +import secrets +import time + +import dateutil.parser +import httpx +from django.conf import settings +from django.contrib.auth import authenticate, get_user_model, login, logout +from django_redis import get_redis_connection +from rest_framework.authentication import SessionAuthentication +from rest_framework.decorators import ( + api_view, + authentication_classes, + permission_classes, +) +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +from apps.auth import utils +from apps.web.models import Student + + +class CsrfExemptSessionAuthentication(SessionAuthentication): + def enforce_csrf(self, request): + return + + +OTP_TIME_OUT = settings.AUTH["OTP_TIME_OUT"] +TEMP_TOKEN_TIMEOUT = settings.AUTH["TEMP_TOKEN_TIMEOUT"] +ACTION_LIST = settings.AUTH["ACTION_LIST"] +TOKEN_RATE_LIMIT = settings.AUTH["TOKEN_RATE_LIMIT"] +TOKEN_RATE_LIMIT_TIME = settings.AUTH["TOKEN_RATE_LIMIT_TIME"] + + +@api_view(["POST"]) +@authentication_classes([CsrfExemptSessionAuthentication]) +@permission_classes([AllowAny]) +def auth_initiate_api(request): + """Step 1: Authentication Initiation (/api/auth/initiate) + + 1. Receives action and turnstile_token from frontend + 2. Verifies Turnstile token with Cloudflare's API + 3. Generates cryptographically secure OTP and temp_token + 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: + return Response({"error": "Missing action or turnstile_token"}, status=400) + + if action not in ACTION_LIST: + 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: + 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) + logging.info( + f"Cleaned up existing temp_token_state for action {existing_state.get('action', 'unknown')}" + ) + except Exception as e: + logging.warning(f"Error cleaning up existing temp_token: {e}") + + # 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_TIME_OUT, 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), + ) + + logging.info(f"Created auth intent for action {action} with OTP and temp_token") + + survey_url = utils.get_survey_url(action) + if not survey_url: + return Response( + {"error": "Something went wrong when fetching the survey URL"}, + status=500, + ) + + # Create response and set temp_token as HttpOnly cookie + response = Response({"otp": otp, "redirect_url": survey_url}, status=200) + response.set_cookie( + "temp_token", + temp_token, + max_age=TEMP_TOKEN_TIMEOUT, + httponly=True, + secure=getattr(settings, "SECURE_COOKIES", True), + samesite="Lax", + ) + return response + + +@api_view(["POST"]) +@authentication_classes([CsrfExemptSessionAuthentication]) +@permission_classes([AllowAny]) +def verify_callback_api(request): + """Callback Verification (/api/auth/verify) + request data includes account, answer_id, action + Handles the verification of questionnaire callback using temp_token from cookie. + """ + # 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: + return Response({"error": "Missing account, answer_id, or action"}, status=400) + + if action not in ACTION_LIST: + return Response({"error": "Invalid action"}, status=400) + + # Get temp_token from HttpOnly cookie + temp_token = request.COOKIES.get("temp_token") + if not temp_token: + 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: + return Response({"error": "Temp token state not found or expired"}, status=401) + + try: + state_data = json.loads(state_data) + except json.JSONDecodeError: + return Response({"error": "Invalid temp token state data"}, status=401) + + # Verify status is pending and action matches + if state_data.get("status") != "pending": + return Response({"error": "Invalid temp token state"}, status=401) + + if state_data.get("action") != action: + 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: + 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: + 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): + 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: + 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): + return Response({"error": "Invalid OTP data format"}, status=401) + + if not expected_temp_token or not initiated_at: + return Response({"error": "Incomplete OTP data"}, status=401) + + # Step 5: StepVerify temp_token matches + if expected_temp_token != temp_token: + 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_TIME_OUT: + return Response( + {"error": "Submission timestamp outside validity window"}, + status=401, + ) + + except (ValueError, TypeError) as e: + logging.exception(f"Error parsing submission timestamp: {e}") + 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) + + logging.info( + f"Successfully verified temp_token for user {account} with action {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: + logging.error( + f"Failed to create session for login: {getattr(error_response, 'data', {}).get('error', 'Unknown error')}", + ) + return error_response + else: + return Response({"error": "Failed to create user session"}, status=500) + if not user.is_active: + logging.warning(f"Inactive user attempted OAuth login: {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 as e: + logging.exception( + f"Error during login session creation or cleanup for user {account}: {e}", + ) + return Response({"error": "Failed to finalize login process"}, status=500) + + # Create response + response = Response( + {"action": action, "expires_at": expires_at, "is_logged_in": is_logged_in}, + status=200, + ) + + # Clear temp_token cookie if login succeeded + if is_logged_in: + response.delete_cookie("temp_token") + + return response + + +def verify_psd_checker(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"]) +def auth_signup_api(request) -> Response: + """Signup API (/api/auth/signup) + + Handles user signup using verified temp_token. + """ + try: + verification_data, error_response = verify_psd_checker(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() + + # 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 as e: + logging.error(f"Error in auth_signup_api: {e}") + return Response({"error": "Failed to complete signup"}, status=500) + + +@api_view(["POST"]) +def auth_reset_password_api(request) -> Response: + """Reset Password API (/api/auth/password) + + Handles password reset using verified temp_token. + """ + try: + verification_data, error_response = verify_psd_checker( + request, + action="reset_password", + ) + 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 as e: + logging.error(f"Error in auth_reset_password_api: {e}") + return Response({"error": "Failed to reset password"}, status=500) + + +@api_view(["POST"]) +@authentication_classes([CsrfExemptSessionAuthentication]) +@permission_classes([AllowAny]) +def auth_login_api(request) -> Response: + account = request.data.get("account", "") + password = request.data.get("password", "") + turnstile_token = request.data.get("turnstile_token", "") + + if not account or not password or not turnstile_token: + return Response( + {"error": "Account, password, and Turnstile token are missing"}, 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: + return error_response + + user = authenticate(username=account, password=password) + + if user is not None and user.is_active: + login(request, user) + Student.objects.get_or_create(user=user) + return Response({"message": "Login successfully"}, status=200) + return Response({"error": "Invalid account or password"}, status=401) + + +@api_view(["POST"]) +@authentication_classes([CsrfExemptSessionAuthentication]) +@permission_classes([AllowAny]) +def auth_logout_api(request) -> Response: + """Logout a user.""" + logout(request) + return Response({"message": "Logged out successfully"}, status=200) diff --git a/apps/spider/crawlers/orc.py b/apps/spider/crawlers/orc.py index a9bc782..aba3fdd 100644 --- a/apps/spider/crawlers/orc.py +++ b/apps/spider/crawlers/orc.py @@ -39,8 +39,9 @@ def crawl_program_urls(): + program_urls = set() # Initialize to empty set for orc_url in [UNDERGRAD_URL]: - program_urls = _get_department_urls_from_url(orc_url) + program_urls.update(_get_department_urls_from_url(orc_url)) return program_urls @@ -58,7 +59,11 @@ def _is_department_url(candidate_url): def _crawl_course_data(course_url): soup = retrieve_soup(course_url) - course_heading = soup.find("h2").get_text() + course_heading_element = soup.find("h2") + if course_heading_element is None: + return None # Return early if no h2 element found + + course_heading = course_heading_element.get_text() if course_heading: split_course_heading = course_heading.split(" – ") children = list(soup.find_all(class_="et_pb_text_inner")[3].children) diff --git a/apps/web/models/forms/review_form.py b/apps/web/models/forms/review_form.py index b376945..1d7b69f 100644 --- a/apps/web/models/forms/review_form.py +++ b/apps/web/models/forms/review_form.py @@ -5,7 +5,7 @@ from lib import constants from lib.terms import is_valid_term -REVIEW_MINIMUM_LENGTH = 100 +REVIEW_MINIMUM_LENGTH = 30 class ReviewForm(forms.ModelForm): diff --git a/apps/web/models/review.py b/apps/web/models/review.py index d4f15da..3da86c2 100644 --- a/apps/web/models/review.py +++ b/apps/web/models/review.py @@ -18,11 +18,15 @@ def get_user_review_for_course(self, user, course): """ Get the review written by a user for a specific course. Returns the Review object if found, None otherwise. + If multiple reviews exist, returns the most recent one. """ try: return self.get(user=user, course=course) except self.model.DoesNotExist: return None + except self.model.MultipleObjectsReturned: + # If somehow there are multiple reviews, return the most recent one + return self.filter(user=user, course=course).order_by("-created_at").first() class Review(models.Model): diff --git a/apps/web/views.py b/apps/web/views.py index 3b67564..c2e3cf7 100644 --- a/apps/web/views.py +++ b/apps/web/views.py @@ -1,57 +1,40 @@ -import datetime -import uuid -import traceback - -import dateutil.parser -from django.contrib.auth import authenticate, login, logout -from django.contrib.auth.decorators import login_required -from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator -from django.db.models import Count -from django.http import ( - HttpResponseBadRequest, - HttpResponseForbidden, - HttpResponseRedirect, - JsonResponse, -) -from django.shortcuts import redirect, render -from django.urls import reverse -from django.views.decorators.http import require_POST, require_safe -from rest_framework.authentication import SessionAuthentication -from rest_framework.decorators import ( - api_view, - authentication_classes, - permission_classes, -) -from rest_framework.permissions import AllowAny, IsAuthenticated -from rest_framework.response import Response - - -class CsrfExemptSessionAuthentication(SessionAuthentication): - def enforce_csrf(self, request): - return - - from apps.web.models import ( Course, CourseMedian, - DistributiveRequirement, Instructor, Review, ReviewVote, - Student, Vote, ) -from apps.web.models.forms import ReviewForm, SignupForm + +from apps.web.models.forms import ReviewForm + from apps.web.serializers import ( CourseSearchSerializer, CourseSerializer, ReviewSerializer, ) + from lib import constants from lib.departments import get_department_name from lib.grades import numeric_value_for_grade from lib.terms import numeric_value_of_term +import datetime +import uuid +import dateutil.parser + +from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator +from django.db.models import Count +from rest_framework.authentication import SessionAuthentication +from rest_framework.decorators import ( + api_view, + permission_classes, +) +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response + + LIMITS = { "courses": 20, "reviews": 5, @@ -105,113 +88,6 @@ def get_prior_course_id(request, current_course_id): return prior_course_id -def signup(request): - if request.method == "POST": - form = SignupForm(request.POST) - if form.is_valid(): - form.save_and_send_confirmation(request) - return render(request, "instructions.html") - else: - return render(request, "signup.html", {"form": form}) - - else: - return render(request, "signup.html", {"form": SignupForm()}) - - -@api_view(["POST"]) -@authentication_classes([CsrfExemptSessionAuthentication]) -@permission_classes([AllowAny]) -def auth_login_api(request): - email = request.data.get("email", "").lower() - password = request.data.get("password", "") - next_url = request.data.get("next", "/courses") - - if not email or not password: - return Response({"error": "Email and password are required"}, status=400) - - username = email.split("@")[0] - user = authenticate(username=username, password=password) - - if user is not None: - if user.is_active: - login(request, user) - if "user_id" in request.session: - try: - student = Student.objects.get(user=user) - student.unauth_session_ids.append(request.session["user_id"]) - student.save() - except Student.DoesNotExist: - student = Student.objects.create( - user=user, unauth_session_ids=[request.session["user_id"]] - ) - request.session["user_id"] = user.username - - return Response( - {"success": True, "next": next_url, "username": user.username} - ) - else: - return Response( - { - "error": "Please activate your account via the activation link first." - }, - status=403, - ) - else: - return Response({"error": "Invalid email or password"}, status=401) - - -@api_view(["POST"]) -@authentication_classes([CsrfExemptSessionAuthentication]) -@permission_classes([AllowAny]) -def auth_logout_api(request): - """ - API endpoint for user logout. - """ - if request.user.is_authenticated: - try: - student = Student.objects.get(user=request.user) - if "user_id" in request.session: - if request.session["user_id"] in student.unauth_session_ids: - student.unauth_session_ids.remove(request.session["user_id"]) - student.save() - except Student.DoesNotExist: - pass - - logout(request) - request.session["userID"] = uuid.uuid4().hex - return Response({"success": True, "message": "Logged out successfully"}) - else: - return Response( - {"success": False, "message": "User not authenticated"}, status=400 - ) - - -@require_safe -def confirmation(request): - link = request.GET.get("link") - - if link: - try: - student = Student.objects.get(confirmation_link=link) - except Student.DoesNotExist: - return render( - request, - "confirmation.html", - {"error": "Confirmation code expired or does not exist."}, - ) - - if student.user.is_active: - return render(request, "confirmation.html", {"already_confirmed": True}) - - student.user.is_active = True - student.user.save() - return render(request, "confirmation.html", {"already_confirmed": False}) - else: - return render( - request, "confirmation.html", {"error": "Please provide confirmation code."} - ) - - @api_view(["GET"]) @permission_classes([AllowAny]) def courses_api(request): @@ -320,11 +196,8 @@ def course_detail_api(request, course_id): @api_view(["DELETE"]) -@permission_classes([AllowAny]) +@permission_classes([IsAuthenticated]) def delete_review_api(request, course_id): - # Check if user is authenticated - if not request.user.is_authenticated: - return Response({"detail": "Authentication required"}, status=403) course = Course.objects.get(id=course_id) Review.objects.delete_reviews_for_user_course(user=request.user, course=course) serializer = CourseSerializer(course, context={"request": request}) @@ -403,7 +276,7 @@ def course_review_search_api(request, course_id): ) -@require_safe +@api_view(["GET"]) def medians(request, course_id): # retrieve course medians for term, and group by term for averaging medians_by_term = {} @@ -420,7 +293,7 @@ def medians(request, course_id): } ) - return JsonResponse( + return Response( { "medians": sorted( [ @@ -437,13 +310,14 @@ def medians(request, course_id): key=lambda x: numeric_value_of_term(x["term"]), reverse=True, ) - } + }, + status=200, ) -@require_safe +@api_view(["GET"]) def course_professors(request, course_id): - return JsonResponse( + return Response( { "professors": sorted( set( @@ -459,46 +333,40 @@ def course_professors(request, course_id): .distinct() ) ) - } + }, + status=200, ) -@require_safe +@api_view(["GET"]) def course_instructors(request, course_id): try: course = Course.objects.get(pk=course_id) instructors = course.get_instructors() - return JsonResponse( - {"instructors": [instructor.name for instructor in instructors]} + return Response( + {"instructors": [instructor.name for instructor in instructors]}, status=200 ) except Course.DoesNotExist: - return JsonResponse({"error": "Course not found"}, status=404) + return Response({"error": "Course not found"}, status=404) -@require_POST +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) def course_vote_api(request, course_id): - if not request.user.is_authenticated: - return HttpResponseForbidden() - try: - import json - - if request.content_type == "application/json": - data = json.loads(request.body) - value = data["value"] - forLayup = data["forLayup"] - else: - value = request.POST["value"] - forLayup = request.POST["forLayup"] - except (KeyError, json.JSONDecodeError): - return HttpResponseBadRequest() + value = request.data["value"] + forLayup = request.data["forLayup"] + except KeyError: + return Response( + {"detail": "Missing required fields: value, forLayup"}, status=400 + ) category = Vote.CATEGORIES.DIFFICULTY if forLayup else Vote.CATEGORIES.QUALITY new_score, is_unvote, new_vote_count = Vote.objects.vote( int(value), course_id, category, request.user ) - return JsonResponse( + return Response( { "new_score": new_score, "was_unvote": is_unvote, @@ -508,7 +376,7 @@ def course_vote_api(request, course_id): @api_view(["POST"]) -@permission_classes([AllowAny]) +@permission_classes([IsAuthenticated]) def review_vote_api(request, review_id): """ API endpoint for voting on reviews (kudos/dislike). @@ -522,8 +390,6 @@ def review_vote_api(request, review_id): - dislike_count: updated dislike count - user_vote: user's current vote (True/False/None) """ - if not request.user.is_authenticated: - return Response({"detail": "Authentication required"}, status=403) try: is_kudos = request.data.get("is_kudos") @@ -558,7 +424,7 @@ def review_vote_api(request, review_id): @api_view(["GET"]) -@permission_classes([AllowAny]) +@permission_classes([IsAuthenticated]) def get_user_review_api(request, course_id): """ API endpoint to get the authenticated user's review for a specific course. @@ -568,8 +434,6 @@ def get_user_review_api(request, course_id): - 404 if no review found - 403 if user is not authenticated """ - if not request.user.is_authenticated: - return Response({"detail": "Authentication required"}, status=403) try: # Get the course diff --git a/docs/Setup.md b/docs/Setup.md index de64cab..9b70362 100644 --- a/docs/Setup.md +++ b/docs/Setup.md @@ -22,7 +22,7 @@ Environment: 6. Make directory for builds of static files: `mkdir staticfiles` -7. Create .env file for storing secrets. The contents should be like: +7. cp .env.example and rename it .env at root dir. The contents of PostgreSQL should be like: ```ini # PostgreSQL @@ -35,6 +35,7 @@ Environment: DEBUG=True OFFERINGS_THRESHOLD_FOR_TERM_UPDATE=100 ``` + Also cp .env.example in frontend/ and rename it .env. 8. Build static files: `make collect` diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..fe8ddce --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +# the env variable used for vite-processed frontend should begin with `VITE_` +VITE_TURNSTILE_SITE_KEY=0x4AAAAAABz2ci0ZN9OaO-dg \ No newline at end of file diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..775d29f --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,12 @@ +# Build artifacts +dist +.turbo + +# Dependencies +node_modules +TailwindPlus + +# Environment files +.env +.env.* +!.env.example diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json new file mode 100644 index 0000000..65952cb --- /dev/null +++ b/frontend/.prettierrc.json @@ -0,0 +1,24 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "trailingComma": "all", + "bracketSpacing": true, + "objectWrap": "preserve", + "bracketSameLine": false, + "arrowParens": "always", + "requirePragma": false, + "insertPragma": false, + "checkIgnorePragma": false, + "proseWrap": "preserve", + "htmlWhitespaceSensitivity": "css", + "vueIndentScriptAndStyle": false, + "endOfLine": "lf", + "embeddedLanguageFormatting": "auto", + "singleAttributePerLine": false, + "plugins": [] +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..1f5e9d3 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,36 @@ +import js from "@eslint/js"; +import pluginVue from "eslint-plugin-vue"; +import prettierConfig from "eslint-config-prettier"; +import globals from "globals"; + +export default [ + // Global ignores + { + ignores: ["dist", "node_modules", "TailwindPlus"], + }, + + // Base configuration for all JavaScript/Vue files + js.configs.recommended, + ...pluginVue.configs["flat/recommended"], + + // Custom rules and settings + { + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: { + ...globals.browser, // For browser environments like document, window + process: "readonly", // To allow process.env.NODE_ENV + "import.meta": "readonly", // Vite uses import.meta.env + }, + }, + rules: { + "no-console": process.env.NODE_ENV === "production" ? "warn" : "off", + "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off", + "vue/multi-word-component-names": "off", + }, + }, + + // Turns off any ESLint rules that would conflict with Prettier's formatting. + prettierConfig, +]; diff --git a/frontend/package.json b/frontend/package.json index fcad6d7..adc9c2f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,22 +6,30 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "lint": "eslint . --fix", + "format": "prettier . --write" }, "dependencies": { "@headlessui/vue": "^1.7.23", "@heroicons/vue": "^2.2.0", - "@tailwindcss/postcss": "^4.1.11", - "axios": "^1.7.9", - "dompurify": "^3.2.6", - "md-editor-v3": "^5.8.4", - "tailwindcss": "^4.1.11", - "vue": "^3.5.13", - "vue-router": "^4.5.0" + "@tailwindcss/postcss": "^4.1.13", + "axios": "^1.12.2", + "dompurify": "^3.2.7", + "md-editor-v3": "^5.8.5", + "tailwindcss": "^4.1.13", + "vue": "^3.5.21", + "vue-router": "^4.5.1" }, "devDependencies": { - "@vitejs/plugin-vue": "^5.2.1", + "@eslint/js": "^9.36.0", + "@vitejs/plugin-vue": "^5.2.4", "autoprefixer": "^10.4.21", - "vite": "^6.1.0" + "eslint": "^9.36.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-vue": "^10.5.0", + "globals": "^16.4.0", + "prettier": "^3.6.2", + "vite": "^6.3.6" } } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 6179a34..6abacb4 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -5,11 +5,10 @@ diff --git a/frontend/src/components/CourseList.vue b/frontend/src/components/CourseList.vue index 446fc9e..f235947 100644 --- a/frontend/src/components/CourseList.vue +++ b/frontend/src/components/CourseList.vue @@ -1,7 +1,6 @@ diff --git a/frontend/src/components/Icon.vue b/frontend/src/components/Icon.vue new file mode 100644 index 0000000..7581da1 --- /dev/null +++ b/frontend/src/components/Icon.vue @@ -0,0 +1,179 @@ + + + diff --git a/frontend/src/components/Login.vue b/frontend/src/components/Login.vue deleted file mode 100644 index 7551ba3..0000000 --- a/frontend/src/components/Login.vue +++ /dev/null @@ -1,200 +0,0 @@ - - - diff --git a/frontend/src/components/ReviewCard.vue b/frontend/src/components/ReviewCard.vue index 10b78e2..857e122 100644 --- a/frontend/src/components/ReviewCard.vue +++ b/frontend/src/components/ReviewCard.vue @@ -17,20 +17,18 @@ -
-
-
-