Skip to content

Commit 90cf722

Browse files
authored
feat: add email for 2fa not yet enabled on upload (#14444)
1 parent d9d0b46 commit 90cf722

File tree

13 files changed

+527
-6
lines changed

13 files changed

+527
-6
lines changed

tests/common/db/accounts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class Meta:
3838
last_login = factory.Faker(
3939
"date_time_between_dates", datetime_start=datetime.datetime(2011, 1, 1)
4040
)
41+
totp_secret = factory.Faker("binary", length=20)
4142

4243

4344
class UserEventFactory(WarehouseFactory):

tests/unit/accounts/test_models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ def test_principals(
235235
],
236236
)
237237
def test_has_single_2fa(self, db_session, has_totp, count_webauthn, expected):
238-
user = DBUserFactory.create()
238+
user = DBUserFactory.create(totp_secret=None)
239239
if has_totp:
240240
user.totp_secret = b"secret"
241241
for i in range(count_webauthn):

tests/unit/accounts/test_services.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -536,14 +536,14 @@ def test_updating_password_undisables(self, user_service):
536536
assert user_service.is_disabled(user.id) == (False, None)
537537

538538
def test_has_two_factor(self, user_service):
539-
user = UserFactory.create()
539+
user = UserFactory.create(totp_secret=None)
540540
assert not user_service.has_two_factor(user.id)
541541

542542
user_service.update_user(user.id, totp_secret=b"foobar")
543543
assert user_service.has_two_factor(user.id)
544544

545545
def test_has_totp(self, user_service):
546-
user = UserFactory.create()
546+
user = UserFactory.create(totp_secret=None)
547547
assert not user_service.has_totp(user.id)
548548
user_service.update_user(user.id, totp_secret=b"foobar")
549549
assert user_service.has_totp(user.id)
@@ -642,7 +642,7 @@ def test_check_totp_value_user_rate_limited(self, user_service, metrics):
642642
]
643643

644644
def test_check_totp_value_invalid_secret(self, user_service):
645-
user = UserFactory.create()
645+
user = UserFactory.create(totp_secret=None)
646646
limiter = pretend.stub(
647647
hit=pretend.call_recorder(lambda *a, **kw: None), test=lambda *a, **kw: True
648648
)

tests/unit/accounts/test_views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2255,7 +2255,7 @@ class TestVerifyEmail:
22552255
def test_verify_email(
22562256
self, db_request, user_service, token_service, is_primary, confirm_message
22572257
):
2258-
user = UserFactory(is_active=False)
2258+
user = UserFactory(is_active=False, totp_secret=None)
22592259
email = EmailFactory(user=user, verified=False, primary=is_primary)
22602260
db_request.user = user
22612261
db_request.GET.update({"token": "RANDOM_KEY"})

tests/unit/email/test_init.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1537,6 +1537,79 @@ def test_gpg_signature_uploaded_email(
15371537
]
15381538

15391539

