diff --git a/dmoj/settings.py b/dmoj/settings.py index 612b63e56c..2e2e2e7636 100644 --- a/dmoj/settings.py +++ b/dmoj/settings.py @@ -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', diff --git a/dmoj/urls.py b/dmoj/urls.py index 42edf08670..f2d117da24 100644 --- a/dmoj/urls.py +++ b/dmoj/urls.py @@ -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//', + 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'), diff --git a/judge/forms.py b/judge/forms.py index d231fb815a..dbfbf80eee 100644 --- a/judge/forms.py +++ b/judge/forms.py @@ -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 @@ -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 @@ -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?')) diff --git a/judge/utils/mail.py b/judge/utils/mail.py new file mode 100644 index 0000000000..afc4414fa3 --- /dev/null +++ b/judge/utils/mail.py @@ -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() diff --git a/judge/views/register.py b/judge/views/register.py index 46063c377d..6eb08a4bca 100644 --- a/judge/views/register.py +++ b/judge/views/register.py @@ -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 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'), @@ -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): diff --git a/judge/views/user.py b/judge/views/user.py index 6a3791b183..20277f57a2 100644 --- a/judge/views/user.py +++ b/judge/views/user.py @@ -1,3 +1,5 @@ +import base64 +import binascii import itertools import json import os @@ -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 @@ -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 @@ -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.'), + ) diff --git a/templates/registration/email_change.html b/templates/registration/email_change.html new file mode 100644 index 0000000000..9430591ab9 --- /dev/null +++ b/templates/registration/email_change.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} + +{% block media %} + +{% endblock %} + +{% block body %} +
+
+ {% csrf_token %} + {{ form.as_table() }}
+
+ +
+
+{% endblock %} diff --git a/templates/registration/email_change_activate_email.html b/templates/registration/email_change_activate_email.html new file mode 100644 index 0000000000..9432740ba4 --- /dev/null +++ b/templates/registration/email_change_activate_email.html @@ -0,0 +1,24 @@ +
+{{ site_name }} +
+ +
+

+{{ 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 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 %} +
+{{ protocol }}://{{ domain }}{{ url('email_change_activate', activation_key=activation_key) }} +

+{% if site_admin_email %} +{% with link='%(email)s'|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 %} +
diff --git a/templates/registration/email_change_activate_email.txt b/templates/registration/email_change_activate_email.txt new file mode 100644 index 0000000000..9634f82f94 --- /dev/null +++ b/templates/registration/email_change_activate_email.txt @@ -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 %} diff --git a/templates/registration/email_change_activate_subject.txt b/templates/registration/email_change_activate_subject.txt new file mode 100644 index 0000000000..607241909d --- /dev/null +++ b/templates/registration/email_change_activate_subject.txt @@ -0,0 +1 @@ +{% trans %}Email change request on {{ site_name }}{% endtrans %} diff --git a/templates/registration/email_change_notify_email.html b/templates/registration/email_change_notify_email.html new file mode 100644 index 0000000000..e8b78ec445 --- /dev/null +++ b/templates/registration/email_change_notify_email.html @@ -0,0 +1,24 @@ +
+{{ site_name }} +
+ +
+

+{{ 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 %} +{% with link='%(email)s'|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 %} + +
+ diff --git a/templates/registration/email_change_notify_email.txt b/templates/registration/email_change_notify_email.txt new file mode 100644 index 0000000000..132eddb70e --- /dev/null +++ b/templates/registration/email_change_notify_email.txt @@ -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 %} diff --git a/templates/registration/email_change_notify_subject.txt b/templates/registration/email_change_notify_subject.txt new file mode 100644 index 0000000000..e1026bfd60 --- /dev/null +++ b/templates/registration/email_change_notify_subject.txt @@ -0,0 +1,2 @@ +{% trans %}Alert: Email change request on {{ site_name }}{% endtrans %} + diff --git a/templates/user/edit-profile.html b/templates/user/edit-profile.html index a7fc040dd4..845d3bf9e6 100644 --- a/templates/user/edit-profile.html +++ b/templates/user/edit-profile.html @@ -338,6 +338,11 @@ {{ _('Change your password') }} + + + {{ _('Change your email') }} + + {% if can_download_data %}