123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263 |
- # Copyright 2020 The Matrix.org Foundation C.I.C.
- #
- # Licensed under the Apache License, Version 2.0 (the "License");
- # you may not use this file except in compliance with the License.
- # You may obtain a copy of the License at
- #
- # http://www.apache.org/licenses/LICENSE-2.0
- #
- # Unless required by applicable law or agreed to in writing, software
- # distributed under the License is distributed on an "AS IS" BASIS,
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- # See the License for the specific language governing permissions and
- # limitations under the License.
- import abc
- import hashlib
- import io
- import logging
- from typing import (
- TYPE_CHECKING,
- Any,
- Awaitable,
- Callable,
- Dict,
- Iterable,
- List,
- Mapping,
- Optional,
- Set,
- )
- from urllib.parse import urlencode
- import attr
- from typing_extensions import NoReturn, Protocol
- from twisted.web.iweb import IRequest
- from twisted.web.server import Request
- from synapse.api.constants import LoginType
- from synapse.api.errors import Codes, NotFoundError, RedirectException, SynapseError
- from synapse.config.sso import SsoAttributeRequirement
- from synapse.handlers.device import DeviceHandler
- from synapse.handlers.register import init_counters_for_auth_provider
- from synapse.handlers.ui_auth import UIAuthSessionDataConstants
- from synapse.http import get_request_user_agent
- from synapse.http.server import respond_with_html, respond_with_redirect
- from synapse.http.site import SynapseRequest
- from synapse.types import (
- JsonDict,
- StrCollection,
- UserID,
- contains_invalid_mxid_characters,
- create_requester,
- )
- from synapse.util.async_helpers import Linearizer
- from synapse.util.stringutils import random_string
- if TYPE_CHECKING:
- from synapse.server import HomeServer
- logger = logging.getLogger(__name__)
- class MappingException(Exception):
- """Used to catch errors when mapping an SSO response to user attributes.
- Note that the msg that is raised is shown to end-users.
- """
- class SsoIdentityProvider(Protocol):
- """Abstract base class to be implemented by SSO Identity Providers
- An Identity Provider, or IdP, is an external HTTP service which authenticates a user
- to say whether they should be allowed to log in, or perform a given action.
- Synapse supports various implementations of IdPs, including OpenID Connect, SAML,
- and CAS.
- The main entry point is `handle_redirect_request`, which should return a URI to
- redirect the user's browser to the IdP's authentication page.
- Each IdP should be registered with the SsoHandler via
- `hs.get_sso_handler().register_identity_provider()`, so that requests to
- `/_matrix/client/r0/login/sso/redirect` can be correctly dispatched.
- """
- @property
- @abc.abstractmethod
- def idp_id(self) -> str:
- """A unique identifier for this SSO provider
- Eg, "saml", "cas", "github"
- """
- @property
- @abc.abstractmethod
- def idp_name(self) -> str:
- """User-facing name for this provider"""
- @property
- def idp_icon(self) -> Optional[str]:
- """Optional MXC URI for user-facing icon"""
- return None
- @property
- def idp_brand(self) -> Optional[str]:
- """Optional branding identifier"""
- return None
- @abc.abstractmethod
- async def handle_redirect_request(
- self,
- request: SynapseRequest,
- client_redirect_url: Optional[bytes],
- ui_auth_session_id: Optional[str] = None,
- ) -> str:
- """Handle an incoming request to /login/sso/redirect
- Args:
- request: the incoming HTTP request
- client_redirect_url: the URL that we should redirect the
- client to after login (or None for UI Auth).
- ui_auth_session_id: The session ID of the ongoing UI Auth (or
- None if this is a login).
- Returns:
- URL to redirect to
- """
- raise NotImplementedError()
- @attr.s(auto_attribs=True)
- class UserAttributes:
- # NB: This struct is documented in docs/sso_mapping_providers.md so that users can
- # populate it with data from their own mapping providers.
- # the localpart of the mxid that the mapper has assigned to the user.
- # if `None`, the mapper has not picked a userid, and the user should be prompted to
- # enter one.
- localpart: Optional[str]
- confirm_localpart: bool = False
- display_name: Optional[str] = None
- picture: Optional[str] = None
- # mypy thinks these are incompatible for some reason.
- emails: StrCollection = attr.Factory(list) # type: ignore[assignment]
- @attr.s(slots=True, auto_attribs=True)
- class UsernameMappingSession:
- """Data we track about SSO sessions"""
- # A unique identifier for this SSO provider, e.g. "oidc" or "saml".
- auth_provider_id: str
- # An optional session ID from the IdP.
- auth_provider_session_id: Optional[str]
- # user ID on the IdP server
- remote_user_id: str
- # attributes returned by the ID mapper
- display_name: Optional[str]
- emails: StrCollection
- # An optional dictionary of extra attributes to be provided to the client in the
- # login response.
- extra_login_attributes: Optional[JsonDict]
- # where to redirect the client back to
- client_redirect_url: str
- # expiry time for the session, in milliseconds
- expiry_time_ms: int
- # choices made by the user
- chosen_localpart: Optional[str] = None
- use_display_name: bool = True
- emails_to_use: StrCollection = ()
- terms_accepted_version: Optional[str] = None
- # the HTTP cookie used to track the mapping session id
- USERNAME_MAPPING_SESSION_COOKIE_NAME = b"username_mapping_session"
- class SsoHandler:
- # The number of attempts to ask the mapping provider for when generating an MXID.
- _MAP_USERNAME_RETRIES = 1000
- # the time a UsernameMappingSession remains valid for
- _MAPPING_SESSION_VALIDITY_PERIOD_MS = 15 * 60 * 1000
- def __init__(self, hs: "HomeServer"):
- self._clock = hs.get_clock()
- self._store = hs.get_datastores().main
- self._server_name = hs.hostname
- self._registration_handler = hs.get_registration_handler()
- self._auth_handler = hs.get_auth_handler()
- self._device_handler = hs.get_device_handler()
- self._error_template = hs.config.sso.sso_error_template
- self._bad_user_template = hs.config.sso.sso_auth_bad_user_template
- self._profile_handler = hs.get_profile_handler()
- self._media_repo = (
- hs.get_media_repository() if hs.config.media.can_load_media_repo else None
- )
- self._http_client = hs.get_proxied_blacklisted_http_client()
- # The following template is shown after a successful user interactive
- # authentication session. It tells the user they can close the window.
- self._sso_auth_success_template = hs.config.sso.sso_auth_success_template
- self._sso_update_profile_information = (
- hs.config.sso.sso_update_profile_information
- )
- # a lock on the mappings
- self._mapping_lock = Linearizer(name="sso_user_mapping", clock=hs.get_clock())
- # a map from session id to session data
- self._username_mapping_sessions: Dict[str, UsernameMappingSession] = {}
- # map from idp_id to SsoIdentityProvider
- self._identity_providers: Dict[str, SsoIdentityProvider] = {}
- self._consent_at_registration = hs.config.consent.user_consent_at_registration
- def register_identity_provider(self, p: SsoIdentityProvider) -> None:
- p_id = p.idp_id
- assert p_id not in self._identity_providers
- self._identity_providers[p_id] = p
- init_counters_for_auth_provider(p_id)
- 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,
- error: str,
- error_description: Optional[str] = None,
- code: int = 400,
- ) -> None:
- """Renders the error template and responds with it.
- This is used to show errors to the user. The template of this page can
- be found under `synapse/res/templates/sso_error.html`.
- Args:
- request: The incoming request from the browser.
- We'll respond with an HTML page describing the error.
- error: A technical identifier for this error.
- error_description: A human-readable description of the error.
- code: The integer error code (an HTTP response code)
- """
- html = self._error_template.render(
- error=error, error_description=error_description
- )
- respond_with_html(request, code, html)
- async def handle_redirect_request(
- self,
- request: SynapseRequest,
- client_redirect_url: bytes,
- idp_id: Optional[str],
- ) -> str:
- """Handle a request to /login/sso/redirect
- Args:
- request: incoming HTTP request
- client_redirect_url: the URL that we should redirect the
- client to after login.
- idp_id: optional identity provider chosen by the client
- Returns:
- the URI to redirect to
- """
- if not self._identity_providers:
- raise SynapseError(
- 400, "Homeserver not configured for SSO.", errcode=Codes.UNRECOGNIZED
- )
- # if the client chose an IdP, use that
- idp: Optional[SsoIdentityProvider] = None
- if idp_id:
- idp = self._identity_providers.get(idp_id)
- if not idp:
- raise NotFoundError("Unknown identity provider")
- # if we only have one auth provider, redirect to it directly
- elif len(self._identity_providers) == 1:
- idp = next(iter(self._identity_providers.values()))
- if idp:
- return await idp.handle_redirect_request(request, client_redirect_url)
- # otherwise, redirect to the IDP picker
- return "/_synapse/client/pick_idp?" + urlencode(
- (("redirectUrl", client_redirect_url),)
- )
- async def get_sso_user_by_remote_user_id(
- self, auth_provider_id: str, remote_user_id: str
- ) -> Optional[str]:
- """
- Maps the user ID of a remote IdP to a mxid for a previously seen user.
- If the user has not been seen yet, this will return None.
- Args:
- auth_provider_id: A unique identifier for this SSO provider, e.g.
- "oidc" or "saml".
- remote_user_id: The user ID according to the remote IdP. This might
- be an e-mail address, a GUID, or some other form. It must be
- unique and immutable.
- Returns:
- The mxid of a previously seen user.
- """
- logger.debug(
- "Looking for existing mapping for user %s:%s",
- auth_provider_id,
- remote_user_id,
- )
- # Check if we already have a mapping for this user.
- previously_registered_user_id = await self._store.get_user_by_external_id(
- auth_provider_id,
- remote_user_id,
- )
- # A match was found, return the user ID.
- if previously_registered_user_id is not None:
- logger.info(
- "Found existing mapping for IdP '%s' and remote_user_id '%s': %s",
- auth_provider_id,
- remote_user_id,
- previously_registered_user_id,
- )
- return previously_registered_user_id
- # No match.
- return None
- async def complete_sso_login_request(
- self,
- auth_provider_id: str,
- remote_user_id: str,
- request: SynapseRequest,
- client_redirect_url: str,
- sso_to_matrix_id_mapper: Callable[[int], Awaitable[UserAttributes]],
- grandfather_existing_users: Callable[[], Awaitable[Optional[str]]],
- extra_login_attributes: Optional[JsonDict] = None,
- auth_provider_session_id: Optional[str] = None,
- ) -> None:
- """
- Given an SSO ID, retrieve the user ID for it and possibly register the user.
- This first checks if the SSO ID has previously been linked to a matrix ID,
- if it has that matrix ID is returned regardless of the current mapping
- logic.
- If a callable is provided for grandfathering users, it is called and can
- potentially return a matrix ID to use. If it does, the SSO ID is linked to
- this matrix ID for subsequent calls.
- The mapping function is called (potentially multiple times) to generate
- a localpart for the user.
- If an unused localpart is generated, the user is registered from the
- given user-agent and IP address and the SSO ID is linked to this matrix
- ID for subsequent calls.
- Finally, we generate a redirect to the supplied redirect uri, with a login token
- Args:
- auth_provider_id: A unique identifier for this SSO provider, e.g.
- "oidc" or "saml".
- remote_user_id: The unique identifier from the SSO provider.
- request: The request to respond to
- client_redirect_url: The redirect URL passed in by the client.
- sso_to_matrix_id_mapper: A callable to generate the user attributes.
- The only parameter is an integer which represents the amount of
- times the returned mxid localpart mapping has failed.
- It is expected that the mapper can raise two exceptions, which
- will get passed through to the caller:
- MappingException if there was a problem mapping the response
- to the user.
- RedirectException to redirect to an additional page (e.g.
- to prompt the user for more information).
- grandfather_existing_users: A callable which can return an previously
- existing matrix ID. The SSO ID is then linked to the returned
- matrix ID.
- extra_login_attributes: An optional dictionary of extra
- attributes to be provided to the client in the login response.
- auth_provider_session_id: An optional session ID from the IdP.
- Raises:
- MappingException if there was a problem mapping the response to a user.
- RedirectException: if the mapping provider needs to redirect the user
- to an additional page. (e.g. to prompt for more information)
- """
- new_user = False
- # grab a lock while we try to find a mapping for this user. This seems...
- # optimistic, especially for implementations that end up redirecting to
- # interstitial pages.
- async with self._mapping_lock.queue(auth_provider_id):
- # first of all, check if we already have a mapping for this user
- user_id = await self.get_sso_user_by_remote_user_id(
- auth_provider_id,
- remote_user_id,
- )
- # Check for grandfathering of users.
- if not user_id:
- user_id = await grandfather_existing_users()
- if user_id:
- # Future logins should also match this user ID.
- await self._store.record_user_external_id(
- auth_provider_id, remote_user_id, user_id
- )
- # Otherwise, generate a new user.
- if not user_id:
- attributes = await self._call_attribute_mapper(sso_to_matrix_id_mapper)
- next_step_url = self._get_url_for_next_new_user_step(
- attributes=attributes
- )
- if next_step_url:
- await self._redirect_to_next_new_user_step(
- auth_provider_id,
- remote_user_id,
- attributes,
- client_redirect_url,
- next_step_url,
- extra_login_attributes,
- auth_provider_session_id,
- )
- user_id = await self._register_mapped_user(
- attributes,
- auth_provider_id,
- remote_user_id,
- get_request_user_agent(request),
- request.getClientAddress().host,
- )
- new_user = True
- elif self._sso_update_profile_information:
- attributes = await self._call_attribute_mapper(sso_to_matrix_id_mapper)
- if attributes.display_name:
- user_id_obj = UserID.from_string(user_id)
- profile_display_name = await self._profile_handler.get_displayname(
- user_id_obj
- )
- if profile_display_name != attributes.display_name:
- requester = create_requester(
- user_id,
- authenticated_entity=user_id,
- )
- await self._profile_handler.set_displayname(
- user_id_obj, requester, attributes.display_name, True
- )
- if attributes.picture:
- await self.set_avatar(user_id, attributes.picture)
- await self._auth_handler.complete_sso_login(
- user_id,
- auth_provider_id,
- request,
- client_redirect_url,
- extra_login_attributes,
- new_user=new_user,
- auth_provider_session_id=auth_provider_session_id,
- )
- async def _call_attribute_mapper(
- self,
- sso_to_matrix_id_mapper: Callable[[int], Awaitable[UserAttributes]],
- ) -> UserAttributes:
- """Call the attribute mapper function in a loop, until we get a unique userid"""
- for i in range(self._MAP_USERNAME_RETRIES):
- try:
- attributes = await sso_to_matrix_id_mapper(i)
- except (RedirectException, MappingException):
- # Mapping providers are allowed to issue a redirect (e.g. to ask
- # the user for more information) and can issue a mapping exception
- # if a name cannot be generated.
- raise
- except Exception as e:
- # Any other exception is unexpected.
- raise MappingException(
- "Could not extract user attributes from SSO response."
- ) from e
- logger.debug(
- "Retrieved user attributes from user mapping provider: %r (attempt %d)",
- attributes,
- i,
- )
- if not attributes.localpart:
- # the mapper has not picked a localpart
- return attributes
- # Check if this mxid already exists
- user_id = UserID(attributes.localpart, self._server_name).to_string()
- if not await self._store.get_users_by_id_case_insensitive(user_id):
- # This mxid is free
- break
- else:
- # Unable to generate a username in 1000 iterations
- # Break and return error to the user
- raise MappingException(
- "Unable to generate a Matrix ID from the SSO response"
- )
- return attributes
- def _get_url_for_next_new_user_step(
- self,
- attributes: Optional[UserAttributes] = None,
- session: Optional[UsernameMappingSession] = None,
- ) -> bytes:
- """Returns the URL to redirect to for the next step of new user registration
- Given attributes from the user mapping provider or a UsernameMappingSession,
- returns the URL to redirect to for the next step of the registration flow.
- Args:
- attributes: the user attributes returned by the user mapping provider,
- from before a UsernameMappingSession has begun.
- session: an active UsernameMappingSession, possibly with some of its
- attributes chosen by the user.
- Returns:
- The URL to redirect to, or an empty value if no redirect is necessary
- """
- # Must provide either attributes or session, not both
- assert (attributes is not None) != (session is not None)
- if (
- attributes
- and (attributes.localpart is None or attributes.confirm_localpart is True)
- ) or (session and session.chosen_localpart is None):
- return b"/_synapse/client/pick_username/account_details"
- elif self._consent_at_registration and not (
- session and session.terms_accepted_version
- ):
- return b"/_synapse/client/new_user_consent"
- else:
- return b"/_synapse/client/sso_register" if session else b""
- async def _redirect_to_next_new_user_step(
- self,
- auth_provider_id: str,
- remote_user_id: str,
- attributes: UserAttributes,
- client_redirect_url: str,
- next_step_url: bytes,
- extra_login_attributes: Optional[JsonDict],
- auth_provider_session_id: Optional[str],
- ) -> NoReturn:
- """Creates a UsernameMappingSession and redirects the browser
- Called if the user mapping provider doesn't return complete information for a new user.
- Raises a RedirectException which redirects the browser to a specified URL.
- Args:
- auth_provider_id: A unique identifier for this SSO provider, e.g.
- "oidc" or "saml".
- remote_user_id: The unique identifier from the SSO provider.
- attributes: the user attributes returned by the user mapping provider.
- client_redirect_url: The redirect URL passed in by the client, which we
- will eventually redirect back to.
- next_step_url: The URL to redirect to for the next step of the new user flow.
- extra_login_attributes: An optional dictionary of extra
- attributes to be provided to the client in the login response.
- auth_provider_session_id: An optional session ID from the IdP.
- Raises:
- RedirectException
- """
- # TODO: If needed, allow using/looking up an existing session here.
- session_id = random_string(16)
- now = self._clock.time_msec()
- session = UsernameMappingSession(
- auth_provider_id=auth_provider_id,
- auth_provider_session_id=auth_provider_session_id,
- remote_user_id=remote_user_id,
- display_name=attributes.display_name,
- emails=attributes.emails,
- client_redirect_url=client_redirect_url,
- expiry_time_ms=now + self._MAPPING_SESSION_VALIDITY_PERIOD_MS,
- extra_login_attributes=extra_login_attributes,
- # Treat the localpart returned by the user mapping provider as though
- # it was chosen by the user. If it's None, it must be chosen eventually.
- chosen_localpart=attributes.localpart,
- # TODO: Consider letting the user mapping provider specify defaults for
- # other user-chosen attributes.
- )
- self._username_mapping_sessions[session_id] = session
- logger.info("Recorded registration session id %s", session_id)
- # Set the cookie and redirect to the next step
- e = RedirectException(next_step_url)
- e.cookies.append(
- b"%s=%s; path=/"
- % (USERNAME_MAPPING_SESSION_COOKIE_NAME, session_id.encode("ascii"))
- )
- raise e
- async def _register_mapped_user(
- self,
- attributes: UserAttributes,
- auth_provider_id: str,
- remote_user_id: str,
- user_agent: str,
- ip_address: str,
- ) -> str:
- """Register a new SSO user.
- This is called once we have successfully mapped the remote user id onto a local
- user id, one way or another.
- Args:
- attributes: user attributes returned by the user mapping provider,
- including a non-empty localpart.
- auth_provider_id: A unique identifier for this SSO provider, e.g.
- "oidc" or "saml".
- remote_user_id: The unique identifier from the SSO provider.
- user_agent: The user-agent in the HTTP request (used for potential
- shadow-banning.)
- ip_address: The IP address of the requester (used for potential
- shadow-banning.)
- Raises:
- a MappingException if the localpart is invalid.
- a SynapseError with code 400 and errcode Codes.USER_IN_USE if the localpart
- is already taken.
- """
- # Since the localpart is provided via a potentially untrusted module,
- # ensure the MXID is valid before registering.
- if not attributes.localpart or contains_invalid_mxid_characters(
- attributes.localpart
- ):
- raise MappingException("localpart is invalid: %s" % (attributes.localpart,))
- logger.debug("Mapped SSO user to local part %s", attributes.localpart)
- registered_user_id = await self._registration_handler.register_user(
- localpart=attributes.localpart,
- default_display_name=attributes.display_name,
- bind_emails=attributes.emails,
- user_agent_ips=[(user_agent, ip_address)],
- auth_provider_id=auth_provider_id,
- )
- await self._store.record_user_external_id(
- auth_provider_id, remote_user_id, registered_user_id
- )
- # Set avatar, if available
- if attributes.picture:
- await self.set_avatar(registered_user_id, attributes.picture)
- return registered_user_id
- async def set_avatar(self, user_id: str, picture_https_url: str) -> bool:
- """Set avatar of the user.
- This downloads the image file from the URL provided, stores that in
- the media repository and then sets the avatar on the user's profile.
- It can detect if the same image is being saved again and bails early by storing
- the hash of the file in the `upload_name` of the avatar image.
- Currently, it only supports server configurations which run the media repository
- within the same process.
- It silently fails and logs a warning by raising an exception and catching it
- internally if:
- * it is unable to fetch the image itself (non 200 status code) or
- * the image supplied is bigger than max allowed size or
- * the image type is not one of the allowed image types.
- Args:
- user_id: matrix user ID in the form @localpart:domain as a string.
- picture_https_url: HTTPS url for the picture image file.
- Returns: `True` if the user's avatar has been successfully set to the image at
- `picture_https_url`.
- """
- if self._media_repo is None:
- logger.info(
- "failed to set user avatar because out-of-process media repositories "
- "are not supported yet "
- )
- return False
- try:
- uid = UserID.from_string(user_id)
- def is_allowed_mime_type(content_type: str) -> bool:
- if (
- self._profile_handler.allowed_avatar_mimetypes
- and content_type
- not in self._profile_handler.allowed_avatar_mimetypes
- ):
- return False
- return True
- # download picture, enforcing size limit & mime type check
- picture = io.BytesIO()
- content_length, headers, uri, code = await self._http_client.get_file(
- url=picture_https_url,
- output_stream=picture,
- max_size=self._profile_handler.max_avatar_size,
- is_allowed_content_type=is_allowed_mime_type,
- )
- if code != 200:
- raise Exception(
- "GET request to download sso avatar image returned {}".format(code)
- )
- # upload name includes hash of the image file's content so that we can
- # easily check if it requires an update or not, the next time user logs in
- upload_name = "sso_avatar_" + hashlib.sha256(picture.read()).hexdigest()
- # bail if user already has the same avatar
- profile = await self._profile_handler.get_profile(user_id)
- if profile["avatar_url"] is not None:
- server_name = profile["avatar_url"].split("/")[-2]
- media_id = profile["avatar_url"].split("/")[-1]
- if server_name == self._server_name:
- media = await self._media_repo.store.get_local_media(media_id)
- if media is not None and upload_name == media["upload_name"]:
- logger.info("skipping saving the user avatar")
- return True
- # store it in media repository
- avatar_mxc_url = await self._media_repo.create_content(
- media_type=headers[b"Content-Type"][0].decode("utf-8"),
- upload_name=upload_name,
- content=picture,
- content_length=content_length,
- auth_user=uid,
- )
- # save it as user avatar
- await self._profile_handler.set_avatar_url(
- uid,
- create_requester(uid),
- str(avatar_mxc_url),
- )
- logger.info("successfully saved the user avatar")
- return True
- except Exception:
- logger.warning("failed to save the user avatar")
- return False
- async def complete_sso_ui_auth_request(
- self,
- auth_provider_id: str,
- remote_user_id: str,
- ui_auth_session_id: str,
- request: Request,
- ) -> None:
- """
- Given an SSO ID, retrieve the user ID for it and complete UIA.
- Note that this requires that the user is mapped in the "user_external_ids"
- table. This will be the case if they have ever logged in via SAML or OIDC in
- recentish synapse versions, but may not be for older users.
- Args:
- auth_provider_id: A unique identifier for this SSO provider, e.g.
- "oidc" or "saml".
- remote_user_id: The unique identifier from the SSO provider.
- ui_auth_session_id: The ID of the user-interactive auth session.
- request: The request to complete.
- """
- user_id = await self.get_sso_user_by_remote_user_id(
- auth_provider_id,
- remote_user_id,
- )
- user_id_to_verify: str = await self._auth_handler.get_session_data(
- ui_auth_session_id, UIAuthSessionDataConstants.REQUEST_USER_ID
- )
- if not user_id:
- logger.warning(
- "Remote user %s/%s has not previously logged in here: UIA will fail",
- auth_provider_id,
- remote_user_id,
- )
- elif user_id != user_id_to_verify:
- logger.warning(
- "Remote user %s/%s mapped onto incorrect user %s: UIA will fail",
- auth_provider_id,
- remote_user_id,
- user_id,
- )
- else:
- # success!
- # Mark the stage of the authentication as successful.
- await self._store.mark_ui_auth_stage_complete(
- ui_auth_session_id, LoginType.SSO, user_id
- )
- # Render the HTML confirmation page and return.
- html = self._sso_auth_success_template
- respond_with_html(request, 200, html)
- return
- # the user_id didn't match: mark the stage of the authentication as unsuccessful
- await self._store.mark_ui_auth_stage_complete(
- ui_auth_session_id, LoginType.SSO, ""
- )
- # render an error page.
- html = self._bad_user_template.render(
- server_name=self._server_name,
- user_id_to_verify=user_id_to_verify,
- )
- respond_with_html(request, 200, html)
- def get_mapping_session(self, session_id: str) -> UsernameMappingSession:
- """Look up the given username mapping session
- If it is not found, raises a SynapseError with an http code of 400
- Args:
- session_id: session to look up
- Returns:
- active mapping session
- Raises:
- SynapseError if the session is not found/has expired
- """
- self._expire_old_sessions()
- session = self._username_mapping_sessions.get(session_id)
- if session:
- return session
- logger.info("Couldn't find session id %s", session_id)
- raise SynapseError(400, "unknown session")
- async def check_username_availability(
- self,
- localpart: str,
- session_id: str,
- ) -> bool:
- """Handle an "is username available" callback check
- Args:
- localpart: desired localpart
- session_id: the session id for the username picker
- Returns:
- True if the username is available
- Raises:
- SynapseError if the localpart is invalid or the session is unknown
- """
- # make sure that there is a valid mapping session, to stop people dictionary-
- # scanning for accounts
- self.get_mapping_session(session_id)
- logger.info(
- "[session %s] Checking for availability of username %s",
- session_id,
- localpart,
- )
- if contains_invalid_mxid_characters(localpart):
- raise SynapseError(400, "localpart is invalid: %s" % (localpart,))
- user_id = UserID(localpart, self._server_name).to_string()
- user_infos = await self._store.get_users_by_id_case_insensitive(user_id)
- logger.info("[session %s] users: %s", session_id, user_infos)
- return not user_infos
- async def handle_submit_username_request(
- self,
- request: SynapseRequest,
- session_id: str,
- localpart: str,
- use_display_name: bool,
- emails_to_use: Iterable[str],
- ) -> None:
- """Handle a request to the username-picker 'submit' endpoint
- Will serve an HTTP response to the request.
- Args:
- request: HTTP request
- localpart: localpart requested by the user
- session_id: ID of the username mapping session, extracted from a cookie
- use_display_name: whether the user wants to use the suggested display name
- emails_to_use: emails that the user would like to use
- """
- try:
- session = self.get_mapping_session(session_id)
- except SynapseError as e:
- self.render_error(request, "bad_session", e.msg, code=e.code)
- return
- # update the session with the user's choices
- session.chosen_localpart = localpart
- session.use_display_name = use_display_name
- emails_from_idp = set(session.emails)
- filtered_emails: Set[str] = set()
- # we iterate through the list rather than just building a set conjunction, so
- # that we can log attempts to use unknown addresses
- for email in emails_to_use:
- if email in emails_from_idp:
- filtered_emails.add(email)
- else:
- logger.warning(
- "[session %s] ignoring user request to use unknown email address %r",
- session_id,
- email,
- )
- session.emails_to_use = filtered_emails
- respond_with_redirect(
- request, self._get_url_for_next_new_user_step(session=session)
- )
- async def handle_terms_accepted(
- self, request: SynapseRequest, session_id: str, terms_version: str
- ) -> None:
- """Handle a request to the new-user 'consent' endpoint
- Will serve an HTTP response to the request.
- Args:
- request: HTTP request
- session_id: ID of the username mapping session, extracted from a cookie
- terms_version: the version of the terms which the user viewed and consented
- to
- """
- logger.info(
- "[session %s] User consented to terms version %s",
- session_id,
- terms_version,
- )
- try:
- session = self.get_mapping_session(session_id)
- except SynapseError as e:
- self.render_error(request, "bad_session", e.msg, code=e.code)
- return
- session.terms_accepted_version = terms_version
- respond_with_redirect(
- request, self._get_url_for_next_new_user_step(session=session)
- )
- async def register_sso_user(self, request: Request, session_id: str) -> None:
- """Called once we have all the info we need to register a new user.
- Does so and serves an HTTP response
- Args:
- request: HTTP request
- session_id: ID of the username mapping session, extracted from a cookie
- """
- try:
- session = self.get_mapping_session(session_id)
- except SynapseError as e:
- self.render_error(request, "bad_session", e.msg, code=e.code)
- return
- logger.info(
- "[session %s] Registering localpart %s",
- session_id,
- session.chosen_localpart,
- )
- attributes = UserAttributes(
- localpart=session.chosen_localpart,
- emails=session.emails_to_use,
- )
- if session.use_display_name:
- attributes.display_name = session.display_name
- # the following will raise a 400 error if the username has been taken in the
- # meantime.
- user_id = await self._register_mapped_user(
- attributes,
- session.auth_provider_id,
- session.remote_user_id,
- get_request_user_agent(request),
- request.getClientAddress().host,
- )
- logger.info(
- "[session %s] Registered userid %s with attributes %s",
- session_id,
- user_id,
- attributes,
- )
- # delete the mapping session and the cookie
- del self._username_mapping_sessions[session_id]
- # delete the cookie
- request.addCookie(
- USERNAME_MAPPING_SESSION_COOKIE_NAME,
- b"",
- expires=b"Thu, 01 Jan 1970 00:00:00 GMT",
- path=b"/",
- )
- auth_result = {}
- if session.terms_accepted_version:
- # TODO: make this less awful.
- auth_result[LoginType.TERMS] = True
- await self._registration_handler.post_registration_actions(
- user_id, auth_result, access_token=None
- )
- await self._auth_handler.complete_sso_login(
- user_id,
- session.auth_provider_id,
- request,
- session.client_redirect_url,
- session.extra_login_attributes,
- new_user=True,
- auth_provider_session_id=session.auth_provider_session_id,
- )
- def _expire_old_sessions(self) -> None:
- to_expire = []
- now = int(self._clock.time_msec())
- for session_id, session in self._username_mapping_sessions.items():
- if session.expiry_time_ms <= now:
- to_expire.append(session_id)
- for session_id in to_expire:
- logger.info("Expiring mapping session %s", session_id)
- del self._username_mapping_sessions[session_id]
- def check_required_attributes(
- self,
- request: SynapseRequest,
- attributes: Mapping[str, List[Any]],
- attribute_requirements: Iterable[SsoAttributeRequirement],
- ) -> bool:
- """
- Confirm that the required attributes were present in the SSO response.
- If all requirements are met, this will return True.
- If any requirement is not met, then the request will be finalized by
- showing an error page to the user and False will be returned.
- Args:
- request: The request to (potentially) respond to.
- attributes: The attributes from the SSO IdP.
- attribute_requirements: The requirements that attributes must meet.
- Returns:
- True if all requirements are met, False if any attribute fails to
- meet the requirement.
- """
- # Ensure that the attributes of the logged in user meet the required
- # attributes.
- for requirement in attribute_requirements:
- if not _check_attribute_requirement(attributes, requirement):
- self.render_error(
- request, "unauthorised", "You are not authorised to log in here."
- )
- return False
- return True
- async def revoke_sessions_for_provider_session_id(
- self,
- auth_provider_id: str,
- auth_provider_session_id: str,
- expected_user_id: Optional[str] = None,
- ) -> None:
- """Revoke any devices and in-flight logins tied to a provider session.
- Can only be called from the main process.
- Args:
- auth_provider_id: A unique identifier for this SSO provider, e.g.
- "oidc" or "saml".
- auth_provider_session_id: The session ID from the provider to logout
- expected_user_id: The user we're expecting to logout. If set, it will ignore
- sessions belonging to other users and log an error.
- """
- # It is expected that this is the main process.
- assert isinstance(
- self._device_handler, DeviceHandler
- ), "revoking SSO sessions can only be called on the main process"
- # Invalidate any running user-mapping sessions
- to_delete = []
- for session_id, session in self._username_mapping_sessions.items():
- if (
- session.auth_provider_id == auth_provider_id
- and session.auth_provider_session_id == auth_provider_session_id
- ):
- to_delete.append(session_id)
- for session_id in to_delete:
- logger.info("Revoking mapping session %s", session_id)
- del self._username_mapping_sessions[session_id]
- # Invalidate any in-flight login tokens
- await self._store.invalidate_login_tokens_by_session_id(
- auth_provider_id=auth_provider_id,
- auth_provider_session_id=auth_provider_session_id,
- )
- # Fetch any device(s) in the store associated with the session ID.
- devices = await self._store.get_devices_by_auth_provider_session_id(
- auth_provider_id=auth_provider_id,
- auth_provider_session_id=auth_provider_session_id,
- )
- # We have no guarantee that all the devices of that session are for the same
- # `user_id`. Hence, we have to iterate over the list of devices and log them out
- # one by one.
- for device in devices:
- user_id = device["user_id"]
- device_id = device["device_id"]
- # If the user_id associated with that device/session is not the one we got
- # out of the `sub` claim, skip that device and show log an error.
- if expected_user_id is not None and user_id != expected_user_id:
- logger.error(
- "Received a logout notification from SSO provider "
- f"{auth_provider_id!r} for the user {expected_user_id!r}, but with "
- f"a session ID ({auth_provider_session_id!r}) which belongs to "
- f"{user_id!r}. This may happen when the SSO provider user mapper "
- "uses something else than the standard attribute as mapping ID. "
- "For OIDC providers, set `backchannel_logout_ignore_sub` to `true` "
- "in the provider config if that is the case."
- )
- continue
- logger.info(
- "Logging out %r (device %r) via SSO (%r) logout notification (session %r).",
- user_id,
- device_id,
- auth_provider_id,
- auth_provider_session_id,
- )
- await self._device_handler.delete_devices(user_id, [device_id])
- def get_username_mapping_session_cookie_from_request(request: IRequest) -> str:
- """Extract the session ID from the cookie
- Raises a SynapseError if the cookie isn't found
- """
- session_id = request.getCookie(USERNAME_MAPPING_SESSION_COOKIE_NAME)
- if not session_id:
- raise SynapseError(code=400, msg="missing session_id")
- return session_id.decode("ascii", errors="replace")
- def _check_attribute_requirement(
- attributes: Mapping[str, List[Any]], req: SsoAttributeRequirement
- ) -> bool:
- """Check if SSO attributes meet the proper requirements.
- Args:
- attributes: A mapping of attributes to an iterable of one or more values.
- requirement: The configured requirement to check.
- Returns:
- True if the required attribute was found and had a proper value.
- """
- if req.attribute not in attributes:
- logger.info("SSO attribute missing: %s", req.attribute)
- return False
- # If the requirement is None, the attribute existing is enough.
- if req.value is None:
- return True
- values = attributes[req.attribute]
- if req.value in values:
- return True
- logger.info(
- "SSO attribute %s did not match required value '%s' (was '%s')",
- req.attribute,
- req.value,
- values,
- )
- return False
|