Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
d3a23a7
chore: load env in settings.py instead of using yaml
A-lexisL Sep 27, 2025
cd4c2dd
chore: use json load for list config
A-lexisL Sep 28, 2025
9f8a03b
fix(settings)!: Remove legacy static file and celery settings
PACHAKUTlQ Sep 29, 2025
7c980f7
fix(routing)!: Remove legacy analytics and recommendations apps
PACHAKUTlQ Sep 29, 2025
b6148aa
fix(Makefile)!: Run server without processing static files
PACHAKUTlQ Sep 29, 2025
cda37c4
fix(chore)!: Remove legacy static files and celery dependencies
PACHAKUTlQ Sep 29, 2025
32c008a
fix(django)!: Remove legacy celery initialization
PACHAKUTlQ Sep 29, 2025
8c491a6
fix(Makefile): Remove nostatic flags
PACHAKUTlQ Sep 29, 2025
1fc0124
feat(config)!: Add config class to auto handle env and yaml config
PACHAKUTlQ Sep 29, 2025
27731c2
feat(config)!: Use new config supporting env, yaml and default
PACHAKUTlQ Sep 29, 2025
0f388c2
feat(config)!: Add example yaml config and .env file
PACHAKUTlQ Sep 29, 2025
0a58389
fix(auth)!: Use new config system format
PACHAKUTlQ Sep 29, 2025
e773643
fix(lib)!: Remove legacy offering threshold env variable to avoid bre…
PACHAKUTlQ Sep 29, 2025
79c0711
feat(config)!: Use deep merge to adapt to configs of all types for ev…
PACHAKUTlQ Sep 29, 2025
2db98b5
fix(settings)!: Use complete default config to avoid errors
PACHAKUTlQ Sep 29, 2025
25502d3
fix(settings)!: Uncomment template parts used by django admin
PACHAKUTlQ Sep 29, 2025
f20f611
fix(auth)!: Use new config system
PACHAKUTlQ Sep 29, 2025
7e867b3
fix(auth)!: Use unified helper function to get quest details
PACHAKUTlQ Sep 29, 2025
cb87a88
fix(config)!: Fix of failed migrations (DB not connected due to wrong…
PACHAKUTlQ Sep 29, 2025
d4907bc
fix(auth)!: Use new config system, change keys to upper cases
PACHAKUTlQ Sep 29, 2025
fce4292
refactor(config)!: Refactor config parsing, use upper case for keys
PACHAKUTlQ Sep 29, 2025
482eedc
fix(settings)!: Add missing TURNSTILE_SECRET_KEY to make default conf…
PACHAKUTlQ Sep 29, 2025
62970e6
fix(auth)!: Use lower-case url as this is from utils but not settings.py
PACHAKUTlQ Sep 29, 2025
1c2ef28
fix(config)!: Correctly implement deep merge
PACHAKUTlQ Sep 29, 2025
0d2264a
fix(settings)!: Remove unused FRONTEND_URL
PACHAKUTlQ Sep 29, 2025
a835147
feat(config)!: Add example .env and config.yaml files
PACHAKUTlQ Sep 29, 2025
4ccf115
feat(chore)!: Update .gitignore and ignore config.yaml
PACHAKUTlQ Sep 29, 2025
03436ab
fix(chore)!: Ignore config.yaml
PACHAKUTlQ Sep 29, 2025
25423c8
fix(settings)!: Remove default SECRET_KEY to fail if not setting it
PACHAKUTlQ Sep 29, 2025
4548233
docs(config): Add docs for config
PACHAKUTlQ Sep 29, 2025
484a2ed
fix(docs): Fix typo
PACHAKUTlQ Sep 29, 2025
4cf80ec
Merge branch 'dev' into config
PACHAKUTlQ Sep 29, 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
68 changes: 42 additions & 26 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,26 +1,42 @@
# 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=
# .env.example
# Copy this file to .env and fill in the secrets for local development.
# DO NOT COMMIT .env TO VERSION CONTROL.
# This file overrides config.yaml

# --- Core Security (REQUIRED IN PRODUCTION) ---
# Generate a new one for production!
SECRET_KEY=django-insecure-my-local-dev-secret-key

# --- Local Overrides ---
# Set to False in production
# DEBUG=True

# --- Infrastructure (REQUIRED) ---
# Use a single URL for database and Redis connections.
# Format: driver://user:password@host:port/dbname
DATABASE__URL=postgres://admin:test@127.0.0.1:5432/coursereview
REDIS__URL=redis://localhost:6379/0

# --- External Services Secrets (REQUIRED) ---
TURNSTILE_SECRET_KEY=dummy0

# Use PARENT__CHILD format to override nested settings
# URL and ID may be specified in config.yaml
QUEST__SIGNUP__API_KEY=dummy1
# QUEST__SIGNUP__URL=
# QUEST__SIGNUP__QUESTIONID=

QUEST__LOGIN__API_KEY=dummy2
# QUEST__LOGIN__URL=
# QUEST__LOGIN__QUESTIONID=

QUEST__RESET__API_KEY=dummy3
# QUEST__RESET__URL=
# QUEST__RESET__QUESTIONID=

# --- Other Overrides (Optional) ---
# Example of overriding a nested value in the AUTH dictionary
# AUTH__OTP_TIMEOUT=60

# Example of overriding a list with a comma-separated string
# ALLOWED_HOSTS=localhost,127.0.0.1,dev.my-app.com
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
config.yaml

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
Expand Down
96 changes: 46 additions & 50 deletions apps/auth/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,51 +12,41 @@

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
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"]


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".
"""

action_details = QUEST_SETTINGS.get(action.upper())

if not action_details:
logging.error(f"Invalid quest action requested: {action}")
return None

try:
question_id = int(action_details.get("QUESTIONID"))
except (ValueError, TypeError):
logging.error(
f"Could not parse 'QUESTIONID' for action '{action}'. Check your settings."
)
return None

return {
"url": action_details.get("URL"),
"api_key": action_details.get("API_KEY"),
"question_id": question_id,
}


async def verify_turnstile_token(
Expand All @@ -65,7 +55,7 @@ async def verify_turnstile_token(
"""Helper function to verify Turnstile token with Cloudflare's API"""

try:
async with httpx.AsyncClient(timeout=OTP_TIME_OUT) as client:
async with httpx.AsyncClient(timeout=OTP_TIMEOUT) as client:
response = await client.post(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
data={
Expand Down Expand Up @@ -99,12 +89,16 @@ async def get_latest_answer(
`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)

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 = get_survey_questionid(action)
question_id = details.get("question_id")
if not question_id:
return None, Response(
{"error": "Configuration error: question ID not found for action"},
Expand All @@ -129,7 +123,7 @@ async def get_latest_answer(
full_url_path = f"{QUEST_BASE_URL}/{quest_api}/json"

try:
async with httpx.AsyncClient(timeout=OTP_TIME_OUT) as client:
async with httpx.AsyncClient(timeout=OTP_TIMEOUT) as client:
response = await client.get(
full_url_path,
params=final_query_params,
Expand Down Expand Up @@ -159,7 +153,8 @@ async def get_latest_answer(
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
# Get the first (latest) row
latest_answer = full_data["data"]["rows"][0]

# Find the otp by matching the question ID
otp = None
Expand Down Expand Up @@ -259,6 +254,7 @@ def create_user_session(
`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:
Expand Down
34 changes: 23 additions & 11 deletions apps/auth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,12 @@ 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"]
AUTH_SETTINGS = settings.AUTH
OTP_TIMEOUT = AUTH_SETTINGS["OTP_TIMEOUT"]
TEMP_TOKEN_TIMEOUT = AUTH_SETTINGS["TEMP_TOKEN_TIMEOUT"]
ACTION_LIST = ["signup", "login", "reset_password"]
TOKEN_RATE_LIMIT = AUTH_SETTINGS["TOKEN_RATE_LIMIT"]
TOKEN_RATE_LIMIT_TIME = AUTH_SETTINGS["TOKEN_RATE_LIMIT_TIME"]


@api_view(["POST"])
Expand Down Expand Up @@ -89,15 +90,17 @@ def auth_initiate_api(request):
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')}"
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))
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()
Expand All @@ -110,7 +113,10 @@ def auth_initiate_api(request):

logging.info(f"Created auth intent for action {action} with OTP and temp_token")

survey_url = utils.get_survey_url(action)
details = utils.get_survey_details(action)
if not details:
return Response({"error": "Invalid action"}, status=400)
survey_url = details.get("url")
if not survey_url:
return Response(
{"error": "Something went wrong when fetching the survey URL"},
Expand Down Expand Up @@ -236,7 +242,7 @@ def verify_callback_api(request):
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:
if submitted_at < initiated_at or (submitted_at - initiated_at) > OTP_TIMEOUT:
return Response(
{"error": "Submission timestamp outside validity window"},
status=401,
Expand Down Expand Up @@ -272,7 +278,11 @@ def verify_callback_api(request):
if user is None:
if error_response:
logging.error(
f"Failed to create session for login: {getattr(error_response, 'data', {}).get('error', 'Unknown error')}",
f"Failed to create session for login: {
getattr(error_response, 'data', {}).get(
'error', 'Unknown error'
)
}",
)
return error_response
else:
Expand All @@ -288,7 +298,9 @@ def verify_callback_api(request):
r.delete(state_key)
except Exception as e:
logging.exception(
f"Error during login session creation or cleanup for user {account}: {e}",
f"Error during login session creation or cleanup for user {account}: {
e
}",
)
return Response({"error": "Failed to finalize login process"}, status=500)

Expand Down
57 changes: 57 additions & 0 deletions config.yaml.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Please copy this file to config.yaml and fill in
# corresponding fields.
# For non-secret, environment-specific configuration.
# Values here will override DEFAULTS in settings.py.
# Environment variables will override values here.

DEBUG: true

# SECRET_KEY: Use env

ALLOWED_HOSTS:
# - "backend.redacted.com"
- "localhost"
- "127.0.0.1"

CORS_ALLOWED_ORIGINS:
# - "https://frontend.redacted.com"
- "http://localhost:5173"
- "http://127.0.0.1:5173"

# SESSION:
# COOKIE_AGE: 2592000 # 30 days
# SAVE_EVERY_REQUEST: true
#
# AUTH:
# OTP_TIMEOUT: 120
# TEMP_TOKEN_TIMEOUT: 600
# TOKEN_RATE_LIMIT: 5
# TOKEN_RATE_LIMIT_TIME: 600
# PASSWORD_LENGTH_MIN: 10
# PASSWORD_LENGTH_MAX: 32
# EMAIL_DOMAIN_NAME: "sjtu.edu.cn"
#
# DATABASE:
# URL: Use env
#
# REDIS:
# URL: Use env
# MAX_CONNECTIONS: 100
#
# TURNSTILE_SECRET_KEY: Use env

QUEST:
# BASE_URL: "https://wj.sjtu.edu.cn/api/v1/public/export"
SIGNUP:
# API_KEY: Use env
URL: "https://wj.sjtu.edu.cn/q/dummy0"
QUESTIONID: 10000000
LOGIN:
# API_KEY: Use env
URL: "https://wj.sjtu.edu.cn/q/dummy1"
QUESTIONID: 10000001
RESET:
# API_KEY: Use env
URL: "https://wj.sjtu.edu.cn/q/dummy2"
QUESTIONID: 10000002
# AUTO_IMPORT_CRAWLED_DATA: true
Loading