Repository: pypi/warehouse

Issue:
2FA Ask for password not username when disabling 2FA
<!--
    NOTE: This issue should be for problems with PyPI itself, including:
    * pypi.org
    * test.pypi.org
    * files.pythonhosted.org

    This issue should NOT be for a project installed from PyPI. If you are
    having an issue with a specific package, you should reach out to the
    maintainers of that project directly instead.

    Furthermore, this issue should NOT be for any non-PyPI properties (like
    python.org, docs.python.org, etc.)
-->

**Describe the bug**
When disabling 2FA for an account, it would make sense to ask for the user's password and not the username for confirmation as that would make it much harder to do drive-by disabling on unattended computers.

**Expected behavior**
<!-- A clear and concise description of what you expected to happen -->
Ask for password when disabling 2FA.

**To Reproduce**
<!-- Steps to reproduce the bug, or a link to PyPI where the bug is visible -->
Try disabling 2FA on https://pypi.org/manage/account/.



Discussion:
Yeah, I thought this was a bit odd.

Separate ticket, but possibly related: should Warehouse require a password to enable 2FA? To prevent similar drive-by enabling of 2FA on unattended computers.
This was a subject of some discussion in #5567 (although I'm currently having trouble finding the exact comment).

As far as I know, requiring a password (or just a second factor) when disabling 2FA is not a consistent practice across major services. Both Google and GitHub will prompt you for some kind of authentication if you haven't accessed their security pages in a while but both retain a cookie that allows future visits without re-authentication, eliminating the drive-by protection. Google requires the user's password, while GitHub allows the user to use one of their second factor methods. Twitter requires the user's password twice: once to access the security page, and again to actually disable 2FA.

The theory behind the current behavior is that (1) the user is already in an authenticated context, one where they have (almost) absolute control over the state of their account, (2) anything that strictly decreases the security of their account should require confirmation, but **not** require them to jump through hoops. Therefore, we make them confirm their intent by typing their username out, but don't encourage security fatigue by re-prompting for their password.

On a more general note, a drive-by attacker can currently do far worse than disable the user's 2FA method: they can transfer all packages to a temporary account, delete the current account (only the username is required), and then create a new, identically named account that they control completely. As far as I know (I might be wrong!), none of that would be visible from an package consumer's perspective.

In summary: I think we should treat drive-by attacks as black swan events and mitigate them in other ways (audit logging, email alerts when an account's security posture changes, &c). That isn't to say that changing the behavior to require a password would be _bad_; I just think it's unnecessary and attempts to cover an attack that I think is unlikely (why reduce the account's security to a single factor when you can own it entirely?)

We could take an approach like Google or GitHub, but I see relatively little payoff: the two are still vulnerable to drive-bys once the user is authenticated.



> This was a subject of some discussion in #5567 (although I'm currently having trouble finding the exact comment).
> 
> As far as I know, requiring a password (or just a second factor) when disabling 2FA is not a consistent practice across major services. Both Google and GitHub will prompt you for some kind of authentication if you haven't accessed their security pages in a while but both retain a cookie that allows future visits without re-authentication, eliminating the drive-by protection. Google requires the user's password, while GitHub allows the user to use one of their second factor methods. Twitter requires the user's password twice: once to access the security page, and again to actually disable 2FA.
> 
> The theory behind the current behavior is that (1) the user is already in an authenticated context, one where they have (almost) absolute control over the state of their account, (2) anything that strictly decreases the security of their account should require confirmation, but **not** require them to jump through hoops. Therefore, we make them confirm their intent by typing their username out, but don't encourage security fatigue by re-prompting for their password.
> 

I understand (1) for sure, but don't follow you regarding (2) to be honest. It seems to me that confirmation via a password is not an unreasonable amount of effort, while using the username effectively is no effort at all. IOW entering a username to authorize a possibly dangerous change is not an expected UX pattern (to me!), in contrast to entering a password.

> On a more general note, a drive-by attacker can currently do far worse than disable the user's 2FA method: they can transfer all packages to a temporary account, delete the current account (only the username is required), and then create a new, identically named account that they control completely. As far as I know (I might be wrong!), none of that would be visible from an package consumer's perspective.
> 

Sure, although I didn't raise this issue for any complex drive-by scenario, but about one that is relatively easy to achieve with very little exposure to a victim's computer, which would reduce the risk for an attack. Additional means to inform the user *when* 2FA is disabled would of course be useful.

