Repository: pypi/warehouse

Issue:
Reset password doesn't work if user doesn't have verified/primary email address.
**Describe the bug**

Password reset is not working. I get the email, follow the link, enter the password twice, submit the form, and get back a page with no error messages. But the page title is "Error processing form -- Log in".

**Expected behavior**

I expected a positive confirmation that my password was reset, and to be able to login with the new password. Neither one happens.

**To Reproduce**

1. Start at `https://pypi.org/account/login/`
2. Click "Forgot password?" link
3. Enter the email address associated with my PyPI account (gerg.ward at gmail dot com)
4. Check my inbox and the email is already there.
5. Click the link: `https://pypi.org/account/reset-password/?token=.eJw1...`
6. Generate a new password outside my browser.
7. Copy/paste the new password into both password fields; text changes to "Passwords match" and the "Reset password" button is enabled.
8. Click "Reset password"
9. Receive the same "Reset password" form again, but with positive confirmation and no error messages. Only the page title gives a clue: "Error processing form –  Reset your password · PyPI". When I try to login with the new password (again, copy/pasted), it does not work.
 
I'll attach HTML and screenshot.

**My Platform**

Firefox 87.0 on Ubuntu 20.04.2.

Same behaviour with Chromium.

No HTTP proxy.

**Additional context**



Discussion:
Screenshot 1: right before I submit the form:

![pypi-password-reset-fail-1](https://user-images.githubusercontent.com/51437/113213624-6f716300-9246-11eb-8c47-72e7416b1746.png)

Screenshot 2: the result of submitting the form:

![pypi-password-reset-fail-2](https://user-images.githubusercontent.com/51437/113213668-7d26e880-9246-11eb-817e-c381c1d6ef20.png)

gzipped HTML of the error page, with tokens censored:

[pypi-password-reset-fail.html.gz](https://github.com/pypa/warehouse/files/6239682/pypi-password-reset-fail.html.gz)

I just went through this flow with a new account and was able to successfully reset my password.

Are you able to open Chrome/Firefox devtools and check for JavaScript/console/network errors? Are you able to try a different platform entirely? Also can you share your PyPI username with us?
PyPI username is `gward`. This account was recently recovered with a [support ticket](https://github.com/pypa/pypi-support/issues/993). I hope that's not relevant, but you never know!

When I load the page initially (eg. by following the reset link in my email), Firefox' JS console logs this:
```
Navigated to https://pypi.org/account/reset-password/?token=.eJw1...
Content Security Policy: The page’s settings blocked the loading of a resource at inline (“script-src”). onloadwff.js:71:794067
Loading failed for the <script> with source “https://www.googletagmanager.com/gtag/js?id=UA-55961911-1”. reset-password:73:1
Loading failed for the <script> with source “https://www.fastly-insights.com/insights.js?k=6a52360a-f306-421e-8ed5-7417d0d4a4e9&dnt=true”. reset-password:74:1
```
(with token censored manually)

When I submit the form, it seems to log exactly the same thing again:
```
Navigated to https://pypi.org/account/reset-password/?token=.eJw1...
Content Security Policy: The page’s settings blocked the loading of a resource at inline (“script-src”). onloadwff.js:71:794067
Loading failed for the <script> with source “https://www.googletagmanager.com/gtag/js?id=UA-55961911-1”. reset-password:73:1
Loading failed for the <script> with source “https://www.fastly-insights.com/insights.js?k=6a52360a-f306-421e-8ed5-7417d0d4a4e9&dnt=true”. reset-password:74:1
```

Oh, I forgot to mention: no obvious errors in the network log. Specifically, the form submit request looks like:
```
{
	"POST": {
		"scheme": "https",
		"host": "pypi.org",
		"filename": "/account/reset-password/",
		"query": {
			"token": ".eJw1jU..."
		},
		"remote": {
			"Address": "151.101.192.223:443"
		}
	}
}
```
(That JSON is generated by Firefox "Copy All" in the network log.)

The response:
```
{
	"Status": "200OK",
	"Version": "HTTP/2",
	"Transferred": "6.36 kB (22.29 kB size)",
	"Referrer Policy": "origin-when-cross-origin"
}
```

Response body:
```
<!DOCTYPE html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <meta name="defaultLanguage" content="en">
    <meta name="availableLanguages" content="en, es, fr, ja, pt_BR, uk, el, de, zh_Hans, ru, he, eo">

    

    <title>
  Error processing form –
  Reset your password
 · PyPI</title>
    <meta name="description" content="The Python Package Index (PyPI) is a repository of software for the Python programming language.">
[...truncated...]
```

I tried it on my Android phone a couple of ways:

* Chrome embedded in my email client: same result
* standalone Android Chrome: same result
* DuckDuckGo Android browser: same result

The ways that this form can fail include:

* No data provided
* Password strength isn't sufficient
* Password fields don't match
* Password has appeared in a breach (via https://haveibeenpwned.com/)

Can you confirm that you're using a new, strong, randomly generated password? Are you generating a different password each time?
I also confirmed that attempting to use a breached password will raise a corresponding error:

![image](https://user-images.githubusercontent.com/294415/113217159-1f44d180-9243-11eb-9fe1-744a59e9f194.png)

Are you able to attempt to reset with the breached password `ilovedogs` to confirm you can see such an error message?
Yes, I am using a new, strong, randomly generated password. Sometimes I use Firefox builtin password generator, sometimes I use the generator in keepassxc, and sometimes I use a command-line tool called `makepasswd`. Either way, they're long and random and full of a delightful mix of lowercase, uppercase, and numbers. I've avoided punctuation _just in case_ that was causing problems.

However, I have NOT used a different password each time. I switched to a shorter password (12 chars instead of 20) for testing on my phone, but I used the same random password for all tests on my phone.

I can certainly try again with a unique password each time.
Oh yeah, I also confirmed that the "known weak password" feature works for me. I tried with "rush2112" and said I need more words. Tried with "ilovedogs" and I got the same result as you did.
Oh, another guilty admission: I have used the same reset token many times. You seem to have some rate limiting on password reset requests (good!!), which means testing with a new token each time would be annoying. I can do that too, if it helps.
OK just tried it with a new reset token and a new random password: same error. Still with Firefox 87.0 on Ubuntu 20.04.2.
OK, can you try again? I noticed that the email address on your account was not marked as verified or primary, which likely happened when you went through the account recovery process.
Ahhhhh, thank you! I just tried it on Chrome on a Mac and it suddenly worked. I'm guessing it's because you tweaked my verification flag, rather than because of the change of browser and platform. ;-)

Thank you!!

(And, also, thank you for your work on PyPI. It's a fantastic piece of infrastructure and you are making the world a better place.)
Wait, I probably should not close this. My problem is solved, but the error reporting could definitely be better. ;-)
Yep, I'd consider this a bug. Will update the title accordingly.

Resolving patch:
diff --git a/warehouse/admin/views/users.py b/warehouse/admin/views/users.py
--- a/warehouse/admin/views/users.py
+++ b/warehouse/admin/views/users.py
@@ -14,6 +14,7 @@
 
 import wtforms
 import wtforms.fields.html5
+import wtforms.validators
 
 from paginate_sqlalchemy import SqlalchemyOrmPage as SQLAlchemyORMPage
 from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound, HTTPSeeOther
@@ -95,6 +96,14 @@ class UserForm(forms.Form):
 
     emails = wtforms.fields.FieldList(wtforms.fields.FormField(EmailForm))
 
+    def validate_emails(self, field):
+        # If there's no email on the account, it's ok. Otherwise, ensure
+        # we have 1 primary email.
+        if field.data and len([1 for email in field.data if email["primary"]]) != 1:
+            raise wtforms.validators.ValidationError(
+                "There must be exactly one primary email"
+            )
+
 
 @view_config(
     route_name="admin.user.detail",
@@ -134,6 +143,7 @@ def user_detail(request):
 
     if request.method == "POST" and form.validate():
         form.populate_obj(user)
+        request.session.flash(f"User {user.username!r} updated", queue="success")
         return HTTPSeeOther(location=request.current_route_path())
 
     return {"user": user, "form": form, "roles": roles, "add_email_form": EmailForm()}
@@ -151,6 +161,11 @@ def user_add_email(request):
     form = EmailForm(request.POST)
 
     if form.validate():
+
+        if form.primary.data:
+            for other in user.emails:
+                other.primary = False
+
         email = Email(
             email=form.email.data,
             user=user,


Test patch:
diff --git a/tests/unit/admin/views/test_users.py b/tests/unit/admin/views/test_users.py
--- a/tests/unit/admin/views/test_users.py
+++ b/tests/unit/admin/views/test_users.py
@@ -147,10 +147,55 @@ def test_updates_user(self, db_request):
         assert resp.location == "/admin/users/{}/".format(user.id)
         assert user.name == "Jane Doe"
 
+    def test_updates_user_no_primary_email(self, db_request):
+        email = EmailFactory.create(primary=True)
+        user = UserFactory.create(emails=[email])
+        db_request.matchdict["user_id"] = str(user.id)
+        db_request.method = "POST"
+        db_request.POST["name"] = "Jane Doe"
+        db_request.POST["emails-0-email"] = email.email
+        # No primary = checkbox unchecked
+
+        db_request.POST = MultiDict(db_request.POST)
+        db_request.current_route_path = pretend.call_recorder(
+            lambda: "/admin/users/{}/".format(user.id)
+        )
+
+        resp = views.user_detail(db_request)
+
+        assert resp["form"].errors == {
+            "emails": ["There must be exactly one primary email"]
+        }
+
+    def test_updates_user_multiple_primary_emails(self, db_request):
+        email1 = EmailFactory.create(primary=True)
+        email2 = EmailFactory.create(primary=True)
+        user = UserFactory.create(emails=[email1, email2])
+        db_request.matchdict["user_id"] = str(user.id)
+        db_request.method = "POST"
+        db_request.POST["name"] = "Jane Doe"
+        db_request.POST["emails-0-email"] = email1.email
+        db_request.POST["emails-0-primary"] = "true"
+        db_request.POST["emails-1-email"] = email2.email
+        db_request.POST["emails-1-primary"] = "true"
+        # No primary = checkbox unchecked
+
+        db_request.POST = MultiDict(db_request.POST)
+        db_request.current_route_path = pretend.call_recorder(
+            lambda: "/admin/users/{}/".format(user.id)
+        )
+
+        resp = views.user_detail(db_request)
+
+        assert resp["form"].errors == {
+            "emails": ["There must be exactly one primary email"]
+        }
+
 
 class TestUserAddEmail:
-    def test_add_email(self, db_request):
-        user = UserFactory.create(emails=[])
+    def test_add_primary_email(self, db_request):
+        old_email = EmailFactory.create(email="old@bar.com", primary=True)
+        user = UserFactory.create(emails=[old_email])
         db_request.matchdict["user_id"] = str(user.id)
         db_request.method = "POST"
         db_request.POST["email"] = "foo@bar.com"
@@ -167,13 +212,39 @@ def test_add_email(self, db_request):
 
         assert resp.status_code == 303
         assert resp.location == "/admin/users/{}/".format(user.id)
-        assert len(user.emails) == 1
+        assert len(user.emails) == 2
+
+        emails = {e.email: e for e in user.emails}
+
+        assert not emails["old@bar.com"].primary
+        assert emails["foo@bar.com"].primary
+        assert emails["foo@bar.com"].verified
+
+    def test_add_non_primary_email(self, db_request):
+        old_email = EmailFactory.create(email="old@bar.com", primary=True)
+        user = UserFactory.create(emails=[old_email])
+        db_request.matchdict["user_id"] = str(user.id)
+        db_request.method = "POST"
+        db_request.POST["email"] = "foo@bar.com"
+        # No "primary" field
+        db_request.POST["verified"] = True
+        db_request.POST = MultiDict(db_request.POST)
+        db_request.route_path = pretend.call_recorder(
+            lambda *a, **kw: "/admin/users/{}/".format(user.id)
+        )
+
+        resp = views.user_add_email(db_request)
+
+        db_request.db.flush()
+
+        assert resp.status_code == 303
+        assert resp.location == "/admin/users/{}/".format(user.id)
+        assert len(user.emails) == 2
 
-        email = user.emails[0]
+        emails = {e.email: e for e in user.emails}
 
-        assert email.email == "foo@bar.com"
-        assert email.primary
-        assert email.verified
+        assert emails["old@bar.com"].primary
+        assert not emails["foo@bar.com"].primary
 
     def test_add_invalid(self, db_request):
         user = UserFactory.create(emails=[])