Skip to content

Commit b26b426

Browse files
authored
admin: add a "wipe factors" button (#13848)
* admin: add a "wipe factors" button This behaves like the "reset password" button, except that it additionally wipes the user's second factors and recovery codes. * tests: fixup, coverage * admin: factor out user password reset logic ...and dedupe.
1 parent 097f579 commit b26b426

File tree

5 files changed

+191
-7
lines changed

5 files changed

+191
-7
lines changed

tests/unit/admin/test_routes.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,13 @@ def test_includeme():
8383
factory="warehouse.accounts.models:UserFactory",
8484
traverse="/{username}",
8585
),
86+
pretend.call(
87+
"admin.user.wipe_factors",
88+
"/admin/users/{username}/wipe_factors/",
89+
domain=warehouse,
90+
factory="warehouse.accounts.models:UserFactory",
91+
traverse="/{username}",
92+
),
8693
pretend.call(
8794
"admin.prohibited_user_names.bulk_add",
8895
"/admin/prohibited_user_names/bulk/",

tests/unit/admin/views/test_users.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@
1818
from webob.multidict import MultiDict, NoVars
1919

2020
from warehouse.accounts.interfaces import IEmailBreachedService, IUserService
21-
from warehouse.accounts.models import DisableReason, ProhibitedUserName
21+
from warehouse.accounts.models import (
22+
DisableReason,
23+
ProhibitedUserName,
24+
RecoveryCode,
25+
WebAuthn,
26+
)
2227
from warehouse.admin.views import users as views
2328
from warehouse.packaging.models import JournalEntry, Project
2429

@@ -456,6 +461,100 @@ def test_user_reset_password_redirects_actual_name(self, db_request):
456461
]
457462

458463

464+
class TestUserWipeFactors:
465+
def test_wipes_factors(self, db_request, monkeypatch):
466+
user = UserFactory.create(
467+
totp_secret=b"aaaaabbbbbcccccddddd",
468+
webauthn=[
469+
WebAuthn(
470+
label="fake", credential_id="fake", public_key="extremely fake"
471+
)
472+
],
473+
recovery_codes=[
474+
RecoveryCode(code="fake"),
475+
],
476+
)
477+
478+
assert user.totp_secret is not None
479+
assert len(user.webauthn) == 1
480+
assert len(user.recovery_codes.all()) == 1
481+
482+
db_request.matchdict["username"] = str(user.username)
483+
db_request.params = {"username": user.username}
484+
db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/foobar")
485+
db_request.user = user
486+
service = pretend.stub(
487+
find_userid=pretend.call_recorder(lambda username: user.username),
488+
disable_password=pretend.call_recorder(
489+
lambda userid, request, reason: None
490+
),
491+
)
492+
db_request.find_service = pretend.call_recorder(lambda iface, context: service)
493+
494+
send_email = pretend.call_recorder(lambda *a, **kw: None)
495+
monkeypatch.setattr(views, "send_password_compromised_email", send_email)
496+
497+
result = views.user_wipe_factors(user, db_request)
498+
499+
assert user.totp_secret is None
500+
assert len(user.webauthn) == 0
501+
assert len(user.recovery_codes.all()) == 0
502+
assert db_request.find_service.calls == [
503+
pretend.call(IUserService, context=None)
504+
]
505+
assert send_email.calls == [pretend.call(db_request, user)]
506+
assert service.disable_password.calls == [
507+
pretend.call(user.id, db_request, reason=DisableReason.CompromisedPassword)
508+
]
509+
assert db_request.route_path.calls == [
510+
pretend.call("admin.user.detail", username=user.username)
511+
]
512+
assert result.status_code == 303
513+
assert result.location == "/foobar"
514+
515+
def test_wipes_factors_bad_confirm(self, db_request, monkeypatch):
516+
user = UserFactory.create()
517+
518+
db_request.matchdict["username"] = str(user.username)
519+
db_request.params = {"username": "wrong"}
520+
db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/foobar")
521+
db_request.user = UserFactory.create()
522+
service = pretend.stub(
523+
find_userid=pretend.call_recorder(lambda username: user.username),
524+
disable_password=pretend.call_recorder(lambda userid, reason: None),
525+
)
526+
db_request.find_service = pretend.call_recorder(lambda iface, context: service)
527+
528+
send_email = pretend.call_recorder(lambda *a, **kw: None)
529+
monkeypatch.setattr(views, "send_password_compromised_email", send_email)
530+
531+
result = views.user_wipe_factors(user, db_request)
532+
533+
assert db_request.find_service.calls == []
534+
assert send_email.calls == []
535+
assert service.disable_password.calls == []
536+
assert db_request.route_path.calls == [
537+
pretend.call("admin.user.detail", username=user.username)
538+
]
539+
assert result.status_code == 303
540+
assert result.location == "/foobar"
541+
542+
def test_user_wipe_factors_redirects_actual_name(self, db_request):
543+
user = UserFactory.create(username="wu-tang")
544+
db_request.matchdict["username"] = "Wu-Tang"
545+
db_request.current_route_path = pretend.call_recorder(
546+
lambda username: "/user/the-redirect/"
547+
)
548+
549+
result = views.user_wipe_factors(user, db_request)
550+
551+
assert isinstance(result, HTTPMovedPermanently)
552+
assert result.headers["Location"] == "/user/the-redirect/"
553+
assert db_request.current_route_path.calls == [
554+
pretend.call(username=user.username)
555+
]
556+
557+
459558
class TestBulkAddProhibitedUserName:
460559
def test_get(self):
461560
request = pretend.stub(method="GET")

