mirror of
https://mau.dev/maunium/synapse.git
synced 2024-12-14 04:23:45 +01:00
UI Auth via SSO: redirect the user to an appropriate SSO. (#9081)
If we have integrations with multiple identity providers, when the user does a UI Auth, we need to redirect them to the right one. There are a few steps to this. First of all we actually need to store the userid of the user we are trying to validate in the UIA session, since the /auth/sso/fallback/web request is unauthenticated. Then, once we get the /auth/sso/fallback/web request, we can fish the user id out of the session, and use it to look up the external id mappings, and hence pick an SSO provider for them.
This commit is contained in:
parent
723b19748a
commit
789d9ebad3
7 changed files with 133 additions and 60 deletions
1
changelog.d/9081.feature
Normal file
1
changelog.d/9081.feature
Normal file
|
@ -0,0 +1 @@
|
|||
Add support for multiple SSO Identity Providers.
|
|
@ -50,7 +50,10 @@ from synapse.api.errors import (
|
|||
)
|
||||
from synapse.api.ratelimiting import Ratelimiter
|
||||
from synapse.handlers._base import BaseHandler
|
||||
from synapse.handlers.ui_auth import INTERACTIVE_AUTH_CHECKERS
|
||||
from synapse.handlers.ui_auth import (
|
||||
INTERACTIVE_AUTH_CHECKERS,
|
||||
UIAuthSessionDataConstants,
|
||||
)
|
||||
from synapse.handlers.ui_auth.checkers import UserInteractiveAuthChecker
|
||||
from synapse.http import get_request_user_agent
|
||||
from synapse.http.server import finish_request, respond_with_html
|
||||
|
@ -335,10 +338,10 @@ class AuthHandler(BaseHandler):
|
|||
request_body.pop("auth", None)
|
||||
return request_body, None
|
||||
|
||||
user_id = requester.user.to_string()
|
||||
requester_user_id = requester.user.to_string()
|
||||
|
||||
# Check if we should be ratelimited due to too many previous failed attempts
|
||||
self._failed_uia_attempts_ratelimiter.ratelimit(user_id, update=False)
|
||||
self._failed_uia_attempts_ratelimiter.ratelimit(requester_user_id, update=False)
|
||||
|
||||
# build a list of supported flows
|
||||
supported_ui_auth_types = await self._get_available_ui_auth_types(
|
||||
|
@ -346,13 +349,16 @@ class AuthHandler(BaseHandler):
|
|||
)
|
||||
flows = [[login_type] for login_type in supported_ui_auth_types]
|
||||
|
||||
def get_new_session_data() -> JsonDict:
|
||||
return {UIAuthSessionDataConstants.REQUEST_USER_ID: requester_user_id}
|
||||
|
||||
try:
|
||||
result, params, session_id = await self.check_ui_auth(
|
||||
flows, request, request_body, description
|
||||
flows, request, request_body, description, get_new_session_data,
|
||||
)
|
||||
except LoginError:
|
||||
# Update the ratelimiter to say we failed (`can_do_action` doesn't raise).
|
||||
self._failed_uia_attempts_ratelimiter.can_do_action(user_id)
|
||||
self._failed_uia_attempts_ratelimiter.can_do_action(requester_user_id)
|
||||
raise
|
||||
|
||||
# find the completed login type
|
||||
|
@ -360,14 +366,14 @@ class AuthHandler(BaseHandler):
|
|||
if login_type not in result:
|
||||
continue
|
||||
|
||||
user_id = result[login_type]
|
||||
validated_user_id = result[login_type]
|
||||
break
|
||||
else:
|
||||
# this can't happen
|
||||
raise Exception("check_auth returned True but no successful login type")
|
||||
|
||||
# check that the UI auth matched the access token
|
||||
if user_id != requester.user.to_string():
|
||||
if validated_user_id != requester_user_id:
|
||||
raise AuthError(403, "Invalid auth")
|
||||
|
||||
# Note that the access token has been validated.
|
||||
|
@ -399,13 +405,9 @@ class AuthHandler(BaseHandler):
|
|||
|
||||
# if sso is enabled, allow the user to log in via SSO iff they have a mapping
|
||||
# from sso to mxid.
|
||||
if self.hs.config.saml2.saml2_enabled or self.hs.config.oidc.oidc_enabled:
|
||||
if await self.store.get_external_ids_by_user(user.to_string()):
|
||||
ui_auth_types.add(LoginType.SSO)
|
||||
|
||||
# Our CAS impl does not (yet) correctly register users in user_external_ids,
|
||||
# so always offer that if it's available.
|
||||
if self.hs.config.cas.cas_enabled:
|
||||
if await self.hs.get_sso_handler().get_identity_providers_for_user(
|
||||
user.to_string()
|
||||
):
|
||||
ui_auth_types.add(LoginType.SSO)
|
||||
|
||||
return ui_auth_types
|
||||
|
@ -424,6 +426,7 @@ class AuthHandler(BaseHandler):
|
|||
request: SynapseRequest,
|
||||
clientdict: Dict[str, Any],
|
||||
description: str,
|
||||
get_new_session_data: Optional[Callable[[], JsonDict]] = None,
|
||||
) -> Tuple[dict, dict, str]:
|
||||
"""
|
||||
Takes a dictionary sent by the client in the login / registration
|
||||
|
@ -447,6 +450,13 @@ class AuthHandler(BaseHandler):
|
|||
description: A human readable string to be displayed to the user that
|
||||
describes the operation happening on their account.
|
||||
|
||||
get_new_session_data:
|
||||
an optional callback which will be called when starting a new session.
|
||||
it should return data to be stored as part of the session.
|
||||
|
||||
The keys of the returned data should be entries in
|
||||
UIAuthSessionDataConstants.
|
||||
|
||||
Returns:
|
||||
A tuple of (creds, params, session_id).
|
||||
|
||||
|
@ -474,10 +484,15 @@ class AuthHandler(BaseHandler):
|
|||
|
||||
# If there's no session ID, create a new session.
|
||||
if not sid:
|
||||
new_session_data = get_new_session_data() if get_new_session_data else {}
|
||||
|
||||
session = await self.store.create_ui_auth_session(
|
||||
clientdict, uri, method, description
|
||||
)
|
||||
|
||||
for k, v in new_session_data.items():
|
||||
await self.set_session_data(session.session_id, k, v)
|
||||
|
||||
else:
|
||||
try:
|
||||
session = await self.store.get_ui_auth_session(sid)
|
||||
|
@ -639,7 +654,8 @@ class AuthHandler(BaseHandler):
|
|||
|
||||
Args:
|
||||
session_id: The ID of this session as returned from check_auth
|
||||
key: The key to store the data under
|
||||
key: The key to store the data under. An entry from
|
||||
UIAuthSessionDataConstants.
|
||||
value: The data to store
|
||||
"""
|
||||
try:
|
||||
|
@ -655,7 +671,8 @@ class AuthHandler(BaseHandler):
|
|||
|
||||
Args:
|
||||
session_id: The ID of this session as returned from check_auth
|
||||
key: The key to store the data under
|
||||
key: The key the data was stored under. An entry from
|
||||
UIAuthSessionDataConstants.
|
||||
default: Value to return if the key has not been set
|
||||
"""
|
||||
try:
|
||||
|
@ -1329,12 +1346,12 @@ class AuthHandler(BaseHandler):
|
|||
else:
|
||||
return False
|
||||
|
||||
async def start_sso_ui_auth(self, redirect_url: str, session_id: str) -> str:
|
||||
async def start_sso_ui_auth(self, request: SynapseRequest, session_id: str) -> str:
|
||||
"""
|
||||
Get the HTML for the SSO redirect confirmation page.
|
||||
|
||||
Args:
|
||||
redirect_url: The URL to redirect to the SSO provider.
|
||||
request: The incoming HTTP request
|
||||
session_id: The user interactive authentication session ID.
|
||||
|
||||
Returns:
|
||||
|
@ -1344,6 +1361,35 @@ class AuthHandler(BaseHandler):
|
|||
session = await self.store.get_ui_auth_session(session_id)
|
||||
except StoreError:
|
||||
raise SynapseError(400, "Unknown session ID: %s" % (session_id,))
|
||||
|
||||
user_id_to_verify = await self.get_session_data(
|
||||
session_id, UIAuthSessionDataConstants.REQUEST_USER_ID
|
||||
) # type: str
|
||||
|
||||
idps = await self.hs.get_sso_handler().get_identity_providers_for_user(
|
||||
user_id_to_verify
|
||||
)
|
||||
|
||||
if not idps:
|
||||
# we checked that the user had some remote identities before offering an SSO
|
||||
# flow, so either it's been deleted or the client has requested SSO despite
|
||||
# it not being offered.
|
||||
raise SynapseError(400, "User has no SSO identities")
|
||||
|
||||
# for now, just pick one
|
||||
idp_id, sso_auth_provider = next(iter(idps.items()))
|
||||
if len(idps) > 0:
|
||||
logger.warning(
|
||||
"User %r has previously logged in with multiple SSO IdPs; arbitrarily "
|
||||
"picking %r",
|
||||
user_id_to_verify,
|
||||
idp_id,
|
||||
)
|
||||
|
||||
redirect_url = await sso_auth_provider.handle_redirect_request(
|
||||
request, None, session_id
|
||||
)
|
||||
|
||||
return self._sso_auth_confirm_template.render(
|
||||
description=session.description, redirect_url=redirect_url,
|
||||
)
|
||||
|
|
|
@ -167,6 +167,37 @@ class SsoHandler:
|
|||
"""Get the configured identity providers"""
|
||||
return self._identity_providers
|
||||
|
||||
async def get_identity_providers_for_user(
|
||||
self, user_id: str
|
||||
) -> Mapping[str, SsoIdentityProvider]:
|
||||
"""Get the SsoIdentityProviders which a user has used
|
||||
|
||||
Given a user id, get the identity providers that that user has used to log in
|
||||
with in the past (and thus could use to re-identify themselves for UI Auth).
|
||||
|
||||
Args:
|
||||
user_id: MXID of user to look up
|
||||
|
||||
Raises:
|
||||
a map of idp_id to SsoIdentityProvider
|
||||
"""
|
||||
external_ids = await self._store.get_external_ids_by_user(user_id)
|
||||
|
||||
valid_idps = {}
|
||||
for idp_id, _ in external_ids:
|
||||
idp = self._identity_providers.get(idp_id)
|
||||
if not idp:
|
||||
logger.warning(
|
||||
"User %r has an SSO mapping for IdP %r, but this is no longer "
|
||||
"configured.",
|
||||
user_id,
|
||||
idp_id,
|
||||
)
|
||||
else:
|
||||
valid_idps[idp_id] = idp
|
||||
|
||||
return valid_idps
|
||||
|
||||
def render_error(
|
||||
self,
|
||||
request: Request,
|
||||
|
|
|
@ -20,3 +20,18 @@ TODO: move more stuff out of AuthHandler in here.
|
|||
"""
|
||||
|
||||
from synapse.handlers.ui_auth.checkers import INTERACTIVE_AUTH_CHECKERS # noqa: F401
|
||||
|
||||
|
||||
class UIAuthSessionDataConstants:
|
||||
"""Constants for use with AuthHandler.set_session_data"""
|
||||
|
||||
# used during registration and password reset to store a hashed copy of the
|
||||
# password, so that the client does not need to submit it each time.
|
||||
PASSWORD_HASH = "password_hash"
|
||||
|
||||
# used during registration to store the mxid of the registered user
|
||||
REGISTERED_USER_ID = "registered_user_id"
|
||||
|
||||
# used by validate_user_via_ui_auth to store the mxid of the user we are validating
|
||||
# for.
|
||||
REQUEST_USER_ID = "request_user_id"
|
||||
|
|
|
@ -20,9 +20,6 @@ from http import HTTPStatus
|
|||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlparse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from synapse.app.homeserver import HomeServer
|
||||
|
||||
from synapse.api.constants import LoginType
|
||||
from synapse.api.errors import (
|
||||
Codes,
|
||||
|
@ -31,6 +28,7 @@ from synapse.api.errors import (
|
|||
ThreepidValidationError,
|
||||
)
|
||||
from synapse.config.emailconfig import ThreepidBehaviour
|
||||
from synapse.handlers.ui_auth import UIAuthSessionDataConstants
|
||||
from synapse.http.server import finish_request, respond_with_html
|
||||
from synapse.http.servlet import (
|
||||
RestServlet,
|
||||
|
@ -46,6 +44,10 @@ from synapse.util.threepids import canonicalise_email, check_3pid_allowed
|
|||
|
||||
from ._base import client_patterns, interactive_auth_handler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from synapse.app.homeserver import HomeServer
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
@ -200,7 +202,9 @@ class PasswordRestServlet(RestServlet):
|
|||
if new_password:
|
||||
password_hash = await self.auth_handler.hash(new_password)
|
||||
await self.auth_handler.set_session_data(
|
||||
e.session_id, "password_hash", password_hash
|
||||
e.session_id,
|
||||
UIAuthSessionDataConstants.PASSWORD_HASH,
|
||||
password_hash,
|
||||
)
|
||||
raise
|
||||
user_id = requester.user.to_string()
|
||||
|
@ -222,7 +226,9 @@ class PasswordRestServlet(RestServlet):
|
|||
if new_password:
|
||||
password_hash = await self.auth_handler.hash(new_password)
|
||||
await self.auth_handler.set_session_data(
|
||||
e.session_id, "password_hash", password_hash
|
||||
e.session_id,
|
||||
UIAuthSessionDataConstants.PASSWORD_HASH,
|
||||
password_hash,
|
||||
)
|
||||
raise
|
||||
|
||||
|
@ -255,7 +261,7 @@ class PasswordRestServlet(RestServlet):
|
|||
password_hash = await self.auth_handler.hash(new_password)
|
||||
elif session_id is not None:
|
||||
password_hash = await self.auth_handler.get_session_data(
|
||||
session_id, "password_hash", None
|
||||
session_id, UIAuthSessionDataConstants.PASSWORD_HASH, None
|
||||
)
|
||||
else:
|
||||
# UI validation was skipped, but the request did not include a new
|
||||
|
|
|
@ -19,7 +19,6 @@ from typing import TYPE_CHECKING
|
|||
from synapse.api.constants import LoginType
|
||||
from synapse.api.errors import SynapseError
|
||||
from synapse.api.urls import CLIENT_API_PREFIX
|
||||
from synapse.handlers.sso import SsoIdentityProvider
|
||||
from synapse.http.server import respond_with_html
|
||||
from synapse.http.servlet import RestServlet, parse_string
|
||||
|
||||
|
@ -46,22 +45,6 @@ class AuthRestServlet(RestServlet):
|
|||
self.auth = hs.get_auth()
|
||||
self.auth_handler = hs.get_auth_handler()
|
||||
self.registration_handler = hs.get_registration_handler()
|
||||
|
||||
# SSO configuration.
|
||||
self._cas_enabled = hs.config.cas_enabled
|
||||
if self._cas_enabled:
|
||||
self._cas_handler = hs.get_cas_handler()
|
||||
self._cas_server_url = hs.config.cas_server_url
|
||||
self._cas_service_url = hs.config.cas_service_url
|
||||
self._saml_enabled = hs.config.saml2_enabled
|
||||
if self._saml_enabled:
|
||||
self._saml_handler = hs.get_saml_handler()
|
||||
self._oidc_enabled = hs.config.oidc_enabled
|
||||
if self._oidc_enabled:
|
||||
self._oidc_handler = hs.get_oidc_handler()
|
||||
self._cas_server_url = hs.config.cas_server_url
|
||||
self._cas_service_url = hs.config.cas_service_url
|
||||
|
||||
self.recaptcha_template = hs.config.recaptcha_template
|
||||
self.terms_template = hs.config.terms_template
|
||||
self.success_template = hs.config.fallback_success_template
|
||||
|
@ -90,21 +73,7 @@ class AuthRestServlet(RestServlet):
|
|||
elif stagetype == LoginType.SSO:
|
||||
# Display a confirmation page which prompts the user to
|
||||
# re-authenticate with their SSO provider.
|
||||
|
||||
if self._cas_enabled:
|
||||
sso_auth_provider = self._cas_handler # type: SsoIdentityProvider
|
||||
elif self._saml_enabled:
|
||||
sso_auth_provider = self._saml_handler
|
||||
elif self._oidc_enabled:
|
||||
sso_auth_provider = self._oidc_handler
|
||||
else:
|
||||
raise SynapseError(400, "Homeserver not configured for SSO.")
|
||||
|
||||
sso_redirect_url = await sso_auth_provider.handle_redirect_request(
|
||||
request, None, session
|
||||
)
|
||||
|
||||
html = await self.auth_handler.start_sso_ui_auth(sso_redirect_url, session)
|
||||
html = await self.auth_handler.start_sso_ui_auth(request, session)
|
||||
|
||||
else:
|
||||
raise SynapseError(404, "Unknown auth stage type")
|
||||
|
|
|
@ -38,6 +38,7 @@ from synapse.config.ratelimiting import FederationRateLimitConfig
|
|||
from synapse.config.registration import RegistrationConfig
|
||||
from synapse.config.server import is_threepid_reserved
|
||||
from synapse.handlers.auth import AuthHandler
|
||||
from synapse.handlers.ui_auth import UIAuthSessionDataConstants
|
||||
from synapse.http.server import finish_request, respond_with_html
|
||||
from synapse.http.servlet import (
|
||||
RestServlet,
|
||||
|
@ -494,11 +495,11 @@ class RegisterRestServlet(RestServlet):
|
|||
# user here. We carry on and go through the auth checks though,
|
||||
# for paranoia.
|
||||
registered_user_id = await self.auth_handler.get_session_data(
|
||||
session_id, "registered_user_id", None
|
||||
session_id, UIAuthSessionDataConstants.REGISTERED_USER_ID, None
|
||||
)
|
||||
# Extract the previously-hashed password from the session.
|
||||
password_hash = await self.auth_handler.get_session_data(
|
||||
session_id, "password_hash", None
|
||||
session_id, UIAuthSessionDataConstants.PASSWORD_HASH, None
|
||||
)
|
||||
|
||||
# Ensure that the username is valid.
|
||||
|
@ -528,7 +529,9 @@ class RegisterRestServlet(RestServlet):
|
|||
if not password_hash and password:
|
||||
password_hash = await self.auth_handler.hash(password)
|
||||
await self.auth_handler.set_session_data(
|
||||
e.session_id, "password_hash", password_hash
|
||||
e.session_id,
|
||||
UIAuthSessionDataConstants.PASSWORD_HASH,
|
||||
password_hash,
|
||||
)
|
||||
raise
|
||||
|
||||
|
@ -629,7 +632,9 @@ class RegisterRestServlet(RestServlet):
|
|||
# Remember that the user account has been registered (and the user
|
||||
# ID it was registered with, since it might not have been specified).
|
||||
await self.auth_handler.set_session_data(
|
||||
session_id, "registered_user_id", registered_user_id
|
||||
session_id,
|
||||
UIAuthSessionDataConstants.REGISTERED_USER_ID,
|
||||
registered_user_id,
|
||||
)
|
||||
|
||||
registered = True
|
||||
|
|
Loading…
Reference in a new issue