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

UI Auth via SSO: redirect the user to an appropriate SSO. #9081

Merged
merged 7 commits into from
Jan 12, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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")
Comment on lines +1374 to +1377
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this point in the flow should we be rendering JSON or HTML errors? (Should this use sso_handler.render_error?)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that said... there are plenty of other places we are raising SynapseErrors in this method. Ideally they'd all be caught and sent to sso_handler.render_error instead, and while we're at it I think start_sso_ui_auth probably just wants to move into SsoHandler, but that feels like a bigger refactor.


# for now, just pick one
idp_id, sso_auth_provider = next(iter(idps.items()))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This theoretically isn't stable before Python 3.7. I suspect we don't care since they can use any identity.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

indeed. the first is no better than the last.

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