(Another idea for a more complex attack: a compromised browser extension can disable 2FA without user interaction since it can parse the username from the account settings page)

> In summary: I think we should treat drive-by attacks as black swan events and mitigate them in other ways (audit logging, email alerts when an account's security posture changes, &c). That isn't to say that changing the behavior to require a password would be _bad_; I just think it's unnecessary and attempts to cover an attack that I think is unlikely (why reduce the account's security to a single factor when you can own it entirely?)
> 

FWIW I gave feedback to a feature that I'm beta-testing, so this seems like a case of an unfinished product feature. What struck me as odd about the use of a username for disabling 2FA was basically me not having seen the pattern before (at all) and the chance to simply use the established pattern to use the user's password for confirming a possibly dangerous action. If in doubt I think it makes sense to not use the username but the password, basically.

> We could take an approach like Google or GitHub, but I see relatively little payoff: the two are still vulnerable to drive-bys once the user is authenticated.

*shrug* I haven't done any user research to proof my experience, so I let you decide what to do with my feedback.
I too think asking for the username is odd, and asking for the password is more appropriate for an action like removing 2FA. Asking for a username is functionally equivalent to a confirmation dialog but requiring slightly more typing/effort for nothing much here.

I don't think asking for the username for a destructive action is a pattern I've seen elsewhere and switching to either a confirmation dialog or to asking for the password are both wins in this case IMHO.
@nlhkabu I have a suggestion here, which is: perhaps you could skim https://simplysecure.org/knowledge-base/ to check whether there's research or advice there on what to require of a user in this scenario?

Thanks for elaborating on your thinking here, @jezdez -- I appreciate the detail!
@brainwane thank you for this link - looks like a great resource, however, unfortunately, I couldn't find anything specific to this scenario.

My preference here is to change this to a password validation pattern, based on this feedback.

I don't think it _matters_ if it is no more secure (although obviously it would matter if it were _less_ secure) -> if it is more familiar and/or makes people feel more comfortable, that's enough to justify the change from a UX perspective.


@woodruffw I've added this to the OTF security work milestone.  Let's make a PR for this once the webauthn PR is done?

The new modal should look like this:

