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 12, 2023
1 parent 98c7557 commit b2d07ad
Show file tree
Hide file tree
Showing 14 changed files with 279 additions and 12 deletions.
7 changes: 7 additions & 0 deletions dmoj/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,16 @@
}
DMOJ_API_PAGE_SIZE = 1000

# Number of password reset per window (in seconds)
DMOJ_PASSWORD_RESET_LIMIT_WINDOW = 3600
DMOJ_PASSWORD_RESET_LIMIT_COUNT = 10

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

# At the bare minimum, dark and light theme CSS file locations must be declared
DMOJ_THEME_CSS = {
'light': 'style.css',
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
24 changes: 24 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 is_email_address_bad
from judge.utils.subscription import newsletter_id
from judge.widgets import HeavyPreviewPageDownWidget, Select2MultipleWidget, Select2Widget

Expand Down Expand Up @@ -95,6 +97,28 @@ 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.'))
if is_email_address_bad(self.cleaned_data['email']):
raise ValidationError(_('Your email provider is not allowed due to history of abuse. '
'Please use a reputable email provider.'))
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
36 changes: 36 additions & 0 deletions judge/utils/mail.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import re

from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template import loader


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


def is_email_address_bad(email):
if '@' in email:
domain = email.split('@')[-1].lower()
return domain in settings.BAD_MAIL_PROVIDERS or any(regex.match(domain) for regex in bad_mail_regex)
return False


def send_mail(
subject_template_name,
email_template_name,
context,
from_email,
to_email,
html_email_template_name,
):
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()
14 changes: 4 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 is_email_address_bad
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,9 @@ 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.'))
if is_email_address_bad(self.cleaned_data['email']):
raise forms.ValidationError(gettext('Your email provider is not allowed due to history of abuse. '
'Please use a reputable email provider.'))
return self.cleaned_data['email']

def clean_organizations(self):
Expand Down
104 changes: 102 additions & 2 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 @@ -507,3 +512,98 @@ def post(self, request, *args, **kwargs):
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(
self.notify_subject_template_name, self.notify_email_template_name, context, None, self.request.user.email,
self.notify_html_email_template_name,
)
send_mail(
self.activate_subject_template_name, self.activate_email_template_name, context, None, new_email,
self.activate_html_email_template_name,
)

return generic_message(
self.request,
_('Email change requested'),
_('Please click on the link sent to the new email address.'),
)

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)
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):
def get(self, request, *args, **kwargs):
activation_key = kwargs['activation_key']
signer = signing.TimestampSigner()
try:
try:
data = signer.unsign_object(
base64.urlsafe_b64decode(activation_key.encode()).decode(),
max_age=settings.DMOJ_EMAIL_CHANGE_EXPIRY_MINUTES * 60,
)
except (binascii.Error, signing.BadSignature):
raise ValueError(_('Invalid activation key. Please try again.'))
except signing.SignatureExpired:
raise ValueError(_('This request is expired. Please try again.'))
if data['id'] != request.user.id:
raise ValueError(
_('Please try again from the account this email change was originally requested from.'),
)
with revisions.create_revision(atomic=True):
if User.objects.filter(email=data['email']).exists():
raise ValueError(_('The email originally requested is in use. Please try again with a new email.'))
request.user.email = data['email']
request.user.save()
revisions.set_user(request.user)
revisions.set_comment(_('Changed email address'))
except ValueError as ve:
return generic_message(request, _('Email change failed'), str(ve), status=403)
else:
return generic_message(
request,
_('Email change succeeded'),
_('The email attached to your account has been changed.'),
)
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 %}
24 changes: 24 additions & 0 deletions templates/registration/email_change_notify_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 {{ new_email }} for your user account at {{ site_name }}.{% endtrans %}
<br><br>
{{ _('If this was you, no further action is required.') }}

<br>
<b>
{% if site_admin_email %}
{% with link='<a href="mailto:%(email)s">%(email)s</a>'|safe|format(email=site_admin_email) %}
{{ _('If this was not you, please email us immediately at %(email)s.', email=link) }}
{% endwith %}
{% else %}
{{ _('If this was not you, please reply to this email immediately.') }}
{% endif %}
</b>
</div></div>

9 changes: 9 additions & 0 deletions templates/registration/email_change_notify_email.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{{ user.get_username() }},
{% trans %}You have requested to change your email address to {{ new_email }} for your user account at {{ site_name }}.{% endtrans %}

{{ _('If this was you, no further action is required.') }}
{% if site_admin_email %}
{{ _('If this was not you, please email us immediately at %(email)s.', email=site_admin_email) }}
{% else %}
{{ _('If this was not you, please reply to this email immediately.') }}
{% endif %}
2 changes: 2 additions & 0 deletions templates/registration/email_change_notify_subject.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{% trans %}Alert: Email change request on {{ site_name }}{% endtrans %}

5 changes: 5 additions & 0 deletions templates/user/edit-profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,11 @@
{{ _('Change your password') }}
</a>
</td></tr>
<tr><td>
<a href="{{ url('email_change') }}" class="inline-header">
{{ _('Change your email') }}
</a>
</td></tr>
{% if can_download_data %}
<tr><td>
<a href="{{ url('user_prepare_data') }}" class="inline-header">
Expand Down

0 comments on commit b2d07ad

Please sign in to comment.