forked from MirrorHub/synapse
Implement a username picker for synapse (#8942)
The final part (for now) of my work to implement a username picker in synapse itself. The idea is that we allow `UsernameMappingProvider`s to return `localpart=None`, in which case, rather than redirecting the browser back to the client, we redirect to a username-picker resource, which allows the user to enter a username. We *then* complete the SSO flow (including doing the client permission checks). The static resources for the username picker itself (in https://github.com/matrix-org/synapse/tree/rav/username_picker/synapse/res/username_picker) are essentially lifted wholesale from https://github.com/matrix-org/matrix-synapse-saml-mozilla/tree/master/matrix_synapse_saml_mozilla/res. As the comment says, we might want to think about making them customisable, but that can be a follow-up. Fixes #8876.
This commit is contained in:
parent
5d4c330ed9
commit
28877fade9
14 changed files with 683 additions and 59 deletions
1
changelog.d/8942.feature
Normal file
1
changelog.d/8942.feature
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Add support for allowing users to pick their own user ID during a single-sign-on login.
|
|
@ -1825,9 +1825,10 @@ oidc_config:
|
||||||
# * user: The claims returned by the UserInfo Endpoint and/or in the ID
|
# * user: The claims returned by the UserInfo Endpoint and/or in the ID
|
||||||
# Token
|
# Token
|
||||||
#
|
#
|
||||||
# This must be configured if using the default mapping provider.
|
# If this is not set, the user will be prompted to choose their
|
||||||
|
# own username.
|
||||||
#
|
#
|
||||||
localpart_template: "{{ user.preferred_username }}"
|
#localpart_template: "{{ user.preferred_username }}"
|
||||||
|
|
||||||
# Jinja2 template for the display name to set on first login.
|
# Jinja2 template for the display name to set on first login.
|
||||||
#
|
#
|
||||||
|
|
|
@ -15,12 +15,18 @@ where SAML mapping providers come into play.
|
||||||
SSO mapping providers are currently supported for OpenID and SAML SSO
|
SSO mapping providers are currently supported for OpenID and SAML SSO
|
||||||
configurations. Please see the details below for how to implement your own.
|
configurations. Please see the details below for how to implement your own.
|
||||||
|
|
||||||
It is the responsibility of the mapping provider to normalise the SSO attributes
|
It is up to the mapping provider whether the user should be assigned a predefined
|
||||||
and map them to a valid Matrix ID. The
|
Matrix ID based on the SSO attributes, or if the user should be allowed to
|
||||||
[specification for Matrix IDs](https://matrix.org/docs/spec/appendices#user-identifiers)
|
choose their own username.
|
||||||
has some information about what is considered valid. Alternately an easy way to
|
|
||||||
ensure it is valid is to use a Synapse utility function:
|
In the first case - where users are automatically allocated a Matrix ID - it is
|
||||||
`synapse.types.map_username_to_mxid_localpart`.
|
the responsibility of the mapping provider to normalise the SSO attributes and
|
||||||
|
map them to a valid Matrix ID. The [specification for Matrix
|
||||||
|
IDs](https://matrix.org/docs/spec/appendices#user-identifiers) has some
|
||||||
|
information about what is considered valid.
|
||||||
|
|
||||||
|
If the mapping provider does not assign a Matrix ID, then Synapse will
|
||||||
|
automatically serve an HTML page allowing the user to pick their own username.
|
||||||
|
|
||||||
External mapping providers are provided to Synapse in the form of an external
|
External mapping providers are provided to Synapse in the form of an external
|
||||||
Python module. You can retrieve this module from [PyPI](https://pypi.org) or elsewhere,
|
Python module. You can retrieve this module from [PyPI](https://pypi.org) or elsewhere,
|
||||||
|
@ -80,8 +86,9 @@ A custom mapping provider must specify the following methods:
|
||||||
with failures=1. The method should then return a different
|
with failures=1. The method should then return a different
|
||||||
`localpart` value, such as `john.doe1`.
|
`localpart` value, such as `john.doe1`.
|
||||||
- Returns a dictionary with two keys:
|
- Returns a dictionary with two keys:
|
||||||
- localpart: A required string, used to generate the Matrix ID.
|
- `localpart`: A string, used to generate the Matrix ID. If this is
|
||||||
- displayname: An optional string, the display name for the user.
|
`None`, the user is prompted to pick their own username.
|
||||||
|
- `displayname`: An optional string, the display name for the user.
|
||||||
* `get_extra_attributes(self, userinfo, token)`
|
* `get_extra_attributes(self, userinfo, token)`
|
||||||
- This method must be async.
|
- This method must be async.
|
||||||
- Arguments:
|
- Arguments:
|
||||||
|
@ -165,12 +172,13 @@ A custom mapping provider must specify the following methods:
|
||||||
redirected to.
|
redirected to.
|
||||||
- This method must return a dictionary, which will then be used by Synapse
|
- This method must return a dictionary, which will then be used by Synapse
|
||||||
to build a new user. The following keys are allowed:
|
to build a new user. The following keys are allowed:
|
||||||
* `mxid_localpart` - Required. The mxid localpart of the new user.
|
* `mxid_localpart` - The mxid localpart of the new user. If this is
|
||||||
|
`None`, the user is prompted to pick their own username.
|
||||||
* `displayname` - The displayname of the new user. If not provided, will default to
|
* `displayname` - The displayname of the new user. If not provided, will default to
|
||||||
the value of `mxid_localpart`.
|
the value of `mxid_localpart`.
|
||||||
* `emails` - A list of emails for the new user. If not provided, will
|
* `emails` - A list of emails for the new user. If not provided, will
|
||||||
default to an empty list.
|
default to an empty list.
|
||||||
|
|
||||||
Alternatively it can raise a `synapse.api.errors.RedirectException` to
|
Alternatively it can raise a `synapse.api.errors.RedirectException` to
|
||||||
redirect the user to another page. This is useful to prompt the user for
|
redirect the user to another page. This is useful to prompt the user for
|
||||||
additional information, e.g. if you want them to provide their own username.
|
additional information, e.g. if you want them to provide their own username.
|
||||||
|
|
|
@ -63,6 +63,7 @@ from synapse.rest import ClientRestResource
|
||||||
from synapse.rest.admin import AdminRestResource
|
from synapse.rest.admin import AdminRestResource
|
||||||
from synapse.rest.health import HealthResource
|
from synapse.rest.health import HealthResource
|
||||||
from synapse.rest.key.v2 import KeyApiV2Resource
|
from synapse.rest.key.v2 import KeyApiV2Resource
|
||||||
|
from synapse.rest.synapse.client.pick_username import pick_username_resource
|
||||||
from synapse.rest.well_known import WellKnownResource
|
from synapse.rest.well_known import WellKnownResource
|
||||||
from synapse.server import HomeServer
|
from synapse.server import HomeServer
|
||||||
from synapse.storage import DataStore
|
from synapse.storage import DataStore
|
||||||
|
@ -192,6 +193,7 @@ class SynapseHomeServer(HomeServer):
|
||||||
"/_matrix/client/versions": client_resource,
|
"/_matrix/client/versions": client_resource,
|
||||||
"/.well-known/matrix/client": WellKnownResource(self),
|
"/.well-known/matrix/client": WellKnownResource(self),
|
||||||
"/_synapse/admin": AdminRestResource(self),
|
"/_synapse/admin": AdminRestResource(self),
|
||||||
|
"/_synapse/client/pick_username": pick_username_resource(self),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -203,9 +203,10 @@ class OIDCConfig(Config):
|
||||||
# * user: The claims returned by the UserInfo Endpoint and/or in the ID
|
# * user: The claims returned by the UserInfo Endpoint and/or in the ID
|
||||||
# Token
|
# Token
|
||||||
#
|
#
|
||||||
# This must be configured if using the default mapping provider.
|
# If this is not set, the user will be prompted to choose their
|
||||||
|
# own username.
|
||||||
#
|
#
|
||||||
localpart_template: "{{{{ user.preferred_username }}}}"
|
#localpart_template: "{{{{ user.preferred_username }}}}"
|
||||||
|
|
||||||
# Jinja2 template for the display name to set on first login.
|
# Jinja2 template for the display name to set on first login.
|
||||||
#
|
#
|
||||||
|
|
|
@ -947,7 +947,7 @@ class OidcHandler(BaseHandler):
|
||||||
|
|
||||||
|
|
||||||
UserAttributeDict = TypedDict(
|
UserAttributeDict = TypedDict(
|
||||||
"UserAttributeDict", {"localpart": str, "display_name": Optional[str]}
|
"UserAttributeDict", {"localpart": Optional[str], "display_name": Optional[str]}
|
||||||
)
|
)
|
||||||
C = TypeVar("C")
|
C = TypeVar("C")
|
||||||
|
|
||||||
|
@ -1028,10 +1028,10 @@ env = Environment(finalize=jinja_finalize)
|
||||||
|
|
||||||
@attr.s
|
@attr.s
|
||||||
class JinjaOidcMappingConfig:
|
class JinjaOidcMappingConfig:
|
||||||
subject_claim = attr.ib() # type: str
|
subject_claim = attr.ib(type=str)
|
||||||
localpart_template = attr.ib() # type: Template
|
localpart_template = attr.ib(type=Optional[Template])
|
||||||
display_name_template = attr.ib() # type: Optional[Template]
|
display_name_template = attr.ib(type=Optional[Template])
|
||||||
extra_attributes = attr.ib() # type: Dict[str, Template]
|
extra_attributes = attr.ib(type=Dict[str, Template])
|
||||||
|
|
||||||
|
|
||||||
class JinjaOidcMappingProvider(OidcMappingProvider[JinjaOidcMappingConfig]):
|
class JinjaOidcMappingProvider(OidcMappingProvider[JinjaOidcMappingConfig]):
|
||||||
|
@ -1047,18 +1047,14 @@ class JinjaOidcMappingProvider(OidcMappingProvider[JinjaOidcMappingConfig]):
|
||||||
def parse_config(config: dict) -> JinjaOidcMappingConfig:
|
def parse_config(config: dict) -> JinjaOidcMappingConfig:
|
||||||
subject_claim = config.get("subject_claim", "sub")
|
subject_claim = config.get("subject_claim", "sub")
|
||||||
|
|
||||||
if "localpart_template" not in config:
|
localpart_template = None # type: Optional[Template]
|
||||||
raise ConfigError(
|
if "localpart_template" in config:
|
||||||
"missing key: oidc_config.user_mapping_provider.config.localpart_template"
|
try:
|
||||||
)
|
localpart_template = env.from_string(config["localpart_template"])
|
||||||
|
except Exception as e:
|
||||||
try:
|
raise ConfigError(
|
||||||
localpart_template = env.from_string(config["localpart_template"])
|
"invalid jinja template", path=["localpart_template"]
|
||||||
except Exception as e:
|
) from e
|
||||||
raise ConfigError(
|
|
||||||
"invalid jinja template for oidc_config.user_mapping_provider.config.localpart_template: %r"
|
|
||||||
% (e,)
|
|
||||||
)
|
|
||||||
|
|
||||||
display_name_template = None # type: Optional[Template]
|
display_name_template = None # type: Optional[Template]
|
||||||
if "display_name_template" in config:
|
if "display_name_template" in config:
|
||||||
|
@ -1066,26 +1062,22 @@ class JinjaOidcMappingProvider(OidcMappingProvider[JinjaOidcMappingConfig]):
|
||||||
display_name_template = env.from_string(config["display_name_template"])
|
display_name_template = env.from_string(config["display_name_template"])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
"invalid jinja template for oidc_config.user_mapping_provider.config.display_name_template: %r"
|
"invalid jinja template", path=["display_name_template"]
|
||||||
% (e,)
|
) from e
|
||||||
)
|
|
||||||
|
|
||||||
extra_attributes = {} # type Dict[str, Template]
|
extra_attributes = {} # type Dict[str, Template]
|
||||||
if "extra_attributes" in config:
|
if "extra_attributes" in config:
|
||||||
extra_attributes_config = config.get("extra_attributes") or {}
|
extra_attributes_config = config.get("extra_attributes") or {}
|
||||||
if not isinstance(extra_attributes_config, dict):
|
if not isinstance(extra_attributes_config, dict):
|
||||||
raise ConfigError(
|
raise ConfigError("must be a dict", path=["extra_attributes"])
|
||||||
"oidc_config.user_mapping_provider.config.extra_attributes must be a dict"
|
|
||||||
)
|
|
||||||
|
|
||||||
for key, value in extra_attributes_config.items():
|
for key, value in extra_attributes_config.items():
|
||||||
try:
|
try:
|
||||||
extra_attributes[key] = env.from_string(value)
|
extra_attributes[key] = env.from_string(value)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
"invalid jinja template for oidc_config.user_mapping_provider.config.extra_attributes.%s: %r"
|
"invalid jinja template", path=["extra_attributes", key]
|
||||||
% (key, e)
|
) from e
|
||||||
)
|
|
||||||
|
|
||||||
return JinjaOidcMappingConfig(
|
return JinjaOidcMappingConfig(
|
||||||
subject_claim=subject_claim,
|
subject_claim=subject_claim,
|
||||||
|
@ -1100,14 +1092,17 @@ class JinjaOidcMappingProvider(OidcMappingProvider[JinjaOidcMappingConfig]):
|
||||||
async def map_user_attributes(
|
async def map_user_attributes(
|
||||||
self, userinfo: UserInfo, token: Token, failures: int
|
self, userinfo: UserInfo, token: Token, failures: int
|
||||||
) -> UserAttributeDict:
|
) -> UserAttributeDict:
|
||||||
localpart = self._config.localpart_template.render(user=userinfo).strip()
|
localpart = None
|
||||||
|
|
||||||
# Ensure only valid characters are included in the MXID.
|
if self._config.localpart_template:
|
||||||
localpart = map_username_to_mxid_localpart(localpart)
|
localpart = self._config.localpart_template.render(user=userinfo).strip()
|
||||||
|
|
||||||
# Append suffix integer if last call to this function failed to produce
|
# Ensure only valid characters are included in the MXID.
|
||||||
# a usable mxid.
|
localpart = map_username_to_mxid_localpart(localpart)
|
||||||
localpart += str(failures) if failures else ""
|
|
||||||
|
# Append suffix integer if last call to this function failed to produce
|
||||||
|
# a usable mxid.
|
||||||
|
localpart += str(failures) if failures else ""
|
||||||
|
|
||||||
display_name = None # type: Optional[str]
|
display_name = None # type: Optional[str]
|
||||||
if self._config.display_name_template is not None:
|
if self._config.display_name_template is not None:
|
||||||
|
|
|
@ -13,17 +13,19 @@
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Awaitable, Callable, List, Optional
|
from typing import TYPE_CHECKING, Awaitable, Callable, Dict, List, Optional
|
||||||
|
|
||||||
import attr
|
import attr
|
||||||
|
from typing_extensions import NoReturn
|
||||||
|
|
||||||
from twisted.web.http import Request
|
from twisted.web.http import Request
|
||||||
|
|
||||||
from synapse.api.errors import RedirectException
|
from synapse.api.errors import RedirectException, SynapseError
|
||||||
from synapse.http.server import respond_with_html
|
from synapse.http.server import respond_with_html
|
||||||
from synapse.http.site import SynapseRequest
|
from synapse.http.site import SynapseRequest
|
||||||
from synapse.types import JsonDict, UserID, contains_invalid_mxid_characters
|
from synapse.types import JsonDict, UserID, contains_invalid_mxid_characters
|
||||||
from synapse.util.async_helpers import Linearizer
|
from synapse.util.async_helpers import Linearizer
|
||||||
|
from synapse.util.stringutils import random_string
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from synapse.server import HomeServer
|
from synapse.server import HomeServer
|
||||||
|
@ -40,16 +42,52 @@ class MappingException(Exception):
|
||||||
|
|
||||||
@attr.s
|
@attr.s
|
||||||
class UserAttributes:
|
class UserAttributes:
|
||||||
localpart = attr.ib(type=str)
|
# 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 = attr.ib(type=Optional[str])
|
||||||
display_name = attr.ib(type=Optional[str], default=None)
|
display_name = attr.ib(type=Optional[str], default=None)
|
||||||
emails = attr.ib(type=List[str], default=attr.Factory(list))
|
emails = attr.ib(type=List[str], default=attr.Factory(list))
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(slots=True)
|
||||||
|
class UsernameMappingSession:
|
||||||
|
"""Data we track about SSO sessions"""
|
||||||
|
|
||||||
|
# A unique identifier for this SSO provider, e.g. "oidc" or "saml".
|
||||||
|
auth_provider_id = attr.ib(type=str)
|
||||||
|
|
||||||
|
# user ID on the IdP server
|
||||||
|
remote_user_id = attr.ib(type=str)
|
||||||
|
|
||||||
|
# attributes returned by the ID mapper
|
||||||
|
display_name = attr.ib(type=Optional[str])
|
||||||
|
emails = attr.ib(type=List[str])
|
||||||
|
|
||||||
|
# An optional dictionary of extra attributes to be provided to the client in the
|
||||||
|
# login response.
|
||||||
|
extra_login_attributes = attr.ib(type=Optional[JsonDict])
|
||||||
|
|
||||||
|
# where to redirect the client back to
|
||||||
|
client_redirect_url = attr.ib(type=str)
|
||||||
|
|
||||||
|
# expiry time for the session, in milliseconds
|
||||||
|
expiry_time_ms = attr.ib(type=int)
|
||||||
|
|
||||||
|
|
||||||
|
# the HTTP cookie used to track the mapping session id
|
||||||
|
USERNAME_MAPPING_SESSION_COOKIE_NAME = b"username_mapping_session"
|
||||||
|
|
||||||
|
|
||||||
class SsoHandler:
|
class SsoHandler:
|
||||||
# The number of attempts to ask the mapping provider for when generating an MXID.
|
# The number of attempts to ask the mapping provider for when generating an MXID.
|
||||||
_MAP_USERNAME_RETRIES = 1000
|
_MAP_USERNAME_RETRIES = 1000
|
||||||
|
|
||||||
|
# the time a UsernameMappingSession remains valid for
|
||||||
|
_MAPPING_SESSION_VALIDITY_PERIOD_MS = 15 * 60 * 1000
|
||||||
|
|
||||||
def __init__(self, hs: "HomeServer"):
|
def __init__(self, hs: "HomeServer"):
|
||||||
|
self._clock = hs.get_clock()
|
||||||
self._store = hs.get_datastore()
|
self._store = hs.get_datastore()
|
||||||
self._server_name = hs.hostname
|
self._server_name = hs.hostname
|
||||||
self._registration_handler = hs.get_registration_handler()
|
self._registration_handler = hs.get_registration_handler()
|
||||||
|
@ -59,6 +97,9 @@ class SsoHandler:
|
||||||
# a lock on the mappings
|
# a lock on the mappings
|
||||||
self._mapping_lock = Linearizer(name="sso_user_mapping", clock=hs.get_clock())
|
self._mapping_lock = Linearizer(name="sso_user_mapping", clock=hs.get_clock())
|
||||||
|
|
||||||
|
# a map from session id to session data
|
||||||
|
self._username_mapping_sessions = {} # type: Dict[str, UsernameMappingSession]
|
||||||
|
|
||||||
def render_error(
|
def render_error(
|
||||||
self, request, error: str, error_description: Optional[str] = None
|
self, request, error: str, error_description: Optional[str] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
|
@ -206,6 +247,18 @@ class SsoHandler:
|
||||||
# Otherwise, generate a new user.
|
# Otherwise, generate a new user.
|
||||||
if not user_id:
|
if not user_id:
|
||||||
attributes = await self._call_attribute_mapper(sso_to_matrix_id_mapper)
|
attributes = await self._call_attribute_mapper(sso_to_matrix_id_mapper)
|
||||||
|
|
||||||
|
if attributes.localpart is None:
|
||||||
|
# the mapper doesn't return a username. bail out with a redirect to
|
||||||
|
# the username picker.
|
||||||
|
await self._redirect_to_username_picker(
|
||||||
|
auth_provider_id,
|
||||||
|
remote_user_id,
|
||||||
|
attributes,
|
||||||
|
client_redirect_url,
|
||||||
|
extra_login_attributes,
|
||||||
|
)
|
||||||
|
|
||||||
user_id = await self._register_mapped_user(
|
user_id = await self._register_mapped_user(
|
||||||
attributes,
|
attributes,
|
||||||
auth_provider_id,
|
auth_provider_id,
|
||||||
|
@ -243,10 +296,8 @@ class SsoHandler:
|
||||||
)
|
)
|
||||||
|
|
||||||
if not attributes.localpart:
|
if not attributes.localpart:
|
||||||
raise MappingException(
|
# the mapper has not picked a localpart
|
||||||
"Error parsing SSO response: SSO mapping provider plugin "
|
return attributes
|
||||||
"did not return a localpart value"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if this mxid already exists
|
# Check if this mxid already exists
|
||||||
user_id = UserID(attributes.localpart, self._server_name).to_string()
|
user_id = UserID(attributes.localpart, self._server_name).to_string()
|
||||||
|
@ -261,6 +312,59 @@ class SsoHandler:
|
||||||
)
|
)
|
||||||
return attributes
|
return attributes
|
||||||
|
|
||||||
|
async def _redirect_to_username_picker(
|
||||||
|
self,
|
||||||
|
auth_provider_id: str,
|
||||||
|
remote_user_id: str,
|
||||||
|
attributes: UserAttributes,
|
||||||
|
client_redirect_url: str,
|
||||||
|
extra_login_attributes: Optional[JsonDict],
|
||||||
|
) -> NoReturn:
|
||||||
|
"""Creates a UsernameMappingSession and redirects the browser
|
||||||
|
|
||||||
|
Called if the user mapping provider doesn't return a localpart for a new user.
|
||||||
|
Raises a RedirectException which redirects the browser to the username picker.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
extra_login_attributes: An optional dictionary of extra
|
||||||
|
attributes to be provided to the client in the login response.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RedirectException
|
||||||
|
"""
|
||||||
|
session_id = random_string(16)
|
||||||
|
now = self._clock.time_msec()
|
||||||
|
session = UsernameMappingSession(
|
||||||
|
auth_provider_id=auth_provider_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,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._username_mapping_sessions[session_id] = session
|
||||||
|
logger.info("Recorded registration session id %s", session_id)
|
||||||
|
|
||||||
|
# Set the cookie and redirect to the username picker
|
||||||
|
e = RedirectException(b"/_synapse/client/pick_username")
|
||||||
|
e.cookies.append(
|
||||||
|
b"%s=%s; path=/"
|
||||||
|
% (USERNAME_MAPPING_SESSION_COOKIE_NAME, session_id.encode("ascii"))
|
||||||
|
)
|
||||||
|
raise e
|
||||||
|
|
||||||
async def _register_mapped_user(
|
async def _register_mapped_user(
|
||||||
self,
|
self,
|
||||||
attributes: UserAttributes,
|
attributes: UserAttributes,
|
||||||
|
@ -269,9 +373,38 @@ class SsoHandler:
|
||||||
user_agent: str,
|
user_agent: str,
|
||||||
ip_address: str,
|
ip_address: str,
|
||||||
) -> 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,
|
# Since the localpart is provided via a potentially untrusted module,
|
||||||
# ensure the MXID is valid before registering.
|
# ensure the MXID is valid before registering.
|
||||||
if contains_invalid_mxid_characters(attributes.localpart):
|
if not attributes.localpart or contains_invalid_mxid_characters(
|
||||||
|
attributes.localpart
|
||||||
|
):
|
||||||
raise MappingException("localpart is invalid: %s" % (attributes.localpart,))
|
raise MappingException("localpart is invalid: %s" % (attributes.localpart,))
|
||||||
|
|
||||||
logger.debug("Mapped SSO user to local part %s", attributes.localpart)
|
logger.debug("Mapped SSO user to local part %s", attributes.localpart)
|
||||||
|
@ -326,3 +459,108 @@ class SsoHandler:
|
||||||
await self._auth_handler.complete_sso_ui_auth(
|
await self._auth_handler.complete_sso_ui_auth(
|
||||||
user_id, ui_auth_session_id, request
|
user_id, ui_auth_session_id, request
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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._expire_old_sessions()
|
||||||
|
session = self._username_mapping_sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
logger.info("Couldn't find session id %s", session_id)
|
||||||
|
raise SynapseError(400, "unknown session")
|
||||||
|
|
||||||
|
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, localpart: str, session_id: 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
|
||||||
|
"""
|
||||||
|
self._expire_old_sessions()
|
||||||
|
session = self._username_mapping_sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
logger.info("Couldn't find session id %s", session_id)
|
||||||
|
raise SynapseError(400, "unknown session")
|
||||||
|
|
||||||
|
logger.info("[session %s] Registering localpart %s", session_id, localpart)
|
||||||
|
|
||||||
|
attributes = UserAttributes(
|
||||||
|
localpart=localpart,
|
||||||
|
display_name=session.display_name,
|
||||||
|
emails=session.emails,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
request.get_user_agent(""),
|
||||||
|
request.getClientIP(),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("[session %s] Registered userid %s", session_id, user_id)
|
||||||
|
|
||||||
|
# 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"/",
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._auth_handler.complete_sso_login(
|
||||||
|
user_id,
|
||||||
|
request,
|
||||||
|
session.client_redirect_url,
|
||||||
|
session.extra_login_attributes,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _expire_old_sessions(self):
|
||||||
|
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]
|
||||||
|
|
19
synapse/res/username_picker/index.html
Normal file
19
synapse/res/username_picker/index.html
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Synapse Login</title>
|
||||||
|
<link rel="stylesheet" href="style.css" type="text/css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<form method="post" class="form__input" id="form" action="submit">
|
||||||
|
<label for="field-username">Please pick your username:</label>
|
||||||
|
<input type="text" name="username" id="field-username" autofocus="">
|
||||||
|
<input type="submit" class="button button--full-width" id="button-submit" value="Submit">
|
||||||
|
</form>
|
||||||
|
<!-- this is used for feedback -->
|
||||||
|
<div role=alert class="tooltip hidden" id="message"></div>
|
||||||
|
<script src="script.js"></script>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
95
synapse/res/username_picker/script.js
Normal file
95
synapse/res/username_picker/script.js
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
let inputField = document.getElementById("field-username");
|
||||||
|
let inputForm = document.getElementById("form");
|
||||||
|
let submitButton = document.getElementById("button-submit");
|
||||||
|
let message = document.getElementById("message");
|
||||||
|
|
||||||
|
// Submit username and receive response
|
||||||
|
function showMessage(messageText) {
|
||||||
|
// Unhide the message text
|
||||||
|
message.classList.remove("hidden");
|
||||||
|
|
||||||
|
message.textContent = messageText;
|
||||||
|
};
|
||||||
|
|
||||||
|
function doSubmit() {
|
||||||
|
showMessage("Success. Please wait a moment for your browser to redirect.");
|
||||||
|
|
||||||
|
// remove the event handler before re-submitting the form.
|
||||||
|
delete inputForm.onsubmit;
|
||||||
|
inputForm.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResponse(response) {
|
||||||
|
// Display message
|
||||||
|
showMessage(response);
|
||||||
|
|
||||||
|
// Enable submit button and input field
|
||||||
|
submitButton.classList.remove('button--disabled');
|
||||||
|
submitButton.value = "Submit";
|
||||||
|
};
|
||||||
|
|
||||||
|
let allowedUsernameCharacters = RegExp("[^a-z0-9\\.\\_\\=\\-\\/]");
|
||||||
|
function usernameIsValid(username) {
|
||||||
|
return !allowedUsernameCharacters.test(username);
|
||||||
|
}
|
||||||
|
let allowedCharactersString = "lowercase letters, digits, ., _, -, /, =";
|
||||||
|
|
||||||
|
function buildQueryString(params) {
|
||||||
|
return Object.keys(params)
|
||||||
|
.map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k]))
|
||||||
|
.join('&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitUsername(username) {
|
||||||
|
if(username.length == 0) {
|
||||||
|
onResponse("Please enter a username.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(!usernameIsValid(username)) {
|
||||||
|
onResponse("Invalid username. Only the following characters are allowed: " + allowedCharactersString);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if this browser doesn't support fetch, skip the availability check.
|
||||||
|
if(!window.fetch) {
|
||||||
|
doSubmit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let check_uri = 'check?' + buildQueryString({"username": username});
|
||||||
|
fetch(check_uri, {
|
||||||
|
// include the cookie
|
||||||
|
"credentials": "same-origin",
|
||||||
|
}).then((response) => {
|
||||||
|
if(!response.ok) {
|
||||||
|
// for non-200 responses, raise the body of the response as an exception
|
||||||
|
return response.text().then((text) => { throw text; });
|
||||||
|
} else {
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
}).then((json) => {
|
||||||
|
if(json.error) {
|
||||||
|
throw json.error;
|
||||||
|
} else if(json.available) {
|
||||||
|
doSubmit();
|
||||||
|
} else {
|
||||||
|
onResponse("This username is not available, please choose another.");
|
||||||
|
}
|
||||||
|
}).catch((err) => {
|
||||||
|
onResponse("Error checking username availability: " + err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickSubmit() {
|
||||||
|
event.preventDefault();
|
||||||
|
if(submitButton.classList.contains('button--disabled')) { return; }
|
||||||
|
|
||||||
|
// Disable submit button and input field
|
||||||
|
submitButton.classList.add('button--disabled');
|
||||||
|
|
||||||
|
// Submit username
|
||||||
|
submitButton.value = "Checking...";
|
||||||
|
submitUsername(inputField.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
inputForm.onsubmit = clickSubmit;
|
27
synapse/res/username_picker/style.css
Normal file
27
synapse/res/username_picker/style.css
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
input[type="text"] {
|
||||||
|
font-size: 100%;
|
||||||
|
background-color: #ededf0;
|
||||||
|
border: 1px solid #fff;
|
||||||
|
border-radius: .2em;
|
||||||
|
padding: .5em .9em;
|
||||||
|
display: block;
|
||||||
|
width: 26em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--disabled {
|
||||||
|
border-color: #fff;
|
||||||
|
background-color: transparent;
|
||||||
|
color: #000;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip {
|
||||||
|
background-color: #f9f9fa;
|
||||||
|
padding: 1em;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
88
synapse/rest/synapse/client/pick_username.py
Normal file
88
synapse/rest/synapse/client/pick_username.py
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# 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.
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pkg_resources
|
||||||
|
|
||||||
|
from twisted.web.http import Request
|
||||||
|
from twisted.web.resource import Resource
|
||||||
|
from twisted.web.static import File
|
||||||
|
|
||||||
|
from synapse.api.errors import SynapseError
|
||||||
|
from synapse.handlers.sso import USERNAME_MAPPING_SESSION_COOKIE_NAME
|
||||||
|
from synapse.http.server import DirectServeHtmlResource, DirectServeJsonResource
|
||||||
|
from synapse.http.servlet import parse_string
|
||||||
|
from synapse.http.site import SynapseRequest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from synapse.server import HomeServer
|
||||||
|
|
||||||
|
|
||||||
|
def pick_username_resource(hs: "HomeServer") -> Resource:
|
||||||
|
"""Factory method to generate the username picker resource.
|
||||||
|
|
||||||
|
This resource gets mounted under /_synapse/client/pick_username. The top-level
|
||||||
|
resource is just a File resource which serves up the static files in the resources
|
||||||
|
"res" directory, but it has a couple of children:
|
||||||
|
|
||||||
|
* "submit", which does the mechanics of registering the new user, and redirects the
|
||||||
|
browser back to the client URL
|
||||||
|
|
||||||
|
* "check": checks if a userid is free.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# XXX should we make this path customisable so that admins can restyle it?
|
||||||
|
base_path = pkg_resources.resource_filename("synapse", "res/username_picker")
|
||||||
|
|
||||||
|
res = File(base_path)
|
||||||
|
res.putChild(b"submit", SubmitResource(hs))
|
||||||
|
res.putChild(b"check", AvailabilityCheckResource(hs))
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
class AvailabilityCheckResource(DirectServeJsonResource):
|
||||||
|
def __init__(self, hs: "HomeServer"):
|
||||||
|
super().__init__()
|
||||||
|
self._sso_handler = hs.get_sso_handler()
|
||||||
|
|
||||||
|
async def _async_render_GET(self, request: Request):
|
||||||
|
localpart = parse_string(request, "username", required=True)
|
||||||
|
|
||||||
|
session_id = request.getCookie(USERNAME_MAPPING_SESSION_COOKIE_NAME)
|
||||||
|
if not session_id:
|
||||||
|
raise SynapseError(code=400, msg="missing session_id")
|
||||||
|
|
||||||
|
is_available = await self._sso_handler.check_username_availability(
|
||||||
|
localpart, session_id.decode("ascii", errors="replace")
|
||||||
|
)
|
||||||
|
return 200, {"available": is_available}
|
||||||
|
|
||||||
|
|
||||||
|
class SubmitResource(DirectServeHtmlResource):
|
||||||
|
def __init__(self, hs: "HomeServer"):
|
||||||
|
super().__init__()
|
||||||
|
self._sso_handler = hs.get_sso_handler()
|
||||||
|
|
||||||
|
async def _async_render_POST(self, request: SynapseRequest):
|
||||||
|
localpart = parse_string(request, "username", required=True)
|
||||||
|
|
||||||
|
session_id = request.getCookie(USERNAME_MAPPING_SESSION_COOKIE_NAME)
|
||||||
|
if not session_id:
|
||||||
|
raise SynapseError(code=400, msg="missing session_id")
|
||||||
|
|
||||||
|
await self._sso_handler.handle_submit_username_request(
|
||||||
|
request, localpart, session_id.decode("ascii", errors="replace")
|
||||||
|
)
|
|
@ -349,15 +349,17 @@ NON_MXID_CHARACTER_PATTERN = re.compile(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def map_username_to_mxid_localpart(username, case_sensitive=False):
|
def map_username_to_mxid_localpart(
|
||||||
|
username: Union[str, bytes], case_sensitive: bool = False
|
||||||
|
) -> str:
|
||||||
"""Map a username onto a string suitable for a MXID
|
"""Map a username onto a string suitable for a MXID
|
||||||
|
|
||||||
This follows the algorithm laid out at
|
This follows the algorithm laid out at
|
||||||
https://matrix.org/docs/spec/appendices.html#mapping-from-other-character-sets.
|
https://matrix.org/docs/spec/appendices.html#mapping-from-other-character-sets.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
username (unicode|bytes): username to be mapped
|
username: username to be mapped
|
||||||
case_sensitive (bool): true if TEST and test should be mapped
|
case_sensitive: true if TEST and test should be mapped
|
||||||
onto different mxids
|
onto different mxids
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
|
@ -13,14 +13,21 @@
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
import json
|
import json
|
||||||
from urllib.parse import parse_qs, urlparse
|
import re
|
||||||
|
from typing import Dict
|
||||||
|
from urllib.parse import parse_qs, urlencode, urlparse
|
||||||
|
|
||||||
from mock import ANY, Mock, patch
|
from mock import ANY, Mock, patch
|
||||||
|
|
||||||
import pymacaroons
|
import pymacaroons
|
||||||
|
|
||||||
|
from twisted.web.resource import Resource
|
||||||
|
|
||||||
|
from synapse.api.errors import RedirectException
|
||||||
from synapse.handlers.oidc_handler import OidcError
|
from synapse.handlers.oidc_handler import OidcError
|
||||||
from synapse.handlers.sso import MappingException
|
from synapse.handlers.sso import MappingException
|
||||||
|
from synapse.rest.client.v1 import login
|
||||||
|
from synapse.rest.synapse.client.pick_username import pick_username_resource
|
||||||
from synapse.server import HomeServer
|
from synapse.server import HomeServer
|
||||||
from synapse.types import UserID
|
from synapse.types import UserID
|
||||||
|
|
||||||
|
@ -793,6 +800,140 @@ class OidcHandlerTestCase(HomeserverTestCase):
|
||||||
"mapping_error", "Unable to generate a Matrix ID from the SSO response"
|
"mapping_error", "Unable to generate a Matrix ID from the SSO response"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_empty_localpart(self):
|
||||||
|
"""Attempts to map onto an empty localpart should be rejected."""
|
||||||
|
userinfo = {
|
||||||
|
"sub": "tester",
|
||||||
|
"username": "",
|
||||||
|
}
|
||||||
|
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
|
||||||
|
self.assertRenderedError("mapping_error", "localpart is invalid: ")
|
||||||
|
|
||||||
|
@override_config(
|
||||||
|
{
|
||||||
|
"oidc_config": {
|
||||||
|
"user_mapping_provider": {
|
||||||
|
"config": {"localpart_template": "{{ user.username }}"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def test_null_localpart(self):
|
||||||
|
"""Mapping onto a null localpart via an empty OIDC attribute should be rejected"""
|
||||||
|
userinfo = {
|
||||||
|
"sub": "tester",
|
||||||
|
"username": None,
|
||||||
|
}
|
||||||
|
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
|
||||||
|
self.assertRenderedError("mapping_error", "localpart is invalid: ")
|
||||||
|
|
||||||
|
|
||||||
|
class UsernamePickerTestCase(HomeserverTestCase):
|
||||||
|
servlets = [login.register_servlets]
|
||||||
|
|
||||||
|
def default_config(self):
|
||||||
|
config = super().default_config()
|
||||||
|
config["public_baseurl"] = BASE_URL
|
||||||
|
oidc_config = {
|
||||||
|
"enabled": True,
|
||||||
|
"client_id": CLIENT_ID,
|
||||||
|
"client_secret": CLIENT_SECRET,
|
||||||
|
"issuer": ISSUER,
|
||||||
|
"scopes": SCOPES,
|
||||||
|
"user_mapping_provider": {
|
||||||
|
"config": {"display_name_template": "{{ user.displayname }}"}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update this config with what's in the default config so that
|
||||||
|
# override_config works as expected.
|
||||||
|
oidc_config.update(config.get("oidc_config", {}))
|
||||||
|
config["oidc_config"] = oidc_config
|
||||||
|
|
||||||
|
# whitelist this client URI so we redirect straight to it rather than
|
||||||
|
# serving a confirmation page
|
||||||
|
config["sso"] = {"client_whitelist": ["https://whitelisted.client"]}
|
||||||
|
return config
|
||||||
|
|
||||||
|
def create_resource_dict(self) -> Dict[str, Resource]:
|
||||||
|
d = super().create_resource_dict()
|
||||||
|
d["/_synapse/client/pick_username"] = pick_username_resource(self.hs)
|
||||||
|
return d
|
||||||
|
|
||||||
|
def test_username_picker(self):
|
||||||
|
"""Test the happy path of a username picker flow."""
|
||||||
|
client_redirect_url = "https://whitelisted.client"
|
||||||
|
|
||||||
|
# first of all, mock up an OIDC callback to the OidcHandler, which should
|
||||||
|
# raise a RedirectException
|
||||||
|
userinfo = {"sub": "tester", "displayname": "Jonny"}
|
||||||
|
f = self.get_failure(
|
||||||
|
_make_callback_with_userinfo(
|
||||||
|
self.hs, userinfo, client_redirect_url=client_redirect_url
|
||||||
|
),
|
||||||
|
RedirectException,
|
||||||
|
)
|
||||||
|
|
||||||
|
# check the Location and cookies returned by the RedirectException
|
||||||
|
self.assertEqual(f.value.location, b"/_synapse/client/pick_username")
|
||||||
|
cookieheader = f.value.cookies[0]
|
||||||
|
regex = re.compile(b"^username_mapping_session=([a-zA-Z]+);")
|
||||||
|
m = regex.search(cookieheader)
|
||||||
|
if not m:
|
||||||
|
self.fail("cookie header %s does not match %s" % (cookieheader, regex))
|
||||||
|
|
||||||
|
# introspect the sso handler a bit to check that the username mapping session
|
||||||
|
# looks ok.
|
||||||
|
session_id = m.group(1).decode("ascii")
|
||||||
|
username_mapping_sessions = self.hs.get_sso_handler()._username_mapping_sessions
|
||||||
|
self.assertIn(
|
||||||
|
session_id, username_mapping_sessions, "session id not found in map"
|
||||||
|
)
|
||||||
|
session = username_mapping_sessions[session_id]
|
||||||
|
self.assertEqual(session.remote_user_id, "tester")
|
||||||
|
self.assertEqual(session.display_name, "Jonny")
|
||||||
|
self.assertEqual(session.client_redirect_url, client_redirect_url)
|
||||||
|
|
||||||
|
# the expiry time should be about 15 minutes away
|
||||||
|
expected_expiry = self.clock.time_msec() + (15 * 60 * 1000)
|
||||||
|
self.assertApproximates(session.expiry_time_ms, expected_expiry, tolerance=1000)
|
||||||
|
|
||||||
|
# Now, submit a username to the username picker, which should serve a redirect
|
||||||
|
# back to the client
|
||||||
|
submit_path = f.value.location + b"/submit"
|
||||||
|
content = urlencode({b"username": b"bobby"}).encode("utf8")
|
||||||
|
chan = self.make_request(
|
||||||
|
"POST",
|
||||||
|
path=submit_path,
|
||||||
|
content=content,
|
||||||
|
content_is_form=True,
|
||||||
|
custom_headers=[
|
||||||
|
("Cookie", cookieheader),
|
||||||
|
# old versions of twisted don't do form-parsing without a valid
|
||||||
|
# content-length header.
|
||||||
|
("Content-Length", str(len(content))),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(chan.code, 302, chan.result)
|
||||||
|
location_headers = chan.headers.getRawHeaders("Location")
|
||||||
|
# ensure that the returned location starts with the requested redirect URL
|
||||||
|
self.assertEqual(
|
||||||
|
location_headers[0][: len(client_redirect_url)], client_redirect_url
|
||||||
|
)
|
||||||
|
|
||||||
|
# fish the login token out of the returned redirect uri
|
||||||
|
parts = urlparse(location_headers[0])
|
||||||
|
query = parse_qs(parts.query)
|
||||||
|
login_token = query["loginToken"][0]
|
||||||
|
|
||||||
|
# finally, submit the matrix login token to the login API, which gives us our
|
||||||
|
# matrix access token, mxid, and device id.
|
||||||
|
chan = self.make_request(
|
||||||
|
"POST", "/login", content={"type": "m.login.token", "token": login_token},
|
||||||
|
)
|
||||||
|
self.assertEqual(chan.code, 200, chan.result)
|
||||||
|
self.assertEqual(chan.json_body["user_id"], "@bobby:test")
|
||||||
|
|
||||||
|
|
||||||
async def _make_callback_with_userinfo(
|
async def _make_callback_with_userinfo(
|
||||||
hs: HomeServer, userinfo: dict, client_redirect_url: str = "http://client/redirect"
|
hs: HomeServer, userinfo: dict, client_redirect_url: str = "http://client/redirect"
|
||||||
|
|
|
@ -20,7 +20,7 @@ import hmac
|
||||||
import inspect
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Dict, Optional, Type, TypeVar, Union
|
from typing import Dict, Iterable, Optional, Tuple, Type, TypeVar, Union
|
||||||
|
|
||||||
from mock import Mock, patch
|
from mock import Mock, patch
|
||||||
|
|
||||||
|
@ -383,6 +383,9 @@ class HomeserverTestCase(TestCase):
|
||||||
federation_auth_origin: str = None,
|
federation_auth_origin: str = None,
|
||||||
content_is_form: bool = False,
|
content_is_form: bool = False,
|
||||||
await_result: bool = True,
|
await_result: bool = True,
|
||||||
|
custom_headers: Optional[
|
||||||
|
Iterable[Tuple[Union[bytes, str], Union[bytes, str]]]
|
||||||
|
] = None,
|
||||||
) -> FakeChannel:
|
) -> FakeChannel:
|
||||||
"""
|
"""
|
||||||
Create a SynapseRequest at the path using the method and containing the
|
Create a SynapseRequest at the path using the method and containing the
|
||||||
|
@ -405,6 +408,8 @@ class HomeserverTestCase(TestCase):
|
||||||
true (the default), will pump the test reactor until the the renderer
|
true (the default), will pump the test reactor until the the renderer
|
||||||
tells the channel the request is finished.
|
tells the channel the request is finished.
|
||||||
|
|
||||||
|
custom_headers: (name, value) pairs to add as request headers
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The FakeChannel object which stores the result of the request.
|
The FakeChannel object which stores the result of the request.
|
||||||
"""
|
"""
|
||||||
|
@ -420,6 +425,7 @@ class HomeserverTestCase(TestCase):
|
||||||
federation_auth_origin,
|
federation_auth_origin,
|
||||||
content_is_form,
|
content_is_form,
|
||||||
await_result,
|
await_result,
|
||||||
|
custom_headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
def setup_test_homeserver(self, *args, **kwargs):
|
def setup_test_homeserver(self, *args, **kwargs):
|
||||||
|
|
Loading…
Reference in a new issue