![Screenshot from 2019-05-23 06-35-57](https://user-images.githubusercontent.com/3323703/58228026-29334a80-7d25-11e9-89d8-90f5d7e940a4.png)

Additional features:

- The user should be able to toggle the visibility of the password, as on our other password fields
- The button should be disabled until a password is entered (as per the current behaviour)

> Let's make a PR for this once the webauthn PR is done?

Sounds good to me!


@brainwane 💯 agreed that as in with other websites (say GitHub or Google), and assuming the user is logged in, the password should be required to edit any sensitive security setting, not just 2FA. Maybe a decorator would help?
As discussed today with @woodruffw, we plan to update this UI to:

- Ask for password on TOTP modal
- Ask for password and key label on webauthn modal
> The button should be disabled until the correct password is entered (as per the current behaviour)

I'll flag that this seems a little odd and tricky to achieve to me; since the password checking should always happen on the backend.
>> The button should be disabled until the correct password is entered (as per the current behaviour)
>
> I'll flag that this seems a little odd and tricky to achieve to me; since the password checking should always happen on the backend.

Agreed. I wonder if this should actually be:

> The button should be disabled until **a non-empty** password is entered

---

Two notes:

* **Elsewhere in the UI, the button is disabled until you enter *any* password, regardless of correctness.**

   I made a todo note a while back to poke at this a bit and see what’s going on. But I went to look just now and I couldn’t find anywhere in the settings panels where the user-side code is checking the *correctness* of the password. Closest I could find was the “Change Password” section:

  ![Screenshot 2019-06-19 at 22 21 12](https://user-images.githubusercontent.com/301220/59802292-a16b3c80-92e0-11e9-894d-c12d53e13acc.png)

  Here, the button is disabled until you enter *something* in the “Old password” field (my password is not three characters 😉 ) and both the passwords entered in the “New password” field match (which is a reasonably check to perform client-side).

  If I go ahead and click “Update password”, I get an error:

  ![Screenshot 2019-06-19 at 22 23 50](https://user-images.githubusercontent.com/301220/59802435-f9a23e80-92e0-11e9-8c45-c1bd7fbb8756.png)

* **The current “disable 2FA” screen checks for correctness of username.**

  This is a PyPI username (I think), but it's not my username:

  ![Screenshot 2019-06-19 at 22 26 09](https://user-images.githubusercontent.com/301220/59802626-48e86f00-92e1-11e9-894d-00b2b84e1ce2.png)

  The button doesn’t get enabled until I enter my username – which isn’t an unreasonable check to perform client-side, because my username isn’t secret information. Is this what @nlhkabu meant by “as per the current behaviour”?

Yes, you're right @alexwlchan - I'll update my comment above :)
This requires some JavaScript work. @yeraydiazdiaz or @di do you have time to help with this?
I can have a go at it.
That would be great, thank you, @yeraydiazdiaz! This is an issue we want to resolve before we can declare the WebAuthn beta finished, so I particularly appreciate your help with this.

Resolving patch:
diff --git a/warehouse/manage/forms.py b/warehouse/manage/forms.py
--- a/warehouse/manage/forms.py
+++ b/warehouse/manage/forms.py
@@ -87,9 +87,9 @@ def __init__(self, *args, user_service, **kwargs):
         self.user_service = user_service
 
 
-class DeleteTOTPForm(UsernameMixin, forms.Form):
+class DeleteTOTPForm(UsernameMixin, PasswordMixin, forms.Form):
 
-    __params__ = ["confirm_username"]
+    __params__ = ["confirm_password"]
 
     def __init__(self, *args, user_service, **kwargs):
         super().__init__(*args, **kwargs)
@@ -246,15 +246,16 @@ def validate_token_scope(self, field):
         self.validated_scope = {"projects": [scope_value]}
 
 
-class DeleteMacaroonForm(forms.Form):
-    __params__ = ["macaroon_id"]
+class DeleteMacaroonForm(UsernameMixin, PasswordMixin, forms.Form):
+    __params__ = ["confirm_password", "macaroon_id"]
 
     macaroon_id = wtforms.StringField(
         validators=[wtforms.validators.DataRequired(message="Identifier required")]
     )
 
-    def __init__(self, *args, macaroon_service, **kwargs):
+    def __init__(self, *args, macaroon_service, user_service, **kwargs):
         super().__init__(*args, **kwargs)
+        self.user_service = user_service
         self.macaroon_service = macaroon_service
 
     def validate_macaroon_id(self, field):
diff --git a/warehouse/manage/views.py b/warehouse/manage/views.py
--- a/warehouse/manage/views.py
+++ b/warehouse/manage/views.py
@@ -470,7 +470,7 @@ def delete_totp(self):
             return HTTPSeeOther(self.request.route_path("manage.account"))
 
         form = DeleteTOTPForm(
-            **self.request.POST,
+            password=self.request.POST["confirm_password"],
             username=self.request.user.username,
             user_service=self.user_service,
         )
@@ -489,7 +489,7 @@ def delete_totp(self):
                 queue="success",
             )
         else:
-            self.request.session.flash("Invalid credentials", queue="error")
+            self.request.session.flash("Invalid credentials. Try again", queue="error")
 
         return HTTPSeeOther(self.request.route_path("manage.account"))
 
@@ -631,7 +631,9 @@ def default_response(self):
                 project_names=self.project_names,
             ),
             "delete_macaroon_form": DeleteMacaroonForm(
-                macaroon_service=self.macaroon_service
+                username=self.request.user.username,
+                user_service=self.user_service,
+                macaroon_service=self.macaroon_service,
             ),
         }
 
@@ -699,7 +701,11 @@ def create_macaroon(self):
     @view_config(request_method="POST", request_param=DeleteMacaroonForm.__params__)
     def delete_macaroon(self):
         form = DeleteMacaroonForm(
-            **self.request.POST, macaroon_service=self.macaroon_service
+            password=self.request.POST["confirm_password"],
+            macaroon_id=self.request.POST["macaroon_id"],
+            macaroon_service=self.macaroon_service,
+            username=self.request.user.username,
+            user_service=self.user_service,
         )
 
         if form.validate():
@@ -730,6 +736,8 @@ def delete_macaroon(self):
             self.request.session.flash(
                 f"Deleted API token '{macaroon.description}'.", queue="success"
             )
