Repository: matrix-org/synapse

Issue:
Make the 'and also log out all my devices' behaviour of password reset optional.
Password reset signs out all of your sessions.

The 'password reset' and 'log out all devices' actions do not need to be inseparable. If you suspect your account has been compromised then logging out your other devices is important. In (I think) all other cases it is overkill.

Can we make a backwards-compatible change to the password reset API by adding an optional "and log out all my devices" parameter which defaults to 'true'?


Discussion:
Related to #6963, though that seems to be about regular password changes rather than password resets.
As far as I can tell this is a duplicate of #6963 since the code paths converge into the same password reset flow (i.e you eventually end up at [`/_matrix/client/r0/account/password`](https://matrix.org/docs/spec/client_server/r0.6.0#post-matrix-client-r0-account-password)).

Regardless I'll look into this.
@neilisfragile to discuss the UX implications of this with @lampholder 
After irl, we want the option to specify via the API but default to the current behaviour. So exactly as @lampholder describes above. 

The idea being that the client can support ' I've been hacked, delete all devices!!!' flow along side a 'oh I just want to update my password' flow. 

Resolving patch:
diff --git a/synapse/handlers/set_password.py b/synapse/handlers/set_password.py
--- a/synapse/handlers/set_password.py
+++ b/synapse/handlers/set_password.py
@@ -13,10 +13,12 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 import logging
+from typing import Optional
 
 from twisted.internet import defer
 
 from synapse.api.errors import Codes, StoreError, SynapseError
+from synapse.types import Requester
 
 from ._base import BaseHandler
 
@@ -32,14 +34,17 @@ def __init__(self, hs):
         self._device_handler = hs.get_device_handler()
 
     @defer.inlineCallbacks
-    def set_password(self, user_id, newpassword, requester=None):
+    def set_password(
+        self,
+        user_id: str,
+        new_password: str,
+        logout_devices: bool,
+        requester: Optional[Requester] = None,
+    ):
         if not self.hs.config.password_localdb_enabled:
             raise SynapseError(403, "Password change disabled", errcode=Codes.FORBIDDEN)
 
-        password_hash = yield self._auth_handler.hash(newpassword)
-
-        except_device_id = requester.device_id if requester else None
-        except_access_token_id = requester.access_token_id if requester else None
+        password_hash = yield self._auth_handler.hash(new_password)
 
         try:
             yield self.store.user_set_password_hash(user_id, password_hash)
@@ -48,14 +53,18 @@ def set_password(self, user_id, newpassword, requester=None):
                 raise SynapseError(404, "Unknown user", Codes.NOT_FOUND)
             raise e
 
-        # we want to log out all of the user's other sessions. First delete
-        # all his other devices.
-        yield self._device_handler.delete_all_devices_for_user(
-            user_id, except_device_id=except_device_id
-        )
-
-        # and now delete any access tokens which weren't associated with
-        # devices (or were associated with this device).
-        yield self._auth_handler.delete_access_tokens_for_user(
-            user_id, except_token_id=except_access_token_id
-        )
+        # Optionally, log out all of the user's other sessions.
+        if logout_devices:
+            except_device_id = requester.device_id if requester else None
+            except_access_token_id = requester.access_token_id if requester else None
+
+            # First delete all of their other devices.
+            yield self._device_handler.delete_all_devices_for_user(
+                user_id, except_device_id=except_device_id
+            )
+
+            # and now delete any access tokens which weren't associated with
+            # devices (or were associated with this device).
+            yield self._auth_handler.delete_access_tokens_for_user(
+                user_id, except_token_id=except_access_token_id
+            )
diff --git a/synapse/rest/admin/users.py b/synapse/rest/admin/users.py
--- a/synapse/rest/admin/users.py
+++ b/synapse/rest/admin/users.py
@@ -221,8 +221,9 @@ async def on_PUT(self, request, user_id):
                     raise SynapseError(400, "Invalid password")
                 else:
                     new_password = body["password"]
+                    logout_devices = True
                     await self.set_password_handler.set_password(
-                        target_user.to_string(), new_password, requester
+                        target_user.to_string(), new_password, logout_devices, requester
                     )
 
             if "deactivated" in body:
@@ -536,9 +537,10 @@ async def on_POST(self, request, target_user_id):
         params = parse_json_object_from_request(request)
         assert_params_in_dict(params, ["new_password"])
         new_password = params["new_password"]
+        logout_devices = params.get("logout_devices", True)
 
         await self._set_password_handler.set_password(
-            target_user_id, new_password, requester
+            target_user_id, new_password, logout_devices, requester
         )
         return 200, {}
 
diff --git a/synapse/rest/client/v2_alpha/account.py b/synapse/rest/client/v2_alpha/account.py
--- a/synapse/rest/client/v2_alpha/account.py
+++ b/synapse/rest/client/v2_alpha/account.py
@@ -265,8 +265,11 @@ async def on_POST(self, request):
 
         assert_params_in_dict(params, ["new_password"])
         new_password = params["new_password"]
+        logout_devices = params.get("logout_devices", True)
 
-        await self._set_password_handler.set_password(user_id, new_password, requester)
+        await self._set_password_handler.set_password(
+            user_id, new_password, logout_devices, requester
+        )
 
         return 200, {}
 


Test patch: