Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
8dde1e8
page for verification, frontend see frontend/src/components/Login.vue…
Sep 4, 2025
4ac466c
fix:update with latest dev
A-lexisL Sep 5, 2025
339ac44
fix: migrate endpoint and fix multireview error
A-lexisL Sep 6, 2025
6959683
fix: config files(webhook excluded)
A-lexisL Sep 6, 2025
44a4d98
feat: oauth config and initiate
A-lexisL Sep 7, 2025
4c99205
feat: add verification code and verify_callback_api with sessionid, t…
A-lexisL Sep 8, 2025
a6c6a3a
feat: update initiate config and verify api for new oauth workflow
A-lexisL Sep 10, 2025
d3b645d
feat:set password api
A-lexisL Sep 10, 2025
602e52c
fix: rm ngrok url and some typos
A-lexisL Sep 10, 2025
3b4c223
refactor: mv helpers to utils.py and improve return
A-lexisL Sep 12, 2025
0395dd9
refactor: mv config into development.yaml and fix some cq
A-lexisL Sep 12, 2025
f5d5b8c
refactor: apart signup and reset_password
A-lexisL Sep 17, 2025
01b1b6b
fix: uncomment turnstile check
A-lexisL Sep 17, 2025
c4a1e56
feat: add frontend for authorization
4rthurCai Sep 17, 2025
3ba635c
fix: rm reset action
A-lexisL Sep 17, 2025
f7d45b4
fix: fix failure in auth and refactor code for better cq
4rthurCai Sep 18, 2025
781c8a0
fix: reset password failure
4rthurCai Sep 18, 2025
00079ef
fix: multiple login methods
4rthurCai Sep 18, 2025
de62e5f
fix: fix redirect interval and turnstile
4rthurCai Sep 18, 2025
72d9ca6
fix: fix user icon after login
4rthurCai Sep 18, 2025
ed579fb
fix: delete debug logging
4rthurCai Sep 18, 2025
e1d0b36
fix: add turnstile for password login
A-lexisL Sep 19, 2025
95f27c7
refactor: frontend
4rthurCai Sep 22, 2025
3aecf9a
refactor: layout and view
4rthurCai Sep 22, 2025
90ff6ea
fix: filter box placeholder position
4rthurCai Sep 22, 2025
3df7395
chore(frontend): Setup prettier
PACHAKUTlQ Sep 23, 2025
b665199
chore(frontend): Setup eslint
PACHAKUTlQ Sep 23, 2025
8343856
style(frontend): Format frontend using prettier
PACHAKUTlQ Sep 23, 2025
bd02511
chore(frontend): Disable linting in TailwindPlus dir
PACHAKUTlQ Sep 23, 2025
b510e1c
chore(Makefile): Update linting and formatting commands
PACHAKUTlQ Sep 23, 2025
4971a97
chore(Makefile): Support installing frontend
PACHAKUTlQ Sep 23, 2025
298f8ce
chore(Makefile): Only display changed files when formatting
PACHAKUTlQ Sep 23, 2025
c51626f
chore(Makefile): Only show formatted files and silent if no changes
PACHAKUTlQ Sep 23, 2025
d68f7a1
chore(frontend): Update dependencies
PACHAKUTlQ Sep 23, 2025
316517c
chore(Makefile)!: Revert success message displaying and ignore errors
PACHAKUTlQ Sep 23, 2025
229ff53
fix: cq
4rthurCai Sep 23, 2025
981c87f
fix: fix error in comment without logging
4rthurCai Sep 23, 2025
11bb947
fix: frontend cq
4rthurCai Sep 23, 2025
1394147
refactor: reset password and signup
4rthurCai Sep 23, 2025
2a91b2e
refactor: refactor frontend
4rthurCai Sep 25, 2025
9183bc8
fix: password strength, otp digit-only
A-lexisL Sep 26, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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=
23 changes: 21 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
Expand All @@ -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!"

Expand All @@ -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..."
Expand Down
1 change: 1 addition & 0 deletions apps/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
default_app_config = "apps.auth.apps.OAuthConfig"
3 changes: 3 additions & 0 deletions apps/auth/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.contrib import admin

# Register your models here.
7 changes: 7 additions & 0 deletions apps/auth/apps.py
Original file line number Diff line number Diff line change
@@ -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
Empty file.
3 changes: 3 additions & 0 deletions apps/auth/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.db import models

# Create your models here.
3 changes: 3 additions & 0 deletions apps/auth/tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.test import TestCase

# Create your tests here.
288 changes: 288 additions & 0 deletions apps/auth/utils.py
Original file line number Diff line number Diff line change
@@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Too many comments

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