+        else:
+            self.request.session.flash("Invalid credentials. Try again", queue="error")
 
         redirect_to = self.request.referer
         if not is_safe_url(redirect_to, host=self.request.host):


Test patch:
diff --git a/tests/unit/manage/test_forms.py b/tests/unit/manage/test_forms.py
--- a/tests/unit/manage/test_forms.py
+++ b/tests/unit/manage/test_forms.py
@@ -167,6 +167,19 @@ def test_creation(self):
 
         assert form.user_service is user_service
 
+    def test_validate_confirm_password(self):
+        user_service = pretend.stub(
+            find_userid=pretend.call_recorder(lambda userid: 1),
+            check_password=pretend.call_recorder(
+                lambda userid, password, tags=None: True
+            ),
+        )
+        form = forms.DeleteTOTPForm(
+            username="username", user_service=user_service, password="password"
+        )
+
+        assert form.validate()
+
 
 class TestProvisionWebAuthnForm:
     def test_creation(self):
@@ -433,16 +446,26 @@ def test_validate_token_scope_valid_project(self):
 class TestDeleteMacaroonForm:
     def test_creation(self):
         macaroon_service = pretend.stub()
-        form = forms.DeleteMacaroonForm(macaroon_service=macaroon_service)
+        user_service = pretend.stub()
+        form = forms.DeleteMacaroonForm(
+            macaroon_service=macaroon_service, user_service=user_service
+        )
 
         assert form.macaroon_service is macaroon_service
+        assert form.user_service is user_service
 
     def test_validate_macaroon_id_invalid(self):
         macaroon_service = pretend.stub(
             find_macaroon=pretend.call_recorder(lambda id: None)
         )
+        user_service = pretend.stub(
+            find_userid=lambda *a, **kw: 1, check_password=lambda *a, **kw: True
+        )
         form = forms.DeleteMacaroonForm(
-            data={"macaroon_id": pretend.stub()}, macaroon_service=macaroon_service
+            data={"macaroon_id": pretend.stub(), "password": "password"},
+            macaroon_service=macaroon_service,
+            user_service=user_service,
+            username="username",
         )
 
         assert not form.validate()
@@ -452,8 +475,14 @@ def test_validate_macaroon_id(self):
         macaroon_service = pretend.stub(
             find_macaroon=pretend.call_recorder(lambda id: pretend.stub())
         )
+        user_service = pretend.stub(
+            find_userid=lambda *a, **kw: 1, check_password=lambda *a, **kw: True
+        )
         form = forms.DeleteMacaroonForm(
-            data={"macaroon_id": pretend.stub()}, macaroon_service=macaroon_service
+            data={"macaroon_id": pretend.stub(), "password": "password"},
+            macaroon_service=macaroon_service,
+            username="username",
+            user_service=user_service,
         )
 
         assert form.validate()
diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py
--- a/tests/unit/manage/test_views.py
+++ b/tests/unit/manage/test_views.py
@@ -1080,7 +1080,7 @@ def test_delete_totp(self, monkeypatch, db_request):
             record_event=pretend.call_recorder(lambda *a, **kw: None),
         )
         request = pretend.stub(
-            POST={"confirm_username": pretend.stub()},
+            POST={"confirm_password": pretend.stub()},
             session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)),
             find_service=lambda *a, **kw: user_service,
             user=pretend.stub(
@@ -1123,13 +1123,13 @@ def test_delete_totp(self, monkeypatch, db_request):
             )
         ]
 
