Repository: saleor/saleor
Pull request: Add mutation to change the authenticated user's password
I want to merge this change because resolve #4287 

<!-- Please mention all relevant issue numbers. -->

### Screenshots

<!-- If your changes affect the UI, providing "before" and "after" screenshots will
greatly reduce the amount of work needed to review your work. -->

### Pull Request Checklist

<!-- Please keep this section. It will make maintainer's life easier. -->

1. [ ] Privileged views and APIs are guarded by proper permission checks.
1. [ ] All visible strings are translated with proper context.
1. [ ] All data-formatting is locale-aware (dates, numbers, and so on).
1. [ ] Database queries are optimized and the number of queries is constant.
1. [ ] Database migration files are up to date.
1. [ ] The changes are tested.
1. [ ] GraphQL schema and type definitions are up to date.
1. [ ] Changes are mentioned in the changelog.


Problem statement:
Add mutation to allow authenticated user to change password
### What I'm trying to achieve
~~I would like to use setPassword mutation for changing users password but I need one time token and there is no query right now for obtaining it.~~

~~setPassword mutation receives:
    token String! - A one-time token required to set the password
    password String! - Password~~

There is need for new mutation that will allow change of password for authenticated user that will take only new password.

Hints from issue discussion:
@mateuszkula Operations like password reset require email confirmation. Flow to obtain the token in that case would be as follows:
- users request an action (e.g. password reset, email change etc),
- the server sends an email with a link containing an expirable token,
- when users click the link they're redirected to a "password reset" view in the storefront that takes the token from URL and passes it to the mutation.

@maarcingebala I agree that such flow for password reset and email change is reasonable  but what if I am logged in and I want to change password? Changing password by email sounds a little bit inconvinient for the end user. Right now in the storefront desing for password change user is required to use current password + new password typed twice. I checked it on few sites and it looks like a popular pattern(current pass + 2x new password). Maybe we need a new mutation for that particular case. What do you think?
You're right, I was thinking about resetting the password when user forgot it. In a case when you're authenticated and you just want to change your password we don't need any emails. @fowczarek will work on a mutation for that.
👍 
Also, we won't need any tokens here, we just need a mutation that takes the new password in the input.
Changed description and title of issue.
P.S.

We should also take current password in mutations input - what if I forgot to logout from public computer? Someone could change my password easily. This is justa bit of extra security.
We'll need to merge #4510 first to unify account mutations. Then the signature for this one should be something like:

```
accountPasswordChange(oldPassword: String!, newPassword: String!): AccountPasswordChange

type AccountPasswordChange {
  errors: [Error!]
  user: User
}
```

Commit message: Fix review remarks

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4bf78a3d9e5..cd80368f54c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -52,6 +52,7 @@ All notable, unreleased changes to this project will be documented in this file.
 - Remove dashboard 2.0 files - #4631 by @dominik-zeglen
 - Refactoring of password recovery system - #4617 by @fowczarek
 - Added capability to filter attributes per global ID - #4640 by @NyanKiyoshi.
+- Add mutation to change the authenticated user's password - #4656 by @fowczarek
 
 ## 2.8.0
 
diff --git a/saleor/account/__init__.py b/saleor/account/__init__.py
index 22db4280f64..ffd3a778573 100755
--- a/saleor/account/__init__.py
+++ b/saleor/account/__init__.py
@@ -8,6 +8,7 @@ class CustomerEvents:
     ACCOUNT_CREATED = "account_created"
     PASSWORD_RESET_LINK_SENT = "password_reset_link_sent"
     PASSWORD_RESET = "password_reset"
+    PASSWORD_CHANGED = "password_changed"
 
     # Order related events
     PLACED_ORDER = "placed_order"  # created an order
diff --git a/saleor/account/events.py b/saleor/account/events.py
index 53b55e9a6b1..43c43373ad7 100644
--- a/saleor/account/events.py
+++ b/saleor/account/events.py
@@ -25,6 +25,10 @@ def customer_password_reset_event(*, user: UserType) -> Optional[CustomerEvent]:
     return CustomerEvent.objects.create(user=user, type=CustomerEvents.PASSWORD_RESET)
 
 
+def customer_password_changed_event(*, user: UserType) -> Optional[CustomerEvent]:
+    return CustomerEvent.objects.create(user=user, type=CustomerEvents.PASSWORD_CHANGED)
+
+
 def customer_placed_order_event(
     *, user: UserType, order: Order
 ) -> Optional[CustomerEvent]:
diff --git a/saleor/graphql/account/mutations/base.py b/saleor/graphql/account/mutations/base.py
index 528c78ec541..8f2e8643294 100644
--- a/saleor/graphql/account/mutations/base.py
+++ b/saleor/graphql/account/mutations/base.py
@@ -114,6 +114,41 @@ def perform_mutation(cls, _root, info, **data):
         return RequestPasswordReset()
 
 
+class PasswordChange(BaseMutation):
+    user = graphene.Field(User, description="An user instance with new password.")
+
+    class Arguments:
+        old_password = graphene.String(
+            required=True, description="Current user password."
+        )
+        new_password = graphene.String(required=True, description="New user password.")
+
+    class Meta:
+        description = "Change the password of the logged in user."
+
+    @classmethod
+    def check_permissions(cls, user):
+        return user.is_authenticated
+
+    @classmethod
+    def perform_mutation(cls, _root, info, **data):
+        user = info.context.user
+        old_password = data["old_password"]
+        new_password = data["new_password"]
+
+        if not user.check_password(old_password):
+            raise ValidationError({"old_password": "Old password isn't valid."})
+        try:
+            password_validation.validate_password(new_password, user)
+        except ValidationError as error:
+            raise ValidationError({"new_password": error.messages})
+
+        user.set_password(new_password)
+        user.save(update_fields=["password"])
+        account_events.customer_password_changed_event(user=user)
+        return PasswordChange(user=user)
+
+
 class BaseAddressUpdate(ModelMutation):
     """Base mutation for address update used by staff and account."""
 