warehouse/admin/routes.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,13 @@ def includeme(config):
8181
factory="warehouse.accounts.models:UserFactory",
8282
traverse="/{username}",
8383
)
84+
config.add_route(
85+
"admin.user.wipe_factors",
86+
"/admin/users/{username}/wipe_factors/",
87+
domain=warehouse,
88+
factory="warehouse.accounts.models:UserFactory",
89+
traverse="/{username}",
90+
)
8491
config.add_route(
8592
"admin.prohibited_user_names.bulk_add",
8693
"/admin/prohibited_user_names/bulk/",

warehouse/admin/templates/admin/users/detail.html

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ <h2 class="card-title">Actions</h2>
107107
<button type="button" class="btn btn-danger" data-toggle="modal" data-target="#pwresetModal" {{ "disabled" if not request.has_permission('admin') }}>
108108
<i class="fa-solid fa-unlock-keyhole"></i> Reset password
109109
</button>
110+
<button type="button" class="btn btn-danger" data-toggle="modal" data-target="#wipeFactorsModal" {{ "disabled" if not request.has_permission('admin') }}>
111+
<i class="fa-solid fa-toilet-paper"></i> Wipe 2FA and recovery codes
112+
</button>
110113
<div class="modal fade" id="nukeModal" tabindex="-1" role="dialog">
111114
<form method="POST" action="{{ request.route_path('admin.user.delete', username=user.username) }}">
112115
<input name="csrf_token" type="hidden" value="{{ request.session.get_csrf_token() }}">
@@ -176,7 +179,41 @@ <h4 class="modal-title" id="pwresetModalLabel">Reset password for {{ user.userna
176179
</div>
177180
</div>
178181
</form>
179-
</div>
182+
</div>
183+
<div class="modal fade" id="wipeFactorsModal" tabindex="-1" role="dialog">
184+
<form method="POST" action="{{ request.route_path('admin.user.wipe_factors', username=user.username) }}">
185+
<input name="csrf_token" type="hidden" value="{{ request.session.get_csrf_token() }}">
186+
<div class="modal-dialog" role="document">
187+
<div class="modal-content">
188+
<div class="modal-header">
189+
<h4 class="modal-title" id="wipeFactorsModalLabel">Wipe second/recovery factors for {{ user.username }}?</h4>
190+
<button type="button" class="close" data-dismiss="modal">
191+
<span>&times;</span>
192+
</button>
193+
</div>
194+
<div class="modal-body">
195+
<p>
196+
This will permanently remove the user's second factors and recovery codes,
197+
and cannot be undone.
198+
</p>
199+
<p>
200+
It will also permanently remove the user's current password,
201+
and send them a password reset request.
202+
</p>
203+
<hr>
204+
<p>
205+
Type the username '{{ user.username }}' to confirm:
206+
</p>
207+
<input type="text" name="username" placeholder="{{ user.username }}">
208+
</div>
209+
<div class="modal-footer">
210+
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
211+
<button type="submit" class="btn btn-danger">Wipe factors</button>
212+
</div>
213+
</div>
214+
</div>
215+
</form>
216+
</div>
180217
</div>
181218
</div>
182219
</div> <!-- .card -->

warehouse/admin/views/users.py

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,14 @@ def user_delete(user, request):
266266
return HTTPSeeOther(request.route_path("admin.user.list"))
267267

268268

269+
def _user_reset_password(user, request):
270+
login_service = request.find_service(IUserService, context=None)
271+
send_password_compromised_email(request, user)
272+
login_service.disable_password(
273+
user.id, request, reason=DisableReason.CompromisedPassword
274+
)
275+
276+
269277
@view_config(
270278
route_name="admin.user.reset_password",
271279
require_methods=["POST"],
@@ -285,16 +293,42 @@ def user_reset_password(user, request):
285293
request.route_path("admin.user.detail", username=user.username)
286294
)
287295

288-
login_service = request.find_service(IUserService, context=None)
289-
send_password_compromised_email(request, user)
290-
login_service.disable_password(
291-
user.id, request, reason=DisableReason.CompromisedPassword
292-
)
296+
_user_reset_password(user, request)
293297

294298
request.session.flash(f"Reset password for {user.username!r}", queue="success")
295299
return HTTPSeeOther(request.route_path("admin.user.detail", username=user.username))
296300

297301

302+
@view_config(
303+
route_name="admin.user.wipe_factors",
304+
require_methods=["POST"],
305+
permission="admin",
306+
has_translations=True,
307+
uses_session=True,
308+
require_csrf=True,
309+
context=User,
310+
)
311+
def user_wipe_factors(user, request):
312+
if user.username != request.matchdict.get("username", user.username):
313+
return HTTPMovedPermanently(request.current_route_path(username=user.username))
314+
315+
if user.username != request.params.get("username"):
316+
request.session.flash("Wrong confirmation input", queue="error")
317+
return HTTPSeeOther(
318+
request.route_path("admin.user.detail", username=user.username)
319+
)
320+
321+
user.totp_secret = None
322+
user.webauthn = []
323+
user.recovery_codes = []
324+
_user_reset_password(user, request)
325+
326+
request.session.flash(
327+
f"Wiped factors and reset password for {user.username!r}", queue="success"
328+
)
329+
return HTTPSeeOther(request.route_path("admin.user.detail", username=user.username))
330+
331+
298332
@view_config(
299333
route_name="admin.prohibited_user_names.bulk_add",
300334
renderer="admin/prohibited_user_names/bulk.html",

0 commit comments

Comments
 (0)