-    def test_delete_totp_bad_username(self, monkeypatch, db_request):
+    def test_delete_totp_bad_password(self, monkeypatch, db_request):
         user_service = pretend.stub(
             get_totp_secret=lambda id: b"secret",
             update_user=pretend.call_recorder(lambda *a, **kw: None),
         )
         request = pretend.stub(
-            POST={"confirm_username": pretend.stub()},
+            POST={"confirm_password": pretend.stub()},
             session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)),
             find_service=lambda *a, **kw: user_service,
             user=pretend.stub(
@@ -1151,7 +1151,7 @@ def test_delete_totp_bad_username(self, monkeypatch, db_request):
 
         assert user_service.update_user.calls == []
         assert request.session.flash.calls == [
-            pretend.call("Invalid credentials", queue="error")
+            pretend.call("Invalid credentials. Try again", queue="error")
         ]
         assert isinstance(result, HTTPSeeOther)
         assert result.headers["Location"] == "/foo/bar/"
@@ -1162,7 +1162,7 @@ def test_delete_totp_not_provisioned(self, monkeypatch, db_request):
             update_user=pretend.call_recorder(lambda *a, **kw: None),
         )
         request = pretend.stub(
-            POST={"confirm_username": pretend.stub()},
+            POST={"confirm_password": pretend.stub()},
             session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)),
             find_service=lambda *a, **kw: user_service,
             user=pretend.stub(
@@ -1469,7 +1469,7 @@ def test_default_response(self, monkeypatch):
         )
 
         request = pretend.stub(
-            user=pretend.stub(id=pretend.stub()),
+            user=pretend.stub(id=pretend.stub(), username=pretend.stub()),
             find_service=lambda interface, **kw: {
                 IMacaroonService: pretend.stub(),
                 IUserService: pretend.stub(),
@@ -1767,7 +1767,7 @@ def test_delete_macaroon_invalid_form(self, monkeypatch):
             delete_macaroon=pretend.call_recorder(lambda id: pretend.stub())
         )
         request = pretend.stub(
-            POST={},
+            POST={"confirm_password": "password", "macaroon_id": "macaroon_id"},
             route_path=pretend.call_recorder(lambda x: pretend.stub()),
             find_service=lambda interface, **kw: {
                 IMacaroonService: macaroon_service,
@@ -1775,6 +1775,8 @@ def test_delete_macaroon_invalid_form(self, monkeypatch):
             }[interface],
             referer="/fake/safe/route",
             host=None,
+            user=pretend.stub(username=pretend.stub()),
+            session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)),
         )
 
         delete_macaroon_obj = pretend.stub(validate=lambda: False)
@@ -1790,13 +1792,16 @@ def test_delete_macaroon_invalid_form(self, monkeypatch):
         assert isinstance(result, HTTPSeeOther)
         assert result.location == "/fake/safe/route"
         assert macaroon_service.delete_macaroon.calls == []
+        assert request.session.flash.calls == [
+            pretend.call("Invalid credentials. Try again", queue="error")
+        ]
 
     def test_delete_macaroon_dangerous_redirect(self, monkeypatch):
         macaroon_service = pretend.stub(
             delete_macaroon=pretend.call_recorder(lambda id: pretend.stub())
         )
         request = pretend.stub(
-            POST={},
+            POST={"confirm_password": "password", "macaroon_id": "macaroon_id"},
             route_path=pretend.call_recorder(lambda x: "/safe/route"),
             find_service=lambda interface, **kw: {
                 IMacaroonService: macaroon_service,
@@ -1804,6 +1809,8 @@ def test_delete_macaroon_dangerous_redirect(self, monkeypatch):
             }[interface],
             referer="http://google.com/",
             host=None,
+            user=pretend.stub(username=pretend.stub()),
+            session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)),
         )
 
         delete_macaroon_obj = pretend.stub(validate=lambda: False)
@@ -1833,7 +1840,7 @@ def test_delete_macaroon(self, monkeypatch):
         )
         user_service = pretend.stub(record_event=record_event)
         request = pretend.stub(
-            POST={},
+            POST={"confirm_password": "password", "macaroon_id": "macaroon_id"},
             route_path=pretend.call_recorder(lambda x: pretend.stub()),
             find_service=lambda interface, **kw: {
                 IMacaroonService: macaroon_service,
@@ -1842,7 +1849,7 @@ def test_delete_macaroon(self, monkeypatch):
             session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)),
             referer="/fake/safe/route",
             host=None,
-            user=pretend.stub(id=pretend.stub()),
+            user=pretend.stub(id=pretend.stub(), username=pretend.stub()),
             remote_addr="0.0.0.0",
         )
 
@@ -1892,7 +1899,7 @@ def test_delete_macaroon_records_events_for_each_project(self, monkeypatch):
         )
         user_service = pretend.stub(record_event=record_event)
         request = pretend.stub(
-            POST={},
+            POST={"confirm_password": pretend.stub(), "macaroon_id": pretend.stub()},
             route_path=pretend.call_recorder(lambda x: pretend.stub()),
             find_service=lambda interface, **kw: {
                 IMacaroonService: macaroon_service,