Skip to content

Commit

Permalink
Implement user email change functionality; #1996
Browse files Browse the repository at this point in the history
  • Loading branch information
Ninjaclasher committed Oct 30, 2023
1 parent c3fb2d7 commit 292ba1a
Show file tree
Hide file tree
Showing 14 changed files with 304 additions and 14 deletions.
12 changes: 11 additions & 1 deletion dmoj/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,16 @@
}
DMOJ_API_PAGE_SIZE = 1000

DMOJ_PASSWORD_RESET_LIMIT_WINDOW = 3600
# Number of password resets per window (in minutes)
DMOJ_PASSWORD_RESET_LIMIT_WINDOW = 60
DMOJ_PASSWORD_RESET_LIMIT_COUNT = 10

# Number of email change requests per window (in minutes)
DMOJ_EMAIL_CHANGE_LIMIT_WINDOW = 60
DMOJ_EMAIL_CHANGE_LIMIT_COUNT = 10
# Number of minutes before an email change request activation key expires
DMOJ_EMAIL_CHANGE_EXPIRY_MINUTES = 60

# At the bare minimum, dark and light theme CSS file locations must be declared
DMOJ_THEME_CSS = {
'light': 'style.css',
Expand Down Expand Up @@ -601,6 +608,9 @@
# Check settings are consistent
assert DMOJ_PROBLEM_MIN_USER_POINTS_VOTE >= DMOJ_PROBLEM_MIN_PROBLEM_POINTS

# <= 1 minute expiry is unusable UX
assert DMOJ_EMAIL_CHANGE_EXPIRY_MINUTES > 1

if DMOJ_PDF_PDFOID_URL:
# If a cache is configured, it must already exist and be a directory
assert DMOJ_PDF_PROBLEM_CACHE is None or os.path.isdir(DMOJ_PDF_PROBLEM_CACHE)
Expand Down
3 changes: 3 additions & 0 deletions dmoj/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@
template_name='registration/password_reset_done.html',
), name='password_reset_done'),
path('social/error/', register.social_auth_error, name='social_auth_error'),
path('email/change/', user.EmailChangeRequestView.as_view(), name='email_change'),
path('email/change/activate/<str:activation_key>/',
user.EmailChangeActivateView.as_view(), name='email_change_activate'),

path('2fa/', two_factor.TwoFactorLoginView.as_view(), name='login_2fa'),
path('2fa/enable/', two_factor.TOTPEnableView.as_view(), name='enable_2fa'),
Expand Down
22 changes: 22 additions & 0 deletions judge/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django import forms
from django.conf import settings
from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.db.models import Q
Expand All @@ -17,6 +18,7 @@
from django_ace import AceWidget
from judge.models import Contest, Language, Organization, Problem, ProblemPointsVote, Profile, Submission, \
WebAuthnCredential
from judge.utils.mail import validate_email_domain
from judge.utils.subscription import newsletter_id
from judge.widgets import HeavyPreviewPageDownWidget, Select2MultipleWidget, Select2Widget

Expand Down Expand Up @@ -95,6 +97,26 @@ def __init__(self, *args, **kwargs):
self.fields['user_script'].widget = AceWidget(mode='javascript', theme=user.profile.resolved_ace_theme)


class EmailChangeForm(Form):
password = CharField(widget=forms.PasswordInput())
email = forms.EmailField()

def __init__(self, *args, user, **kwargs):
super().__init__(*args, **kwargs)
self.user = user

def clean_email(self):
if User.objects.filter(email=self.cleaned_data['email']).exists():
raise ValidationError(_('This email address is already taken.'))
validate_email_domain(self.cleaned_data['email'])
return self.cleaned_data['email']

def clean_password(self):
if not self.user.check_password(self.cleaned_data['password']):
raise ValidationError(_('Invalid password'))
return self.cleaned_data['password']


class DownloadDataForm(Form):
comment_download = BooleanField(required=False, label=_('Download comments?'))
submission_download = BooleanField(required=False, label=_('Download submissions?'))
Expand Down
42 changes: 42 additions & 0 deletions judge/utils/mail.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import re
from typing import Any, Dict, List, Optional, Pattern

from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.forms import ValidationError
from django.template import loader
from django.utils.translation import gettext


