Allow existing users to login via OpenID Connect. (#8345)

Co-authored-by: Benjamin Koch <bbbsnowball@gmail.com>

This adds configuration flags that will match a user to pre-existing users
when logging in via OpenID Connect. This is useful when switching to
an existing SSO system.
This commit is contained in:
Tdxdxoz 2020-09-25 19:01:45 +08:00 committed by GitHub
parent 3e87d79e1c
commit abd04b6af0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 76 additions and 17 deletions

1
changelog.d/8345.feature Normal file
View file

@ -0,0 +1 @@
Add a configuration option that allows existing users to log in with OpenID Connect. Contributed by @BBBSnowball and @OmmyZhang.

View file

@ -1689,6 +1689,11 @@ oidc_config:
# #
#skip_verification: true #skip_verification: true
# Uncomment to allow a user logging in via OIDC to match a pre-existing account instead
# of failing. This could be used if switching from password logins to OIDC. Defaults to false.
#
#allow_existing_users: true
# An external module can be provided here as a custom solution to mapping # An external module can be provided here as a custom solution to mapping
# attributes returned from a OIDC provider onto a matrix user. # attributes returned from a OIDC provider onto a matrix user.
# #

View file

@ -56,6 +56,7 @@ class OIDCConfig(Config):
self.oidc_userinfo_endpoint = oidc_config.get("userinfo_endpoint") self.oidc_userinfo_endpoint = oidc_config.get("userinfo_endpoint")
self.oidc_jwks_uri = oidc_config.get("jwks_uri") self.oidc_jwks_uri = oidc_config.get("jwks_uri")
self.oidc_skip_verification = oidc_config.get("skip_verification", False) self.oidc_skip_verification = oidc_config.get("skip_verification", False)
self.oidc_allow_existing_users = oidc_config.get("allow_existing_users", False)
ump_config = oidc_config.get("user_mapping_provider", {}) ump_config = oidc_config.get("user_mapping_provider", {})
ump_config.setdefault("module", DEFAULT_USER_MAPPING_PROVIDER) ump_config.setdefault("module", DEFAULT_USER_MAPPING_PROVIDER)
@ -158,6 +159,11 @@ class OIDCConfig(Config):
# #
#skip_verification: true #skip_verification: true
# Uncomment to allow a user logging in via OIDC to match a pre-existing account instead
# of failing. This could be used if switching from password logins to OIDC. Defaults to false.
#
#allow_existing_users: true
# An external module can be provided here as a custom solution to mapping # An external module can be provided here as a custom solution to mapping
# attributes returned from a OIDC provider onto a matrix user. # attributes returned from a OIDC provider onto a matrix user.
# #

View file

@ -114,6 +114,7 @@ class OidcHandler:
hs.config.oidc_user_mapping_provider_config hs.config.oidc_user_mapping_provider_config
) # type: OidcMappingProvider ) # type: OidcMappingProvider
self._skip_verification = hs.config.oidc_skip_verification # type: bool self._skip_verification = hs.config.oidc_skip_verification # type: bool
self._allow_existing_users = hs.config.oidc_allow_existing_users # type: bool
self._http_client = hs.get_proxied_http_client() self._http_client = hs.get_proxied_http_client()
self._auth_handler = hs.get_auth_handler() self._auth_handler = hs.get_auth_handler()
@ -849,7 +850,8 @@ class OidcHandler:
If we don't find the user that way, we should register the user, If we don't find the user that way, we should register the user,
mapping the localpart and the display name from the UserInfo. mapping the localpart and the display name from the UserInfo.
If a user already exists with the mxid we've mapped, raise an exception. If a user already exists with the mxid we've mapped and allow_existing_users
is disabled, raise an exception.
Args: Args:
userinfo: an object representing the user userinfo: an object representing the user
@ -905,21 +907,31 @@ class OidcHandler:
localpart = map_username_to_mxid_localpart(attributes["localpart"]) localpart = map_username_to_mxid_localpart(attributes["localpart"])
user_id = UserID(localpart, self._hostname) user_id = UserID(localpart, self._hostname).to_string()
if await self._datastore.get_users_by_id_case_insensitive(user_id.to_string()): users = await self._datastore.get_users_by_id_case_insensitive(user_id)
# This mxid is taken if users:
raise MappingException( if self._allow_existing_users:
"mxid '{}' is already taken".format(user_id.to_string()) if len(users) == 1:
registered_user_id = next(iter(users))
elif user_id in users:
registered_user_id = user_id
else:
raise MappingException(
"Attempted to login as '{}' but it matches more than one user inexactly: {}".format(
user_id, list(users.keys())
)
)
else:
# This mxid is taken
raise MappingException("mxid '{}' is already taken".format(user_id))
else:
# It's the first time this user is logging in and the mapped mxid was
# not taken, register the user
registered_user_id = await self._registration_handler.register_user(
localpart=localpart,
default_display_name=attributes["display_name"],
user_agent_ips=(user_agent, ip_address),
) )
# It's the first time this user is logging in and the mapped mxid was
# not taken, register the user
registered_user_id = await self._registration_handler.register_user(
localpart=localpart,
default_display_name=attributes["display_name"],
user_agent_ips=(user_agent, ip_address),
)
await self._datastore.record_user_external_id( await self._datastore.record_user_external_id(
self._auth_provider_id, remote_user_id, registered_user_id, self._auth_provider_id, remote_user_id, registered_user_id,
) )

View file

@ -393,7 +393,7 @@ class RegistrationWorkerStore(SQLBaseStore):
async def get_user_by_external_id( async def get_user_by_external_id(
self, auth_provider: str, external_id: str self, auth_provider: str, external_id: str
) -> str: ) -> Optional[str]:
"""Look up a user by their external auth id """Look up a user by their external auth id
Args: Args:
@ -401,7 +401,7 @@ class RegistrationWorkerStore(SQLBaseStore):
external_id: id on that system external_id: id on that system
Returns: Returns:
str|None: the mxid of the user, or None if they are not known the mxid of the user, or None if they are not known
""" """
return await self.db_pool.simple_select_one_onecol( return await self.db_pool.simple_select_one_onecol(
table="user_external_ids", table="user_external_ids",

View file

@ -617,3 +617,38 @@ class OidcHandlerTestCase(HomeserverTestCase):
) )
) )
self.assertEqual(mxid, "@test_user_2:test") self.assertEqual(mxid, "@test_user_2:test")
# Test if the mxid is already taken
store = self.hs.get_datastore()
user3 = UserID.from_string("@test_user_3:test")
self.get_success(
store.register_user(user_id=user3.to_string(), password_hash=None)
)
userinfo = {"sub": "test3", "username": "test_user_3"}
e = self.get_failure(
self.handler._map_userinfo_to_user(
userinfo, token, "user-agent", "10.10.10.10"
),
MappingException,
)
self.assertEqual(str(e.value), "mxid '@test_user_3:test' is already taken")
@override_config({"oidc_config": {"allow_existing_users": True}})
def test_map_userinfo_to_existing_user(self):
"""Existing users can log in with OpenID Connect when allow_existing_users is True."""
store = self.hs.get_datastore()
user4 = UserID.from_string("@test_user_4:test")
self.get_success(
store.register_user(user_id=user4.to_string(), password_hash=None)
)
userinfo = {
"sub": "test4",
"username": "test_user_4",
}
token = {}
mxid = self.get_success(
self.handler._map_userinfo_to_user(
userinfo, token, "user-agent", "10.10.10.10"
)
)
self.assertEqual(mxid, "@test_user_4:test")