Skip to content

Commit 63274ad

Browse files
authored
chore: disallow password resets to unverified email (#18088)
1 parent c92eaf2 commit 63274ad

File tree

10 files changed

+335
-116
lines changed

10 files changed

+335
-116
lines changed

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ def pyramid_request(pyramid_services, jinja):
228228
dummy_request.log = pretend.stub(
229229
bind=pretend.call_recorder(lambda *args, **kwargs: dummy_request.log),
230230
info=pretend.call_recorder(lambda *args, **kwargs: None),
231+
warning=pretend.call_recorder(lambda *args, **kwargs: None),
231232
error=pretend.call_recorder(lambda *args, **kwargs: None),
232233
)
233234

tests/unit/accounts/test_views.py

Lines changed: 86 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1845,26 +1845,30 @@ def test_get(self, pyramid_request, user_service):
18451845
]
18461846

18471847
def test_request_password_reset(
1848-
self, monkeypatch, pyramid_request, pyramid_config, user_service, token_service
1848+
self,
1849+
monkeypatch,
1850+
pyramid_request,
1851+
user_service,
1852+
token_service,
1853+
mocker,
18491854
):
1850-
stub_user = pretend.stub(
1851-
id=pretend.stub(),
1852-
username=pretend.stub(),
1853-
emails=[pretend.stub(email="[email protected]")],
1854-
can_reset_password=True,
1855-
record_event=pretend.call_recorder(lambda *a, **kw: None),
1855+
user = UserFactory.create(with_verified_primary_email=True)
1856+
mock_record_event = mocker.patch(
1857+
"warehouse.accounts.models.HasEvents.record_event",
1858+
autospec=True,
1859+
return_value=True,
18561860
)
18571861
pyramid_request.method = "POST"
18581862
token_service.dumps = pretend.call_recorder(lambda a: "TOK")
1859-
user_service.get_user_by_username = pretend.call_recorder(lambda a: stub_user)
1863+
user_service.get_user_by_username = pretend.call_recorder(lambda a: user)
18601864
pyramid_request.find_service = pretend.call_recorder(
18611865
lambda interface, **kw: {
18621866
IUserService: user_service,
18631867
ITokenService: token_service,
18641868
}[interface]
18651869
)
18661870
form_obj = pretend.stub(
1867-
username_or_email=pretend.stub(data=stub_user.username),
1871+
username_or_email=pretend.stub(data=user.username),
18681872
validate=pretend.call_recorder(lambda: True),
18691873
)
18701874
form_class = pretend.call_recorder(lambda d, user_service: form_obj)
@@ -1879,9 +1883,7 @@ def test_request_password_reset(
18791883
result = views.request_password_reset(pyramid_request, _form_class=form_class)
18801884

18811885
assert result == {"n_hours": n_hours}
1882-
assert user_service.get_user_by_username.calls == [
1883-
pretend.call(stub_user.username)
1884-
]
1886+
assert user_service.get_user_by_username.calls == [pretend.call(user.username)]
18851887
assert pyramid_request.find_service.calls == [
18861888
pretend.call(IUserService, context=None),
18871889
pretend.call(ITokenService, name="password"),
@@ -1891,22 +1893,21 @@ def test_request_password_reset(
18911893
pretend.call(pyramid_request.POST, user_service=user_service)
18921894
]
18931895
assert send_password_reset_email.calls == [
1894-
pretend.call(pyramid_request, (stub_user, None))
1895-
]
1896-
assert stub_user.record_event.calls == [
1897-
pretend.call(
1898-
tag=EventTag.Account.PasswordResetRequest,
1899-
request=pyramid_request,
1900-
)
1896+
pretend.call(pyramid_request, (user, user.primary_email))
19011897
]
1898+
mock_record_event.assert_called_once_with(
1899+
user,
1900+
tag=EventTag.Account.PasswordResetRequest,
1901+
request=pyramid_request,
1902+
)
19021903

19031904
def test_request_password_reset_with_email(
19041905
self, monkeypatch, pyramid_request, pyramid_config, user_service, token_service
19051906
):
19061907
stub_user = pretend.stub(
19071908
id=uuid.uuid4(),
19081909
1909-
emails=[pretend.stub(email="[email protected]")],
1910+
emails=[pretend.stub(email="[email protected]", verified=True)],
19101911
can_reset_password=True,
19111912
record_event=pretend.call_recorder(lambda *a, **kw: None),
19121913
)
@@ -1977,8 +1978,8 @@ def test_request_password_reset_with_non_primary_email(
19771978
id=uuid.uuid4(),
19781979
19791980
emails=[
1980-
pretend.stub(email="[email protected]"),
1981-
pretend.stub(email="[email protected]"),
1981+
pretend.stub(email="[email protected]", verified=True),
1982+
pretend.stub(email="[email protected]", verified=True),
19821983
],
19831984
can_reset_password=True,
19841985
record_event=pretend.call_recorder(lambda *a, **kw: None),
@@ -2055,7 +2056,7 @@ def test_too_many_password_reset_requests(
20552056
stub_user = pretend.stub(
20562057
id=uuid.uuid4(),
20572058
2058-
emails=[pretend.stub(email="[email protected]")],
2059+
emails=[pretend.stub(email="[email protected]", verified=True)],
20592060
can_reset_password=True,
20602061
record_event=pretend.call_recorder(lambda *a, **kw: None),
20612062
)
@@ -2100,26 +2101,26 @@ def test_too_many_password_reset_requests(
21002101
pretend.call(stub_user.id)
21012102
]
21022103

2103-
def test_password_reset_prohibited(
2104-
self, monkeypatch, pyramid_request, pyramid_config, user_service
2105-
):
2106-
stub_user = pretend.stub(
2107-
id=pretend.stub(),
2108-
username=pretend.stub(),
2109-
emails=[pretend.stub(email="[email protected]")],
2110-
can_reset_password=False,
2111-
record_event=pretend.call_recorder(lambda *a, **kw: None),
2104+
def test_password_reset_prohibited(self, pyramid_request, user_service, mocker):
2105+
user = UserFactory.create(
2106+
with_verified_primary_email=True,
2107+
prohibit_password_reset=True,
2108+
)
2109+
mock_record_event = mocker.patch(
2110+
"warehouse.accounts.models.HasEvents.record_event",
2111+
autospec=True,
2112+
return_value=True,
21122113
)
21132114
pyramid_request.method = "POST"
21142115
pyramid_request.route_path = pretend.call_recorder(lambda a: "/the-redirect")
2115-
user_service.get_user_by_username = pretend.call_recorder(lambda a: stub_user)
2116+
user_service.get_user_by_username = pretend.call_recorder(lambda a: user)
21162117
pyramid_request.find_service = pretend.call_recorder(
21172118
lambda interface, **kw: {
21182119
IUserService: user_service,
21192120
}[interface]
21202121
)
21212122
form_obj = pretend.stub(
2122-
username_or_email=pretend.stub(data=stub_user.username),
2123+
username_or_email=pretend.stub(data=user.username),
21232124
validate=pretend.call_recorder(lambda: True),
21242125
)
21252126
form_class = pretend.call_recorder(lambda d, user_service: form_obj)
@@ -2132,12 +2133,11 @@ def test_password_reset_prohibited(
21322133
]
21332134
assert result.headers["Location"] == "/the-redirect"
21342135

2135-
assert stub_user.record_event.calls == [
2136-
pretend.call(
2137-
tag=EventTag.Account.PasswordResetAttempt,
2138-
request=pyramid_request,
2139-
)
2140-
]
2136+
mock_record_event.assert_called_once_with(
2137+
user,
2138+
tag=EventTag.Account.PasswordResetAttempt,
2139+
request=pyramid_request,
2140+
)
21412141

21422142
def test_password_reset_with_nonexistent_email(
21432143
self, monkeypatch, pyramid_request, pyramid_config, user_service, token_service
@@ -2169,6 +2169,52 @@ def test_redirect_authenticated_user(self):
21692169
assert isinstance(result, HTTPSeeOther)
21702170
assert result.headers["Location"] == "/the-redirect"
21712171

2172+
@pytest.mark.parametrize(
2173+
"user_input",
2174+
[
2175+
"email",
2176+
"username",
2177+
],
2178+
)
2179+
def test_unverified_email_sends_alt_notice(self, db_request, mocker, user_input):
2180+
unverified_email = EmailFactory(verified=False)
2181+
2182+
form_input = {
2183+
"email": unverified_email.email,
2184+
"username": unverified_email.user.username,
2185+
}.get(user_input)
2186+
2187+
mock_send_email = mocker.patch(
2188+
"warehouse.accounts.views.send_password_reset_unverified_email",
2189+
autospec=True,
2190+
return_value=None,
2191+
)
2192+
# Prevent form's validation from checking deliverability
2193+
mock_form_validation = mocker.patch(
2194+
"warehouse.accounts.forms."
2195+
"RequestPasswordResetForm.validate_username_or_email",
2196+
autospec=True,
2197+
return_value=True,
2198+
)
2199+
2200+
db_request.method = "POST"
2201+
db_request.POST = MultiDict({"username_or_email": form_input})
2202+
2203+
result = views.request_password_reset(db_request)
2204+
2205+
assert result == {"n_hours": 6}
2206+
mock_form_validation.assert_called_once()
2207+
mock_send_email.assert_called_once_with(
2208+
db_request, (unverified_email.user, unverified_email)
2209+
)
2210+
assert db_request.log.warning.calls == [
2211+
pretend.call(
2212+
"User requested password reset for unverified email",
2213+
username=unverified_email.user.username,
2214+
email_address=unverified_email.email,
2215+
)
2216+
]
2217+
21722218

21732219
class TestResetPassword:
21742220
@pytest.mark.parametrize("dates_utc", [True, False])

tests/unit/email/test_init.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -535,17 +535,14 @@ def retry(exc):
535535

536536
class TestSendPasswordResetEmail:
537537
@pytest.mark.parametrize(
538-
("verified", "email_addr"),
538+
"email_addr",
539539
[
540-
(True, None),
541-
(False, None),
542-
(True, "[email protected]"),
543-
(False, "[email protected]"),
540+
None,
541+
544542
],
545543
)
546544
def test_send_password_reset_email(
547545
self,
548-
verified,
549546
email_addr,
550547
pyramid_request,
551548
pyramid_config,
@@ -556,7 +553,7 @@ def test_send_password_reset_email(
556553
stub_user = pretend.stub(
557554
id="id",
558555
559-
primary_email=pretend.stub(email="[email protected]", verified=verified),
556+
primary_email=pretend.stub(email="[email protected]", verified=True),
560557
username="username_value",
561558
name="name_value",
562559
last_login="last_login",
@@ -565,7 +562,7 @@ def test_send_password_reset_email(
565562
if email_addr is None:
566563
stub_email = None
567564
else:
568-
stub_email = pretend.stub(email=email_addr, verified=verified)
565+
stub_email = pretend.stub(email=email_addr, verified=True)
569566
pyramid_request.method = "POST"
570567
token_service.dumps = pretend.call_recorder(lambda a: "TOKEN")
571568

@@ -654,12 +651,37 @@ def test_send_password_reset_email(
654651
"warehouse.emails.scheduled",
655652
tags=[
656653
"template_name:password-reset",
657-
"allow_unverified:True",
654+
"allow_unverified:False",
658655
"repeat_window:none",
659656
],
660657
)
661658
]
662659

660+
def test_unverified_email_sends_alt_notice(self, pyramid_config, db_request):
661+
unverified_email = EmailFactory.create(verified=False)
662+
663+
subject_renderer = pyramid_config.testing_add_renderer(
664+
"email/password-reset-unverified/subject.txt"
665+
)
666+
subject_renderer.string_response = "Email Subject"
667+
body_renderer = pyramid_config.testing_add_renderer(
668+
"email/password-reset-unverified/body.txt"
669+
)
670+
body_renderer.string_response = "Email Body"
671+
html_renderer = pyramid_config.testing_add_renderer(
672+
"email/password-reset-unverified/body.html"
673+
)
674+
html_renderer.string_response = "Email HTML Body"
675+
676+
result = email.send_password_reset_unverified_email(
677+
db_request, (unverified_email.user, unverified_email)
678+
)
679+
680+
assert result == {}
681+
subject_renderer.assert_()
682+
body_renderer.assert_()
683+
html_renderer.assert_()
684+
663685

664686
class TestEmailVerificationEmail:
665687
def test_email_verification_email(

warehouse/accounts/views.py

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
send_organization_member_invite_declined_email,
7575
send_password_change_email,
7676
send_password_reset_email,
77+
send_password_reset_unverified_email,
7778
send_recovery_code_reminder_email,
7879
)
7980
from warehouse.events.tags import EventTag
@@ -795,19 +796,43 @@ def request_password_reset(request, _form_class=RequestPasswordResetForm):
795796
user_service = request.find_service(IUserService, context=None)
796797
form = _form_class(request.POST, user_service=user_service)
797798
if request.method == "POST" and form.validate():
798-
user = user_service.get_user_by_username(form.username_or_email.data)
799+
form_field_input = form.username_or_email.data
799800

800-
if user is None:
801-
user = user_service.get_user_by_email(form.username_or_email.data)
802-
if user is not None:
803-
email = first_true(
804-
user.emails, pred=lambda e: e.email == form.username_or_email.data
805-
)
801+
requested_email = None
802+
user = user_service.get_user_by_username(form_field_input)
803+
804+
if user:
805+
requested_email = user.primary_email
806806
else:
807+
if user := user_service.get_user_by_email(form_field_input):
808+
requested_email = first_true(
809+
user.emails,
810+
pred=lambda e: e.email == form_field_input,
811+
)
812+
else:
813+
# We could not find the user by username nor email.
814+
# Return a response as if we did, to avoid leaking registered emails.
815+
token_service = request.find_service(ITokenService, name="password")
816+
n_hours = token_service.max_age // 60 // 60
817+
return {"n_hours": n_hours}
818+
819+
if requested_email and not requested_email.verified:
820+
# No verified email, log the attempt, ping the rate limit,
821+
# notify to the email as to why no reset, return generic response.
822+
send_password_reset_unverified_email(request, (user, requested_email))
823+
user.record_event(
824+
tag=EventTag.Account.PasswordResetAttempt,
825+
request=request,
826+
)
827+
request.log.warning(
828+
"User requested password reset for unverified email",
829+
username=user.username,
830+
email_address=requested_email.email,
831+
)
832+
user_service.ratelimiters["password.reset"].hit(user.id)
833+
807834
token_service = request.find_service(ITokenService, name="password")
808835
n_hours = token_service.max_age // 60 // 60
809-
# We could not find the user by username nor email.
810-
# Return a response as if we did, to avoid leaking registered emails.
811836
return {"n_hours": n_hours}
812837

813838
if not user_service.ratelimiters["password.reset"].test(user.id):
@@ -816,7 +841,7 @@ def request_password_reset(request, _form_class=RequestPasswordResetForm):
816841
)
817842

818843
if user.can_reset_password:
819-
send_password_reset_email(request, (user, email))
844+
send_password_reset_email(request, (user, requested_email))
820845
user.record_event(
821846
tag=EventTag.Account.PasswordResetRequest,
822847
request=request,

warehouse/email/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ def wrapper(request, user_or_users, **kwargs):
222222
return inner
223223

224224

225-
@_email("password-reset", allow_unverified=True)
225+
@_email("password-reset")
226226
def send_password_reset_email(request, user_and_email):
227227
user, _ = user_and_email
228228
token_service = request.find_service(ITokenService, name="password")
@@ -246,6 +246,17 @@ def send_password_reset_email(request, user_and_email):
246246
}
247247

248248

249+
@_email("password-reset-unverified", allow_unverified=True)
250+
def send_password_reset_unverified_email(_request, _user_and_email):
251+
"""
252+
This email is sent to users who have not verified their email address
253+
when they request a password reset. It is sent to the email address
254+
they provided, which may not be their primary email address.
255+
"""
256+
# No params are used in the template, return an empty dict
257+
return {}
258+
259+
249260
@_email("verify-email", allow_unverified=True)
250261
def send_email_verification_email(request, user_and_email):
251262
user, email = user_and_email

0 commit comments

Comments
 (0)