bad_mail_regex: List[Pattern[str]] = list(map(re.compile, settings.BAD_MAIL_PROVIDER_REGEX))


def validate_email_domain(email: str) -> None:
if '@' in email:
domain = email.split('@')[-1].lower()
if domain in settings.BAD_MAIL_PROVIDERS or any(regex.match(domain) for regex in bad_mail_regex):
raise ValidationError(gettext('Your email provider is not allowed due to history of abuse. '
'Please use a reputable email provider.'))


# Inspired by django.contrib.auth.forms.PasswordResetForm.send_mail
def send_mail(
context: Dict[str, Any],
*,
from_email: Optional[str] = None,
to_email: str,
subject_template_name: str,
email_template_name: str,
html_email_template_name: Optional[str] = None,
) -> None:
subject = loader.render_to_string(subject_template_name, context)
# Email subject *must not* contain newlines
subject = ''.join(subject.splitlines())
body = loader.render_to_string(email_template_name, context)

email_message = EmailMultiAlternatives(subject, body, from_email, [to_email])
if html_email_template_name is not None:
html_email = loader.render_to_string(html_email_template_name, context)
email_message.attach_alternative(html_email, 'text/html')

email_message.send()
12 changes: 2 additions & 10 deletions judge/views/register.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
# coding=utf-8
import re

from django import forms
from django.conf import settings
from django.contrib.auth.models import User
Expand All @@ -14,12 +12,11 @@
from sortedm2m.forms import SortedMultipleChoiceField

from judge.models import Language, Organization, Profile, TIMEZONE
from judge.utils.mail import validate_email_domain
from judge.utils.recaptcha import ReCaptchaField, ReCaptchaWidget
from judge.utils.subscription import Subscription, newsletter_id
from judge.widgets import Select2MultipleWidget, Select2Widget

bad_mail_regex = list(map(re.compile, settings.BAD_MAIL_PROVIDER_REGEX))


