Implement email verification flow (#21603)

* feat(): add email_verification property to UserProfile

* feat(users): add useful properties to SuppressedEmailVerification model

* feat(devhub): add email verification flow view logic

* chore(): use date only values for socketlabs recipient search
This commit is contained in:
Kevin Meinhardt 2024-01-08 13:13:41 +01:00 коммит произвёл GitHub
Родитель b0a52c5336
Коммит 4ca4686edd
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
13 изменённых файлов: 537 добавлений и 11 удалений

Просмотреть файл

@ -0,0 +1,7 @@
{% load i18n %}{# L10n: This is an email. Whitespace matters #}{% blocktrans %}Hello,
Your email was successfully verified.
Regards,
The Mozilla Add-ons Team{% endblocktrans %}

Просмотреть файл

@ -1,9 +1,14 @@
{% if waffle.switch("suppressed-email") and request.user.is_authenticated and request.user.suppressed_email %}
{% if waffle.switch("suppressed-email") and request.user.is_authenticated and request.user.suppressed_email and request.path != url('devhub.email_verification') %}
<div class="notification-box warning" id="suppressed-email">
<p>
{% trans email=request.user.email %}
We have discovered that your email "{{ email }}" is unable to receive emails from us. Please update your email address to one that can receive emails from us.
{% endtrans %}
<a href="{{ url('devhub.email_verification') }}">
{% trans %}
Learn more
{% endtrans %}
</a>
</p>
</div>
{% endif %}

Просмотреть файл

@ -0,0 +1,57 @@
{% extends "devhub/base.html" %}
{% set title = _('Email Address Verification') %}
{% block title %}{{ dev_page_title(title) }}{% endblock %}
{% block content %}
<h1>{{ title }}</h1>
<div id="{{ state }}">
{% if state == "email_verified" %}
{% trans %}
Your email address is verified.
{% endtrans %}
{% elif state == "email_suppressed" %}
{% trans %}
Please verify your email by clicking "Verify email" above.
{% endtrans %}
{% elif state == "verification_expired" %}
{% trans %}
Could not verify email address. The verification link has expired.
{% endtrans %}
{% elif state == "verification_pending" %}
{% trans %}
Working... Please be patient.
{% endtrans %}
<div class="loader"></div>
{% elif state == "verification_failed" %}
{% trans %}
Failed to send confirmation email. Please try again.
If you no longer have access to your email address, please update your mozilla account email address.
<a
href="https://support.mozilla.org/en-US/kb/change-primary-email-address-firefox-accounts"
target="_blank"
>
Change email address
</a>
{% endtrans %}
{% elif state == "verification_timedout" %}
{% trans %}
This is taking longer than expected. Try again.
{% endtrans %}
{% elif state == "confirmation_pending" %}
{% trans email=request.user.email %}
An email with a confirmation link has been sent to your email address: {{ email }}. Please click the link to confirm your email address. If you did not receive the email, please check your spam folder.
{% endtrans %}
{% elif state == "confirmation_unauthorized" %}
{% trans email=request.user.email %}
The provided code is associated with another user's email. Please use the link in the email sent to your email address {{ email }}.
{% endtrans %}
{% endif %}
{% if render_button %}
{% with submit_text=button_text %}
{% include 'devhub/verify_email_form.html' %}
{% endwith %}
{% endif %}
</div>
{% endblock %}

Просмотреть файл

@ -0,0 +1,6 @@
<form action="{{ url('devhub.email_verification') }}" method="post">
{% csrf_token %}
<button class="Button" type="submit">
{{ submit_text | default('Verify email address') }}
</button>
</form>

Просмотреть файл

@ -12,6 +12,7 @@ from django.urls import reverse
from django.utils.encoding import force_str
from django.utils.translation import trim_whitespace
import freezegun
import pytest
import responses
from pyquery import PyQuery as pq
@ -39,12 +40,17 @@ from olympia.constants.promoted import RECOMMENDED
from olympia.devhub.decorators import dev_required
from olympia.devhub.models import BlogPost
from olympia.devhub.tasks import validate
from olympia.devhub.views import get_next_version_number
from olympia.devhub.views import VERIFY_EMAIL_STATE, get_next_version_number
from olympia.files.models import FileUpload
from olympia.files.tests.test_models import UploadMixin
from olympia.ratings.models import Rating
from olympia.translations.models import Translation, delete_translation
from olympia.users.models import IPNetworkUserRestriction, SuppressedEmail, UserProfile
from olympia.users.models import (
IPNetworkUserRestriction,
SuppressedEmail,
SuppressedEmailVerification,
UserProfile,
)
from olympia.users.tests.test_views import UserViewBase
from olympia.versions.models import Version, VersionPreview
from olympia.zadmin.models import set_config
@ -460,6 +466,10 @@ class TestHome(TestCase):
assert self.user_profile.email in doc('#suppressed-email').text()
assert doc('#suppressed-email').length == 1
assert 'Learn more' in doc('#suppressed-email a').text()
assert reverse('devhub.email_verification') in doc('#suppressed-email a').attr(
'href'
)
@override_switch('suppressed-email', active=False)
def test_suppressed_email_hidden_by_flase(self):
@ -2172,3 +2182,269 @@ class TestStatsLinksInManageMySubmissionsPage(TestCase):
assert reverse('stats.overview', args=[self.addon.slug]) in str(
response.content
)
class TestVerifyEmail(TestCase):
def setUp(self):
super().setUp()
self.url = reverse('devhub.email_verification')
self.user_profile = user_factory()
self.client.force_login(self.user_profile)
def _create_suppressed_email(self, user):
return SuppressedEmail.objects.create(email=user.email)
def _create_suppressed_email_verification(
self, user, suppressed_email=None, status=None
):
if suppressed_email is None:
suppressed_email = self._create_suppressed_email(user)
if status is None:
status = SuppressedEmailVerification.STATUS_CHOICES.Pending
return SuppressedEmailVerification.objects.create(
suppressed_email=suppressed_email,
status=status,
)
def _get(self, url=None):
url = self.url if url is None else url
return self.client.get(self.url)
def _post(self, url=None):
url = self.url if url is None else url
return self.client.post(self.url)
def _doc(self, content=None):
if content is None:
content = self._get().content
return pq(content)
def _set_url_code(self, code):
self.url += f'?code={code}'
def _assert_id_in_doc(self, doc, tag):
assert doc(f'#{tag}').length == 1, f'#{tag} not in {doc}'
def _assert_text_in_doc(self, doc, text):
assert text in doc.text(), f'"{text}" not in "{doc.text()}"'
def _assert_verify_button(self, doc, text):
print('text', doc("button[type='submit']").text())
assert text in doc("button[type='submit']").text()
def _assert_redirect_self(self, response, url=None):
url = self.url if url is None else url
self.assert3xx(response, url)
def test_hide_suppressed_email_snippet(self):
"""
on verification page, do not show the suppressed email snippet
"""
doc = self._doc()
assert doc('#suppressed-email').length == 0
def test_email_verified(self):
assert not self.user_profile.suppressed_email
doc = self._doc()
self._assert_text_in_doc(doc, 'Your email address is verified.')
self._assert_id_in_doc(doc, VERIFY_EMAIL_STATE['email_verified'])
def test_email_suppressed(self):
"""
current user has a suppressed email and no verification.
"""
self._create_suppressed_email(self.user_profile)
assert not self.user_profile.email_verification
doc = self._doc()
self._assert_text_in_doc(doc, 'Please verify your email')
self._assert_verify_button(doc, 'Verify email')
self._assert_id_in_doc(doc, VERIFY_EMAIL_STATE['email_suppressed'])
@mock.patch('olympia.devhub.views.send_suppressed_email_confirmation')
def test_create_verification(self, send_suppressed_email_confirmation_mock):
"""
post request to create verification
"""
send_suppressed_email_confirmation_mock.delay.return_value = None
self._create_suppressed_email(self.user_profile)
assert not self.user_profile.email_verification
response = self._post()
self._assert_redirect_self(response)
assert self.user_profile.reload().email_verification
assert send_suppressed_email_confirmation_mock.delay.call_count == 1
@mock.patch('olympia.devhub.views.send_suppressed_email_confirmation')
def test_create_verification_existing(
self, send_suppressed_email_confirmation_mock
):
"""
post request to create verification when one already exists
will delete the existing one and create a new one
"""
send_suppressed_email_confirmation_mock.delay.return_value = None
verification = self._create_suppressed_email_verification(self.user_profile)
assert self.user_profile.email_verification
response = self._post()
self._assert_redirect_self(response)
assert self.user_profile.reload().email_verification
assert not SuppressedEmailVerification.objects.filter(
pk=verification.pk
).exists()
def test_create_verification_not_suppressed(self):
"""
post request to create verification when email is not suppressed
"""
assert not self.user_profile.suppressed_email
assert not self.user_profile.email_verification
response = self._post()
self._assert_redirect_self(response)
def test_verification_expired(self):
"""
user has a verification that is expired, regardless of status.
"""
verification = self._create_suppressed_email_verification(
self.user_profile, None
)
with freezegun.freeze_time(verification.created) as frozen_time:
frozen_time.tick(timedelta(days=31))
assert verification.is_expired
doc = self._doc()
self._assert_text_in_doc(
doc,
(
'Could not verify email address. '
'The verification link has expired.'
),
)
self._assert_verify_button(doc, 'Try again')
self._assert_id_in_doc(doc, VERIFY_EMAIL_STATE['verification_expired'])
def test_verification_pending(self):
"""
current user has a verification in `Pending`. waiting for email to be sent
"""
self._create_suppressed_email_verification(self.user_profile)
assert self.user_profile.email_verification
doc = self._doc()
self._assert_text_in_doc(doc, 'Working... Please be patient.')
assert doc('.loader').length == 1
self._assert_id_in_doc(doc, VERIFY_EMAIL_STATE['verification_pending'])
def test_verification_timedout(self):
"""
current user has a verification in `Pending`.
timeout exceeded so we show static message
"""
verification = self._create_suppressed_email_verification(self.user_profile)
assert self.user_profile.email_verification
with freezegun.freeze_time(verification.created) as frozen_time:
frozen_time.tick(timedelta(seconds=31))
doc = self._doc()
self._assert_text_in_doc(doc, 'This is taking longer than expected.')
self._assert_id_in_doc(doc, VERIFY_EMAIL_STATE['verification_timedout'])
self._assert_verify_button(doc, 'Try again')
def test_verification_failed(self):
"""
current user has a verification in `Failed`.
"""
self._create_suppressed_email_verification(
self.user_profile,
None,
SuppressedEmailVerification.STATUS_CHOICES.Failed,
)
assert self.user_profile.email_verification
doc = self._doc()
self._assert_text_in_doc(doc, 'Failed to send confirmation email. ')
self._assert_verify_button(doc, 'Try again')
self._assert_id_in_doc(doc, VERIFY_EMAIL_STATE['verification_failed'])
def test_confirmation_pending(self):
"""
current user has a verification in `Delivered`.
waiting for confirmation link to be clicked
"""
self._create_suppressed_email_verification(
self.user_profile,
None,
SuppressedEmailVerification.STATUS_CHOICES.Delivered,
)
assert self.user_profile.email_verification
doc = self._doc()
self._assert_text_in_doc(doc, 'An email with a confirmation link has been sent')
self._assert_id_in_doc(doc, 'confirmation_pending')
def test_confirmation_link_invalid_code(self):
self._create_suppressed_email_verification(
self.user_profile,
None,
SuppressedEmailVerification.STATUS_CHOICES.Delivered,
)
self._set_url_code('invalid')
response = self._get()
self._assert_redirect_self(response, reverse('devhub.email_verification'))
def test_confirmation_link_unauthorized_code(self):
"""
given code matches a verification that does not belong to the user.
"""
self._create_suppressed_email_verification(
self.user_profile,
None,
SuppressedEmailVerification.STATUS_CHOICES.Delivered,
)
verification = self._create_suppressed_email_verification(
user_factory(), None, SuppressedEmailVerification.STATUS_CHOICES.Delivered
)
self._set_url_code(verification.confirmation_code)
doc = self._doc()
self._assert_text_in_doc(
doc, "The provided code is associated with another user's email"
)
self._assert_id_in_doc(doc, 'confirmation_unauthorized')
self._assert_verify_button(doc, 'Try again')
def test_confirmation_link_valid_code(self):
"""
given code is valid and belongs to the user. remove the email suppression
"""
verification = self._create_suppressed_email_verification(
self.user_profile,
None,
SuppressedEmailVerification.STATUS_CHOICES.Delivered,
)
self._set_url_code(verification.confirmation_code)
assert not verification.is_expired
response = self._get()
assert len(mail.outbox) == 1
assert 'Your email was successfully verified.' in mail.outbox[0].body
expected_redirect = reverse('devhub.email_verification')
self.assert3xx(response, expected_redirect)

Просмотреть файл

@ -268,5 +268,8 @@ urlpatterns = decorate(
),
# logout page
re_path(r'^logout', views.logout, name='devhub.logout'),
re_path(
r'^verify-email', views.email_verification, name='devhub.email_verification'
),
],
)

