Repository: matrix-org/synapse

Issue:
Need a way to allow non-SSO users to use Key Backup on SSO-using hosts
**Description:**

On a host with both SSO users and non-SSO (e.g. bots) coexisting, it's impossible to use Key Backup as the latter. If we disable password auth (which makes sense when using SSO), even if we use our non-SSO user with its access token, an auth check will still be performed when uploading keys to key backup – and that will fail due to no available auth flow (and a 401) since there is no auth flow that the bot user can use.

Code-wise we end up [here](https://github.com/matrix-org/synapse/blob/67aae05ece9b6e07fedc73f737c0d6db6351d6c7/synapse/rest/client/keys.py#L75-L116) when setting up Key Backup in Element, and then [this](https://github.com/matrix-org/synapse/blob/67aae05ece9b6e07fedc73f737c0d6db6351d6c7/synapse/handlers/auth.py#L389) gives us no available options.



Discussion:
@tadzik: for context, how does a "non-SSO user" get an access token in the first place if password auth is disabled?

Possibly the solution here is just to relax the `_password_enabled` check [here](https://github.com/matrix-org/synapse/blob/v1.59.1/synapse/handlers/auth.py#L396), to allow users to present a password for re-authentication even if passwords are disabled for login?
@tadzik do you actually mean this API? I don't think the one you linked actually requires UI auth? https://github.com/matrix-org/synapse/blob/b83bc5fab57b37f75a79d02213d6032c586fd36e/synapse/rest/client/keys.py#L274-L291
> for context, how does a "non-SSO user" get an access token in the first place if password auth is disabled?

@richvdh in this case the user is registered with an admin API, with shared secret registration – which gives us the access_token in the response.

@erikjohnston yeah, that looks about right – I may have mislinked things.
> Possibly the solution here is just to relax the `_password_enabled` check [here](https://github.com/matrix-org/synapse/blob/v1.59.1/synapse/handlers/auth.py#L396), to allow users to present a password for re-authentication even if passwords are disabled for login?

I think that is probably fine, the only concern would be for servers that have changed from password to SSO, where this change will potentially show the password box for users that had signed up with a password. I don't think that is a big deal though.
> I think that is probably fine, the only concern would be for servers that have changed from password to SSO, where this change will potentially show the password box for users that had signed up with a password.

possibly the `password_enabled` setting needs more options: `True`, `False`, `"only_for_reauth"`?

Resolving patch:
diff --git a/synapse/config/auth.py b/synapse/config/auth.py
--- a/synapse/config/auth.py
+++ b/synapse/config/auth.py
@@ -29,7 +29,18 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None:
         if password_config is None:
             password_config = {}
 
-        self.password_enabled = password_config.get("enabled", True)
+        passwords_enabled = password_config.get("enabled", True)
+        # 'only_for_reauth' allows users who have previously set a password to use it,
+        # even though passwords would otherwise be disabled.
+        passwords_for_reauth_only = passwords_enabled == "only_for_reauth"
+
+        self.password_enabled_for_login = (
+            passwords_enabled and not passwords_for_reauth_only
+        )
+        self.password_enabled_for_reauth = (
+            passwords_for_reauth_only or passwords_enabled
+        )
+
         self.password_localdb_enabled = password_config.get("localdb_enabled", True)
         self.password_pepper = password_config.get("pepper", "")
 
@@ -46,7 +57,9 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None:
     def generate_config_section(self, **kwargs: Any) -> str:
         return """\
         password_config:
-           # Uncomment to disable password login
+           # Uncomment to disable password login.
+           # Set to `only_for_reauth` to permit reauthentication for users that
+           # have passwords and are already logged in.
            #
            #enabled: false
 
diff --git a/synapse/handlers/auth.py b/synapse/handlers/auth.py
--- a/synapse/handlers/auth.py
+++ b/synapse/handlers/auth.py
@@ -210,7 +210,8 @@ def __init__(self, hs: "HomeServer"):
 
         self.hs = hs  # FIXME better possibility to access registrationHandler later?
         self.macaroon_gen = hs.get_macaroon_generator()
-        self._password_enabled = hs.config.auth.password_enabled
+        self._password_enabled_for_login = hs.config.auth.password_enabled_for_login
+        self._password_enabled_for_reauth = hs.config.auth.password_enabled_for_reauth
         self._password_localdb_enabled = hs.config.auth.password_localdb_enabled
         self._third_party_rules = hs.get_third_party_event_rules()
 
@@ -387,13 +388,13 @@ def get_new_session_data() -> JsonDict:
         return params, session_id
 
     async def _get_available_ui_auth_types(self, user: UserID) -> Iterable[str]:
-        """Get a list of the authentication types this user can use"""
+        """Get a list of the user-interactive authentication types this user can use."""
 
         ui_auth_types = set()
 
         # if the HS supports password auth, and the user has a non-null password, we
         # support password auth
-        if self._password_localdb_enabled and self._password_enabled:
+        if self._password_localdb_enabled and self._password_enabled_for_reauth:
             lookupres = await self._find_user_id_and_pwd_hash(user.to_string())
             if lookupres:
                 _, password_hash = lookupres
@@ -402,7 +403,7 @@ async def _get_available_ui_auth_types(self, user: UserID) -> Iterable[str]:
 
         # also allow auth from password providers
         for t in self.password_auth_provider.get_supported_login_types().keys():
-            if t == LoginType.PASSWORD and not self._password_enabled:
+            if t == LoginType.PASSWORD and not self._password_enabled_for_reauth:
                 continue
             ui_auth_types.add(t)
 
@@ -710,7 +711,7 @@ async def _check_auth_dict(
             return res
 
         # fall back to the v1 login flow
-        canonical_id, _ = await self.validate_login(authdict)
+        canonical_id, _ = await self.validate_login(authdict, is_reauth=True)
         return canonical_id
 
     def _get_params_recaptcha(self) -> dict:
@@ -1064,7 +1065,7 @@ def can_change_password(self) -> bool:
         Returns:
             Whether users on this server are allowed to change or set a password
         """
-        return self._password_enabled and self._password_localdb_enabled
+        return self._password_enabled_for_login and self._password_localdb_enabled
 
     def get_supported_login_types(self) -> Iterable[str]:
         """Get a the login types supported for the /login API
@@ -1089,9 +1090,9 @@ def get_supported_login_types(self) -> Iterable[str]:
         # that comes first, where it's present.
         if LoginType.PASSWORD in types:
             types.remove(LoginType.PASSWORD)
-            if self._password_enabled:
+            if self._password_enabled_for_login:
                 types.insert(0, LoginType.PASSWORD)
-        elif self._password_localdb_enabled and self._password_enabled:
+        elif self._password_localdb_enabled and self._password_enabled_for_login:
             types.insert(0, LoginType.PASSWORD)
 
         return types
@@ -1100,6 +1101,7 @@ async def validate_login(
         self,
         login_submission: Dict[str, Any],
         ratelimit: bool = False,
+        is_reauth: bool = False,
     ) -> Tuple[str, Optional[Callable[["LoginResponse"], Awaitable[None]]]]:
         """Authenticates the user for the /login API
 
@@ -1110,6 +1112,9 @@ async def validate_login(
             login_submission: the whole of the login submission
                 (including 'type' and other relevant fields)
             ratelimit: whether to apply the failed_login_attempt ratelimiter
+            is_reauth: whether this is part of a User-Interactive Authorisation
+                flow to reauthenticate for a privileged action (rather than a
+                new login)
         Returns:
             A tuple of the canonical user id, and optional callback
                 to be called once the access token and device id are issued
@@ -1132,8 +1137,14 @@ async def validate_login(
         # special case to check for "password" for the check_password interface
         # for the auth providers
         password = login_submission.get("password")
+
         if login_type == LoginType.PASSWORD:
-            if not self._password_enabled:
+            if is_reauth:
+                passwords_allowed_here = self._password_enabled_for_reauth
+            else:
+                passwords_allowed_here = self._password_enabled_for_login
+
+            if not passwords_allowed_here:
                 raise SynapseError(400, "Password login has been disabled.")
             if not isinstance(password, str):
                 raise SynapseError(400, "Bad parameter: password", Codes.INVALID_PARAM)


Test patch:
diff --git a/tests/rest/client/test_auth.py b/tests/rest/client/test_auth.py
--- a/tests/rest/client/test_auth.py
+++ b/tests/rest/client/test_auth.py
@@ -195,8 +195,17 @@ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
         self.user_pass = "pass"
         self.user = self.register_user("test", self.user_pass)
         self.device_id = "dev1"
+
+        # Force-enable password login for just long enough to log in.
+        auth_handler = self.hs.get_auth_handler()
+        allow_auth_for_login = auth_handler._password_enabled_for_login
+        auth_handler._password_enabled_for_login = True
+
         self.user_tok = self.login("test", self.user_pass, self.device_id)
 
+        # Restore password login to however it was.
+        auth_handler._password_enabled_for_login = allow_auth_for_login
+
     def delete_device(
         self,
         access_token: str,
@@ -263,6 +272,38 @@ def test_ui_auth(self) -> None:
             },
         )
 
+    @override_config({"password_config": {"enabled": "only_for_reauth"}})
+    def test_ui_auth_with_passwords_for_reauth_only(self) -> None:
+        """
+        Test user interactive authentication outside of registration.
+        """
+
+        # Attempt to delete this device.
+        # Returns a 401 as per the spec
+        channel = self.delete_device(
+            self.user_tok, self.device_id, HTTPStatus.UNAUTHORIZED
+        )
+
+        # Grab the session
+        session = channel.json_body["session"]
+        # Ensure that flows are what is expected.
+        self.assertIn({"stages": ["m.login.password"]}, channel.json_body["flows"])
+
+        # Make another request providing the UI auth flow.
+        self.delete_device(
+            self.user_tok,
+            self.device_id,
+            HTTPStatus.OK,
+            {
+                "auth": {
+                    "type": "m.login.password",
+                    "identifier": {"type": "m.id.user", "user": self.user},
+                    "password": self.user_pass,
+                    "session": session,
+                },
+            },
+        )
+
     def test_grandfathered_identifier(self) -> None:
         """Check behaviour without "identifier" dict