Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
32 changes: 15 additions & 17 deletions web-app/django/VIM/apps/main/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,15 @@
path("", views.home, name="home"),
path("about/", views.about, name="about"),
path("register/", views.register, name="register"),
path(
"verify-email-pending/", views.verify_email_pending, name="verify_email_pending"
),
path("verify-email/<uidb64>/<token>/", views.verify_email, name="verify_email"),
path(
"resend-verification/",
views.resend_verification_email,
name="resend_verification",
),
path(
"accounts/logout/",
auth_views.LogoutView.as_view(next_page="main:home"),
Expand All @@ -18,50 +26,40 @@
path(
"password-change/",
auth_views.PasswordChangeView.as_view(
template_name="main/registration/changePassword.html",
template_name="main/auth/changePassword.html",
success_url="/",
),
name="change_password",
),
path(
"accounts/login/",
auth_views.LoginView.as_view(
template_name="main/registration/login.html",
form_class=EmailAuthenticationForm,
redirect_authenticated_user=True,
redirect_field_name="next",
next_page="main:home",
),
name="login",
),
path("accounts/login/", views.custom_login, name="login"),
path(
"password-reset/",
auth_views.PasswordResetView.as_view(
template_name="main/registration/resetPassword.html",
email_template_name="main/registration/resetPasswordEmail.html",
template_name="main/auth/resetPassword.html",
email_template_name="main/auth/resetPasswordEmail.html",
success_url="/password-reset/done/",
),
name="reset_password",
),
path(
"password-reset/done/",
auth_views.PasswordResetDoneView.as_view(
template_name="main/registration/resetPasswordDone.html",
template_name="main/auth/resetPasswordDone.html",
),
name="reset_password_done",
),
path(
"reset/<uidb64>/<token>",
auth_views.PasswordResetConfirmView.as_view(
template_name="main/registration/resetPasswordConfirm.html",
template_name="main/auth/resetPasswordConfirm.html",
success_url="/reset-password-complete/",
),
name="password_reset_confirm",
),
path(
"reset/done/",
auth_views.PasswordResetCompleteView.as_view(
template_name="main/registration/resetPasswordComplete.html",
template_name="main/auth/resetPasswordComplete.html",
),
name="password_reset_complete",
),
Expand Down
2 changes: 1 addition & 1 deletion web-app/django/VIM/apps/main/utils/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ def send_verification_email(user, request):
"verification_url": verification_url,
"site_name": settings.SITE_NAME,
}
body_html = render_to_string("main/registration/verification_email.html", context)
body_html = render_to_string("main/auth/verification_email.html", context)

# Send email asynchronously
subject = f"Verify your {settings.SITE_NAME} account"
Expand Down
68 changes: 68 additions & 0 deletions web-app/django/VIM/apps/main/utils/rate_limiting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Rate limiting utilities for email operations."""

import hashlib
import time
from django.conf import settings
from django.core.cache import cache


def get_email_cooldown_remaining(email):
"""
Return remaining cooldown seconds for an email (0 if none).

Args:
email: Email address to check

Returns:
int: Remaining seconds (0 if no cooldown active)
"""
email_hash = hashlib.sha256(email.lower().encode()).hexdigest()
cache_key = f"email_resend_cooldown:{email_hash}"
expiration_time = cache.get(cache_key)

if expiration_time:
remaining = int(expiration_time - time.time())
return max(0, remaining)
return 0


def check_email_resend_cooldown(email):
"""
Check if email resend is allowed and return remaining cooldown time.

Uses atomic cache.add() (set-if-not-exists) to prevent TOCTOU races.

Args:
email: Email address to check cooldown for

Returns:
tuple: (allowed: bool, remaining_seconds: int)
"""
email_hash = hashlib.sha256(email.lower().encode()).hexdigest()
cache_key = f"email_resend_cooldown:{email_hash}"
current_time = time.time()
expiration_time = current_time + settings.RESEND_EMAIL_COOLDOWN

# Atomically start cooldown only if not already set.
added = cache.add(
cache_key, expiration_time, timeout=settings.RESEND_EMAIL_COOLDOWN
)

if added:
return True, 0

# Cooldown already active; compute remaining time.
existing_expiration = cache.get(cache_key)
if existing_expiration:
remaining = int(existing_expiration - current_time)
if remaining > 0:
return False, remaining
# Cooldown expired but key still present (backend quirk).
# Set new cooldown to prevent race condition with concurrent requests.
cache.set(cache_key, expiration_time, timeout=settings.RESEND_EMAIL_COOLDOWN)
return True, 0

# Key disappeared between add() and get().
# Set new cooldown to prevent race condition with concurrent requests.
cache.set(cache_key, expiration_time, timeout=settings.RESEND_EMAIL_COOLDOWN)
return True, 0
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
37 changes: 37 additions & 0 deletions web-app/django/VIM/apps/main/utils/session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Session utilities for user verification workflow."""

from django.utils import timezone
from django.conf import settings


def set_pending_verification_email(request, email):
"""Store email in session with timestamp for expiry tracking."""
request.session["pending_verification_email"] = email
request.session["pending_verification_timestamp"] = timezone.now().timestamp()


def clear_pending_verification_email(request):
"""Clear pending verification email and timestamp from session."""
request.session.pop("pending_verification_email", None)
request.session.pop("pending_verification_timestamp", None)


def get_pending_verification_email(request):
"""
Get pending verification email if not expired.

Returns:
str or None: Email address if valid and not expired, None otherwise
"""
email = request.session.get("pending_verification_email")
timestamp = request.session.get("pending_verification_timestamp", 0)

# Check if email exists and is not expired
if (
not email
or timezone.now().timestamp() - timestamp
> settings.PENDING_EMAIL_SESSION_EXPIRY
):
return None

return email
Loading