class CustomRegistrationForm(RegistrationForm):
username = forms.RegexField(regex=r'^\w+$', max_length=30, label=_('Username'),
Expand All @@ -43,12 +40,7 @@ def clean_email(self):
if User.objects.filter(email=self.cleaned_data['email']).exists():
raise forms.ValidationError(gettext('The email address "%s" is already taken. Only one registration '
'is allowed per address.') % self.cleaned_data['email'])
if '@' in self.cleaned_data['email']:
domain = self.cleaned_data['email'].split('@')[-1].lower()
if (domain in settings.BAD_MAIL_PROVIDERS or
any(regex.match(domain) for regex in bad_mail_regex)):
raise forms.ValidationError(gettext('Your email provider is not allowed due to history of abuse. '
'Please use a reputable email provider.'))
validate_email_domain(self.cleaned_data['email'])
return self.cleaned_data['email']

def clean_organizations(self):
Expand Down
126 changes: 123 additions & 3 deletions judge/views/user.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import base64
import binascii
import itertools
import json
import os
Expand All @@ -8,9 +10,11 @@
from django.contrib.auth import logout as auth_logout
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import Permission
from django.contrib.auth.models import Permission, User
from django.contrib.auth.views import LoginView, PasswordChangeView, PasswordResetView, redirect_to_login
from django.contrib.contenttypes.models import ContentType
from django.contrib.sites.shortcuts import get_current_site
from django.core import signing
from django.core.cache import cache
from django.core.exceptions import PermissionDenied, ValidationError
from django.db.models import Count, Max, Min
Expand All @@ -27,13 +31,14 @@
from django.views.generic import DetailView, FormView, ListView, TemplateView, View
from reversion import revisions

from judge.forms import CustomAuthenticationForm, DownloadDataForm, ProfileForm, newsletter_id
from judge.forms import CustomAuthenticationForm, DownloadDataForm, EmailChangeForm, ProfileForm, newsletter_id
from judge.models import Profile, Submission
from judge.performance_points import get_pp_breakdown
from judge.ratings import rating_class, rating_progress
from judge.tasks import prepare_user_data
from judge.utils.celery import task_status_by_id, task_status_url_by_id
from judge.utils.infinite_paginator import InfinitePaginationMixin
from judge.utils.mail import send_mail
from judge.utils.problems import contest_completed_ids, user_completed_ids
from judge.utils.pwned import PwnedPasswordsValidator
from judge.utils.ranker import ranker
Expand Down Expand Up @@ -494,6 +499,9 @@ def post(self, request, *args, **kwargs):
return HttpResponseRedirect(request.get_full_path())


MINUTES_TO_SECONDS = 60


class CustomPasswordResetView(PasswordResetView):
template_name = 'registration/password_reset.html'
html_email_template_name = 'registration/password_reset_email.html'
Expand All @@ -502,8 +510,120 @@ class CustomPasswordResetView(PasswordResetView):

def post(self, request, *args, **kwargs):
key = f'pwreset!{request.META["REMOTE_ADDR"]}'
cache.add(key, 0, timeout=settings.DMOJ_PASSWORD_RESET_LIMIT_WINDOW)
cache.add(key, 0, timeout=settings.DMOJ_PASSWORD_RESET_LIMIT_WINDOW * MINUTES_TO_SECONDS)
if cache.incr(key) > settings.DMOJ_PASSWORD_RESET_LIMIT_COUNT:
return HttpResponse(_('You have sent too many password reset requests. Please try again later.'),
content_type='text/plain', status=429)
return super().post(request, *args, **kwargs)


class EmailChangeRequestView(LoginRequiredMixin, TitleMixin, FormView):
title = _('Change your email')
template_name = 'registration/email_change.html'
form_class = EmailChangeForm

activate_html_email_template_name = 'registration/email_change_activate_email.html'
activate_email_template_name = 'registration/email_change_activate_email.txt'
activate_subject_template_name = 'registration/email_change_activate_subject.txt'
notify_html_email_template_name = 'registration/email_change_notify_email.html'
notify_email_template_name = 'registration/email_change_notify_email.txt'
notify_subject_template_name = 'registration/email_change_notify_subject.txt'

def form_valid(self, form):
signer = signing.TimestampSigner()
new_email = form.cleaned_data['email']
activation_key = base64.urlsafe_b64encode(signer.sign_object({
'id': self.request.user.id,
'email': new_email,
}).encode()).decode()

current_site = get_current_site(self.request)
context = {
'domain': current_site.domain,
'site_name': current_site.name,
'protocol': 'https' if self.request.is_secure() else 'http',
'site_admin_email': settings.SITE_ADMIN_EMAIL,
'expiry_minutes': settings.DMOJ_EMAIL_CHANGE_EXPIRY_MINUTES,
'user': self.request.user,
'activation_key': activation_key,
'new_email': new_email,
}
send_mail(
context,
to_email=self.request.user.email,
subject_template_name=self.notify_subject_template_name,
email_template_name=self.notify_email_template_name,
html_email_template_name=self.notify_html_email_template_name,
)
send_mail(
context,
to_email=new_email,
subject_template_name=self.activate_subject_template_name,
email_template_name=self.activate_email_template_name,
html_email_template_name=self.activate_html_email_template_name,
)

return generic_message(
self.request,
_('Email change requested'),
_('Please click on the link sent to %s.') % new_email,
)

def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['user'] = self.request.user
return kwargs

def post(self, request, *args, **kwargs):
key = f'emailchange!{request.META["REMOTE_ADDR"]}'
cache.add(key, 0, timeout=settings.DMOJ_EMAIL_CHANGE_LIMIT_WINDOW * MINUTES_TO_SECONDS)
if cache.incr(key) > settings.DMOJ_EMAIL_CHANGE_LIMIT_COUNT:
return HttpResponse(_('You have sent too many email change requests. Please try again later.'),
content_type='text/plain', status=429)
return super().post(request, *args, **kwargs)


class EmailChangeActivateView(LoginRequiredMixin, View):
class EmailChangeFailedError(Exception):
pass

def update_user_email(self, request, activation_key):
signer = signing.TimestampSigner()
try:
data = signer.unsign_object(
base64.urlsafe_b64decode(activation_key.encode()).decode(),
max_age=settings.DMOJ_EMAIL_CHANGE_EXPIRY_MINUTES * MINUTES_TO_SECONDS,
)
except (binascii.Error, signing.BadSignature):
raise self.EmailChangeFailedError(_('Invalid activation key. Please try again.'))
except signing.SignatureExpired:
raise self.EmailChangeFailedError(_('This request has expired. Please try again.'))
if data['id'] != request.user.id:
raise self.EmailChangeFailedError(
_('Please try again while logged in to the account this email change was originally requested from.'),
)
from_email = request.user.email
to_email = data['email']
with revisions.create_revision(atomic=True):
if User.objects.filter(email=to_email).exists():
raise self.EmailChangeFailedError(
_('The email you originally requested has since been registered by another user. '
'Please try again with a new email.'),
)
request.user.email = to_email
request.user.save()
revisions.set_user(request.user)
revisions.set_comment(_('Changed email address from %s to %s') % (from_email, to_email))
return to_email

def get(self, request, *args, **kwargs):
try:
to_email = self.update_user_email(request, kwargs['activation_key'])
except self.EmailChangeFailedError as e:
return generic_message(request, _('Email change failed'), str(e), status=403)
else:
return generic_message(
request,
_('Email successfully changed'),
_('The email attached to your account has been changed to %s.') % to_email,
)
23 changes: 23 additions & 0 deletions templates/registration/email_change.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{% extends "base.html" %}

{% block media %}
<style>
.errorlist {
list-style-type: none;
margin-block-start: 0;
margin-block-end: 0.5em;
padding: 0;
}
</style>
{% endblock %}

{% block body %}
<div class="centered-form" style="text-align: center">
<form action="" method="post" class="form-area">
{% csrf_token %}
<table border="0" class="django-as-table">{{ form.as_table() }}</table>
<hr>
<button class="submit-bar" type="submit">{{ _('Request email change') }}</button>
</form>
</div>
{% endblock %}
24 changes: 24 additions & 0 deletions templates/registration/email_change_activate_email.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<div style="border:2px solid #fd0;margin:4px 0;"><div style="background:#000;border:6px solid #000;">
<a href="{{ protocol }}://{{ domain }}"><img src="{{ protocol }}://{{ domain }}/static/icons/logo.svg" alt="{{ site_name }}" width="160" height="44"></a>
</div></div>

<div style="border:2px solid #337ab7;margin:4px 0;"><div style="background:#fafafa;border:12px solid #fafafa;font-family:segoe ui,lucida grande,Arial,sans-serif;font-size:14px;">
<br><br>
{{ user.get_username() }},
<br>
{% trans %}You have requested to change your email address to this email for your user account at {{ site_name }}.{% endtrans %}
<br><br>
{% trans trimmed count=expiry_minutes %}
Please click the link to confirm this email change. The link will expire in {{ count }} minute.
{% pluralize %}
Please click the link to confirm this email change. The link will expire in {{ count }} minutes.
{% endtrans %}
<br>
<a href="{{ protocol }}://{{ domain }}{{ url('email_change_activate', activation_key=activation_key) }}">{{ protocol }}://{{ domain }}{{ url('email_change_activate', activation_key=activation_key) }}</a>
<br><br>
{% if site_admin_email %}
{% with link='<a href="mailto:%(email)s">%(email)s</a>'|safe|format(email=site_admin_email) %}
{{ _('If you have encounter any problems, feel free to shoot us an email at %(email)s.', email=link) }}
{% endwith %}
{% endif %}
</div></div>
15 changes: 15 additions & 0 deletions templates/registration/email_change_activate_email.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{{ user.get_username() }},

{% trans %}You have requested to change your email address to this email for your user account at {{ site_name }}.{% endtrans %}

{% trans trimmed count=expiry_minutes %}
Please go to this page to confirm this email change. The link will expire in {{ count }} minute.
{% pluralize %}
Please go to this page to confirm this email change. The link will expire in {{ count }} minutes.
{% endtrans %}

{{ protocol }}://{{ domain }}{{ url('email_change_activate', activation_key=activation_key) }}

{% if site_admin_email %}
{{ _('If you have encounter any problems, feel free to shoot us an email at %(email)s.', email=site_admin_email) }}
{% endif %}
1 change: 1 addition & 0 deletions templates/registration/email_change_activate_subject.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{% trans %}Email change request on {{ site_name }}{% endtrans %}
Loading

0 comments on commit 292ba1a

Please sign in to comment.