Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Commit

Permalink
UI Auth via SSO: redirect the user to an appropriate SSO. (#9081)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
richvdh authored Jan 12, 2021
1 parent 723b197 commit 789d9eb
Show file tree
Hide file tree
Showing 7 changed files with 133 additions and 60 deletions.
1 change: 1 addition & 0 deletions changelog.d/9081.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add support for multiple SSO Identity Providers.
82 changes: 64 additions & 18 deletions synapse/handlers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@
)
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
Expand Down Expand Up @@ -335,39 +338,42 @@ async def validate_user_via_ui_auth(
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(
requester.user
)
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
for login_type in supported_ui_auth_types:
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.
Expand Down Expand Up @@ -399,13 +405,9 @@ async def _get_available_ui_auth_types(self, user: UserID) -> Iterable[str]:

# 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
Expand All @@ -424,6 +426,7 @@ async def check_ui_auth(
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
Expand All @@ -447,6 +450,13 @@ async def check_ui_auth(
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).
Expand Down Expand Up @@ -474,10 +484,15 @@ async def check_ui_auth(

# 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)
Expand Down Expand Up @@ -639,7 +654,8 @@ async def set_session_data(self, session_id: str, key: str, value: Any) -> None:
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:
Expand All @@ -655,7 +671,8 @@ async def get_session_data(
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:
Expand Down Expand Up @@ -1329,12 +1346,12 @@ def _do_validate_hash(checked_hash: bytes):
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:
Expand All @@ -1344,6 +1361,35 @@ async def start_sso_ui_auth(self, redirect_url: str, session_id: str) -> str:
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,
)
Expand Down
31 changes: 31 additions & 0 deletions synapse/handlers/sso.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,37 @@ def get_identity_providers(self) -> Mapping[str, SsoIdentityProvider]:
"""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,
Expand Down
15 changes: 15 additions & 0 deletions synapse/handlers/ui_auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,18 @@
"""

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"
18 changes: 12 additions & 6 deletions synapse/rest/client/v2_alpha/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,6 @@
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,
Expand All @@ -31,6 +28,7 @@
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,
Expand All @@ -46,6 +44,10 @@

from ._base import client_patterns, interactive_auth_handler

if TYPE_CHECKING:
from synapse.app.homeserver import HomeServer


logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -200,7 +202,9 @@ async def on_POST(self, request):
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()
Expand All @@ -222,7 +226,9 @@ async def on_POST(self, request):
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

Expand Down Expand Up @@ -255,7 +261,7 @@ async def on_POST(self, request):
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
Expand Down
33 changes: 1 addition & 32 deletions synapse/rest/client/v2_alpha/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
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

Expand All @@ -46,22 +45,6 @@ def __init__(self, hs: "HomeServer"):
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
Expand Down Expand Up @@ -90,21 +73,7 @@ async def on_GET(self, request, stagetype):
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")
Expand Down
Loading

0 comments on commit 789d9eb

Please sign in to comment.