Просмотреть файл

@ -51,6 +51,7 @@ from olympia.amo.utils import (
escape_all,
is_safe_url,
send_mail,
send_mail_jinja,
)
from olympia.api.models import APIKey, APIKeyConfirmation
from olympia.devhub.decorators import (
@ -70,7 +71,11 @@ from olympia.reviewers.forms import PublicWhiteboardForm
from olympia.reviewers.models import Whiteboard
from olympia.reviewers.templatetags.code_manager import code_manager_url
from olympia.reviewers.utils import ReviewHelper
from olympia.users.models import DeveloperAgreementRestriction
from olympia.users.models import (
DeveloperAgreementRestriction,
SuppressedEmailVerification,
)
from olympia.users.tasks import send_suppressed_email_confirmation
from olympia.users.utils import (
RestrictionChecker,
send_addon_author_add_mail,
@ -2074,3 +2079,103 @@ def logout(request):
logout_user(request, response)
return response
VERIFY_EMAIL_STATE = {
'email_verified': 'email_verified',
'email_suppressed': 'email_suppressed',
'verification_expired': 'verification_expired',
'verification_pending': 'verification_pending',
'verification_failed': 'verification_failed',
'verification_timedout': 'verification_timedout',
'confirmation_pending': 'confirmation_pending',
'confirmation_unauthorized': 'confirmation_unauthorized',
}
RENDER_BUTTON_STATES = [
VERIFY_EMAIL_STATE['email_suppressed'],
VERIFY_EMAIL_STATE['verification_expired'],
VERIFY_EMAIL_STATE['verification_failed'],
VERIFY_EMAIL_STATE['verification_timedout'],
VERIFY_EMAIL_STATE['confirmation_unauthorized'],
]
def get_button_text(state):
if state == VERIFY_EMAIL_STATE['email_suppressed']:
return gettext('Verify email')
return gettext('Try again')
@login_required
def email_verification(request):
data = {'state': None}
email_verification = request.user.email_verification
suppressed_email = request.user.suppressed_email
if request.method == 'POST':
if email_verification:
email_verification.delete()
if suppressed_email:
email_verification = SuppressedEmailVerification.objects.create(
suppressed_email=suppressed_email
)
send_suppressed_email_confirmation.delay(email_verification.id)
return redirect('devhub.email_verification')
if email_verification:
if not email_verification.is_expired:
if (
email_verification.status
== SuppressedEmailVerification.STATUS_CHOICES.Pending
):
if email_verification.is_timedout:
data['state'] = VERIFY_EMAIL_STATE['verification_timedout']
else:
data['state'] = VERIFY_EMAIL_STATE['verification_pending']
elif (
email_verification.status
== SuppressedEmailVerification.STATUS_CHOICES.Failed
):
data['state'] = VERIFY_EMAIL_STATE['verification_failed']
elif (
email_verification.status
== SuppressedEmailVerification.STATUS_CHOICES.Delivered
):
if code := request.GET.get('code'):
if code == email_verification.confirmation_code:
suppressed_email.delete()
send_mail_jinja(
gettext('Your email has been verified'),
'devhub/emails/verify-email-completed.ltxt',
{},
recipient_list=[request.user.email],
)
if SuppressedEmailVerification.objects.filter(
confirmation_code=code
).exists():
data['state'] = VERIFY_EMAIL_STATE['confirmation_unauthorized']
else:
return redirect('devhub.email_verification')
else:
data['state'] = VERIFY_EMAIL_STATE['confirmation_pending']
else:
data['state'] = VERIFY_EMAIL_STATE['verification_expired']
elif suppressed_email:
data['state'] = VERIFY_EMAIL_STATE['email_suppressed']
else:
data['state'] = VERIFY_EMAIL_STATE['email_verified']
if data['state'] is None:
raise Exception('Invalid view must result in assigned state')
if data['state'] in RENDER_BUTTON_STATES:
data['render_button'] = True
data['button_text'] = get_button_text(data['state'])
return TemplateResponse(request, 'devhub/verify_email.html', context=data)

Просмотреть файл

@ -688,6 +688,12 @@ class UserProfile(OnChangeMixin, ModelBase, AbstractBaseUser):
def suppressed_email(self):
return SuppressedEmail.objects.filter(email=self.email).first()
@property
def email_verification(self):
return SuppressedEmailVerification.objects.filter(
suppressed_email=self.suppressed_email
).first()
class UserNotification(ModelBase):
user = models.ForeignKey(
@ -1373,3 +1379,11 @@ class SuppressedEmailVerification(ModelBase):
@property
def expiration(self):
return self.created + timedelta(days=30)
@property
def is_expired(self):
return self.expiration < datetime.now()
@property
def is_timedout(self):
return self.created + timedelta(seconds=30) < datetime.now()

Просмотреть файл

@ -5,6 +5,7 @@ import tempfile
import urllib.parse
from django.conf import settings
from django.urls import reverse
from django.utils.translation import gettext
import requests
@ -179,8 +180,9 @@ def send_suppressed_email_confirmation(suppressed_email_verification_id):
verification.status = SuppressedEmailVerification.STATUS_CHOICES.Pending
confirmation_link = (
# TODO: replace with email-verification reverse path
'' + '?code=' + str(verification.confirmation_code)
reverse('devhub.email_verification')
+ '?code='
+ str(verification.confirmation_code)
)
send_mail_jinja(
@ -232,7 +234,7 @@ def check_suppressed_email_confirmation(suppressed_email_verification_id, page_s
day=before.day,
)
end_date = datetime.datetime.now() + datetime.timedelta(days=1)
date_format = '%Y-%m-%dT%H:%M:%S%z+0100'
date_format = '%Y-%m-%d'
params = {
'toEmailAddress': email,

Просмотреть файл

@ -920,6 +920,16 @@ class TestUserProfile(TestCase):
assert user.reload().suppressed_email == suppressed_email
def test_email_verification(self):
user = user_factory()
assert not user.email_verification
verification = SuppressedEmailVerification.objects.create(
suppressed_email=SuppressedEmail.objects.create(email=user.email)
)
assert user.reload().email_verification.id == verification.id
class TestDeniedName(TestCase):
fixtures = ['users/test_backends']
@ -1560,3 +1570,21 @@ class TestSuppressedEmailVerification(TestCase):
SuppressedEmailVerification.objects.create(
suppressed_email=self.suppressed_email, status='invalid'
)
def test_is_expired(self):
email_verification = SuppressedEmailVerification.objects.create(
suppressed_email=self.suppressed_email
)
assert not email_verification.is_expired
with freeze_time(email_verification.created + timedelta(days=31)):
assert email_verification.is_expired
def test_is_timedout(self):
email_verification = SuppressedEmailVerification.objects.create(
suppressed_email=self.suppressed_email
)
assert not email_verification.is_timedout
with freeze_time(email_verification.created + timedelta(seconds=31)):
assert email_verification.is_timedout

Просмотреть файл

@ -9,6 +9,7 @@ from urllib.parse import parse_qs, urlparse
from django.conf import settings
from django.core import mail
from django.urls import reverse
import pytest
import responses
@ -392,8 +393,9 @@ class TestSendSuppressedEmailConfirmation(TestCase):
assert len(mail.outbox) == 1
expected_confirmation_link = (
# TODO: replace with reverse devhub.email_verification
'' + '?code=' + str(verification.confirmation_code)
reverse('devhub.email_verification')
+ '?code='
+ str(verification.confirmation_code)
)
assert expected_confirmation_link in mail.outbox[0].body
assert str(verification.confirmation_code)[-5:] in mail.outbox[0].subject
@ -536,8 +538,8 @@ class TestCheckSuppressedEmailConfirmation(TestCase):
parsed_url = urlparse(responses.calls[0].request.url)
search_params = parse_qs(parsed_url.query)
assert search_params['startDate'][0] == '2023-06-25T00:00:00+0100'
assert search_params['endDate'][0] == '2023-06-27T11:00:00+0100'
assert search_params['startDate'][0] == '2023-06-25'
assert search_params['endDate'][0] == '2023-06-27'
def test_pagination(self):
verification = SuppressedEmailVerification.objects.create(

Просмотреть файл

@ -241,3 +241,17 @@ h2.submission-count {
margin-top: 0;
margin-bottom: 20px;
}
.loader {
border: 5px solid #f3f3f3; /* Light grey */
border-top: 5px solid #0C99D5; /* Blue */
border-radius: 50%;
width: 5vh;
height: 5vh;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

Просмотреть файл

@ -5,6 +5,13 @@ $(document).ready(function () {
// Edit Add-on
$('#edit-addon').exists(initEditAddon);
// Poll for suppressed email removal updates.
$('#verification_pending').exists(function () {
setTimeout(function () {
window.location.reload();
}, 10_000);
});
//Ownership
$('#authors_confirmed').exists(function () {
initAuthorFields();