diff --git a/saleor/graphql/account/schema.py b/saleor/graphql/account/schema.py
index 7f95f33f8a4..3a6e326b9da 100644
--- a/saleor/graphql/account/schema.py
+++ b/saleor/graphql/account/schema.py
@@ -18,6 +18,7 @@
     AccountUpdate,
 )
 from .mutations.base import (
+    PasswordChange,
     RequestPasswordReset,
     SetPassword,
     UserClearStoredMeta,
@@ -118,6 +119,7 @@ class AccountMutations(graphene.ObjectType):
     # Base mutations
     request_password_reset = RequestPasswordReset.Field()
     set_password = SetPassword.Field()
+    password_change = PasswordChange.Field()
 
     user_update_metadata = UserUpdateMeta.Field()
     user_clear_stored_metadata = UserClearStoredMeta.Field()
diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql
index a17cae798d3..539790a1f93 100644
--- a/saleor/graphql/schema.graphql
+++ b/saleor/graphql/schema.graphql
@@ -1949,6 +1949,7 @@ type Mutations {
   checkoutClearPrivateMetadata(id: ID!, input: MetaPath!): CheckoutClearStoredPrivateMeta
   requestPasswordReset(email: String!, redirectUrl: String!): RequestPasswordReset
   setPassword(token: String!, email: String!, password: String!): SetPassword
+  passwordChange(newPassword: String!, oldPassword: String!): PasswordChange
   userUpdateMetadata(id: ID!, input: MetaInput!): UserUpdateMeta
   userClearStoredMetadata(id: ID!, input: MetaPath!): UserClearStoredMeta
   accountAddressCreate(input: AddressInput!, type: AddressTypeEnum): AccountAddressCreate
@@ -2342,6 +2343,11 @@ type PageUpdate {
   page: Page
 }
 
+type PasswordChange {
+  errors: [Error!]
+  user: User
+}
+
 type PasswordReset {
   errors: [Error!]
 }
diff --git a/tests/api/test_account.py b/tests/api/test_account.py
index 7d2678ac712..456c2f23995 100644
--- a/tests/api/test_account.py
+++ b/tests/api/test_account.py
@@ -1471,6 +1471,78 @@ def test_password_reset_email_non_existing_user(
     send_password_reset_mock.assert_not_called()
 
 
+CHANGE_PASSWORD_MUTATION = """
+    mutation PasswordChange($oldPassword: String!, $newPassword: String!) {
+        passwordChange(oldPassword: $oldPassword, newPassword: $newPassword) {
+            errors {
+                field
+                message
+            }
+            user {
+                email
+            }
+        }
+    }
+"""
+
+
+def test_password_change(user_api_client):
+    customer_user = user_api_client.user
+    new_password = "spanish-inquisition"
+
+    variables = {"oldPassword": "password", "newPassword": new_password}
+    response = user_api_client.post_graphql(CHANGE_PASSWORD_MUTATION, variables)
+    content = get_graphql_content(response)
+    data = content["data"]["passwordChange"]
+    assert not data["errors"]
+    assert data["user"]["email"] == customer_user.email
+
+    customer_user.refresh_from_db()
+    assert customer_user.check_password(new_password)
+
+    password_change_event = account_events.CustomerEvent.objects.get()
+    assert password_change_event.type == account_events.CustomerEvents.PASSWORD_CHANGED
+    assert password_change_event.user == customer_user
+
+
+def test_password_change_incorrect_old_password(user_api_client):
+    customer_user = user_api_client.user
+    variables = {"oldPassword": "incorrect", "newPassword": ""}
+    response = user_api_client.post_graphql(CHANGE_PASSWORD_MUTATION, variables)
+    content = get_graphql_content(response)
+    data = content["data"]["passwordChange"]
+    customer_user.refresh_from_db()
+    assert customer_user.check_password("password")
+    assert data["errors"]
+    assert data["errors"][0]["field"] == "oldPassword"
+
+
+def test_password_change_invalid_new_password(user_api_client, settings):
+    settings.AUTH_PASSWORD_VALIDATORS = [
+        {
+            "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
+            "OPTIONS": {"min_length": 5},
+        },
+        {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
+    ]
+
+    customer_user = user_api_client.user
+    variables = {"oldPassword": "password", "newPassword": "1234"}
+    response = user_api_client.post_graphql(CHANGE_PASSWORD_MUTATION, variables)
+    content = get_graphql_content(response)
+    errors = content["data"]["passwordChange"]["errors"]
+    customer_user.refresh_from_db()
+    assert customer_user.check_password("password")
+    assert len(errors) == 2
+    assert errors[1]["field"] == "newPassword"
+    assert (
+        errors[0]["message"]
+        == "This password is too short. It must contain at least 5 characters."
+    )
+    assert errors[1]["field"] == "newPassword"
+    assert errors[1]["message"] == "This password is entirely numeric."
+
+
 def test_create_address_mutation(
     staff_api_client, customer_user, permission_manage_users
 ):


Code review comments:
diff_hunk: @@ -114,6 +114,41 @@ def perform_mutation(cls, _root, info, **data):
         return RequestPasswordReset()
 
 
+class PasswordChange(BaseMutation):
+    user = graphene.Field(User, description="An user instance with new password.")
path: saleor/graphql/account/mutations/base.py
text: @user1:
```suggestion
    user = graphene.Field(User, description="A user instance with a new password.")
```