mirror of
https://git.anonymousland.org/anonymousland/synapse-product.git
synced 2024-12-29 00:46:12 -05:00
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
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
|
||||
# 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.
|
||||
#
|
||||
|
@ -15,12 +15,18 @@ where SAML mapping providers come into play.
|
||||
SSO mapping providers are currently supported for OpenID and SAML SSO
|
||||
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
|
||||
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. Alternately an easy way to
|
||||
ensure it is valid is to use a Synapse utility function:
|
||||
`synapse.types.map_username_to_mxid_localpart`.
|
||||
It is up to the mapping provider whether the user should be assigned a predefined
|
||||
Matrix ID based on the SSO attributes, or if the user should be allowed to
|
||||
choose their own username.
|
||||
|
||||
In the first case - where users are automatically allocated a Matrix ID - it is
|
||||
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
|
||||
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
|
||||
`localpart` value, such as `john.doe1`.
|
||||
- Returns a dictionary with two keys:
|
||||
- localpart: A required string, used to generate the Matrix ID.
|
||||
- displayname: An optional string, the display name for the user.
|
||||
- `localpart`: A string, used to generate the Matrix ID. If this is
|
||||
`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)`
|
||||
- This method must be async.
|
||||
- Arguments:
|
||||
@ -165,12 +172,13 @@ A custom mapping provider must specify the following methods:
|
||||
redirected to.
|
||||
- This method must return a dictionary, which will then be used by Synapse
|
||||
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
|
||||
the value of `mxid_localpart`.
|
||||
* `emails` - A list of emails for the new user. If not provided, will
|
||||
default to an empty list.
|
||||
|
||||
|
||||
Alternatively it can raise a `synapse.api.errors.RedirectException` to
|
||||
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.
|
||||
|
@ -63,6 +63,7 @@ from synapse.rest import ClientRestResource
|
||||
from synapse.rest.admin import AdminRestResource
|
||||
from synapse.rest.health import HealthResource
|
||||
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.server import HomeServer
|
||||
from synapse.storage import DataStore
|
||||
@ -192,6 +193,7 @@ class SynapseHomeServer(HomeServer):
|
||||
"/_matrix/client/versions": client_resource,
|
||||
"/.well-known/matrix/client": WellKnownResource(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
|
||||
# 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.
|
||||
#
|
||||
|
@ -947,7 +947,7 @@ class OidcHandler(BaseHandler):
|
||||
|
||||
|
||||
UserAttributeDict = TypedDict(
|
||||
"UserAttributeDict", {"localpart": str, "display_name": Optional[str]}
|
||||
"UserAttributeDict", {"localpart": Optional[str], "display_name": Optional[str]}
|
||||
)
|
||||
C = TypeVar("C")
|
||||
|
||||
@ -1028,10 +1028,10 @@ env = Environment(finalize=jinja_finalize)
|
||||
|
||||
@attr.s
|
||||
class JinjaOidcMappingConfig:
|
||||
subject_claim = attr.ib() # type: str
|
||||
localpart_template = attr.ib() # type: Template
|
||||
display_name_template = attr.ib() # type: Optional[Template]
|
||||
extra_attributes = attr.ib() # type: Dict[str, Template]
|
||||
subject_claim = attr.ib(type=str)
|
||||
localpart_template = attr.ib(type=Optional[Template])
|
||||
display_name_template = attr.ib(type=Optional[Template])
|
||||
extra_attributes = attr.ib(type=Dict[str, Template])
|
||||
|
||||
|
||||
class JinjaOidcMappingProvider(OidcMappingProvider[JinjaOidcMappingConfig]):
|
||||
@ -1047,18 +1047,14 @@ class JinjaOidcMappingProvider(OidcMappingProvider[JinjaOidcMappingConfig]):
|
||||
def parse_config(config: dict) -> JinjaOidcMappingConfig:
|
||||
subject_claim = config.get("subject_claim", "sub")
|
||||
|
||||
if "localpart_template" not in config:
|
||||
raise ConfigError(
|
||||
"missing key: oidc_config.user_mapping_provider.config.localpart_template"
|
||||
)
|
||||
|
||||
try:
|
||||
localpart_template = env.from_string(config["localpart_template"])
|
||||
except Exception as e:
|
||||
raise ConfigError(
|
||||
"invalid jinja template for oidc_config.user_mapping_provider.config.localpart_template: %r"
|
||||
% (e,)
|
||||
)
|
||||
localpart_template = None # type: Optional[Template]
|
||||
if "localpart_template" in config:
|
||||
try:
|
||||
localpart_template = env.from_string(config["localpart_template"])
|
||||
except Exception as e:
|
||||
raise ConfigError(
|
||||
"invalid jinja template", path=["localpart_template"]
|
||||
) from e
|
||||
|
||||
display_name_template = None # type: Optional[Template]
|
||||
if "display_name_template" in config:
|
||||
@ -1066,26 +1062,22 @@ class JinjaOidcMappingProvider(OidcMappingProvider[JinjaOidcMappingConfig]):
|
||||
display_name_template = env.from_string(config["display_name_template"])
|
||||
except Exception as e:
|
||||
raise ConfigError(
|
||||
"invalid jinja template for oidc_config.user_mapping_provider.config.display_name_template: %r"
|
||||
% (e,)
|
||||
)
|
||||
"invalid jinja template", path=["display_name_template"]
|
||||
) from e
|
||||
|
||||
extra_attributes = {} # type Dict[str, Template]
|
||||
if "extra_attributes" in config:
|
||||
extra_attributes_config = config.get("extra_attributes") or {}
|
||||
if not isinstance(extra_attributes_config, dict):
|
||||
raise ConfigError(
|
||||
"oidc_config.user_mapping_provider.config.extra_attributes must be a dict"
|
||||
)
|
||||
raise ConfigError("must be a dict", path=["extra_attributes"])
|
||||
|
||||
for key, value in extra_attributes_config.items():
|
||||
try:
|
||||
extra_attributes[key] = env.from_string(value)
|
||||
except Exception as e:
|
||||
raise ConfigError(
|
||||
"invalid jinja template for oidc_config.user_mapping_provider.config.extra_attributes.%s: %r"
|
||||
% (key, e)
|
||||
)
|
||||
"invalid jinja template", path=["extra_attributes", key]
|
||||
) from e
|
||||
|
||||
return JinjaOidcMappingConfig(
|
||||
subject_claim=subject_claim,
|
||||
@ -1100,14 +1092,17 @@ class JinjaOidcMappingProvider(OidcMappingProvider[JinjaOidcMappingConfig]):
|
||||
async def map_user_attributes(
|
||||
self, userinfo: UserInfo, token: Token, failures: int
|
||||
) -> UserAttributeDict:
|
||||
localpart = self._config.localpart_template.render(user=userinfo).strip()
|
||||
localpart = None
|
||||
|
||||
# Ensure only valid characters are included in the MXID.
|
||||
localpart = map_username_to_mxid_localpart(localpart)
|
||||
if self._config.localpart_template:
|
||||
localpart = self._config.localpart_template.render(user=userinfo).strip()
|
||||
|
||||
# Append suffix integer if last call to this function failed to produce
|
||||
# a usable mxid.
|
||||
localpart += str(failures) if failures else ""
|
||||
# Ensure only valid characters are included in the MXID.
|
||||
localpart = map_username_to_mxid_localpart(localpart)
|
||||
|
||||
# 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]
|
||||
if self._config.display_name_template is not None:
|
||||
|
@ -13,17 +13,19 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Awaitable, Callable, List, Optional
|
||||
from typing import TYPE_CHECKING, Awaitable, Callable, Dict, List, Optional
|
||||
|
||||
import attr
|
||||
from typing_extensions import NoReturn
|
||||
|
||||
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.site import SynapseRequest
|
||||
from synapse.types import JsonDict, UserID, contains_invalid_mxid_characters
|
||||
from synapse.util.async_helpers import Linearizer
|
||||
from synapse.util.stringutils import random_string
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from synapse.server import HomeServer
|
||||
@ -40,16 +42,52 @@ class MappingException(Exception):
|
||||
|
||||
@attr.s
|
||||
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)
|
||||
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:
|
||||
# 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_datastore()
|
||||
self._server_name = hs.hostname
|
||||
self._registration_handler = hs.get_registration_handler()
|
||||
@ -59,6 +97,9 @@ class SsoHandler:
|
||||
# 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 = {} # type: Dict[str, UsernameMappingSession]
|
||||
|
||||
def render_error(
|
||||
self, request, error: str, error_description: Optional[str] = None
|
||||
) -> None:
|
||||
@ -206,6 +247,18 @@ class SsoHandler:
|
||||
# Otherwise, generate a new user.
|
||||
if not user_id:
|
||||
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(
|
||||
attributes,
|
||||
auth_provider_id,
|
||||
@ -243,10 +296,8 @@ class SsoHandler:
|
||||
)
|
||||
|
||||
if not attributes.localpart:
|
||||
raise MappingException(
|
||||
"Error parsing SSO response: SSO mapping provider plugin "
|
||||
"did not return a localpart value"
|
||||
)
|
||||
# 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()
|
||||
@ -261,6 +312,59 @@ class SsoHandler:
|
||||
)
|
||||
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(
|
||||
self,
|
||||
attributes: UserAttributes,
|
||||
@ -269,9 +373,38 @@ class SsoHandler:
|
||||
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 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,))
|
||||
|
||||
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(
|
||||
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
|
||||
|
||||
This follows the algorithm laid out at
|
||||
https://matrix.org/docs/spec/appendices.html#mapping-from-other-character-sets.
|
||||
|
||||
Args:
|
||||
username (unicode|bytes): username to be mapped
|
||||
case_sensitive (bool): true if TEST and test should be mapped
|
||||
username: username to be mapped
|
||||
case_sensitive: true if TEST and test should be mapped
|
||||
onto different mxids
|
||||
|
||||
Returns:
|
||||
|
@ -13,14 +13,21 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
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
|
||||
|
||||
import pymacaroons
|
||||
|
||||
from twisted.web.resource import Resource
|
||||
|
||||
from synapse.api.errors import RedirectException
|
||||
from synapse.handlers.oidc_handler import OidcError
|
||||
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.types import UserID
|
||||
|
||||
@ -793,6 +800,140 @@ class OidcHandlerTestCase(HomeserverTestCase):
|
||||
"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(
|
||||
hs: HomeServer, userinfo: dict, client_redirect_url: str = "http://client/redirect"
|
||||
|
@ -20,7 +20,7 @@ import hmac
|
||||
import inspect
|
||||
import logging
|
||||
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
|
||||
|
||||
@ -383,6 +383,9 @@ class HomeserverTestCase(TestCase):
|
||||
federation_auth_origin: str = None,
|
||||
content_is_form: bool = False,
|
||||
await_result: bool = True,
|
||||
custom_headers: Optional[
|
||||
Iterable[Tuple[Union[bytes, str], Union[bytes, str]]]
|
||||
] = None,
|
||||
) -> FakeChannel:
|
||||
"""
|
||||
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
|
||||
tells the channel the request is finished.
|
||||
|
||||
custom_headers: (name, value) pairs to add as request headers
|
||||
|
||||
Returns:
|
||||
The FakeChannel object which stores the result of the request.
|
||||
"""
|
||||
@ -420,6 +425,7 @@ class HomeserverTestCase(TestCase):
|
||||
federation_auth_origin,
|
||||
content_is_form,
|
||||
await_result,
|
||||
custom_headers,
|
||||
)
|
||||
|
||||
def setup_test_homeserver(self, *args, **kwargs):
|
||||
|
Loading…
Reference in New Issue
Block a user