From 54c1583363b8f8dd5ade2c9887e9bbed03d2c3dc Mon Sep 17 00:00:00 2001 From: Mike Fiedler Date: Fri, 2 Aug 2024 09:02:19 -0400 Subject: [PATCH] chore: enforce 2fa for legacy uploaders (#16365) --- tests/common/db/accounts.py | 11 ++ tests/unit/email/test_init.py | 119 ++++++--------- tests/unit/forklift/test_legacy.py | 30 ++-- warehouse/email/__init__.py | 9 -- warehouse/forklift/legacy.py | 23 +-- warehouse/locale/messages.pot | 122 ---------------- .../two-factor-not-yet-enabled/body.html | 135 ------------------ .../email/two-factor-not-yet-enabled/body.txt | 113 --------------- .../two-factor-not-yet-enabled/subject.txt | 17 --- 9 files changed, 86 insertions(+), 493 deletions(-) delete mode 100644 warehouse/templates/email/two-factor-not-yet-enabled/body.html delete mode 100644 warehouse/templates/email/two-factor-not-yet-enabled/body.txt delete mode 100644 warehouse/templates/email/two-factor-not-yet-enabled/subject.txt diff --git a/tests/common/db/accounts.py b/tests/common/db/accounts.py index 2c9c5021d97d..d523d9a1796f 100644 --- a/tests/common/db/accounts.py +++ b/tests/common/db/accounts.py @@ -23,6 +23,17 @@ class UserFactory(WarehouseFactory): class Meta: model = User + class Params: + # Shortcut to create a user with a verified primary email + with_verified_primary_email = factory.Trait( + email=factory.RelatedFactory( + "tests.common.db.accounts.EmailFactory", + factory_related_name="user", + primary=True, + verified=True, + ) + ) + username = factory.Faker("pystr", max_chars=12) name = factory.Faker("word") password = "!" diff --git a/tests/unit/email/test_init.py b/tests/unit/email/test_init.py index 44a694dbff4f..92b2e5378318 100644 --- a/tests/unit/email/test_init.py +++ b/tests/unit/email/test_init.py @@ -202,7 +202,7 @@ def test_doesnt_send_with_unverified(self, primary_email, address): assert request.task.calls == [] assert task.delay.calls == [] - def test_doesnt_send_within_reset_window(self, pyramid_request, pyramid_services): + def test_doesnt_send_within_repeat_window(self, pyramid_request, pyramid_services): email_service = pretend.stub( last_sent=pretend.call_recorder( lambda to, subject: datetime.datetime.now() @@ -226,6 +226,49 @@ def test_doesnt_send_within_reset_window(self, pyramid_request, pyramid_services assert pyramid_request.task.calls == [] assert task.delay.calls == [] + def test_sends_when_outside_repeat_window(self, db_request, pyramid_services): + email_service = pretend.stub( + last_sent=pretend.call_recorder( + lambda to, subject: datetime.datetime.now() + - datetime.timedelta(seconds=69) + ) + ) + pyramid_services.register_service(email_service, IEmailSender, None, name="") + + task = pretend.stub(delay=pretend.call_recorder(lambda *a, **kw: None)) + db_request.task = pretend.call_recorder(lambda x: task) + + user = UserFactory.create(with_verified_primary_email=True) + + msg = EmailMessage(subject="My Subject", body_text="My Body") + + email._send_email_to_user( + db_request, user, msg, repeat_window=datetime.timedelta(seconds=42) + ) + + assert db_request.task.calls == [pretend.call(email.send_email)] + assert task.delay.calls == [ + pretend.call( + f"{user.name} <{user.primary_email.email}>", + { + "sender": None, + "subject": "My Subject", + "body_text": "My Body", + "body_html": None, + }, + { + "tag": "account:email:sent", + "user_id": user.id, + "additional": { + "from_": None, + "to": user.email, + "subject": "My Subject", + "redact_ip": False, + }, + }, + ) + ] + @pytest.mark.parametrize( ("username", "primary_email", "address", "expected"), [ @@ -1557,80 +1600,6 @@ def test_password_reset_by_admin_email( ] -class Test2FAonUploadEmail: - def test_send_two_factor_not_yet_enabled_email( - self, pyramid_request, pyramid_config, monkeypatch - ): - stub_user = pretend.stub( - id="id", - username="username", - name="", - email="email@example.com", - primary_email=pretend.stub(email="email@example.com", verified=True), - has_2fa=False, - ) - subject_renderer = pyramid_config.testing_add_renderer( - "email/two-factor-not-yet-enabled/subject.txt" - ) - subject_renderer.string_response = "Email Subject" - body_renderer = pyramid_config.testing_add_renderer( - "email/two-factor-not-yet-enabled/body.txt" - ) - body_renderer.string_response = "Email Body" - html_renderer = pyramid_config.testing_add_renderer( - "email/two-factor-not-yet-enabled/body.html" - ) - html_renderer.string_response = "Email HTML Body" - - send_email = pretend.stub( - delay=pretend.call_recorder(lambda *args, **kwargs: None) - ) - pyramid_request.task = pretend.call_recorder(lambda *args, **kwargs: send_email) - monkeypatch.setattr(email, "send_email", send_email) - - pyramid_request.db = pretend.stub( - query=lambda a: pretend.stub( - filter=lambda *a: pretend.stub( - one=lambda: pretend.stub(user_id=stub_user.id) - ) - ), - ) - pyramid_request.user = stub_user - pyramid_request.registry.settings = {"mail.sender": "noreply@example.com"} - - result = email.send_two_factor_not_yet_enabled_email( - pyramid_request, - stub_user, - ) - - assert result == {"username": stub_user.username} - assert pyramid_request.task.calls == [pretend.call(send_email)] - assert send_email.delay.calls == [ - pretend.call( - f"{stub_user.username} <{stub_user.email}>", - { - "sender": None, - "subject": "Email Subject", - "body_text": "Email Body", - "body_html": ( - "\n\n" - "

Email HTML Body

\n\n" - ), - }, - { - "tag": "account:email:sent", - "user_id": stub_user.id, - "additional": { - "from_": "noreply@example.com", - "to": stub_user.email, - "subject": "Email Subject", - "redact_ip": False, - }, - }, - ) - ] - - class TestAccountDeletionEmail: def test_account_deletion_email( self, pyramid_request, pyramid_config, metrics, monkeypatch diff --git a/tests/unit/forklift/test_legacy.py b/tests/unit/forklift/test_legacy.py index a69d8ae0d618..871d919caa27 100644 --- a/tests/unit/forklift/test_legacy.py +++ b/tests/unit/forklift/test_legacy.py @@ -432,8 +432,9 @@ def test_fails_disallow_new_upload(self, pyramid_config, pyramid_request): def test_fails_invalid_version(self, pyramid_config, pyramid_request, version): pyramid_request.POST["protocol_version"] = version pyramid_request.flags = pretend.stub(enabled=lambda *a: False) + pyramid_request.help_url = pretend.call_recorder(lambda **kw: "/the/help/url/") - user = pretend.stub(primary_email=pretend.stub(verified=True)) + user = UserFactory.create(with_verified_primary_email=True) pyramid_config.testing_securitypolicy(identity=user) pyramid_request.user = user @@ -4248,11 +4249,10 @@ def test_upload_succeeds_with_gpg_signature_field( assert resp.status_code == 200 - def test_upload_succeeds_without_two_factor( + def test_upload_fails_without_two_factor( self, pyramid_config, db_request, metrics, project_service, monkeypatch ): - user = UserFactory.create(totp_secret=None) - EmailFactory.create(user=user) + user = UserFactory.create(totp_secret=None, with_verified_primary_email=True) pyramid_config.testing_securitypolicy(identity=user) db_request.user = user @@ -4278,19 +4278,23 @@ def test_upload_succeeds_without_two_factor( IProjectService: project_service, }.get(svc) db_request.user_agent = "warehouse-tests/6.6.6" + db_request.help_url = pretend.call_recorder(lambda **kw: "/the/help/url/") - send_email = pretend.call_recorder(lambda *a, **kw: None) - monkeypatch.setattr(legacy, "send_two_factor_not_yet_enabled_email", send_email) + with pytest.raises(HTTPBadRequest) as excinfo: + legacy.file_upload(db_request) - resp = legacy.file_upload(db_request) + resp = excinfo.value - assert resp.status_code == 200 - assert resp.body == ( - b"Two factor authentication is not enabled for your account." + assert resp.status_code == 400 + assert resp.status == ( + ( + "400 User {!r} does not have two-factor authentication enabled. " + "Please enable two-factor authentication before attempting to " + "upload to PyPI. See /the/help/url/ for more information." + ).format(user.username) ) - - assert send_email.calls == [ - pretend.call(db_request, user), + assert db_request.help_url.calls == [ + pretend.call(_anchor="two-factor-authentication") ] @pytest.mark.parametrize( diff --git a/warehouse/email/__init__.py b/warehouse/email/__init__.py index d931886a2aed..1f21b46ee6be 100644 --- a/warehouse/email/__init__.py +++ b/warehouse/email/__init__.py @@ -365,15 +365,6 @@ def send_account_recovery_initiated_email( } -@_email( - "two-factor-not-yet-enabled", - allow_unverified=True, - repeat_window=datetime.timedelta(days=14), -) -def send_two_factor_not_yet_enabled_email(request, user): - return {"username": user.username} - - @_email("account-deleted") def send_account_deletion_email(request, user): return {"username": user.username} diff --git a/warehouse/forklift/legacy.py b/warehouse/forklift/legacy.py index d274354fdaa9..d50f5fc8b3df 100644 --- a/warehouse/forklift/legacy.py +++ b/warehouse/forklift/legacy.py @@ -54,10 +54,7 @@ from warehouse.authnz import Permissions from warehouse.classifiers.models import Classifier from warehouse.constants import MAX_FILESIZE, MAX_PROJECT_SIZE, ONE_GIB, ONE_MIB -from warehouse.email import ( - send_api_token_used_in_trusted_publisher_project_email, - send_two_factor_not_yet_enabled_email, -) +from warehouse.email import send_api_token_used_in_trusted_publisher_project_email from warehouse.events.tags import EventTag from warehouse.forklift import metadata from warehouse.forklift.forms import UploadForm, _filetype_extension_mapping @@ -519,6 +516,19 @@ def file_upload(request): project_help=request.help_url(_anchor="verified-email"), ), ) from None + # Ensure user has enabled 2FA before they can upload a file. + if not request.user.has_two_factor: + raise _exc_with_message( + HTTPBadRequest, + ( + "User {!r} does not have two-factor authentication enabled. " + "Please enable two-factor authentication before attempting to " + "upload to PyPI. See {project_help} for more information." + ).format( + request.user.username, + project_help=request.help_url(_anchor="two-factor-authentication"), + ), + ) from None # Do some cleanup of the various form fields for key in list(request.POST): @@ -1257,11 +1267,6 @@ def file_upload(request): }, ) - # Check if the user has any 2FA methods enabled, and if not, email them. - if request.user and not request.user.has_two_factor: - warnings.append("Two factor authentication is not enabled for your account.") - send_two_factor_not_yet_enabled_email(request, request.user) - request.db.flush() # flush db now so server default values are populated for celery # Push updates to BigQuery diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index da6fe34dc9e6..b4f1740077ce 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -2501,128 +2501,6 @@ msgid "" "method to your PyPI account %(username)s." msgstr "" -#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:17 -#, python-format -msgid "Hi %(username)s!" -msgstr "" - -#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:19 -#, python-format -msgid "" -"Earlier this year, we announced that PyPI " -"would require all users to enable a form of two-factor authentication on " -"their accounts by the end of 2023." -msgstr "" - -#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:26 -msgid "" -"Keeping your PyPI account secure is important to all of us. We encourage " -"you to enable two-factor authentication on your PyPI account as soon as " -"possible." -msgstr "" - -#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:34 -msgid "What forms of 2FA can I use?" -msgstr "" - -#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:37 -msgid "We currently offer two main forms of 2FA for your account:" -msgstr "" - -#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:42 -#, python-format -msgid "" -"Security device including modern browsers " -"(preferred) (e.g. Yubikey, Google Titan)" -msgstr "" - -#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:48 -#, python-format -msgid "Authentication app (e.g. Google Authenticator)" -msgstr "" - -#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:54 -#, python-format -msgid "" -"Once one of these secure forms is enabled on your account, you will also " -"need to use either Trusted " -"Publishers (preferred) or API tokens to" -" upload to PyPI." -msgstr "" - -#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:63 -msgid "What do I do if I lose my 2FA device?" -msgstr "" - -#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:66 -msgid "" -"As part of 2FA enrollment, you will receive one-time use recovery codes. " -"One of them must be used to confirm receipt before 2FA is fully active. " -"Keep these recovery codes safe - they are equivalent to " -"your 2FA device. Should you lose access to your 2FA device, use a " -"recovery code to log in and swap your 2FA to a new device." -msgstr "" - -#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:77 -#, python-format -msgid "Read more aboutrecovery codes." -msgstr "" - -#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:83 -msgid "Why is PyPI requiring 2FA?" -msgstr "" - -#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:86 -msgid "" -"Keeping all users of PyPI is a shared responsibility we take seriously. " -"Strong passwords combined with 2FA is a recognized secure practice for " -"over a decade." -msgstr "" - -#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:93 -msgid "" -"We are requiring 2FA to protect your account and the packages you upload," -" and to protect PyPI itself from malicious actors. The most damaging " -"attacks are account takeover and malicious package upload." -msgstr "" - -#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:101 -#, python-format -msgid "" -"To see this and other security events for your account, visit your account security history." -msgstr "" - -#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:107 -#, python-format -msgid "Read more on this blog post." -msgstr "" - -#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:112 -#, python-format -msgid "" -"If you run into problems, read the FAQ " -"page. If the solutions there are unable to resolve the issue, contact" -" us via support@pypi.org." -msgstr "" - -#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:119 -msgid "Thanks," -msgstr "" - -#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:121 -msgid "The PyPI Admins" -msgstr "" - -#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:127 -#, python-format -msgid "" -"You're receiving this email because you have not yet enabled two-factor " -"authentication on your PyPI account. If you have enabled 2FA and believe " -"this message is an error, please let us know via support@pypi.org." -msgstr "" - #: warehouse/templates/email/two-factor-removed/body.html:18 #, python-format msgid "" diff --git a/warehouse/templates/email/two-factor-not-yet-enabled/body.html b/warehouse/templates/email/two-factor-not-yet-enabled/body.html deleted file mode 100644 index 8c21218e8294..000000000000 --- a/warehouse/templates/email/two-factor-not-yet-enabled/body.html +++ /dev/null @@ -1,135 +0,0 @@ -{# - # Licensed under the Apache License, Version 2.0 (the "License"); - # you may not use this file except in compliance with the License. - # You may obtain a copy of the License at - # - # http://www.apache.org/licenses/LICENSE-2.0 - # - # Unless required by applicable law or agreed to in writing, software - # distributed under the License is distributed on an "AS IS" BASIS, - # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - # See the License for the specific language governing permissions and - # limitations under the License. --#} -{% extends "email/_base/body.html" %} - -{% block content %} -

{% trans username=username %}Hi {{ username }}!{% endtrans %}

-

- {% trans blogpost="https://blog.pypi.org/posts/2023-05-25-securing-pypi-with-2fa/" %} - Earlier this year, we announced - that PyPI would require all users to enable a form of two-factor - authentication on their accounts by the end of 2023. - {% endtrans %} -

-

- {% trans %} - Keeping your PyPI account secure is important to all of us. - We encourage you to enable two-factor authentication - on your PyPI account as soon as possible. - {% endtrans %} -

- -

- {% trans %}What forms of 2FA can I use?{% endtrans %} -

-

- {% trans %}We currently offer two main forms of 2FA for your - account:{% endtrans %} -

- -

- {% trans trusted_publishers="https://docs.pypi.org/trusted-publishers/", api_token="https://pypi.org/help/#apitoken" %} - Once one of these secure forms is enabled on your account, - you will also need to use either - Trusted Publishers (preferred) or - API tokens to upload to PyPI. - {% endtrans %} -

- -

- {% trans %}What do I do if I lose my 2FA device?{% endtrans %} -

-

- {% trans %} - As part of 2FA enrollment, you will receive one-time use recovery codes. - One of them must be used to confirm receipt before 2FA is fully active. - Keep these recovery codes safe - they are equivalent to - your 2FA device. - Should you lose access to your 2FA device, use a recovery code to log in - and - swap your 2FA to a new device. - {% endtrans %} -

-

- {% trans recovery_codes="https://pypi.org/help/#recoverycodes" %} - Read more aboutrecovery codes. - {% endtrans %} -

- -

- {% trans %}Why is PyPI requiring 2FA?{% endtrans %} -

-

- {% trans %} - Keeping all users of PyPI is a shared responsibility we take seriously. - Strong passwords combined with 2FA is a recognized secure practice for - over a decade. - {% endtrans %} -

-

- {% trans %} - We are requiring 2FA to protect your account and the packages you upload, - and to protect PyPI itself from malicious actors. - The most damaging attacks are account takeover and malicious package - upload. - {% endtrans %} -

-

- {% trans account_events="https://pypi.org/manage/account/#account-events" %} - To see this and other security events for your account, - visit your account security history. - {% endtrans %} -

-

- {% trans blog_post="https://blog.pypi.org/posts/2023-05-25-securing-pypi-with-2fa/#why-now" %} - Read more on this blog post. - {% endtrans %} -

-

- {% trans help_page="https://pypi.org/help/", support_email="mailto:support@pypi.org" %} - If you run into problems, read the FAQ page. - If the solutions there are unable to resolve the issue, contact us via - support@pypi.org. - {% endtrans %} -

-

- {% trans %}Thanks,{% endtrans %} -
- {% trans %}The PyPI Admins{% endtrans %} -

-{% endblock %} - -{% block reason %} -

- {% trans support_mail="mailto:support@pypi.org" %} - You're receiving this email because you have not yet enabled two-factor - authentication on your PyPI account. - If you have enabled 2FA and believe this message is an error, - please let us know via - support@pypi.org. - {% endtrans %} -

-{% endblock %} diff --git a/warehouse/templates/email/two-factor-not-yet-enabled/body.txt b/warehouse/templates/email/two-factor-not-yet-enabled/body.txt deleted file mode 100644 index 99e262c7eb0f..000000000000 --- a/warehouse/templates/email/two-factor-not-yet-enabled/body.txt +++ /dev/null @@ -1,113 +0,0 @@ -{# - # Licensed under the Apache License, Version 2.0 (the "License"); - # you may not use this file except in compliance with the License. - # You may obtain a copy of the License at - # - # http://www.apache.org/licenses/LICENSE-2.0 - # - # Unless required by applicable law or agreed to in writing, software - # distributed under the License is distributed on an "AS IS" BASIS, - # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - # See the License for the specific language governing permissions and - # limitations under the License. --#} -{% extends "email/_base/body.txt" %} - -{% block content %} -{% trans username=username %}Hi {{ username }}!{% endtrans %} - -{% trans %} -Earlier this year, we announced -that PyPI would require all users to enable a form of two-factor -authentication on their accounts by the end of 2023. -{% endtrans %} -{% trans blogpost="https://blog.pypi.org/posts/2023-05-25-securing-pypi-with-2fa/" %} -Read more: {{ blogpost }} -{% endtrans %} - - -{% trans %} -Keeping your PyPI account secure is important to all of us. -We encourage you to enable two-factor authentication -on your PyPI account as soon as possible. -{% endtrans %} - -* {% trans %}What forms of 2FA can I use?{% endtrans %} - -{% trans %}We currently offer two main forms of 2FA for your account:{% endtrans %} -{% trans utfkey="https://pypi.org/help/#utfkey" %} -- Security device including modern browsers (preferred) (e.g. Yubikey, Google Titan) {{ utfkey }} -{% endtrans %} -{% trans totp="https://pypi.org/help/#totp" %} -- Authentication app (e.g. Google Authenticator) {{ totp }} -{% endtrans %} - -{% trans %} -Once one of these secure forms is enabled on your account, -to upload to PyPI you will also need to use either: -{% endtrans %} -{% trans trusted_publishers="https://docs.pypi.org/trusted-publishers/" %} -- Trusted Publishers (preferred) {{ trusted_publishers }} -{% endtrans %} -{% trans api_token="https://pypi.org/help/#apitoken" %} -- API tokens {{ api_token }} -{% endtrans %} - -* {% trans %}What do I do if I lose my 2FA device?{% endtrans %} - -{% trans %} -As part of 2FA enrollment, you will receive one-time use recovery codes. -One of them must be used to confirm receipt before 2FA is fully active. - -Keep these recovery codes safe - they are equivalent to -your 2FA device. - -Should you lose access to your 2FA device, use a recovery code to log in -and swap your 2FA to a new device. -{% endtrans %} -{% trans recovery_codes="https://pypi.org/help/#recoverycodes" %} -Read more about recovery codes: {{ recovery_codes }} -{% endtrans %} - -* {% trans %}Why is PyPI requiring 2FA?{% endtrans %} - -{% trans %} -Keeping all users of PyPI is a shared responsibility we take seriously. -Strong passwords combined with 2FA is a recognized secure practice for -over a decade. -{% endtrans %} - -{% trans %} -We are requiring 2FA to protect your account and the packages you upload, -and to protect PyPI itself from malicious actors. -The most damaging attacks are account takeover and malicious package -upload. -{% endtrans %} - -{% trans account_events="https://pypi.org/manage/account/#account-events" %} -To see this and other security events for your account, -visit your account security history at: {{ account_events }} -{% endtrans %} -{% trans blog_post="https://blog.pypi.org/posts/2023-05-25-securing-pypi-with-2fa/#why-now" %} -Read more on this blog post: {{ blog_post }} -{% endtrans %} - -{% trans help_page="https://pypi.org/help/" %} -If you run into problems, read the FAQ page: {{ help_page }} -{% endtrans %} -{% trans %} -If the solutions there are unable to resolve the issue, contact us via support@pypi.org -{% endtrans %} - -{% trans %}Thanks,{% endtrans %} -{% trans %}The PyPI Admins{% endtrans %} -{% endblock %} - -{% block reason %} -{% trans %} -You're receiving this email because you have not yet enabled two-factor -authentication on your PyPI account. -If you have enabled 2FA and believe this message is an error, -please let us know via support@pypi.org . -{% endtrans %} -{% endblock %} diff --git a/warehouse/templates/email/two-factor-not-yet-enabled/subject.txt b/warehouse/templates/email/two-factor-not-yet-enabled/subject.txt deleted file mode 100644 index 0528efef1fd3..000000000000 --- a/warehouse/templates/email/two-factor-not-yet-enabled/subject.txt +++ /dev/null @@ -1,17 +0,0 @@ -{# - # Licensed under the Apache License, Version 2.0 (the "License"); - # you may not use this file except in compliance with the License. - # You may obtain a copy of the License at - # - # http://www.apache.org/licenses/LICENSE-2.0 - # - # Unless required by applicable law or agreed to in writing, software - # distributed under the License is distributed on an "AS IS" BASIS, - # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - # See the License for the specific language governing permissions and - # limitations under the License. --#} - -{% extends "email/_base/subject.txt" %} - -{% block subject %}{% trans %}Your PyPI account will soon require 2FA{% endtrans %}{% endblock %}