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 11, 2023
1 parent 98c7557 commit 91179ae
Show file tree
Hide file tree
Showing 14 changed files with 277 additions and 12 deletions.
4 changes: 4 additions & 0 deletions dmoj/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@
DMOJ_PASSWORD_RESET_LIMIT_WINDOW = 3600
DMOJ_PASSWORD_RESET_LIMIT_COUNT = 10

DMOJ_EMAIL_CHANGE_LIMIT_WINDOW = 3600
DMOJ_EMAIL_CHANGE_LIMIT_COUNT = 10
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
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.forms import validate_email
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(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
17 changes: 17 additions & 0 deletions judge/utils/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import re

from django import forms
from django.conf import settings
from django.utils.translation import gettext


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


def validate_email(email):
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 forms.ValidationError(gettext('Your email provider is not allowed due to history of abuse. '
'Please use a reputable email provider.'))
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.forms import validate_email
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(self.cleaned_data['email'])
return self.cleaned_data['email']

def clean_organizations(self):
Expand Down
128 changes: 126 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,15 +10,19 @@
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.core.mail import EmailMultiAlternatives
from django.db.models import Count, Max, Min
from django.db.models.functions import ExtractYear, TruncDate
from django.http import Http404, HttpResponse, HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404, render
from django.template import loader
from django.urls import reverse
from django.utils import timezone
from django.utils.formats import date_format
Expand All @@ -27,7 +33,7 @@
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
Expand Down Expand Up @@ -507,3 +513,121 @@ 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 send_mail(
self,
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])
html_email = loader.render_to_string(html_email_template_name, context)
email_message.attach_alternative(html_email, 'text/html')

email_message.send()

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,
}
self.send_mail(
self.notify_subject_template_name, self.notify_email_template_name, context, None, self.request.user.email,
self.notify_html_email_template_name,
)
self.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):
signer = signing.TimestampSigner()
try:
try:
data = signer.unsign_object(
base64.urlsafe_b64decode(self.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.'),
)

def dispatch(self, request, *args, **kwargs):
self.activation_key = kwargs.get('activation_key')
return super().dispatch(request, *args, **kwargs)
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 91179ae

Please sign in to comment.