mirror of
https://github.com/DMOJ/online-judge.git
synced 2024-11-25 16:32:37 +08:00
Implement user email change functionality; #1996
This commit is contained in:
parent
c3fb2d717d
commit
292ba1a600
@ -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)
|
||||
|
@ -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'),
|
||||
|
@ -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
42
judge/utils/mail.py
Normal 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()
|
@ -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):
|
||||
|
@ -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,
|
||||
)
|
||||
|
23
templates/registration/email_change.html
Normal file
23
templates/registration/email_change.html
Normal 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 %}
|
24
templates/registration/email_change_activate_email.html
Normal file
24
templates/registration/email_change_activate_email.html
Normal 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>
|
15
templates/registration/email_change_activate_email.txt
Normal file
15
templates/registration/email_change_activate_email.txt
Normal 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 %}
|
1
templates/registration/email_change_activate_subject.txt
Normal file
1
templates/registration/email_change_activate_subject.txt
Normal file
@ -0,0 +1 @@
|
||||
{% trans %}Email change request on {{ site_name }}{% endtrans %}
|
23
templates/registration/email_change_notify_email.html
Normal file
23
templates/registration/email_change_notify_email.html
Normal 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>
|
9
templates/registration/email_change_notify_email.txt
Normal file
9
templates/registration/email_change_notify_email.txt
Normal 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 %}
|
1
templates/registration/email_change_notify_subject.txt
Normal file
1
templates/registration/email_change_notify_subject.txt
Normal file
@ -0,0 +1 @@
|
||||
{% trans %}Alert: Email change request on {{ site_name }}{% endtrans %}
|
@ -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">
|
||||
|
Loading…
Reference in New Issue
Block a user