1540+
class Test2FAonUploadEmail:
1541+
def test_send_two_factor_not_yet_enabled_email(
1542+
self, pyramid_request, pyramid_config, monkeypatch
1543+
):
1544+
stub_user = pretend.stub(
1545+
id="id",
1546+
username="username",
1547+
name="",
1548+
1549+
primary_email=pretend.stub(email="[email protected]", verified=True),
1550+
has_2fa=False,
1551+
)
1552+
subject_renderer = pyramid_config.testing_add_renderer(
1553+
"email/two-factor-not-yet-enabled/subject.txt"
1554+
)
1555+
subject_renderer.string_response = "Email Subject"
1556+
body_renderer = pyramid_config.testing_add_renderer(
1557+
"email/two-factor-not-yet-enabled/body.txt"
1558+
)
1559+
body_renderer.string_response = "Email Body"
1560+
html_renderer = pyramid_config.testing_add_renderer(
1561+
"email/two-factor-not-yet-enabled/body.html"
1562+
)
1563+
html_renderer.string_response = "Email HTML Body"
1564+
1565+
send_email = pretend.stub(
1566+
delay=pretend.call_recorder(lambda *args, **kwargs: None)
1567+
)
1568+
pyramid_request.task = pretend.call_recorder(lambda *args, **kwargs: send_email)
1569+
monkeypatch.setattr(email, "send_email", send_email)
1570+
1571+
pyramid_request.db = pretend.stub(
1572+
query=lambda a: pretend.stub(
1573+
filter=lambda *a: pretend.stub(
1574+
one=lambda: pretend.stub(user_id=stub_user.id)
1575+
)
1576+
),
1577+
)
1578+
pyramid_request.user = stub_user
1579+
pyramid_request.registry.settings = {"mail.sender": "[email protected]"}
1580+
1581+
result = email.send_two_factor_not_yet_enabled_email(
1582+
pyramid_request,
1583+
stub_user,
1584+
)
1585+
1586+
assert result == {"username": stub_user.username}
1587+
assert pyramid_request.task.calls == [pretend.call(send_email)]
1588+
assert send_email.delay.calls == [
1589+
pretend.call(
1590+
f"{stub_user.username} <{stub_user.email}>",
1591+
{
1592+
"subject": "Email Subject",
1593+
"body_text": "Email Body",
1594+
"body_html": (
1595+
"<html>\n<head></head>\n"
1596+
"<body><p>Email HTML Body</p></body>\n</html>\n"
1597+
),
1598+
},
1599+
{
1600+
"tag": "account:email:sent",
1601+
"user_id": stub_user.id,
1602+
"additional": {
1603+
"from_": "[email protected]",
1604+
"to": stub_user.email,
1605+
"subject": "Email Subject",
1606+
"redact_ip": False,
1607+
},
1608+
},
1609+
)
1610+
]
1611+
1612+
15401613
class TestAccountDeletionEmail:
15411614
def test_account_deletion_email(
15421615
self, pyramid_request, pyramid_config, metrics, monkeypatch

tests/unit/forklift/test_legacy.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3798,6 +3798,51 @@ def test_upload_succeeds_with_signature(
37983798
pretend.call(db_request, user, project_name="example"),
37993799
]
38003800

3801+
def test_upload_succeeds_without_two_factor(
3802+
self, pyramid_config, db_request, metrics, project_service, monkeypatch
3803+
):
3804+
user = UserFactory.create(totp_secret=None)
3805+
EmailFactory.create(user=user)
3806+
3807+
pyramid_config.testing_securitypolicy(identity=user)
3808+
db_request.user = user
3809+
db_request.POST = MultiDict(
3810+
{
3811+
"metadata_version": "1.2",
3812+
"name": "example",
3813+
"version": "1.0",
3814+
"filetype": "sdist",
3815+
"md5_digest": _TAR_GZ_PKG_MD5,
3816+
"content": pretend.stub(
3817+
filename="example-1.0.tar.gz",
3818+
file=io.BytesIO(_TAR_GZ_PKG_TESTDATA),
3819+
type="application/tar",
3820+
),
3821+
}
3822+
)
3823+
3824+
storage_service = pretend.stub(store=lambda path, filepath, meta: None)
3825+
db_request.find_service = lambda svc, name=None, context=None: {
3826+
IFileStorage: storage_service,
3827+
IMetricsService: metrics,
3828+
IProjectService: project_service,
3829+
}.get(svc)
3830+
db_request.user_agent = "warehouse-tests/6.6.6"
3831+
3832+
send_email = pretend.call_recorder(lambda *a, **kw: None)
3833+
monkeypatch.setattr(legacy, "send_two_factor_not_yet_enabled_email", send_email)
3834+
3835+
resp = legacy.file_upload(db_request)
3836+
3837+
assert resp.status_code == 200
3838+
assert resp.body == (
3839+
b"Two factor authentication is not enabled for your account."
3840+
)
3841+
3842+
assert send_email.calls == [
3843+
pretend.call(db_request, user),
3844+
]
3845+
38013846
@pytest.mark.parametrize(
38023847
("emails_verified", "expected_success"),
38033848
[

tests/unit/packaging/test_tasks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -941,7 +941,7 @@ def test_compute_2fa_metrics(db_request, monkeypatch):
941941
)
942942

943943
# A critical maintainer with two WebAuthn methods enabled
944-
third_critical_project_maintainer = UserFactory.create()
944+
third_critical_project_maintainer = UserFactory.create(totp_secret=None)
945945
RoleFactory.create(user=third_critical_project_maintainer, project=critical_project)
946946
webauthn = WebAuthn(
947947
user_id=third_critical_project_maintainer.id,

warehouse/email/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,15 @@ def send_basic_auth_with_two_factor_email(request, user, *, project_name):
342342
return {"project_name": project_name}
343343

344344

345+
@_email(
346+
"two-factor-not-yet-enabled",
347+
allow_unverified=True,
348+
repeat_window=datetime.timedelta(days=14),
349+
)
350+
def send_two_factor_not_yet_enabled_email(request, user):
351+
return {"username": user.username}
352+
353+
345354
@_email("gpg-signature-uploaded", repeat_window=datetime.timedelta(days=1))
346355
def send_gpg_signature_uploaded_email(request, user, *, project_name):
347356
return {"project_name": project_name}

warehouse/forklift/legacy.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
from warehouse.email import (
5151
send_basic_auth_with_two_factor_email,
5252
send_gpg_signature_uploaded_email,
53+
send_two_factor_not_yet_enabled_email,
5354
)
5455
from warehouse.errors import BasicAuthTwoFactorEnabled
5556
from warehouse.events.tags import EventTag
@@ -1493,6 +1494,11 @@ def file_upload(request):
14931494
},
14941495
)
14951496

1497+
# Check if the user has any 2FA methods enabled, and if not, email them.
1498+
if request.user and not request.user.has_two_factor:
1499+
warnings.append("Two factor authentication is not enabled for your account.")
1500+
send_two_factor_not_yet_enabled_email(request, request.user)
1501+
14961502
request.db.flush() # flush db now so server default values are populated for celery
14971503

14981504
# Push updates to BigQuery

warehouse/locale/messages.pot

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2504,6 +2504,128 @@ msgid ""
25042504
"method to your PyPI account <strong>%(username)s</strong>."
25052505
msgstr ""
25062506

2507+
#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:17
2508+
#, python-format
2509+
msgid "Hi %(username)s!"
2510+
msgstr ""
2511+
2512+
#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:19
2513+
#, python-format
2514+
msgid ""
2515+
"Earlier this year, <a href=\"%(blogpost)s\">we announced</a> that PyPI "
2516+
"would require all users to enable a form of two-factor authentication on "
2517+
"their accounts by the end of 2023."
2518+
msgstr ""
2519+
2520+
#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:26
2521+
msgid ""
2522+
"Keeping your PyPI account secure is important to all of us. We encourage "
2523+
"you to enable two-factor authentication on your PyPI account as soon as "
2524+
"possible."
2525+
msgstr ""
2526+
2527+
#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:34
2528+
msgid "What forms of 2FA can I use?"
2529+
msgstr ""
2530+
2531+
#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:37
2532+
msgid "We currently offer two main forms of 2FA for your account:"
2533+
msgstr ""
2534+
2535+
#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:42
2536+
#, python-format
2537+
msgid ""
2538+
"<a href=\"%(utfkey)s\">Security device</a> including modern browsers "
2539+
"(preferred) (e.g. Yubikey, Google Titan)"
2540+
msgstr ""
2541+
2542+
#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:48
2543+
#, python-format
2544+
msgid "<a href=\"%(totp)s\">Authentication app</a> (e.g. Google Authenticator)"
2545+
msgstr ""
2546+
2547+
#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:54
2548+
#, python-format
2549+
msgid ""
2550+
"Once one of these secure forms is enabled on your account, you will also "
2551+
"need to use either <a href=\"%(trusted_publishers)s\">Trusted "
2552+
"Publishers</a> (preferred) or <a href=\"%(api_token)s\">API tokens</a> to"
2553+
" upload to PyPI."
2554+
msgstr ""
2555+
2556+
#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:63
2557+
msgid "What do I do if I lose my 2FA device?"
2558+
msgstr ""
2559+
2560+
#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:66
2561+
msgid ""
2562+
"As part of 2FA enrollment, you will receive one-time use recovery codes. "
2563+
"One of them must be used to confirm receipt before 2FA is fully active. "
2564+
"<strong>Keep these recovery codes safe</strong> - they are equivalent to "
2565+
"your 2FA device. Should you lose access to your 2FA device, use a "
2566+
"recovery code to log in and swap your 2FA to a new device."
2567+
msgstr ""
2568+
2569+
#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:77
2570+
#, python-format
2571+
msgid "Read more about<a href=\"%(recovery_codes)s\">recovery codes</a>."
2572+
msgstr ""
2573+
2574+
#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:83
2575+
msgid "Why is PyPI requiring 2FA?"
2576+
msgstr ""
2577+
2578+
#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:86
2579+
msgid ""
2580+
"Keeping all users of PyPI is a shared responsibility we take seriously. "
2581+
"Strong passwords combined with 2FA is a recognized secure practice for "
2582+
"over a decade."
2583+
msgstr ""
2584+
2585+
#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:93
2586+
msgid ""
2587+
"We are requiring 2FA to protect your account and the packages you upload,"
2588+
" and to protect PyPI itself from malicious actors. The most damaging "
2589+
"attacks are account takeover and malicious package upload."
2590+
msgstr ""
2591+
2592+
#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:101
2593+
#, python-format
2594+
msgid ""
2595+
"To see this and other security events for your account, visit your <a "
2596+
"href=\"%(account_events)s\">account security history</a>."
2597+
msgstr ""
2598+
2599+
#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:107
2600+
#, python-format
2601+
msgid "Read more on <a href=\"%(blog_post)s\">this blog post.</a>"
2602+
msgstr ""
2603+
2604+
#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:112
2605+
#, python-format
2606+
msgid ""
2607+
"If you run into problems, read the <a href=\"%(help_page)s\">FAQ "
2608+
"page</a>. If the solutions there are unable to resolve the issue, contact"
2609+
" us via <a href=\"%(support_email)s\">[email protected]</a>."
2610+
msgstr ""
2611+
2612+
#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:119
2613+
msgid "Thanks,"
2614+
msgstr ""
2615+
2616+
#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:121
2617+
msgid "The PyPI Admins"
2618+
msgstr ""
2619+
2620+
#: warehouse/templates/email/two-factor-not-yet-enabled/body.html:127
2621+
#, python-format
2622+
msgid ""
2623+
"You're receiving this email because you have not yet enabled two-factor "
2624+
"authentication on your PyPI account. If you have enabled 2FA and believe "
2625+
"this message is an error, please let us know via <a "
2626+
"href=\"%(support_mail)s\">[email protected]</a>."
2627+
msgstr ""
2628+
25072629
#: warehouse/templates/email/two-factor-removed/body.html:18
25082630
#, python-format
25092631
msgid ""

0 commit comments

Comments
 (0)