Merge branch 'social_login' into develop
commit
18ab35284a
|
@ -0,0 +1 @@
|
||||||
|
Improve the user experience of setting up an account via single-sign on.
|
|
@ -0,0 +1 @@
|
||||||
|
Fix single-sign-on when the endpoints are routed to synapse workers.
|
|
@ -0,0 +1 @@
|
||||||
|
Improve the user experience of setting up an account via single-sign on.
|
|
@ -0,0 +1 @@
|
||||||
|
Improve the user experience of setting up an account via single-sign on.
|
|
@ -1815,7 +1815,8 @@ saml2_config:
|
||||||
#
|
#
|
||||||
# localpart_template: Jinja2 template for the localpart of the MXID.
|
# localpart_template: Jinja2 template for the localpart of the MXID.
|
||||||
# If this is not set, the user will be prompted to choose their
|
# If this is not set, the user will be prompted to choose their
|
||||||
# own username.
|
# own username (see 'sso_auth_account_details.html' in the 'sso'
|
||||||
|
# section of this file).
|
||||||
#
|
#
|
||||||
# display_name_template: Jinja2 template for the display name to set
|
# display_name_template: Jinja2 template for the display name to set
|
||||||
# on first login. If unset, no displayname will be set.
|
# on first login. If unset, no displayname will be set.
|
||||||
|
@ -1978,10 +1979,40 @@ sso:
|
||||||
#
|
#
|
||||||
# * idp: the 'idp_id' of the chosen IDP.
|
# * idp: the 'idp_id' of the chosen IDP.
|
||||||
#
|
#
|
||||||
|
# * HTML page to prompt new users to enter a userid and confirm other
|
||||||
|
# details: 'sso_auth_account_details.html'. This is only shown if the
|
||||||
|
# SSO implementation (with any user_mapping_provider) does not return
|
||||||
|
# a localpart.
|
||||||
|
#
|
||||||
|
# When rendering, this template is given the following variables:
|
||||||
|
#
|
||||||
|
# * server_name: the homeserver's name.
|
||||||
|
#
|
||||||
|
# * idp: details of the SSO Identity Provider that the user logged in
|
||||||
|
# with: an object with the following attributes:
|
||||||
|
#
|
||||||
|
# * idp_id: unique identifier for the IdP
|
||||||
|
# * idp_name: user-facing name for the IdP
|
||||||
|
# * idp_icon: if specified in the IdP config, an MXC URI for an icon
|
||||||
|
# for the IdP
|
||||||
|
# * idp_brand: if specified in the IdP config, a textual identifier
|
||||||
|
# for the brand of the IdP
|
||||||
|
#
|
||||||
|
# * user_attributes: an object containing details about the user that
|
||||||
|
# we received from the IdP. May have the following attributes:
|
||||||
|
#
|
||||||
|
# * display_name: the user's display_name
|
||||||
|
# * emails: a list of email addresses
|
||||||
|
#
|
||||||
|
# The template should render a form which submits the following fields:
|
||||||
|
#
|
||||||
|
# * username: the localpart of the user's chosen user id
|
||||||
|
#
|
||||||
# * HTML page for a confirmation step before redirecting back to the client
|
# * HTML page for a confirmation step before redirecting back to the client
|
||||||
# with the login token: 'sso_redirect_confirm.html'.
|
# with the login token: 'sso_redirect_confirm.html'.
|
||||||
#
|
#
|
||||||
# When rendering, this template is given three variables:
|
# When rendering, this template is given the following variables:
|
||||||
|
#
|
||||||
# * redirect_url: the URL the user is about to be redirected to. Needs
|
# * redirect_url: the URL the user is about to be redirected to. Needs
|
||||||
# manual escaping (see
|
# manual escaping (see
|
||||||
# https://jinja.palletsprojects.com/en/2.11.x/templates/#html-escaping).
|
# https://jinja.palletsprojects.com/en/2.11.x/templates/#html-escaping).
|
||||||
|
@ -1994,6 +2025,17 @@ sso:
|
||||||
#
|
#
|
||||||
# * server_name: the homeserver's name.
|
# * server_name: the homeserver's name.
|
||||||
#
|
#
|
||||||
|
# * new_user: a boolean indicating whether this is the user's first time
|
||||||
|
# logging in.
|
||||||
|
#
|
||||||
|
# * user_id: the user's matrix ID.
|
||||||
|
#
|
||||||
|
# * user_profile.avatar_url: an MXC URI for the user's avatar, if any.
|
||||||
|
# None if the user has not set an avatar.
|
||||||
|
#
|
||||||
|
# * user_profile.display_name: the user's display name. None if the user
|
||||||
|
# has not set a display name.
|
||||||
|
#
|
||||||
# * HTML page which notifies the user that they are authenticating to confirm
|
# * HTML page which notifies the user that they are authenticating to confirm
|
||||||
# an operation on their account during the user interactive authentication
|
# an operation on their account during the user interactive authentication
|
||||||
# process: 'sso_auth_confirm.html'.
|
# process: 'sso_auth_confirm.html'.
|
||||||
|
|
|
@ -228,7 +228,6 @@ expressions:
|
||||||
^/_matrix/client/(api/v1|r0|unstable)/joined_groups$
|
^/_matrix/client/(api/v1|r0|unstable)/joined_groups$
|
||||||
^/_matrix/client/(api/v1|r0|unstable)/publicised_groups$
|
^/_matrix/client/(api/v1|r0|unstable)/publicised_groups$
|
||||||
^/_matrix/client/(api/v1|r0|unstable)/publicised_groups/
|
^/_matrix/client/(api/v1|r0|unstable)/publicised_groups/
|
||||||
^/_synapse/client/password_reset/email/submit_token$
|
|
||||||
|
|
||||||
# Registration/login requests
|
# Registration/login requests
|
||||||
^/_matrix/client/(api/v1|r0|unstable)/login$
|
^/_matrix/client/(api/v1|r0|unstable)/login$
|
||||||
|
@ -259,25 +258,28 @@ Additionally, the following endpoints should be included if Synapse is configure
|
||||||
to use SSO (you only need to include the ones for whichever SSO provider you're
|
to use SSO (you only need to include the ones for whichever SSO provider you're
|
||||||
using):
|
using):
|
||||||
|
|
||||||
|
# for all SSO providers
|
||||||
|
^/_matrix/client/(api/v1|r0|unstable)/login/sso/redirect
|
||||||
|
^/_synapse/client/pick_idp$
|
||||||
|
^/_synapse/client/pick_username
|
||||||
|
^/_synapse/client/sso_register$
|
||||||
|
|
||||||
# OpenID Connect requests.
|
# OpenID Connect requests.
|
||||||
^/_matrix/client/(api/v1|r0|unstable)/login/sso/redirect$
|
|
||||||
^/_synapse/oidc/callback$
|
^/_synapse/oidc/callback$
|
||||||
|
|
||||||
# SAML requests.
|
# SAML requests.
|
||||||
^/_matrix/client/(api/v1|r0|unstable)/login/sso/redirect$
|
|
||||||
^/_matrix/saml2/authn_response$
|
^/_matrix/saml2/authn_response$
|
||||||
|
|
||||||
# CAS requests.
|
# CAS requests.
|
||||||
^/_matrix/client/(api/v1|r0|unstable)/login/(cas|sso)/redirect$
|
|
||||||
^/_matrix/client/(api/v1|r0|unstable)/login/cas/ticket$
|
^/_matrix/client/(api/v1|r0|unstable)/login/cas/ticket$
|
||||||
|
|
||||||
|
Ensure that all SSO logins go to a single process.
|
||||||
|
For multiple workers not handling the SSO endpoints properly, see
|
||||||
|
[#7530](https://github.com/matrix-org/synapse/issues/7530).
|
||||||
|
|
||||||
Note that a HTTP listener with `client` and `federation` resources must be
|
Note that a HTTP listener with `client` and `federation` resources must be
|
||||||
configured in the `worker_listeners` option in the worker config.
|
configured in the `worker_listeners` option in the worker config.
|
||||||
|
|
||||||
Ensure that all SSO logins go to a single process (usually the main process).
|
|
||||||
For multiple workers not handling the SSO endpoints properly, see
|
|
||||||
[#7530](https://github.com/matrix-org/synapse/issues/7530).
|
|
||||||
|
|
||||||
#### Load balancing
|
#### Load balancing
|
||||||
|
|
||||||
It is possible to run multiple instances of this worker app, with incoming requests
|
It is possible to run multiple instances of this worker app, with incoming requests
|
||||||
|
|
|
@ -22,6 +22,7 @@ from typing import Dict, Iterable, Optional, Set
|
||||||
from typing_extensions import ContextManager
|
from typing_extensions import ContextManager
|
||||||
|
|
||||||
from twisted.internet import address
|
from twisted.internet import address
|
||||||
|
from twisted.web.resource import IResource
|
||||||
|
|
||||||
import synapse
|
import synapse
|
||||||
import synapse.events
|
import synapse.events
|
||||||
|
@ -90,9 +91,8 @@ from synapse.replication.tcp.streams import (
|
||||||
ToDeviceStream,
|
ToDeviceStream,
|
||||||
)
|
)
|
||||||
from synapse.rest.admin import register_servlets_for_media_repo
|
from synapse.rest.admin import register_servlets_for_media_repo
|
||||||
from synapse.rest.client.v1 import events, room
|
from synapse.rest.client.v1 import events, login, room
|
||||||
from synapse.rest.client.v1.initial_sync import InitialSyncRestServlet
|
from synapse.rest.client.v1.initial_sync import InitialSyncRestServlet
|
||||||
from synapse.rest.client.v1.login import LoginRestServlet
|
|
||||||
from synapse.rest.client.v1.profile import (
|
from synapse.rest.client.v1.profile import (
|
||||||
ProfileAvatarURLRestServlet,
|
ProfileAvatarURLRestServlet,
|
||||||
ProfileDisplaynameRestServlet,
|
ProfileDisplaynameRestServlet,
|
||||||
|
@ -127,6 +127,7 @@ from synapse.rest.client.v2_alpha.sendtodevice import SendToDeviceRestServlet
|
||||||
from synapse.rest.client.versions import VersionsRestServlet
|
from synapse.rest.client.versions import VersionsRestServlet
|
||||||
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 import build_synapse_client_resource_tree
|
||||||
from synapse.server import HomeServer, cache_in_self
|
from synapse.server import HomeServer, cache_in_self
|
||||||
from synapse.storage.databases.main.censor_events import CensorEventsStore
|
from synapse.storage.databases.main.censor_events import CensorEventsStore
|
||||||
from synapse.storage.databases.main.client_ips import ClientIpWorkerStore
|
from synapse.storage.databases.main.client_ips import ClientIpWorkerStore
|
||||||
|
@ -507,7 +508,7 @@ class GenericWorkerServer(HomeServer):
|
||||||
site_tag = port
|
site_tag = port
|
||||||
|
|
||||||
# We always include a health resource.
|
# We always include a health resource.
|
||||||
resources = {"/health": HealthResource()}
|
resources = {"/health": HealthResource()} # type: Dict[str, IResource]
|
||||||
|
|
||||||
for res in listener_config.http_options.resources:
|
for res in listener_config.http_options.resources:
|
||||||
for name in res.names:
|
for name in res.names:
|
||||||
|
@ -517,7 +518,7 @@ class GenericWorkerServer(HomeServer):
|
||||||
resource = JsonResource(self, canonical_json=False)
|
resource = JsonResource(self, canonical_json=False)
|
||||||
|
|
||||||
RegisterRestServlet(self).register(resource)
|
RegisterRestServlet(self).register(resource)
|
||||||
LoginRestServlet(self).register(resource)
|
login.register_servlets(self, resource)
|
||||||
ThreepidRestServlet(self).register(resource)
|
ThreepidRestServlet(self).register(resource)
|
||||||
DevicesRestServlet(self).register(resource)
|
DevicesRestServlet(self).register(resource)
|
||||||
KeyQueryServlet(self).register(resource)
|
KeyQueryServlet(self).register(resource)
|
||||||
|
@ -557,6 +558,8 @@ class GenericWorkerServer(HomeServer):
|
||||||
groups.register_servlets(self, resource)
|
groups.register_servlets(self, resource)
|
||||||
|
|
||||||
resources.update({CLIENT_API_PREFIX: resource})
|
resources.update({CLIENT_API_PREFIX: resource})
|
||||||
|
|
||||||
|
resources.update(build_synapse_client_resource_tree(self))
|
||||||
elif name == "federation":
|
elif name == "federation":
|
||||||
resources.update({FEDERATION_PREFIX: TransportLayerServer(self)})
|
resources.update({FEDERATION_PREFIX: TransportLayerServer(self)})
|
||||||
elif name == "media":
|
elif name == "media":
|
||||||
|
|
|
@ -60,8 +60,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_idp import PickIdpResource
|
from synapse.rest.synapse.client import build_synapse_client_resource_tree
|
||||||
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
|
||||||
|
@ -190,21 +189,10 @@ 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),
|
**build_synapse_client_resource_tree(self),
|
||||||
"/_synapse/client/pick_idp": PickIdpResource(self),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.get_config().oidc_enabled:
|
|
||||||
from synapse.rest.oidc import OIDCResource
|
|
||||||
|
|
||||||
resources["/_synapse/oidc"] = OIDCResource(self)
|
|
||||||
|
|
||||||
if self.get_config().saml2_enabled:
|
|
||||||
from synapse.rest.saml2 import SAML2Resource
|
|
||||||
|
|
||||||
resources["/_matrix/saml2"] = SAML2Resource(self)
|
|
||||||
|
|
||||||
if self.get_config().threepid_behaviour_email == ThreepidBehaviour.LOCAL:
|
if self.get_config().threepid_behaviour_email == ThreepidBehaviour.LOCAL:
|
||||||
from synapse.rest.synapse.client.password_reset import (
|
from synapse.rest.synapse.client.password_reset import (
|
||||||
PasswordResetSubmitTokenResource,
|
PasswordResetSubmitTokenResource,
|
||||||
|
|
|
@ -18,18 +18,18 @@
|
||||||
import argparse
|
import argparse
|
||||||
import errno
|
import errno
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
import urllib.parse
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from textwrap import dedent
|
from textwrap import dedent
|
||||||
from typing import Any, Callable, Iterable, List, MutableMapping, Optional
|
from typing import Any, Iterable, List, MutableMapping, Optional
|
||||||
|
|
||||||
import attr
|
import attr
|
||||||
import jinja2
|
import jinja2
|
||||||
import pkg_resources
|
import pkg_resources
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
from synapse.util.templates import _create_mxc_to_http_filter, _format_ts_filter
|
||||||
|
|
||||||
|
|
||||||
class ConfigError(Exception):
|
class ConfigError(Exception):
|
||||||
"""Represents a problem parsing the configuration
|
"""Represents a problem parsing the configuration
|
||||||
|
@ -262,6 +262,7 @@ class Config:
|
||||||
# Search the custom template directory as well
|
# Search the custom template directory as well
|
||||||
search_directories.insert(0, custom_template_directory)
|
search_directories.insert(0, custom_template_directory)
|
||||||
|
|
||||||
|
# TODO: switch to synapse.util.templates.build_jinja_env
|
||||||
loader = jinja2.FileSystemLoader(search_directories)
|
loader = jinja2.FileSystemLoader(search_directories)
|
||||||
env = jinja2.Environment(loader=loader, autoescape=jinja2.select_autoescape(),)
|
env = jinja2.Environment(loader=loader, autoescape=jinja2.select_autoescape(),)
|
||||||
|
|
||||||
|
@ -277,38 +278,6 @@ class Config:
|
||||||
return [env.get_template(filename) for filename in filenames]
|
return [env.get_template(filename) for filename in filenames]
|
||||||
|
|
||||||
|
|
||||||
def _format_ts_filter(value: int, format: str):
|
|
||||||
return time.strftime(format, time.localtime(value / 1000))
|
|
||||||
|
|
||||||
|
|
||||||
def _create_mxc_to_http_filter(public_baseurl: str) -> Callable:
|
|
||||||
"""Create and return a jinja2 filter that converts MXC urls to HTTP
|
|
||||||
|
|
||||||
Args:
|
|
||||||
public_baseurl: The public, accessible base URL of the homeserver
|
|
||||||
"""
|
|
||||||
|
|
||||||
def mxc_to_http_filter(value, width, height, resize_method="crop"):
|
|
||||||
if value[0:6] != "mxc://":
|
|
||||||
return ""
|
|
||||||
|
|
||||||
server_and_media_id = value[6:]
|
|
||||||
fragment = None
|
|
||||||
if "#" in server_and_media_id:
|
|
||||||
server_and_media_id, fragment = server_and_media_id.split("#", 1)
|
|
||||||
fragment = "#" + fragment
|
|
||||||
|
|
||||||
params = {"width": width, "height": height, "method": resize_method}
|
|
||||||
return "%s_matrix/media/v1/thumbnail/%s?%s%s" % (
|
|
||||||
public_baseurl,
|
|
||||||
server_and_media_id,
|
|
||||||
urllib.parse.urlencode(params),
|
|
||||||
fragment or "",
|
|
||||||
)
|
|
||||||
|
|
||||||
return mxc_to_http_filter
|
|
||||||
|
|
||||||
|
|
||||||
class RootConfig:
|
class RootConfig:
|
||||||
"""
|
"""
|
||||||
Holder of an application's configuration.
|
Holder of an application's configuration.
|
||||||
|
|
|
@ -151,7 +151,8 @@ class OIDCConfig(Config):
|
||||||
#
|
#
|
||||||
# localpart_template: Jinja2 template for the localpart of the MXID.
|
# localpart_template: Jinja2 template for the localpart of the MXID.
|
||||||
# If this is not set, the user will be prompted to choose their
|
# If this is not set, the user will be prompted to choose their
|
||||||
# own username.
|
# own username (see 'sso_auth_account_details.html' in the 'sso'
|
||||||
|
# section of this file).
|
||||||
#
|
#
|
||||||
# display_name_template: Jinja2 template for the display name to set
|
# display_name_template: Jinja2 template for the display name to set
|
||||||
# on first login. If unset, no displayname will be set.
|
# on first login. If unset, no displayname will be set.
|
||||||
|
|
|
@ -27,7 +27,7 @@ class SSOConfig(Config):
|
||||||
sso_config = config.get("sso") or {} # type: Dict[str, Any]
|
sso_config = config.get("sso") or {} # type: Dict[str, Any]
|
||||||
|
|
||||||
# The sso-specific template_dir
|
# The sso-specific template_dir
|
||||||
template_dir = sso_config.get("template_dir")
|
self.sso_template_dir = sso_config.get("template_dir")
|
||||||
|
|
||||||
# Read templates from disk
|
# Read templates from disk
|
||||||
(
|
(
|
||||||
|
@ -48,7 +48,7 @@ class SSOConfig(Config):
|
||||||
"sso_auth_success.html",
|
"sso_auth_success.html",
|
||||||
"sso_auth_bad_user.html",
|
"sso_auth_bad_user.html",
|
||||||
],
|
],
|
||||||
template_dir,
|
self.sso_template_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
# These templates have no placeholders, so render them here
|
# These templates have no placeholders, so render them here
|
||||||
|
@ -124,10 +124,40 @@ class SSOConfig(Config):
|
||||||
#
|
#
|
||||||
# * idp: the 'idp_id' of the chosen IDP.
|
# * idp: the 'idp_id' of the chosen IDP.
|
||||||
#
|
#
|
||||||
|
# * HTML page to prompt new users to enter a userid and confirm other
|
||||||
|
# details: 'sso_auth_account_details.html'. This is only shown if the
|
||||||
|
# SSO implementation (with any user_mapping_provider) does not return
|
||||||
|
# a localpart.
|
||||||
|
#
|
||||||
|
# When rendering, this template is given the following variables:
|
||||||
|
#
|
||||||
|
# * server_name: the homeserver's name.
|
||||||
|
#
|
||||||
|
# * idp: details of the SSO Identity Provider that the user logged in
|
||||||
|
# with: an object with the following attributes:
|
||||||
|
#
|
||||||
|
# * idp_id: unique identifier for the IdP
|
||||||
|
# * idp_name: user-facing name for the IdP
|
||||||
|
# * idp_icon: if specified in the IdP config, an MXC URI for an icon
|
||||||
|
# for the IdP
|
||||||
|
# * idp_brand: if specified in the IdP config, a textual identifier
|
||||||
|
# for the brand of the IdP
|
||||||
|
#
|
||||||
|
# * user_attributes: an object containing details about the user that
|
||||||
|
# we received from the IdP. May have the following attributes:
|
||||||
|
#
|
||||||
|
# * display_name: the user's display_name
|
||||||
|
# * emails: a list of email addresses
|
||||||
|
#
|
||||||
|
# The template should render a form which submits the following fields:
|
||||||
|
#
|
||||||
|
# * username: the localpart of the user's chosen user id
|
||||||
|
#
|
||||||
# * HTML page for a confirmation step before redirecting back to the client
|
# * HTML page for a confirmation step before redirecting back to the client
|
||||||
# with the login token: 'sso_redirect_confirm.html'.
|
# with the login token: 'sso_redirect_confirm.html'.
|
||||||
#
|
#
|
||||||
# When rendering, this template is given three variables:
|
# When rendering, this template is given the following variables:
|
||||||
|
#
|
||||||
# * redirect_url: the URL the user is about to be redirected to. Needs
|
# * redirect_url: the URL the user is about to be redirected to. Needs
|
||||||
# manual escaping (see
|
# manual escaping (see
|
||||||
# https://jinja.palletsprojects.com/en/2.11.x/templates/#html-escaping).
|
# https://jinja.palletsprojects.com/en/2.11.x/templates/#html-escaping).
|
||||||
|
@ -140,6 +170,17 @@ class SSOConfig(Config):
|
||||||
#
|
#
|
||||||
# * server_name: the homeserver's name.
|
# * server_name: the homeserver's name.
|
||||||
#
|
#
|
||||||
|
# * new_user: a boolean indicating whether this is the user's first time
|
||||||
|
# logging in.
|
||||||
|
#
|
||||||
|
# * user_id: the user's matrix ID.
|
||||||
|
#
|
||||||
|
# * user_profile.avatar_url: an MXC URI for the user's avatar, if any.
|
||||||
|
# None if the user has not set an avatar.
|
||||||
|
#
|
||||||
|
# * user_profile.display_name: the user's display name. None if the user
|
||||||
|
# has not set a display name.
|
||||||
|
#
|
||||||
# * HTML page which notifies the user that they are authenticating to confirm
|
# * HTML page which notifies the user that they are authenticating to confirm
|
||||||
# an operation on their account during the user interactive authentication
|
# an operation on their account during the user interactive authentication
|
||||||
# process: 'sso_auth_confirm.html'.
|
# process: 'sso_auth_confirm.html'.
|
||||||
|
|
|
@ -61,6 +61,7 @@ from synapse.http.site import SynapseRequest
|
||||||
from synapse.logging.context import defer_to_thread
|
from synapse.logging.context import defer_to_thread
|
||||||
from synapse.metrics.background_process_metrics import run_as_background_process
|
from synapse.metrics.background_process_metrics import run_as_background_process
|
||||||
from synapse.module_api import ModuleApi
|
from synapse.module_api import ModuleApi
|
||||||
|
from synapse.storage.roommember import ProfileInfo
|
||||||
from synapse.types import JsonDict, Requester, UserID
|
from synapse.types import JsonDict, Requester, UserID
|
||||||
from synapse.util import stringutils as stringutils
|
from synapse.util import stringutils as stringutils
|
||||||
from synapse.util.async_helpers import maybe_awaitable
|
from synapse.util.async_helpers import maybe_awaitable
|
||||||
|
@ -1386,6 +1387,7 @@ class AuthHandler(BaseHandler):
|
||||||
request: Request,
|
request: Request,
|
||||||
client_redirect_url: str,
|
client_redirect_url: str,
|
||||||
extra_attributes: Optional[JsonDict] = None,
|
extra_attributes: Optional[JsonDict] = None,
|
||||||
|
new_user: bool = False,
|
||||||
):
|
):
|
||||||
"""Having figured out a mxid for this user, complete the HTTP request
|
"""Having figured out a mxid for this user, complete the HTTP request
|
||||||
|
|
||||||
|
@ -1396,6 +1398,8 @@ class AuthHandler(BaseHandler):
|
||||||
process.
|
process.
|
||||||
extra_attributes: Extra attributes which will be passed to the client
|
extra_attributes: Extra attributes which will be passed to the client
|
||||||
during successful login. Must be JSON serializable.
|
during successful login. Must be JSON serializable.
|
||||||
|
new_user: True if we should use wording appropriate to a user who has just
|
||||||
|
registered.
|
||||||
"""
|
"""
|
||||||
# If the account has been deactivated, do not proceed with the login
|
# If the account has been deactivated, do not proceed with the login
|
||||||
# flow.
|
# flow.
|
||||||
|
@ -1404,8 +1408,17 @@ class AuthHandler(BaseHandler):
|
||||||
respond_with_html(request, 403, self._sso_account_deactivated_template)
|
respond_with_html(request, 403, self._sso_account_deactivated_template)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
profile = await self.store.get_profileinfo(
|
||||||
|
UserID.from_string(registered_user_id).localpart
|
||||||
|
)
|
||||||
|
|
||||||
self._complete_sso_login(
|
self._complete_sso_login(
|
||||||
registered_user_id, request, client_redirect_url, extra_attributes
|
registered_user_id,
|
||||||
|
request,
|
||||||
|
client_redirect_url,
|
||||||
|
extra_attributes,
|
||||||
|
new_user=new_user,
|
||||||
|
user_profile_data=profile,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _complete_sso_login(
|
def _complete_sso_login(
|
||||||
|
@ -1414,12 +1427,18 @@ class AuthHandler(BaseHandler):
|
||||||
request: Request,
|
request: Request,
|
||||||
client_redirect_url: str,
|
client_redirect_url: str,
|
||||||
extra_attributes: Optional[JsonDict] = None,
|
extra_attributes: Optional[JsonDict] = None,
|
||||||
|
new_user: bool = False,
|
||||||
|
user_profile_data: Optional[ProfileInfo] = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
The synchronous portion of complete_sso_login.
|
The synchronous portion of complete_sso_login.
|
||||||
|
|
||||||
This exists purely for backwards compatibility of synapse.module_api.ModuleApi.
|
This exists purely for backwards compatibility of synapse.module_api.ModuleApi.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
if user_profile_data is None:
|
||||||
|
user_profile_data = ProfileInfo(None, None)
|
||||||
|
|
||||||
# Store any extra attributes which will be passed in the login response.
|
# Store any extra attributes which will be passed in the login response.
|
||||||
# Note that this is per-user so it may overwrite a previous value, this
|
# Note that this is per-user so it may overwrite a previous value, this
|
||||||
# is considered OK since the newest SSO attributes should be most valid.
|
# is considered OK since the newest SSO attributes should be most valid.
|
||||||
|
@ -1457,6 +1476,9 @@ class AuthHandler(BaseHandler):
|
||||||
display_url=redirect_url_no_params,
|
display_url=redirect_url_no_params,
|
||||||
redirect_url=redirect_url,
|
redirect_url=redirect_url,
|
||||||
server_name=self._server_name,
|
server_name=self._server_name,
|
||||||
|
new_user=new_user,
|
||||||
|
user_id=registered_user_id,
|
||||||
|
user_profile=user_profile_data,
|
||||||
)
|
)
|
||||||
respond_with_html(request, 200, html)
|
respond_with_html(request, 200, html)
|
||||||
|
|
||||||
|
|
|
@ -21,12 +21,13 @@ import attr
|
||||||
from typing_extensions import NoReturn, Protocol
|
from typing_extensions import NoReturn, Protocol
|
||||||
|
|
||||||
from twisted.web.http import Request
|
from twisted.web.http import Request
|
||||||
|
from twisted.web.iweb import IRequest
|
||||||
|
|
||||||
from synapse.api.constants import LoginType
|
from synapse.api.constants import LoginType
|
||||||
from synapse.api.errors import Codes, NotFoundError, RedirectException, SynapseError
|
from synapse.api.errors import Codes, NotFoundError, RedirectException, SynapseError
|
||||||
from synapse.handlers.ui_auth import UIAuthSessionDataConstants
|
from synapse.handlers.ui_auth import UIAuthSessionDataConstants
|
||||||
from synapse.http import get_request_user_agent
|
from synapse.http import get_request_user_agent
|
||||||
from synapse.http.server import respond_with_html
|
from synapse.http.server import respond_with_html, respond_with_redirect
|
||||||
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
|
||||||
|
@ -141,6 +142,9 @@ class UsernameMappingSession:
|
||||||
# expiry time for the session, in milliseconds
|
# expiry time for the session, in milliseconds
|
||||||
expiry_time_ms = attr.ib(type=int)
|
expiry_time_ms = attr.ib(type=int)
|
||||||
|
|
||||||
|
# choices made by the user
|
||||||
|
chosen_localpart = attr.ib(type=Optional[str], default=None)
|
||||||
|
|
||||||
|
|
||||||
# the HTTP cookie used to track the mapping session id
|
# the HTTP cookie used to track the mapping session id
|
||||||
USERNAME_MAPPING_SESSION_COOKIE_NAME = b"username_mapping_session"
|
USERNAME_MAPPING_SESSION_COOKIE_NAME = b"username_mapping_session"
|
||||||
|
@ -387,6 +391,8 @@ class SsoHandler:
|
||||||
to an additional page. (e.g. to prompt for more information)
|
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...
|
# grab a lock while we try to find a mapping for this user. This seems...
|
||||||
# optimistic, especially for implementations that end up redirecting to
|
# optimistic, especially for implementations that end up redirecting to
|
||||||
# interstitial pages.
|
# interstitial pages.
|
||||||
|
@ -427,9 +433,14 @@ class SsoHandler:
|
||||||
get_request_user_agent(request),
|
get_request_user_agent(request),
|
||||||
request.getClientIP(),
|
request.getClientIP(),
|
||||||
)
|
)
|
||||||
|
new_user = True
|
||||||
|
|
||||||
await self._auth_handler.complete_sso_login(
|
await self._auth_handler.complete_sso_login(
|
||||||
user_id, request, client_redirect_url, extra_login_attributes
|
user_id,
|
||||||
|
request,
|
||||||
|
client_redirect_url,
|
||||||
|
extra_login_attributes,
|
||||||
|
new_user=new_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _call_attribute_mapper(
|
async def _call_attribute_mapper(
|
||||||
|
@ -519,7 +530,7 @@ class SsoHandler:
|
||||||
logger.info("Recorded registration session id %s", session_id)
|
logger.info("Recorded registration session id %s", session_id)
|
||||||
|
|
||||||
# Set the cookie and redirect to the username picker
|
# Set the cookie and redirect to the username picker
|
||||||
e = RedirectException(b"/_synapse/client/pick_username")
|
e = RedirectException(b"/_synapse/client/pick_username/account_details")
|
||||||
e.cookies.append(
|
e.cookies.append(
|
||||||
b"%s=%s; path=/"
|
b"%s=%s; path=/"
|
||||||
% (USERNAME_MAPPING_SESSION_COOKIE_NAME, session_id.encode("ascii"))
|
% (USERNAME_MAPPING_SESSION_COOKIE_NAME, session_id.encode("ascii"))
|
||||||
|
@ -647,6 +658,25 @@ class SsoHandler:
|
||||||
)
|
)
|
||||||
respond_with_html(request, 200, html)
|
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(
|
async def check_username_availability(
|
||||||
self, localpart: str, session_id: str,
|
self, localpart: str, session_id: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
@ -663,12 +693,7 @@ class SsoHandler:
|
||||||
|
|
||||||
# make sure that there is a valid mapping session, to stop people dictionary-
|
# make sure that there is a valid mapping session, to stop people dictionary-
|
||||||
# scanning for accounts
|
# scanning for accounts
|
||||||
|
self.get_mapping_session(session_id)
|
||||||
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(
|
logger.info(
|
||||||
"[session %s] Checking for availability of username %s",
|
"[session %s] Checking for availability of username %s",
|
||||||
|
@ -696,16 +721,33 @@ class SsoHandler:
|
||||||
localpart: localpart requested by the user
|
localpart: localpart requested by the user
|
||||||
session_id: ID of the username mapping session, extracted from a cookie
|
session_id: ID of the username mapping session, extracted from a cookie
|
||||||
"""
|
"""
|
||||||
self._expire_old_sessions()
|
session = self.get_mapping_session(session_id)
|
||||||
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)
|
# update the session with the user's choices
|
||||||
|
session.chosen_localpart = localpart
|
||||||
|
|
||||||
|
# we're done; now we can register the user
|
||||||
|
respond_with_redirect(request, b"/_synapse/client/sso_register")
|
||||||
|
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
session = self.get_mapping_session(session_id)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[session %s] Registering localpart %s",
|
||||||
|
session_id,
|
||||||
|
session.chosen_localpart,
|
||||||
|
)
|
||||||
|
|
||||||
attributes = UserAttributes(
|
attributes = UserAttributes(
|
||||||
localpart=localpart,
|
localpart=session.chosen_localpart,
|
||||||
display_name=session.display_name,
|
display_name=session.display_name,
|
||||||
emails=session.emails,
|
emails=session.emails,
|
||||||
)
|
)
|
||||||
|
@ -720,7 +762,12 @@ class SsoHandler:
|
||||||
request.getClientIP(),
|
request.getClientIP(),
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("[session %s] Registered userid %s", session_id, user_id)
|
logger.info(
|
||||||
|
"[session %s] Registered userid %s with attributes %s",
|
||||||
|
session_id,
|
||||||
|
user_id,
|
||||||
|
attributes,
|
||||||
|
)
|
||||||
|
|
||||||
# delete the mapping session and the cookie
|
# delete the mapping session and the cookie
|
||||||
del self._username_mapping_sessions[session_id]
|
del self._username_mapping_sessions[session_id]
|
||||||
|
@ -738,6 +785,7 @@ class SsoHandler:
|
||||||
request,
|
request,
|
||||||
session.client_redirect_url,
|
session.client_redirect_url,
|
||||||
session.extra_login_attributes,
|
session.extra_login_attributes,
|
||||||
|
new_user=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _expire_old_sessions(self):
|
def _expire_old_sessions(self):
|
||||||
|
@ -751,3 +799,14 @@ class SsoHandler:
|
||||||
for session_id in to_expire:
|
for session_id in to_expire:
|
||||||
logger.info("Expiring mapping session %s", session_id)
|
logger.info("Expiring mapping session %s", session_id)
|
||||||
del self._username_mapping_sessions[session_id]
|
del self._username_mapping_sessions[session_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")
|
||||||
|
|
|
@ -761,6 +761,13 @@ def set_clickjacking_protection_headers(request: Request):
|
||||||
request.setHeader(b"Content-Security-Policy", b"frame-ancestors 'none';")
|
request.setHeader(b"Content-Security-Policy", b"frame-ancestors 'none';")
|
||||||
|
|
||||||
|
|
||||||
|
def respond_with_redirect(request: Request, url: bytes) -> None:
|
||||||
|
"""Write a 302 response to the request, if it is still alive."""
|
||||||
|
logger.debug("Redirect to %s", url.decode("utf-8"))
|
||||||
|
request.redirect(url)
|
||||||
|
finish_request(request)
|
||||||
|
|
||||||
|
|
||||||
def finish_request(request: Request):
|
def finish_request(request: Request):
|
||||||
""" Finish writing the response to the request.
|
""" Finish writing the response to the request.
|
||||||
|
|
||||||
|
|
|
@ -279,7 +279,11 @@ class ModuleApi:
|
||||||
)
|
)
|
||||||
|
|
||||||
async def complete_sso_login_async(
|
async def complete_sso_login_async(
|
||||||
self, registered_user_id: str, request: SynapseRequest, client_redirect_url: str
|
self,
|
||||||
|
registered_user_id: str,
|
||||||
|
request: SynapseRequest,
|
||||||
|
client_redirect_url: str,
|
||||||
|
new_user: bool = False,
|
||||||
):
|
):
|
||||||
"""Complete a SSO login by redirecting the user to a page to confirm whether they
|
"""Complete a SSO login by redirecting the user to a page to confirm whether they
|
||||||
want their access token sent to `client_redirect_url`, or redirect them to that
|
want their access token sent to `client_redirect_url`, or redirect them to that
|
||||||
|
@ -291,9 +295,11 @@ class ModuleApi:
|
||||||
request: The request to respond to.
|
request: The request to respond to.
|
||||||
client_redirect_url: The URL to which to offer to redirect the user (or to
|
client_redirect_url: The URL to which to offer to redirect the user (or to
|
||||||
redirect them directly if whitelisted).
|
redirect them directly if whitelisted).
|
||||||
|
new_user: set to true to use wording for the consent appropriate to a user
|
||||||
|
who has just registered.
|
||||||
"""
|
"""
|
||||||
await self._auth_handler.complete_sso_login(
|
await self._auth_handler.complete_sso_login(
|
||||||
registered_user_id, request, client_redirect_url,
|
registered_user_id, request, client_redirect_url, new_user=new_user
|
||||||
)
|
)
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
|
|
|
@ -0,0 +1,83 @@
|
||||||
|
body {
|
||||||
|
font-family: "Inter", "Helvetica", "Arial", sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #17191C;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
max-width: 480px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 24px auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
header p {
|
||||||
|
color: #737D8C;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 img {
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 8px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 360px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 24px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
border: none;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 12px;
|
||||||
|
color: white;
|
||||||
|
background-color: #418DED;
|
||||||
|
font-weight: bold;
|
||||||
|
display: block;
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 16px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile .avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 100%;
|
||||||
|
display: block;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile .display-name {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.profile .user-id {
|
||||||
|
color: #737D8C;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile .display-name, .profile .user-id {
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
|
@ -0,0 +1,115 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Synapse Login</title>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, user-scalable=no">
|
||||||
|
<style type="text/css">
|
||||||
|
{% include "sso.css" without context %}
|
||||||
|
|
||||||
|
.username_input {
|
||||||
|
display: flex;
|
||||||
|
border: 2px solid #418DED;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
position: relative;
|
||||||
|
margin: 16px 0;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username_input label {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
left: 14px;
|
||||||
|
font-size: 80%;
|
||||||
|
background: white;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username_input input {
|
||||||
|
flex: 1;
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username_input div {
|
||||||
|
color: #8D99A5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idp-pick-details {
|
||||||
|
border: 1px solid #E9ECF1;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idp-pick-details h2 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idp-pick-details .idp-detail {
|
||||||
|
border-top: 1px solid #E9ECF1;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idp-pick-details .use, .idp-pick-details .idp-value {
|
||||||
|
color: #737D8C;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idp-pick-details .idp-value {
|
||||||
|
margin: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idp-pick-details .avatar {
|
||||||
|
width: 53px;
|
||||||
|
height: 53px;
|
||||||
|
border-radius: 100%;
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Your account is nearly ready</h1>
|
||||||
|
<p>Check your details before creating an account on {{ server_name }}</p>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<form method="post" class="form__input" id="form">
|
||||||
|
<div class="username_input">
|
||||||
|
<label for="field-username">Username</label>
|
||||||
|
<div class="prefix">@</div>
|
||||||
|
<input type="text" name="username" id="field-username" autofocus required pattern="[a-z0-9\-=_\/\.]+">
|
||||||
|
<div class="postfix">:{{ server_name }}</div>
|
||||||
|
</div>
|
||||||
|
<input type="submit" value="Continue" class="primary-button">
|
||||||
|
{% if user_attributes %}
|
||||||
|
<section class="idp-pick-details">
|
||||||
|
<h2><img src="{{ idp.idp_icon | mxc_to_http(24, 24) }}"/>Information from {{ idp.idp_name }}</h2>
|
||||||
|
{% if user_attributes.avatar_url %}
|
||||||
|
<div class="idp-detail idp-avatar">
|
||||||
|
<img src="{{ user_attributes.avatar_url }}" class="avatar" />
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if user_attributes.display_name %}
|
||||||
|
<div class="idp-detail">
|
||||||
|
<p class="idp-value">{{ user_attributes.display_name }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% for email in user_attributes.emails %}
|
||||||
|
<div class="idp-detail">
|
||||||
|
<p class="idp-value">{{ email }}</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
<script type="text/javascript">
|
||||||
|
{% include "sso_auth_account_details.js" without context %}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,76 @@
|
||||||
|
const usernameField = document.getElementById("field-username");
|
||||||
|
|
||||||
|
function throttle(fn, wait) {
|
||||||
|
let timeout;
|
||||||
|
return function() {
|
||||||
|
const args = Array.from(arguments);
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
timeout = setTimeout(fn.bind.apply(fn, [null].concat(args)), wait);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkUsernameAvailable(username) {
|
||||||
|
let check_uri = 'check?username=' + encodeURIComponent(username);
|
||||||
|
return 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 new Error(text); });
|
||||||
|
} else {
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
}).then((json) => {
|
||||||
|
if(json.error) {
|
||||||
|
return {message: json.error};
|
||||||
|
} else if(json.available) {
|
||||||
|
return {available: true};
|
||||||
|
} else {
|
||||||
|
return {message: username + " is not available, please choose another."};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateUsername(username) {
|
||||||
|
usernameField.setCustomValidity("");
|
||||||
|
if (usernameField.validity.valueMissing) {
|
||||||
|
usernameField.setCustomValidity("Please provide a username");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (usernameField.validity.patternMismatch) {
|
||||||
|
usernameField.setCustomValidity("Invalid username, please only use " + allowedCharactersString);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
usernameField.setCustomValidity("Checking if username is available …");
|
||||||
|
throttledCheckUsernameAvailable(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
const throttledCheckUsernameAvailable = throttle(function(username) {
|
||||||
|
const handleError = function(err) {
|
||||||
|
// don't prevent form submission on error
|
||||||
|
usernameField.setCustomValidity("");
|
||||||
|
console.log(err.message);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
checkUsernameAvailable(username).then(function(result) {
|
||||||
|
if (!result.available) {
|
||||||
|
usernameField.setCustomValidity(result.message);
|
||||||
|
usernameField.reportValidity();
|
||||||
|
} else {
|
||||||
|
usernameField.setCustomValidity("");
|
||||||
|
}
|
||||||
|
}, handleError);
|
||||||
|
} catch (err) {
|
||||||
|
handleError(err);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
usernameField.addEventListener("input", function(evt) {
|
||||||
|
validateUsername(usernameField.value);
|
||||||
|
});
|
||||||
|
usernameField.addEventListener("change", function(evt) {
|
||||||
|
validateUsername(usernameField.value);
|
||||||
|
});
|
|
@ -3,12 +3,34 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>SSO redirect confirmation</title>
|
<title>SSO redirect confirmation</title>
|
||||||
|
<meta name="viewport" content="width=device-width, user-scalable=no">
|
||||||
|
<style type="text/css">
|
||||||
|
{% include "sso.css" without context %}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<p>The application at <span style="font-weight:bold">{{ display_url }}</span> is requesting full access to your <span style="font-weight:bold">{{ server_name }}</span> Matrix account.</p>
|
<header>
|
||||||
<p>If you don't recognise this address, you should ignore this and close this tab.</p>
|
{% if new_user %}
|
||||||
<p>
|
<h1>Your account is now ready</h1>
|
||||||
<a href="{{ redirect_url }}">I trust this address</a>
|
<p>You've made your account on {{ server_name }}.</p>
|
||||||
</p>
|
{% else %}
|
||||||
|
<h1>Log in</h1>
|
||||||
|
{% endif %}
|
||||||
|
<p>Continue to confirm you trust <strong>{{ display_url }}</strong>.</p>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{% if user_profile.avatar_url %}
|
||||||
|
<div class="profile">
|
||||||
|
<img src="{{ user_profile.avatar_url | mxc_to_http(64, 64) }}" class="avatar" />
|
||||||
|
<div class="profile-details">
|
||||||
|
{% if user_profile.display_name %}
|
||||||
|
<div class="display-name">{{ user_profile.display_name }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="user-id">{{ user_id }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ redirect_url }}" class="primary-button">Continue</a>
|
||||||
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -1,19 +0,0 @@
|
||||||
<!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>
|
|
|
@ -1,95 +0,0 @@
|
||||||
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;
|
|
|
@ -1,27 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
|
@ -100,6 +100,7 @@ class ConsentResource(DirectServeHtmlResource):
|
||||||
|
|
||||||
consent_template_directory = hs.config.user_consent_template_dir
|
consent_template_directory = hs.config.user_consent_template_dir
|
||||||
|
|
||||||
|
# TODO: switch to synapse.util.templates.build_jinja_env
|
||||||
loader = jinja2.FileSystemLoader(consent_template_directory)
|
loader = jinja2.FileSystemLoader(consent_template_directory)
|
||||||
self._jinja_env = jinja2.Environment(
|
self._jinja_env = jinja2.Environment(
|
||||||
loader=loader, autoescape=jinja2.select_autoescape(["html", "htm", "xml"])
|
loader=loader, autoescape=jinja2.select_autoescape(["html", "htm", "xml"])
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
# Copyright 2020 The Matrix.org Foundation C.I.C.
|
# Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
|
@ -12,3 +12,50 @@
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
# 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.
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Mapping
|
||||||
|
|
||||||
|
from twisted.web.resource import Resource
|
||||||
|
|
||||||
|
from synapse.rest.synapse.client.pick_idp import PickIdpResource
|
||||||
|
from synapse.rest.synapse.client.pick_username import pick_username_resource
|
||||||
|
from synapse.rest.synapse.client.sso_register import SsoRegisterResource
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from synapse.server import HomeServer
|
||||||
|
|
||||||
|
|
||||||
|
def build_synapse_client_resource_tree(hs: "HomeServer") -> Mapping[str, Resource]:
|
||||||
|
"""Builds a resource tree to include synapse-specific client resources
|
||||||
|
|
||||||
|
These are resources which should be loaded on all workers which expose a C-S API:
|
||||||
|
ie, the main process, and any generic workers so configured.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
map from path to Resource.
|
||||||
|
"""
|
||||||
|
resources = {
|
||||||
|
# SSO bits. These are always loaded, whether or not SSO login is actually
|
||||||
|
# enabled (they just won't work very well if it's not)
|
||||||
|
"/_synapse/client/pick_idp": PickIdpResource(hs),
|
||||||
|
"/_synapse/client/pick_username": pick_username_resource(hs),
|
||||||
|
"/_synapse/client/sso_register": SsoRegisterResource(hs),
|
||||||
|
}
|
||||||
|
|
||||||
|
# provider-specific SSO bits. Only load these if they are enabled, since they
|
||||||
|
# rely on optional dependencies.
|
||||||
|
if hs.config.oidc_enabled:
|
||||||
|
from synapse.rest.oidc import OIDCResource
|
||||||
|
|
||||||
|
resources["/_synapse/oidc"] = OIDCResource(hs)
|
||||||
|
|
||||||
|
if hs.config.saml2_enabled:
|
||||||
|
from synapse.rest.saml2 import SAML2Resource
|
||||||
|
|
||||||
|
# This is mounted under '/_matrix' for backwards-compatibility.
|
||||||
|
resources["/_matrix/saml2"] = SAML2Resource(hs)
|
||||||
|
|
||||||
|
return resources
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["build_synapse_client_resource_tree"]
|
||||||
|
|
|
@ -12,42 +12,42 @@
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
# 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.
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import pkg_resources
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from twisted.web.http import Request
|
from twisted.web.http import Request
|
||||||
from twisted.web.resource import Resource
|
from twisted.web.resource import Resource
|
||||||
from twisted.web.static import File
|
|
||||||
|
|
||||||
from synapse.api.errors import SynapseError
|
from synapse.api.errors import SynapseError
|
||||||
from synapse.handlers.sso import USERNAME_MAPPING_SESSION_COOKIE_NAME
|
from synapse.handlers.sso import get_username_mapping_session_cookie_from_request
|
||||||
from synapse.http.server import DirectServeHtmlResource, DirectServeJsonResource
|
from synapse.http.server import (
|
||||||
|
DirectServeHtmlResource,
|
||||||
|
DirectServeJsonResource,
|
||||||
|
respond_with_html,
|
||||||
|
)
|
||||||
from synapse.http.servlet import parse_string
|
from synapse.http.servlet import parse_string
|
||||||
from synapse.http.site import SynapseRequest
|
from synapse.http.site import SynapseRequest
|
||||||
|
from synapse.util.templates import build_jinja_env
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from synapse.server import HomeServer
|
from synapse.server import HomeServer
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def pick_username_resource(hs: "HomeServer") -> Resource:
|
def pick_username_resource(hs: "HomeServer") -> Resource:
|
||||||
"""Factory method to generate the username picker resource.
|
"""Factory method to generate the username picker resource.
|
||||||
|
|
||||||
This resource gets mounted under /_synapse/client/pick_username. The top-level
|
This resource gets mounted under /_synapse/client/pick_username and has two
|
||||||
resource is just a File resource which serves up the static files in the resources
|
children:
|
||||||
"res" directory, but it has a couple of children:
|
|
||||||
|
|
||||||
* "submit", which does the mechanics of registering the new user, and redirects the
|
* "account_details": renders the form and handles the POSTed response
|
||||||
browser back to the client URL
|
* "check": a JSON endpoint which checks if a userid is free.
|
||||||
|
|
||||||
* "check": checks if a userid is free.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# XXX should we make this path customisable so that admins can restyle it?
|
res = Resource()
|
||||||
base_path = pkg_resources.resource_filename("synapse", "res/username_picker")
|
res.putChild(b"account_details", AccountDetailsResource(hs))
|
||||||
|
|
||||||
res = File(base_path)
|
|
||||||
res.putChild(b"submit", SubmitResource(hs))
|
|
||||||
res.putChild(b"check", AvailabilityCheckResource(hs))
|
res.putChild(b"check", AvailabilityCheckResource(hs))
|
||||||
|
|
||||||
return res
|
return res
|
||||||
|
@ -61,28 +61,63 @@ class AvailabilityCheckResource(DirectServeJsonResource):
|
||||||
async def _async_render_GET(self, request: Request):
|
async def _async_render_GET(self, request: Request):
|
||||||
localpart = parse_string(request, "username", required=True)
|
localpart = parse_string(request, "username", required=True)
|
||||||
|
|
||||||
session_id = request.getCookie(USERNAME_MAPPING_SESSION_COOKIE_NAME)
|
session_id = get_username_mapping_session_cookie_from_request(request)
|
||||||
if not session_id:
|
|
||||||
raise SynapseError(code=400, msg="missing session_id")
|
|
||||||
|
|
||||||
is_available = await self._sso_handler.check_username_availability(
|
is_available = await self._sso_handler.check_username_availability(
|
||||||
localpart, session_id.decode("ascii", errors="replace")
|
localpart, session_id
|
||||||
)
|
)
|
||||||
return 200, {"available": is_available}
|
return 200, {"available": is_available}
|
||||||
|
|
||||||
|
|
||||||
class SubmitResource(DirectServeHtmlResource):
|
class AccountDetailsResource(DirectServeHtmlResource):
|
||||||
def __init__(self, hs: "HomeServer"):
|
def __init__(self, hs: "HomeServer"):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._sso_handler = hs.get_sso_handler()
|
self._sso_handler = hs.get_sso_handler()
|
||||||
|
|
||||||
async def _async_render_POST(self, request: SynapseRequest):
|
def template_search_dirs():
|
||||||
localpart = parse_string(request, "username", required=True)
|
if hs.config.sso.sso_template_dir:
|
||||||
|
yield hs.config.sso.sso_template_dir
|
||||||
|
yield hs.config.sso.default_template_dir
|
||||||
|
|
||||||
session_id = request.getCookie(USERNAME_MAPPING_SESSION_COOKIE_NAME)
|
self._jinja_env = build_jinja_env(template_search_dirs(), hs.config)
|
||||||
if not session_id:
|
|
||||||
raise SynapseError(code=400, msg="missing session_id")
|
async def _async_render_GET(self, request: Request) -> None:
|
||||||
|
try:
|
||||||
|
session_id = get_username_mapping_session_cookie_from_request(request)
|
||||||
|
session = self._sso_handler.get_mapping_session(session_id)
|
||||||
|
except SynapseError as e:
|
||||||
|
logger.warning("Error fetching session: %s", e)
|
||||||
|
self._sso_handler.render_error(request, "bad_session", e.msg, code=e.code)
|
||||||
|
return
|
||||||
|
|
||||||
|
idp_id = session.auth_provider_id
|
||||||
|
template_params = {
|
||||||
|
"idp": self._sso_handler.get_identity_providers()[idp_id],
|
||||||
|
"user_attributes": {
|
||||||
|
"display_name": session.display_name,
|
||||||
|
"emails": session.emails,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
template = self._jinja_env.get_template("sso_auth_account_details.html")
|
||||||
|
html = template.render(template_params)
|
||||||
|
respond_with_html(request, 200, html)
|
||||||
|
|
||||||
|
async def _async_render_POST(self, request: SynapseRequest):
|
||||||
|
try:
|
||||||
|
session_id = get_username_mapping_session_cookie_from_request(request)
|
||||||
|
except SynapseError as e:
|
||||||
|
logger.warning("Error fetching session cookie: %s", e)
|
||||||
|
self._sso_handler.render_error(request, "bad_session", e.msg, code=e.code)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
localpart = parse_string(request, "username", required=True)
|
||||||
|
except SynapseError as e:
|
||||||
|
logger.warning("[session %s] bad param: %s", session_id, e)
|
||||||
|
self._sso_handler.render_error(request, "bad_param", e.msg, code=e.code)
|
||||||
|
return
|
||||||
|
|
||||||
await self._sso_handler.handle_submit_username_request(
|
await self._sso_handler.handle_submit_username_request(
|
||||||
request, localpart, session_id.decode("ascii", errors="replace")
|
request, localpart, session_id
|
||||||
)
|
)
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2021 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 logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from twisted.web.http import Request
|
||||||
|
|
||||||
|
from synapse.api.errors import SynapseError
|
||||||
|
from synapse.handlers.sso import get_username_mapping_session_cookie_from_request
|
||||||
|
from synapse.http.server import DirectServeHtmlResource
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from synapse.server import HomeServer
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SsoRegisterResource(DirectServeHtmlResource):
|
||||||
|
"""A resource which completes SSO registration
|
||||||
|
|
||||||
|
This resource gets mounted at /_synapse/client/sso_register, and is shown
|
||||||
|
after we collect username and/or consent for a new SSO user. It (finally) registers
|
||||||
|
the user, and confirms redirect to the client
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, hs: "HomeServer"):
|
||||||
|
super().__init__()
|
||||||
|
self._sso_handler = hs.get_sso_handler()
|
||||||
|
|
||||||
|
async def _async_render_GET(self, request: Request) -> None:
|
||||||
|
try:
|
||||||
|
session_id = get_username_mapping_session_cookie_from_request(request)
|
||||||
|
except SynapseError as e:
|
||||||
|
logger.warning("Error fetching session cookie: %s", e)
|
||||||
|
self._sso_handler.render_error(request, "bad_session", e.msg, code=e.code)
|
||||||
|
return
|
||||||
|
await self._sso_handler.register_sso_user(request, session_id)
|
|
@ -472,6 +472,26 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore):
|
||||||
|
|
||||||
return await self.db_pool.runInteraction("get_users_by_id_case_insensitive", f)
|
return await self.db_pool.runInteraction("get_users_by_id_case_insensitive", f)
|
||||||
|
|
||||||
|
async def record_user_external_id(
|
||||||
|
self, auth_provider: str, external_id: str, user_id: str
|
||||||
|
) -> None:
|
||||||
|
"""Record a mapping from an external user id to a mxid
|
||||||
|
|
||||||
|
Args:
|
||||||
|
auth_provider: identifier for the remote auth provider
|
||||||
|
external_id: id on that system
|
||||||
|
user_id: complete mxid that it is mapped to
|
||||||
|
"""
|
||||||
|
await self.db_pool.simple_insert(
|
||||||
|
table="user_external_ids",
|
||||||
|
values={
|
||||||
|
"auth_provider": auth_provider,
|
||||||
|
"external_id": external_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
},
|
||||||
|
desc="record_user_external_id",
|
||||||
|
)
|
||||||
|
|
||||||
async def get_user_by_external_id(
|
async def get_user_by_external_id(
|
||||||
self, auth_provider: str, external_id: str
|
self, auth_provider: str, external_id: str
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
|
@ -1400,26 +1420,6 @@ class RegistrationStore(StatsStore, RegistrationBackgroundUpdateStore):
|
||||||
|
|
||||||
self._invalidate_cache_and_stream(txn, self.get_user_by_id, (user_id,))
|
self._invalidate_cache_and_stream(txn, self.get_user_by_id, (user_id,))
|
||||||
|
|
||||||
async def record_user_external_id(
|
|
||||||
self, auth_provider: str, external_id: str, user_id: str
|
|
||||||
) -> None:
|
|
||||||
"""Record a mapping from an external user id to a mxid
|
|
||||||
|
|
||||||
Args:
|
|
||||||
auth_provider: identifier for the remote auth provider
|
|
||||||
external_id: id on that system
|
|
||||||
user_id: complete mxid that it is mapped to
|
|
||||||
"""
|
|
||||||
await self.db_pool.simple_insert(
|
|
||||||
table="user_external_ids",
|
|
||||||
values={
|
|
||||||
"auth_provider": auth_provider,
|
|
||||||
"external_id": external_id,
|
|
||||||
"user_id": user_id,
|
|
||||||
},
|
|
||||||
desc="record_user_external_id",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def user_set_password_hash(
|
async def user_set_password_hash(
|
||||||
self, user_id: str, password_hash: Optional[str]
|
self, user_id: str, password_hash: Optional[str]
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
@ -0,0 +1,106 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2021 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.
|
||||||
|
|
||||||
|
"""Utilities for dealing with jinja2 templates"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
from typing import TYPE_CHECKING, Callable, Iterable, Union
|
||||||
|
|
||||||
|
import jinja2
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from synapse.config.homeserver import HomeServerConfig
|
||||||
|
|
||||||
|
|
||||||
|
def build_jinja_env(
|
||||||
|
template_search_directories: Iterable[str],
|
||||||
|
config: "HomeServerConfig",
|
||||||
|
autoescape: Union[bool, Callable[[str], bool], None] = None,
|
||||||
|
) -> jinja2.Environment:
|
||||||
|
"""Set up a Jinja2 environment to load templates from the given search path
|
||||||
|
|
||||||
|
The returned environment defines the following filters:
|
||||||
|
- format_ts: formats timestamps as strings in the server's local timezone
|
||||||
|
(XXX: why is that useful??)
|
||||||
|
- mxc_to_http: converts mxc: uris to http URIs. Args are:
|
||||||
|
(uri, width, height, resize_method="crop")
|
||||||
|
|
||||||
|
and the following global variables:
|
||||||
|
- server_name: matrix server name
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template_search_directories: directories to search for templates
|
||||||
|
|
||||||
|
config: homeserver config, for things like `server_name` and `public_baseurl`
|
||||||
|
|
||||||
|
autoescape: whether template variables should be autoescaped. bool, or
|
||||||
|
a function mapping from template name to bool. Defaults to escaping templates
|
||||||
|
whose names end in .html, .xml or .htm.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
jinja environment
|
||||||
|
"""
|
||||||
|
|
||||||
|
if autoescape is None:
|
||||||
|
autoescape = jinja2.select_autoescape()
|
||||||
|
|
||||||
|
loader = jinja2.FileSystemLoader(template_search_directories)
|
||||||
|
env = jinja2.Environment(loader=loader, autoescape=autoescape)
|
||||||
|
|
||||||
|
# Update the environment with our custom filters
|
||||||
|
env.filters.update(
|
||||||
|
{
|
||||||
|
"format_ts": _format_ts_filter,
|
||||||
|
"mxc_to_http": _create_mxc_to_http_filter(config.public_baseurl),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# common variables for all templates
|
||||||
|
env.globals.update({"server_name": config.server_name})
|
||||||
|
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def _create_mxc_to_http_filter(public_baseurl: str) -> Callable:
|
||||||
|
"""Create and return a jinja2 filter that converts MXC urls to HTTP
|
||||||
|
|
||||||
|
Args:
|
||||||
|
public_baseurl: The public, accessible base URL of the homeserver
|
||||||
|
"""
|
||||||
|
|
||||||
|
def mxc_to_http_filter(value, width, height, resize_method="crop"):
|
||||||
|
if value[0:6] != "mxc://":
|
||||||
|
return ""
|
||||||
|
|
||||||
|
server_and_media_id = value[6:]
|
||||||
|
fragment = None
|
||||||
|
if "#" in server_and_media_id:
|
||||||
|
server_and_media_id, fragment = server_and_media_id.split("#", 1)
|
||||||
|
fragment = "#" + fragment
|
||||||
|
|
||||||
|
params = {"width": width, "height": height, "method": resize_method}
|
||||||
|
return "%s_matrix/media/v1/thumbnail/%s?%s%s" % (
|
||||||
|
public_baseurl,
|
||||||
|
server_and_media_id,
|
||||||
|
urllib.parse.urlencode(params),
|
||||||
|
fragment or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
return mxc_to_http_filter
|
||||||
|
|
||||||
|
|
||||||
|
def _format_ts_filter(value: int, format: str):
|
||||||
|
return time.strftime(format, time.localtime(value / 1000))
|
|
@ -62,7 +62,7 @@ class CasHandlerTestCase(HomeserverTestCase):
|
||||||
|
|
||||||
# check that the auth handler got called as expected
|
# check that the auth handler got called as expected
|
||||||
auth_handler.complete_sso_login.assert_called_once_with(
|
auth_handler.complete_sso_login.assert_called_once_with(
|
||||||
"@test_user:test", request, "redirect_uri", None
|
"@test_user:test", request, "redirect_uri", None, new_user=True
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_map_cas_user_to_existing_user(self):
|
def test_map_cas_user_to_existing_user(self):
|
||||||
|
@ -85,7 +85,7 @@ class CasHandlerTestCase(HomeserverTestCase):
|
||||||
|
|
||||||
# check that the auth handler got called as expected
|
# check that the auth handler got called as expected
|
||||||
auth_handler.complete_sso_login.assert_called_once_with(
|
auth_handler.complete_sso_login.assert_called_once_with(
|
||||||
"@test_user:test", request, "redirect_uri", None
|
"@test_user:test", request, "redirect_uri", None, new_user=False
|
||||||
)
|
)
|
||||||
|
|
||||||
# Subsequent calls should map to the same mxid.
|
# Subsequent calls should map to the same mxid.
|
||||||
|
@ -94,7 +94,7 @@ class CasHandlerTestCase(HomeserverTestCase):
|
||||||
self.handler._handle_cas_response(request, cas_response, "redirect_uri", "")
|
self.handler._handle_cas_response(request, cas_response, "redirect_uri", "")
|
||||||
)
|
)
|
||||||
auth_handler.complete_sso_login.assert_called_once_with(
|
auth_handler.complete_sso_login.assert_called_once_with(
|
||||||
"@test_user:test", request, "redirect_uri", None
|
"@test_user:test", request, "redirect_uri", None, new_user=False
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_map_cas_user_to_invalid_localpart(self):
|
def test_map_cas_user_to_invalid_localpart(self):
|
||||||
|
@ -112,7 +112,7 @@ class CasHandlerTestCase(HomeserverTestCase):
|
||||||
|
|
||||||
# check that the auth handler got called as expected
|
# check that the auth handler got called as expected
|
||||||
auth_handler.complete_sso_login.assert_called_once_with(
|
auth_handler.complete_sso_login.assert_called_once_with(
|
||||||
"@f=c3=b6=c3=b6:test", request, "redirect_uri", None
|
"@f=c3=b6=c3=b6:test", request, "redirect_uri", None, new_user=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -419,7 +419,7 @@ class OidcHandlerTestCase(HomeserverTestCase):
|
||||||
self.get_success(self.handler.handle_oidc_callback(request))
|
self.get_success(self.handler.handle_oidc_callback(request))
|
||||||
|
|
||||||
auth_handler.complete_sso_login.assert_called_once_with(
|
auth_handler.complete_sso_login.assert_called_once_with(
|
||||||
expected_user_id, request, client_redirect_url, None,
|
expected_user_id, request, client_redirect_url, None, new_user=True
|
||||||
)
|
)
|
||||||
self.provider._exchange_code.assert_called_once_with(code)
|
self.provider._exchange_code.assert_called_once_with(code)
|
||||||
self.provider._parse_id_token.assert_called_once_with(token, nonce=nonce)
|
self.provider._parse_id_token.assert_called_once_with(token, nonce=nonce)
|
||||||
|
@ -450,7 +450,7 @@ class OidcHandlerTestCase(HomeserverTestCase):
|
||||||
self.get_success(self.handler.handle_oidc_callback(request))
|
self.get_success(self.handler.handle_oidc_callback(request))
|
||||||
|
|
||||||
auth_handler.complete_sso_login.assert_called_once_with(
|
auth_handler.complete_sso_login.assert_called_once_with(
|
||||||
expected_user_id, request, client_redirect_url, None,
|
expected_user_id, request, client_redirect_url, None, new_user=False
|
||||||
)
|
)
|
||||||
self.provider._exchange_code.assert_called_once_with(code)
|
self.provider._exchange_code.assert_called_once_with(code)
|
||||||
self.provider._parse_id_token.assert_not_called()
|
self.provider._parse_id_token.assert_not_called()
|
||||||
|
@ -623,7 +623,11 @@ class OidcHandlerTestCase(HomeserverTestCase):
|
||||||
self.get_success(self.handler.handle_oidc_callback(request))
|
self.get_success(self.handler.handle_oidc_callback(request))
|
||||||
|
|
||||||
auth_handler.complete_sso_login.assert_called_once_with(
|
auth_handler.complete_sso_login.assert_called_once_with(
|
||||||
"@foo:test", request, client_redirect_url, {"phone": "1234567"},
|
"@foo:test",
|
||||||
|
request,
|
||||||
|
client_redirect_url,
|
||||||
|
{"phone": "1234567"},
|
||||||
|
new_user=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_map_userinfo_to_user(self):
|
def test_map_userinfo_to_user(self):
|
||||||
|
@ -637,7 +641,7 @@ class OidcHandlerTestCase(HomeserverTestCase):
|
||||||
}
|
}
|
||||||
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
|
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
|
||||||
auth_handler.complete_sso_login.assert_called_once_with(
|
auth_handler.complete_sso_login.assert_called_once_with(
|
||||||
"@test_user:test", ANY, ANY, None,
|
"@test_user:test", ANY, ANY, None, new_user=True
|
||||||
)
|
)
|
||||||
auth_handler.complete_sso_login.reset_mock()
|
auth_handler.complete_sso_login.reset_mock()
|
||||||
|
|
||||||
|
@ -648,7 +652,7 @@ class OidcHandlerTestCase(HomeserverTestCase):
|
||||||
}
|
}
|
||||||
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
|
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
|
||||||
auth_handler.complete_sso_login.assert_called_once_with(
|
auth_handler.complete_sso_login.assert_called_once_with(
|
||||||
"@test_user_2:test", ANY, ANY, None,
|
"@test_user_2:test", ANY, ANY, None, new_user=True
|
||||||
)
|
)
|
||||||
auth_handler.complete_sso_login.reset_mock()
|
auth_handler.complete_sso_login.reset_mock()
|
||||||
|
|
||||||
|
@ -685,14 +689,14 @@ class OidcHandlerTestCase(HomeserverTestCase):
|
||||||
}
|
}
|
||||||
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
|
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
|
||||||
auth_handler.complete_sso_login.assert_called_once_with(
|
auth_handler.complete_sso_login.assert_called_once_with(
|
||||||
user.to_string(), ANY, ANY, None,
|
user.to_string(), ANY, ANY, None, new_user=False
|
||||||
)
|
)
|
||||||
auth_handler.complete_sso_login.reset_mock()
|
auth_handler.complete_sso_login.reset_mock()
|
||||||
|
|
||||||
# Subsequent calls should map to the same mxid.
|
# Subsequent calls should map to the same mxid.
|
||||||
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
|
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
|
||||||
auth_handler.complete_sso_login.assert_called_once_with(
|
auth_handler.complete_sso_login.assert_called_once_with(
|
||||||
user.to_string(), ANY, ANY, None,
|
user.to_string(), ANY, ANY, None, new_user=False
|
||||||
)
|
)
|
||||||
auth_handler.complete_sso_login.reset_mock()
|
auth_handler.complete_sso_login.reset_mock()
|
||||||
|
|
||||||
|
@ -707,7 +711,7 @@ class OidcHandlerTestCase(HomeserverTestCase):
|
||||||
}
|
}
|
||||||
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
|
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
|
||||||
auth_handler.complete_sso_login.assert_called_once_with(
|
auth_handler.complete_sso_login.assert_called_once_with(
|
||||||
user.to_string(), ANY, ANY, None,
|
user.to_string(), ANY, ANY, None, new_user=False
|
||||||
)
|
)
|
||||||
auth_handler.complete_sso_login.reset_mock()
|
auth_handler.complete_sso_login.reset_mock()
|
||||||
|
|
||||||
|
@ -743,7 +747,7 @@ class OidcHandlerTestCase(HomeserverTestCase):
|
||||||
|
|
||||||
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
|
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
|
||||||
auth_handler.complete_sso_login.assert_called_once_with(
|
auth_handler.complete_sso_login.assert_called_once_with(
|
||||||
"@TEST_USER_2:test", ANY, ANY, None,
|
"@TEST_USER_2:test", ANY, ANY, None, new_user=False
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_map_userinfo_to_invalid_localpart(self):
|
def test_map_userinfo_to_invalid_localpart(self):
|
||||||
|
@ -779,7 +783,7 @@ class OidcHandlerTestCase(HomeserverTestCase):
|
||||||
|
|
||||||
# test_user is already taken, so test_user1 gets registered instead.
|
# test_user is already taken, so test_user1 gets registered instead.
|
||||||
auth_handler.complete_sso_login.assert_called_once_with(
|
auth_handler.complete_sso_login.assert_called_once_with(
|
||||||
"@test_user1:test", ANY, ANY, None,
|
"@test_user1:test", ANY, ANY, None, new_user=True
|
||||||
)
|
)
|
||||||
auth_handler.complete_sso_login.reset_mock()
|
auth_handler.complete_sso_login.reset_mock()
|
||||||
|
|
||||||
|
|
|
@ -131,7 +131,7 @@ class SamlHandlerTestCase(HomeserverTestCase):
|
||||||
|
|
||||||
# check that the auth handler got called as expected
|
# check that the auth handler got called as expected
|
||||||
auth_handler.complete_sso_login.assert_called_once_with(
|
auth_handler.complete_sso_login.assert_called_once_with(
|
||||||
"@test_user:test", request, "redirect_uri", None
|
"@test_user:test", request, "redirect_uri", None, new_user=True
|
||||||
)
|
)
|
||||||
|
|
||||||
@override_config({"saml2_config": {"grandfathered_mxid_source_attribute": "mxid"}})
|
@override_config({"saml2_config": {"grandfathered_mxid_source_attribute": "mxid"}})
|
||||||
|
@ -157,7 +157,7 @@ class SamlHandlerTestCase(HomeserverTestCase):
|
||||||
|
|
||||||
# check that the auth handler got called as expected
|
# check that the auth handler got called as expected
|
||||||
auth_handler.complete_sso_login.assert_called_once_with(
|
auth_handler.complete_sso_login.assert_called_once_with(
|
||||||
"@test_user:test", request, "", None
|
"@test_user:test", request, "", None, new_user=False
|
||||||
)
|
)
|
||||||
|
|
||||||
# Subsequent calls should map to the same mxid.
|
# Subsequent calls should map to the same mxid.
|
||||||
|
@ -166,7 +166,7 @@ class SamlHandlerTestCase(HomeserverTestCase):
|
||||||
self.handler._handle_authn_response(request, saml_response, "")
|
self.handler._handle_authn_response(request, saml_response, "")
|
||||||
)
|
)
|
||||||
auth_handler.complete_sso_login.assert_called_once_with(
|
auth_handler.complete_sso_login.assert_called_once_with(
|
||||||
"@test_user:test", request, "", None
|
"@test_user:test", request, "", None, new_user=False
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_map_saml_response_to_invalid_localpart(self):
|
def test_map_saml_response_to_invalid_localpart(self):
|
||||||
|
@ -214,7 +214,7 @@ class SamlHandlerTestCase(HomeserverTestCase):
|
||||||
|
|
||||||
# test_user is already taken, so test_user1 gets registered instead.
|
# test_user is already taken, so test_user1 gets registered instead.
|
||||||
auth_handler.complete_sso_login.assert_called_once_with(
|
auth_handler.complete_sso_login.assert_called_once_with(
|
||||||
"@test_user1:test", request, "", None
|
"@test_user1:test", request, "", None, new_user=True
|
||||||
)
|
)
|
||||||
auth_handler.complete_sso_login.reset_mock()
|
auth_handler.complete_sso_login.reset_mock()
|
||||||
|
|
||||||
|
|
|
@ -29,8 +29,7 @@ from synapse.appservice import ApplicationService
|
||||||
from synapse.rest.client.v1 import login, logout
|
from synapse.rest.client.v1 import login, logout
|
||||||
from synapse.rest.client.v2_alpha import devices, register
|
from synapse.rest.client.v2_alpha import devices, register
|
||||||
from synapse.rest.client.v2_alpha.account import WhoamiRestServlet
|
from synapse.rest.client.v2_alpha.account import WhoamiRestServlet
|
||||||
from synapse.rest.synapse.client.pick_idp import PickIdpResource
|
from synapse.rest.synapse.client import build_synapse_client_resource_tree
|
||||||
from synapse.rest.synapse.client.pick_username import pick_username_resource
|
|
||||||
from synapse.types import create_requester
|
from synapse.types import create_requester
|
||||||
|
|
||||||
from tests import unittest
|
from tests import unittest
|
||||||
|
@ -423,11 +422,8 @@ class MultiSSOTestCase(unittest.HomeserverTestCase):
|
||||||
return config
|
return config
|
||||||
|
|
||||||
def create_resource_dict(self) -> Dict[str, Resource]:
|
def create_resource_dict(self) -> Dict[str, Resource]:
|
||||||
from synapse.rest.oidc import OIDCResource
|
|
||||||
|
|
||||||
d = super().create_resource_dict()
|
d = super().create_resource_dict()
|
||||||
d["/_synapse/client/pick_idp"] = PickIdpResource(self.hs)
|
d.update(build_synapse_client_resource_tree(self.hs))
|
||||||
d["/_synapse/oidc"] = OIDCResource(self.hs)
|
|
||||||
return d
|
return d
|
||||||
|
|
||||||
def test_get_login_flows(self):
|
def test_get_login_flows(self):
|
||||||
|
@ -1211,11 +1207,8 @@ class UsernamePickerTestCase(HomeserverTestCase):
|
||||||
return config
|
return config
|
||||||
|
|
||||||
def create_resource_dict(self) -> Dict[str, Resource]:
|
def create_resource_dict(self) -> Dict[str, Resource]:
|
||||||
from synapse.rest.oidc import OIDCResource
|
|
||||||
|
|
||||||
d = super().create_resource_dict()
|
d = super().create_resource_dict()
|
||||||
d["/_synapse/client/pick_username"] = pick_username_resource(self.hs)
|
d.update(build_synapse_client_resource_tree(self.hs))
|
||||||
d["/_synapse/oidc"] = OIDCResource(self.hs)
|
|
||||||
return d
|
return d
|
||||||
|
|
||||||
def test_username_picker(self):
|
def test_username_picker(self):
|
||||||
|
@ -1229,7 +1222,7 @@ class UsernamePickerTestCase(HomeserverTestCase):
|
||||||
# that should redirect to the username picker
|
# that should redirect to the username picker
|
||||||
self.assertEqual(channel.code, 302, channel.result)
|
self.assertEqual(channel.code, 302, channel.result)
|
||||||
picker_url = channel.headers.getRawHeaders("Location")[0]
|
picker_url = channel.headers.getRawHeaders("Location")[0]
|
||||||
self.assertEqual(picker_url, "/_synapse/client/pick_username")
|
self.assertEqual(picker_url, "/_synapse/client/pick_username/account_details")
|
||||||
|
|
||||||
# ... with a username_mapping_session cookie
|
# ... with a username_mapping_session cookie
|
||||||
cookies = {} # type: Dict[str,str]
|
cookies = {} # type: Dict[str,str]
|
||||||
|
@ -1253,12 +1246,11 @@ class UsernamePickerTestCase(HomeserverTestCase):
|
||||||
self.assertApproximates(session.expiry_time_ms, expected_expiry, tolerance=1000)
|
self.assertApproximates(session.expiry_time_ms, expected_expiry, tolerance=1000)
|
||||||
|
|
||||||
# Now, submit a username to the username picker, which should serve a redirect
|
# Now, submit a username to the username picker, which should serve a redirect
|
||||||
# back to the client
|
# to the completion page
|
||||||
submit_path = picker_url + "/submit"
|
|
||||||
content = urlencode({b"username": b"bobby"}).encode("utf8")
|
content = urlencode({b"username": b"bobby"}).encode("utf8")
|
||||||
chan = self.make_request(
|
chan = self.make_request(
|
||||||
"POST",
|
"POST",
|
||||||
path=submit_path,
|
path=picker_url,
|
||||||
content=content,
|
content=content,
|
||||||
content_is_form=True,
|
content_is_form=True,
|
||||||
custom_headers=[
|
custom_headers=[
|
||||||
|
@ -1270,6 +1262,16 @@ class UsernamePickerTestCase(HomeserverTestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(chan.code, 302, chan.result)
|
self.assertEqual(chan.code, 302, chan.result)
|
||||||
location_headers = chan.headers.getRawHeaders("Location")
|
location_headers = chan.headers.getRawHeaders("Location")
|
||||||
|
|
||||||
|
# send a request to the completion page, which should 302 to the client redirectUrl
|
||||||
|
chan = self.make_request(
|
||||||
|
"GET",
|
||||||
|
path=location_headers[0],
|
||||||
|
custom_headers=[("Cookie", "username_mapping_session=" + session_id)],
|
||||||
|
)
|
||||||
|
self.assertEqual(chan.code, 302, chan.result)
|
||||||
|
location_headers = chan.headers.getRawHeaders("Location")
|
||||||
|
|
||||||
# ensure that the returned location matches the requested redirect URL
|
# ensure that the returned location matches the requested redirect URL
|
||||||
path, query = location_headers[0].split("?", 1)
|
path, query = location_headers[0].split("?", 1)
|
||||||
self.assertEqual(path, "https://x")
|
self.assertEqual(path, "https://x")
|
||||||
|
|
|
@ -22,7 +22,7 @@ from synapse.api.constants import LoginType
|
||||||
from synapse.handlers.ui_auth.checkers import UserInteractiveAuthChecker
|
from synapse.handlers.ui_auth.checkers import UserInteractiveAuthChecker
|
||||||
from synapse.rest.client.v1 import login
|
from synapse.rest.client.v1 import login
|
||||||
from synapse.rest.client.v2_alpha import auth, devices, register
|
from synapse.rest.client.v2_alpha import auth, devices, register
|
||||||
from synapse.rest.oidc import OIDCResource
|
from synapse.rest.synapse.client import build_synapse_client_resource_tree
|
||||||
from synapse.types import JsonDict, UserID
|
from synapse.types import JsonDict, UserID
|
||||||
|
|
||||||
from tests import unittest
|
from tests import unittest
|
||||||
|
@ -173,9 +173,7 @@ class UIAuthTests(unittest.HomeserverTestCase):
|
||||||
|
|
||||||
def create_resource_dict(self):
|
def create_resource_dict(self):
|
||||||
resource_dict = super().create_resource_dict()
|
resource_dict = super().create_resource_dict()
|
||||||
if HAS_OIDC:
|
resource_dict.update(build_synapse_client_resource_tree(self.hs))
|
||||||
# mount the OIDC resource at /_synapse/oidc
|
|
||||||
resource_dict["/_synapse/oidc"] = OIDCResource(self.hs)
|
|
||||||
return resource_dict
|
return resource_dict
|
||||||
|
|
||||||
def prepare(self, reactor, clock, hs):
|
def prepare(self, reactor, clock, hs):
|
||||||
|
|
Loading…
Reference in New Issue