Implement user email change functionality; #1996

This commit is contained in:
Evan 2023-10-11 17:20:23 +00:00 committed by Evan Zhang
parent c3fb2d717d
commit 292ba1a600
14 changed files with 304 additions and 14 deletions

View File

@ -107,9 +107,16 @@ DMOJ_STATS_SUBMISSION_RESULT_COLORS = {
}
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',
@ -601,6 +608,9 @@ except IOError:
# 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)

View File

@ -60,6 +60,9 @@ register_patterns = [
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'),

View File

@ -6,6 +6,7 @@ import webauthn
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
@ -17,6 +18,7 @@ from django.utils.translation import gettext_lazy as _, ngettext_lazy
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
@ -95,6 +97,26 @@ class ProfileForm(ModelForm):
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?'))

42
judge/utils/mail.py Normal file
View File

@ -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()

View File

@ -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
@ -14,12 +12,11 @@ from registration.forms import RegistrationForm
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'),
@ -43,12 +40,7 @@ class CustomRegistrationForm(RegistrationForm):
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):

View File

@ -1,3 +1,5 @@
import base64
import binascii
import itertools
import json
import os
@ -8,9 +10,11 @@ from django.conf import settings
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
@ -27,13 +31,14 @@ from django.views.decorators.http import require_POST
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
@ -494,6 +499,9 @@ class UserLogoutView(TitleMixin, TemplateView):
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'
@ -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,
)

View File

@ -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 %}

View File

@ -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>

View File

@ -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 %}

View File

@ -0,0 +1 @@
{% trans %}Email change request on {{ site_name }}{% endtrans %}

View File

@ -0,0 +1,23 @@
<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 %}Someone (hopefully you!) has requested to change the email address associated with your user account at {{ site_name }} to {{ new_email }}. {% 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 change your password and email us immediately at %(email)s.', email=link) }}
{% endwith %}
{% else %}
{{ _('If this was not you, please change your password and reply to this email immediately.') }}
{% endif %}
</b>
</div></div>

View File

@ -0,0 +1,9 @@
{{ user.get_username() }},
{% trans %}Someone (hopefully you!) has requested to change the email address associated with your user account at {{ site_name }} to {{ new_email }}. {% endtrans %}
{{ _('If this was you, no further action is required.') }}
{% if site_admin_email %}
{{ _('If this was not you, please change your password and email us immediately at %(email)s.', email=site_admin_email) }}
{% else %}
{{ _('If this was not you, please change your password and reply to this email immediately.') }}
{% endif %}

View File

@ -0,0 +1 @@
{% trans %}Alert: Email change request on {{ site_name }}{% endtrans %}

View File

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