From f85ec1946581fa7d79cef150f0a08f0130caef8b Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Mon, 13 Jan 2020 15:47:33 +0000 Subject: [PATCH] Allow users to pick a username on login (#1) This is essentially a rewrite to collect the username from the user when they first log in, rather than try to determine it algorithmically from SAML attributes. --- MANIFEST.in | 6 +- README.md | 38 ++- doc/login_flow.svg | 1 + doc/login_flow.txt | 65 +++++ matrix_synapse_saml_mozilla/__init__.py | 21 ++ matrix_synapse_saml_mozilla/_sessions.py | 65 +++++ .../mapping_provider.py | 115 ++++---- matrix_synapse_saml_mozilla/res/index.html | 20 ++ matrix_synapse_saml_mozilla/res/script.js | 117 ++++++++ matrix_synapse_saml_mozilla/res/style.css | 178 ++++++++++++ .../username_picker.py | 270 ++++++++++++++++++ setup.cfg | 12 + setup.py | 15 +- tests/__init__.py | 7 +- tests/test_attributes.py | 127 +++----- tox.ini | 16 +- 16 files changed, 896 insertions(+), 177 deletions(-) create mode 100644 doc/login_flow.svg create mode 100644 doc/login_flow.txt create mode 100644 matrix_synapse_saml_mozilla/__init__.py create mode 100644 matrix_synapse_saml_mozilla/_sessions.py rename matrix_synapse_saml_mozilla.py => matrix_synapse_saml_mozilla/mapping_provider.py (50%) create mode 100644 matrix_synapse_saml_mozilla/res/index.html create mode 100644 matrix_synapse_saml_mozilla/res/script.js create mode 100644 matrix_synapse_saml_mozilla/res/style.css create mode 100644 matrix_synapse_saml_mozilla/username_picker.py diff --git a/MANIFEST.in b/MANIFEST.in index f6edbec..9965876 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,10 @@ include *.in -include *.py include LICENSE include tox.ini include requirements.txt + +prune doc + +recursive-include matrix_synapse_saml_mozilla *.py +graft matrix_synapse_saml_mozilla/res recursive-include tests *.py diff --git a/README.md b/README.md index 0d86591..2b89eff 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -# Synapse Mozilla SAML MXID Mapper +# Synapse Mozilla SAML MXID Mapper -Custom SAML auth response -> MXID mapping algorithm used during the Mozilla -Matrix trial run. +A Synapse plugin module which allows users to choose their username when they +first log in. ## Installation @@ -11,8 +11,6 @@ This plugin can be installed via [PyPi](https://pypi.org): pip install matrix-synapse-saml-mozilla ``` -## Usage - ### Config Add the following in your Synapse config: @@ -21,8 +19,22 @@ Add the following in your Synapse config: saml2_config: user_mapping_provider: module: "matrix_synapse_saml_mozilla.SamlMappingProvider" - config: - mxid_source_attribute: "uid" +``` + +Also, under the HTTP client `listener`, configure an `additional_resource` as per +the below: + +```yaml +listeners: + - port: + type: http + + resources: + - names: [client] + + additional_resources: + "/_matrix/saml2/pick_username": + module: "matrix_synapse_saml_mozilla.pick_username_resource" ``` ### Configuration Options @@ -30,11 +42,13 @@ Add the following in your Synapse config: Synapse allows SAML mapping providers to specify custom configuration through the `saml2_config.user_mapping_provider.config` option. -The options supported by this provider are currently: +There are no options currently supported by this provider. + +## Implementation notes + +The login flow looks something like this: -* `mxid_source_attribute` - The SAML attribute (after mapping via the - attribute maps) to use to derive the Matrix - ID from. 'uid' by default. +![login flow](doc/login_flow.svg) ## Development and Testing @@ -42,7 +56,7 @@ This repository uses `tox` to run linting and tests. ### Linting -Code is linted with the `flake8` tool. Run `tox -e pep8` to check for linting +Code is linted with the `flake8` tool. Run `tox -e lint` to check for linting errors in the codebase. ### Tests diff --git a/doc/login_flow.svg b/doc/login_flow.svg new file mode 100644 index 0000000..cb2517f --- /dev/null +++ b/doc/login_flow.svg @@ -0,0 +1 @@ +Mozilla matrix login flowRiotSynapseSAML2 IdPGET /login"type":"m.login.sso"(Embedded) BrowserGET /login/sso/redirect?redirectUrl=<clienturl>Generate SAML request302 to IdPGET https://auth.mozilla.auth0.com/samlp/...?SAMLRequest=<SAML request>200 login formsubmit login form with auth credentials200: auto-submitting HTML form including SAML ResponsePOST /_matrix/saml2/authn_responseSAMLResponse=<response>Check if known user302 to username pickerincluding username_mapping_session cookieGET /_matrix/saml2/pick_username/200 with form pageGET /_matrix/saml2/pick_username/check?username=<username>200 {"available": true/false}or 200 {"error": "..."}POST /_matrix/saml2/pick_username/submitusername=<username>302 to original clienturl with loginTokenPOST /login{"type": "m.login.token", "token": "<token>"}access token etc \ No newline at end of file diff --git a/doc/login_flow.txt b/doc/login_flow.txt new file mode 100644 index 0000000..6520f7e --- /dev/null +++ b/doc/login_flow.txt @@ -0,0 +1,65 @@ + +title Mozilla matrix login flow + +participant Riot +participant "(Embedded) Browser" as B +participant "Synapse" as HS +participant "SAML2 IdP" as IdP + +activate Riot + +Riot->HS:""GET /login +activate HS +Riot<--HS:"""type":"m.login.sso" +deactivate HS + +create B +Riot->B: +activate B +B->HS:""GET /login/sso/redirect\n--?redirectUrl=--"" +activate HS +HS->HS:Generate SAML request +B<--HS:302 to IdP +deactivate HS + + +B->IdP: ""GET https://auth.mozilla.auth0.com/samlp/...\n--?SAMLRequest= +activate IdP +B<--IdP: 200 login form +deactivate IdP +B->IdP: submit login form with auth credentials +activate IdP +IdP-->B:200: auto-submitting HTML form including SAML Response +deactivate IdP + +B->HS:""POST /_matrix/saml2/authn_response\n--SAMLResponse= +activate HS +HS->HS:Check if known user +B<--HS:302 to username picker\n--including ""username_mapping_session"" cookie +deactivate HS + +B->HS:""GET /_matrix/saml2/pick_username/ +activate HS +B<--HS: 200 with form page +deactivate HS + +B->HS:""GET /_matrix/saml2/pick_username/check\n--?username= +activate HS +B<--HS:200 ""{"available": true/false}""\n--or 200 ""{"error": "..."}"" +deactivate HS + +B->HS:""POST /_matrix/saml2/pick_username/submit\n--username= +activate HS +B<--HS:302 to original clienturl with loginToken +deactivate HS +Riot<-B: +deactivate B + +destroysilent B + +Riot->HS: ""POST /login\n--{"type": "m.login.token", "token": ""} +activate HS +Riot<--HS:""access token"" etc +deactivate HS + + diff --git a/matrix_synapse_saml_mozilla/__init__.py b/matrix_synapse_saml_mozilla/__init__.py new file mode 100644 index 0000000..4da96ef --- /dev/null +++ b/matrix_synapse_saml_mozilla/__init__.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 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 matrix_synapse_saml_mozilla.mapping_provider import SamlMappingProvider +from matrix_synapse_saml_mozilla.username_picker import pick_username_resource + +__version__ = "0.0.1" + +__all__ = ["SamlMappingProvider", "pick_username_resource"] diff --git a/matrix_synapse_saml_mozilla/_sessions.py b/matrix_synapse_saml_mozilla/_sessions.py new file mode 100644 index 0000000..0c9bf79 --- /dev/null +++ b/matrix_synapse_saml_mozilla/_sessions.py @@ -0,0 +1,65 @@ +# -*- 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. +import logging +from typing import Optional + +import attr +import time + + +SESSION_COOKIE_NAME = b"username_mapping_session" + +logger = logging.getLogger(__name__) + + +@attr.s +class UsernameMappingSession: + """Data we track about SAML2 sessions""" + + # user ID on the SAML server + remote_user_id = attr.ib(type=str) + + # displayname, per the SAML attributes + displayname = attr.ib(type=Optional[str]) + + # 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) + + +# a map from session id to session data +username_mapping_sessions = {} # type: dict[str, UsernameMappingSession] + + +def expire_old_sessions(gettime=time.time): + """Delete any sessions which have passed their expiry_time""" + to_expire = [] + now = int(gettime() * 1000) + + for session_id, session in 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 username_mapping_sessions[session_id] + + +def get_mapping_session(session_id: str) -> Optional[UsernameMappingSession]: + """Look up the given session id, first expiring any old sessions""" + expire_old_sessions() + return username_mapping_sessions.get(session_id, None) diff --git a/matrix_synapse_saml_mozilla.py b/matrix_synapse_saml_mozilla/mapping_provider.py similarity index 50% rename from matrix_synapse_saml_mozilla.py rename to matrix_synapse_saml_mozilla/mapping_provider.py index ac1dbed..4392d91 100644 --- a/matrix_synapse_saml_mozilla.py +++ b/matrix_synapse_saml_mozilla/mapping_provider.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright 2019 The Matrix.org Foundation C.I.C. +# 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. @@ -12,100 +12,94 @@ # 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 +import random +import string +import time from typing import Tuple -import re + import attr -import string import saml2.response -__version__ = "0.0.1" +import synapse.module_api +from synapse.module_api.errors import RedirectException + +from matrix_synapse_saml_mozilla._sessions import ( + UsernameMappingSession, + username_mapping_sessions, + expire_old_sessions, + SESSION_COOKIE_NAME, +) + +logger = logging.getLogger(__name__) + + +MAPPING_SESSION_VALIDITY_PERIOD_MS = 15 * 60 * 1000 @attr.s class SamlConfig(object): - mxid_source_attribute = attr.ib() + pass class SamlMappingProvider(object): - def __init__(self, parsed_config: SamlConfig): + def __init__( + self, parsed_config: SamlConfig, module_api: synapse.module_api.ModuleApi + ): """A Mozilla-flavoured, Synapse user mapping provider Args: parsed_config: A configuration object. The result of self.parse_config """ - self._mxid_source_attribute = parsed_config.mxid_source_attribute - - mxid_localpart_allowed_characters = set( - "_-./=" + string.ascii_lowercase + string.digits - ) - self._dot_replace_pattern = re.compile( - ("[^%s]" % (re.escape("".join(mxid_localpart_allowed_characters)),)) - ) - self._multiple_to_single_dot_pattern = re.compile(r"\.{2,}") - self._string_end_dot_pattern = re.compile(r"\.$") + self._random = random.SystemRandom() def saml_response_to_user_attributes( - self, - saml_response: saml2.response.AuthnResponse, - failures: int = 0, + self, + saml_response: saml2.response.AuthnResponse, + failures: int, + client_redirect_url: str, ) -> dict: """Maps some text from a SAML response to attributes of a new user - Args: saml_response: A SAML auth response object failures: How many times a call to this function with this saml_response has resulted in a failure + client_redirect_url: where the client wants to redirect back to + Returns: dict: A dict containing new user attributes. Possible keys: * mxid_localpart (str): Required. The localpart of the user's mxid * displayname (str): The displayname of the user """ - # The calling function will catch the KeyError if this fails - mxid_source = saml_response.ava[self._mxid_source_attribute][0] - - # Truncate the username to the first found '@' character to prevent complete - # emails being leaked - pos = mxid_source.find("@") - if pos >= 0: - mxid_source = mxid_source[:pos] - mxid_localpart = self._dotreplace_for_mxid(mxid_source) - - # Append suffix integer if last call to this function failed to produce - # a usable mxid - localpart = mxid_localpart + (str(failures) if failures else "") - - # Retrieve the display name from the saml response + remote_user_id = saml_response.ava["uid"][0] displayname = saml_response.ava.get("displayName", [None])[0] - return { - "mxid_localpart": localpart, - "displayname": displayname, - } - - def _dotreplace_for_mxid(self, username: str) -> str: - """Replace non-allowed mxid characters with a '.' - - Args: - username (str): The username to process + expire_old_sessions() - Returns: - str: The processed username - """ - username = username.lower() - username = self._dot_replace_pattern.sub(".", username) + # make up a cryptorandom session id + session_id = "".join( + self._random.choice(string.ascii_letters) for _ in range(16) + ) - # regular mxids aren't allowed to start with an underscore either - username = re.sub("^_", "", username) + now = int(time.time() * 1000) + session = UsernameMappingSession( + remote_user_id=remote_user_id, + displayname=displayname, + client_redirect_url=client_redirect_url, + expiry_time_ms=now + MAPPING_SESSION_VALIDITY_PERIOD_MS, + ) - # Change all instances of multiple dots together into a single dot - username = self._multiple_to_single_dot_pattern.sub(".", username) + username_mapping_sessions[session_id] = session + logger.info("Recorded registration session id %s", session_id) - # Remove any trailing dots - username = self._string_end_dot_pattern.sub("", username) - return username + # Redirect to the username picker + e = RedirectException(b"/_matrix/saml2/pick_username/") + e.cookies.append( + b"%s=%s; path=/" % (SESSION_COOKIE_NAME, session_id.encode("ascii"),) + ) + raise e @staticmethod def parse_config(config: dict) -> SamlConfig: @@ -115,8 +109,7 @@ class SamlMappingProvider(object): Returns: SamlConfig: A custom config object """ - mxid_source_attribute = config.get("mxid_source_attribute", "uid") - return SamlConfig(mxid_source_attribute) + return SamlConfig() @staticmethod def get_saml_attributes(config: SamlConfig) -> Tuple[set, set]: @@ -131,4 +124,4 @@ class SamlMappingProvider(object): second set consists of those attributes which can be used if available, but are not necessary """ - return {"uid", config.mxid_source_attribute}, {"displayName"} + return {"uid"}, {"displayName"} diff --git a/matrix_synapse_saml_mozilla/res/index.html b/matrix_synapse_saml_mozilla/res/index.html new file mode 100644 index 0000000..c6463a8 --- /dev/null +++ b/matrix_synapse_saml_mozilla/res/index.html @@ -0,0 +1,20 @@ + + + + Synapse Login + + + +
+
+ + + +
+ + +
+ + diff --git a/matrix_synapse_saml_mozilla/res/script.js b/matrix_synapse_saml_mozilla/res/script.js new file mode 100644 index 0000000..1fa4e39 --- /dev/null +++ b/matrix_synapse_saml_mozilla/res/script.js @@ -0,0 +1,117 @@ +let inputField = document.getElementById("field-username"); +let inputForm = document.getElementById("form"); +let submitButton = document.getElementById("button-submit"); +let message = document.getElementById("message"); + +// Remove input field placeholder if the text field is not empty +let switchClass = function(input) { + if (input.value.length > 0) { + input.classList.add('has-contents'); + } + else { + input.classList.remove('has-contents'); + } +}; + +// Submit username and receive response +let showMessage = function(messageText) { + // Unhide the message text + message.classList.remove("hidden"); + + message.innerHTML = messageText; +}; + +let onResponse = function(response, success) { + // Display message + showMessage(response); + + if(success) { + inputForm.submit(); + return; + } + + // Enable submit button and input field + submitButton.classList.remove('button--disabled'); + submitButton.value = "Submit" +}; + +// We allow upper case characters here, but then lowercase before sending to the server +let allowedUsernameCharacters = RegExp("[^a-zA-Z0-9\\.\\_\\=\\-\\/]"); +let usernameIsValid = function(username) { + return !allowedUsernameCharacters.test(username); +} +let allowedCharactersString = "" + +"a-z, " + +"0-9, " + +"., " + +"_, " + +"-, " + +"/, " + +"="; + +let buildQueryString = function(params) { + return Object.keys(params) + .map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k])) + .join('&'); +} + +let submitUsername = function(username) { + if(username.length == 0) { + onResponse("Please enter a username.", false); + return; + } + if(!usernameIsValid(username)) { + onResponse("Invalid username. Only the following characters are allowed: " + allowedCharactersString, false); + return; + } + + let check_uri = 'check?' + buildQueryString({"username": username}); + fetch(check_uri, { + "credentials": "include", + }).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) { + onResponse("Success. Please wait a moment for your browser to redirect.", true); + } else { + onResponse("This username is not available, please choose another.", false); + } + }).catch((err) => { + onResponse("Error checking username availability: " + err, false); + }); +} + +let clickSubmit = function() { + 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); +}; + +submitButton.onclick = clickSubmit; + +// Listen for events on inputField +inputField.addEventListener('keypress', function(event) { + // Listen for Enter on input field + if(event.which === 13) { + event.preventDefault(); + clickSubmit(); + return true; + } + switchClass(inputField); +}); +inputField.addEventListener('change', function() { + switchClass(inputField); +}); + diff --git a/matrix_synapse_saml_mozilla/res/style.css b/matrix_synapse_saml_mozilla/res/style.css new file mode 100644 index 0000000..45377f8 --- /dev/null +++ b/matrix_synapse_saml_mozilla/res/style.css @@ -0,0 +1,178 @@ +body { + background: #ededf0; + color: #737373; + font-family: "Open Sans", sans-serif; + letter-spacing: 0.03em; + margin: 0; + padding: 0; + display: grid; + grid-template-rows: auto 1fr; } + +.card { + background-color: #fff; + padding: 2em; + position: relative; + margin: auto; + width: 100%; + box-shadow: 0 0.25em 0.25em 0 rgba(210, 210, 210, 0.5); + border-radius: 0.125em; } + @media (min-width: 25em) { + .card { + max-width: 26em; + padding: 2.5em; } } + @supports (display: grid) { + .card { + grid-row-start: 2; } + @media (min-height: 50em) { + .card { + top: -3em; + /* compensate for negative margin for footer links */ } } } + .card__back { + margin-bottom: 1em; } + .card__heading { + font-size: 1.4em; + font-weight: 400; + text-transform: capitalize; + padding-left: 2.125em; + position: relative; + min-height: 1.5em; } + .card__heading--iconless { + padding-left: 0; } + .card__heading img { + width: 1.5em; + height: 1.5em; + position: absolute; + left: 0; + top: 0; } + .card__heading--success { + color: #12bc00; } + .card__heading--error { + color: #ff0039; } + .card [data-screen]:focus { + outline: none; } + +* { + box-sizing: border-box; } + +form { + margin: 0; } + form * { + font-family: inherit; } + +label { + margin: 2em 0; + display: block; } + +input[type="text"], +input[type="email"], +input[type="password"] { + font-size: 100%; + background-color: #ededf0; + border: 1px solid #fff; + border-radius: .2em; + padding: .5em .9em; + display: block; + width: 100%; + margin-bottom: 1em; } + input[type="text"]:hover, input[type="text"]:focus, + input[type="email"]:hover, + input[type="email"]:focus, + input[type="password"]:hover, + input[type="password"]:focus { + border: 1px solid #0060df; + outline: none; } + .focus-styles input[type="text"]:focus, .focus-styles + input[type="email"]:focus, .focus-styles + input[type="password"]:focus { + border-color: transparent; } + +.form__input { + position: relative; } + p + .form__input { + margin-top: 2.5em; + /* leave space to fit a paragraph above a field */ } + .form__input label { + margin: 0; + position: absolute; + top: .5em; + left: .9em; } + .form__input input:focus + label, + .form__input input.has-contents + label { + position: absolute; + top: -1.5em; + color: #0060df; + font-weight: bold; } + .form__input input:focus + label > span, + .form__input input.has-contents + label > span { + font-size: 0.75em; } + +html, +body { + height: 100%; } + +.button { + text-align: center; + text-decoration: none; + padding: .93em 2em; + display: block; + font-size: 87.5%; + letter-spacing: .04em; + line-height: 1.57; + font-family: inherit; + border-radius: 2em; + background-color: #0060df; + color: #fff; + border: 1px solid transparent; + transition: background-color .1s ease-in-out; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; } + .button:hover { + background-color: #fff; + color: #0060df; + border-color: currentColor; + text-decoration: none; } + .button:active { + background-color: #0060df; + color: #fff; + border-color: #0060df; } + .button--full-width { + width: 100%; } + .button--secondary { + border-color: #b1b1b3; + background-color: transparent; + color: #000; + text-transform: none; } + .button--secondary:hover { + background-color: #000; + color: #fff; + border-color: transparent; } + .button--secondary:hover svg > path { + fill: #fff; } + .button--secondary:active { + background-color: transparent; + border-color: #000; + color: #000; } + .button--disabled { + border-color: #fff; + background-color: transparent; + color: #000; + text-transform: none; } + .button--disabled:hover { + background-color: #fff; + color: #000; + border-color: transparent; } + +.hidden { + display: none; } + +.tooltip { + background-color: #f9f9fa; + padding: 1em; + margin: 1em 0; } + .tooltip p:last-child { + margin-bottom: 0; } + .tooltip a:last-child { + margin-left: .5em; } + .tooltip:target { + display: block; } diff --git a/matrix_synapse_saml_mozilla/username_picker.py b/matrix_synapse_saml_mozilla/username_picker.py new file mode 100644 index 0000000..0d7a16f --- /dev/null +++ b/matrix_synapse_saml_mozilla/username_picker.py @@ -0,0 +1,270 @@ +# -*- 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. +import html +import json +import logging +import urllib.parse +from typing import Any + +import pkg_resources +from twisted.web.resource import Resource +from twisted.web.server import NOT_DONE_YET, Request +from twisted.web.static import File + +import synapse.module_api +from synapse.module_api import run_in_background +from synapse.module_api.errors import SynapseError + +from matrix_synapse_saml_mozilla._sessions import ( + get_mapping_session, + username_mapping_sessions, + SESSION_COOKIE_NAME, +) + +""" +This file implements the "username picker" resource, which is mapped as an +additional_resource into the synapse resource tree. + +The top-level resource is just a File resource which serves up the static files in the +"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" (TODO): checks if a userid is free. +""" + +logger = logging.getLogger(__name__) + + +def pick_username_resource( + parsed_config, module_api: synapse.module_api.ModuleApi +) -> Resource: + """Factory method to generate the top-level username picker resource""" + base_path = pkg_resources.resource_filename("matrix_synapse_saml_mozilla", "res") + res = File(base_path) + res.putChild(b"submit", SubmitResource(module_api)) + res.putChild(b"check", AvailabilityCheckResource(module_api)) + return res + + +def parse_config(config: dict): + return None + + +pick_username_resource.parse_config = parse_config + + +HTML_ERROR_TEMPLATE = """ + + + + Error {code} + + +

{msg}

+ + +""" + + +def _wrap_for_html_exceptions(f): + async def wrapped(self, request): + try: + return await f(self, request) + except Exception: + logger.exception("Error handling request %s" % (request,)) + _return_html_error(500, "Internal server error", request) + + return wrapped + + +def _wrap_for_text_exceptions(f): + async def wrapped(self, request): + try: + return await f(self, request) + except Exception: + logger.exception("Error handling request %s" % (request,)) + body = b"Internal server error" + request.setResponseCode(500) + request.setHeader(b"Content-Type", b"text/plain; charset=utf-8") + request.setHeader(b"Content-Length", b"%i" % (len(body),)) + request.write(body) + request.finish() + + return wrapped + + +class AsyncResource(Resource): + """Extends twisted.web.Resource to add support for async_render_X methods""" + + def render(self, request: Request): + method = request.method.decode("ascii") + m = getattr(self, "async_render_" + method, None) + if not m and method == "HEAD": + m = getattr(self, "async_render_GET", None) + if not m: + return super().render(request) + + async def run(): + with request.processing(): + return await m(request) + + run_in_background(run) + return NOT_DONE_YET + + +class SubmitResource(AsyncResource): + def __init__(self, module_api: synapse.module_api.ModuleApi): + super().__init__() + self._module_api = module_api + + @_wrap_for_html_exceptions + async def async_render_POST(self, request: Request): + session_id = request.getCookie(SESSION_COOKIE_NAME) + if not session_id: + _return_html_error(400, "missing session_id", request) + return + + session_id = session_id.decode("ascii", errors="replace") + session = get_mapping_session(session_id) + if not session: + logger.info("Session ID %s not found", session_id) + _return_html_error(403, "Unknown session", request) + return + + # we don't clear the session from the dict until the ID is successfully + # registered, so the user can go round and have another go if need be. + # + # this means there's theoretically a race where a single user can register + # two accounts. I'm going to assume that's not a dealbreaker. + + if b"username" not in request.args: + _return_html_error(400, "missing username", request) + return + localpart = request.args[b"username"][0].decode("utf-8", errors="replace") + logger.info("Registering username %s", localpart) + try: + registered_user_id = await self._module_api.register_user( + localpart=localpart, displayname=session.displayname + ) + except SynapseError as e: + logger.warning("Error during registration: %s", e) + _return_html_error(e.code, e.msg, request) + return + + await self._module_api.record_user_external_id( + "saml", session.remote_user_id, registered_user_id + ) + + del username_mapping_sessions[session_id] + + login_token = self._module_api.generate_short_term_login_token( + registered_user_id + ) + redirect_url = _add_login_token_to_redirect_url( + session.client_redirect_url, login_token + ) + + # delete the cookie + request.addCookie( + SESSION_COOKIE_NAME, + b"", + expires=b"Thu, 01 Jan 1970 00:00:00 GMT", + path=b"/", + ) + request.redirect(redirect_url) + request.finish() + + +class AvailabilityCheckResource(AsyncResource): + def __init__(self, module_api: synapse.module_api.ModuleApi): + super().__init__() + self._module_api = module_api + + @_wrap_for_text_exceptions + async def async_render_GET(self, request: Request): + # make sure that there is a valid mapping session, to stop people dictionary- + # scanning for accounts + session_id = request.getCookie(SESSION_COOKIE_NAME) + if not session_id: + _return_json({"error": "missing session_id"}, request) + return + + session_id = session_id.decode("ascii", errors="replace") + session = get_mapping_session(session_id) + if not session: + logger.info("Couldn't find session id %s", session_id) + _return_json({"error": "unknown session"}, request) + return + + if b"username" not in request.args: + _return_json({"error": "missing username"}, request) + return + localpart = request.args[b"username"][0].decode("utf-8", errors="replace") + logger.info("Checking for availability of username %s", localpart) + try: + user_id = self._module_api.get_qualified_user_id(localpart) + registered_id = await self._module_api.check_user_exists(user_id) + available = registered_id is None + except Exception as e: + logger.warning( + "Error checking for availability of %s: %s %s" % (localpart, type(e), e) + ) + available = False + response = {"available": available} + _return_json(response, request) + + +def _add_login_token_to_redirect_url(url, token): + url_parts = list(urllib.parse.urlparse(url)) + query = dict(urllib.parse.parse_qsl(url_parts[4])) + query.update({"loginToken": token}) + url_parts[4] = urllib.parse.urlencode(query) + return urllib.parse.urlunparse(url_parts) + + +def _return_html_error(code: int, msg: str, request: Request): + """Sends an HTML error page""" + body = HTML_ERROR_TEMPLATE.format(code=code, msg=html.escape(msg)).encode("utf-8") + request.setResponseCode(code) + request.setHeader(b"Content-Type", b"text/html; charset=utf-8") + request.setHeader(b"Content-Length", b"%i" % (len(body),)) + request.write(body) + try: + request.finish() + except RuntimeError as e: + logger.info("Connection disconnected before response was written: %r", e) + + +def _return_json(json_obj: Any, request: Request): + json_bytes = json.dumps(json_obj).encode("utf-8") + + request.setHeader(b"Content-Type", b"application/json") + request.setHeader(b"Content-Length", b"%d" % (len(json_bytes),)) + request.setHeader(b"Cache-Control", b"no-cache, no-store, must-revalidate") + request.setHeader(b"Access-Control-Allow-Origin", b"*") + request.setHeader( + b"Access-Control-Allow-Methods", b"GET, POST, PUT, DELETE, OPTIONS" + ) + request.setHeader( + b"Access-Control-Allow-Headers", + b"Origin, X-Requested-With, Content-Type, Accept, Authorization", + ) + request.write(json_bytes) + try: + request.finish() + except RuntimeError as e: + logger.info("Connection disconnected before response was written: %r", e) diff --git a/setup.cfg b/setup.cfg index ab5366c..f3d6356 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,3 +2,15 @@ max-line-length = 90 # W503 requires that binary operators be at the end, not start, of lines. Erik doesn't like it. ignore = W503 + +[isort] +line_length = 88 +not_skip = __init__.py +sections = FUTURE,STDLIB,THIRDPARTY,SYNAPSE,FIRSTPARTY,TESTS,LOCALFOLDER +default_section = THIRDPARTY +known_synapse = synapse +known_first_party = matrix_synapse_saml_mozilla +known_tests = tests +multi_line_output = 3 +include_trailing_comma = true +combine_as_imports = true diff --git a/setup.py b/setup.py index 5e70e50..e15a6c4 100755 --- a/setup.py +++ b/setup.py @@ -34,25 +34,22 @@ def exec_file(path_segments, name): the constant and executing it.""" result = {} code = read_file(path_segments) - lines = [line for line in code.split('\n') if line.startswith(name)] + lines = [line for line in code.split("\n") if line.startswith(name)] exec("\n".join(lines), result) return result[name] setup( name="matrix-synapse-saml-mozilla", - version=exec_file(("matrix_synapse_saml_mozilla.py",), "__version__"), + version=exec_file(("matrix_synapse_saml_mozilla/__init__.py",), "__version__"), py_modules=["matrix-synapse-saml-mozilla"], description="An Mozilla-flavoured SAML MXID mapper for Synapse", - install_requires=[ - "attr>=0.3.1", - "pysaml2>=4.5.0", - ], + install_requires=["attr>=0.3.1", "pysaml2>=4.5.0"], long_description=read_file(("README.md",)), long_description_content_type="text/markdown", classifiers=[ - 'Development Status :: 4 - Beta', - 'License :: OSI Approved :: Apache Software License', - 'Programming Language :: Python :: 3', + "Development Status :: 4 - Beta", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", ], ) diff --git a/tests/__init__.py b/tests/__init__.py index c04c70f..4bd8d25 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,16 +1,15 @@ from typing import Tuple + from matrix_synapse_saml_mozilla import SamlMappingProvider def create_mapping_provider() -> Tuple[SamlMappingProvider, dict]: # Default configuration - config_dict = { - "mxid_source_attribute": "uid" - } + config_dict = {} # Convert the config dictionary to a SamlMappingProvider.SamlConfig object config = SamlMappingProvider.parse_config(config_dict) # Create a new instance of the provider with the specified config # Return the config dict as well for other test methods to use - return SamlMappingProvider(config), config_dict + return SamlMappingProvider(config, None), config_dict diff --git a/tests/test_attributes.py b/tests/test_attributes.py index e599f6d..a89af77 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -14,103 +14,58 @@ # limitations under the License. import logging +import re +import time import unittest -from typing import Optional -from . import create_mapping_provider - -logging.basicConfig() +from synapse.api.errors import RedirectException +from matrix_synapse_saml_mozilla._sessions import username_mapping_sessions -def _make_test_saml_response( - provider_config: dict, - source_attribute_value: str, - display_name: Optional[str] = None -): - """Create a fake object based off of saml2.response.AuthnResponse - - Args: - provider_config: The config dictionary used when creating the provider object - source_attribute_value: The desired value that the mapping provider will - pull out of the response object to turn into a Matrix UserID. - display_name: The desired displayname that the mapping provider will pull - out of the response object to turn into a Matrix user displayname. - - Returns: - An object masquerading as a saml2.response.AuthnResponse object - """ +from . import create_mapping_provider - class FakeResponse(object): +logging.basicConfig() - def __init__(self): - self.ava = { - provider_config["mxid_source_attribute"]: [source_attribute_value], - } - if display_name: - self.ava["displayName"] = [display_name] +class FakeResponse: + def __init__(self, source_uid, display_name): + self.ava = { + "uid": [source_uid], + } - return FakeResponse() + if display_name: + self.ava["displayName"] = [display_name] class SamlUserAttributeTestCase(unittest.TestCase): - - def _attribute_test( - self, - input_uid: str, - input_displayname: Optional[str], - output_localpart: str, - output_displayname: Optional[str], - ): - """Creates a dummy response, feeds it to the provider and checks the output - - Args: - input_uid: The value of the mxid_source_attribute that the provider will - base the generated localpart off of. - input_displayname: The saml auth response displayName value that the - provider will generate a Matrix user displayname from. - output_localpart: The expected mxid localpart. - output_displayname: The expected matrix displayname. - + def test_redirect(self): + """Creates a dummy response, feeds it to the provider and checks that it + redirects to the username picker. """ provider, config = create_mapping_provider() - response = _make_test_saml_response(config, input_uid, input_displayname) - - attribute_dict = provider.saml_response_to_user_attributes(response) - self.assertEqual(attribute_dict["mxid_localpart"], output_localpart) - self.assertEqual(attribute_dict["displayname"], output_displayname) - - def test_normal_user(self): - self._attribute_test("john*doe2000#@example.com", None, "john.doe2000", None) - - def test_normal_user_displayname(self): - self._attribute_test( - "john*doe2000#@example.com", "Jonny", "john.doe2000", "Jonny" - ) - - def test_multiple_adjacent_symbols(self): - self._attribute_test("bob%^$&#!bobby@example.com", None, "bob.bobby", None) - - def test_username_does_not_end_with_dot(self): - """This is allowed in mxid syntax, but is not aesthetically pleasing""" - self._attribute_test("bob.bobby$@example.com", None, "bob.bobby", None) - - def test_username_no_email(self): - self._attribute_test("bob.bobby", None, "bob.bobby", None) - - def test_username_starting_with_underscore(self): - self._attribute_test( - "_twilight (sparkle)@somewhere.com", None, "twilight.sparkle", None + response = FakeResponse(123435, "Jonny") + + # we expect this to redirect to the username picker + with self.assertRaises(RedirectException) as cm: + provider.saml_response_to_user_attributes(response, 0, "http://client/") + self.assertEqual(cm.exception.location, b"/_matrix/saml2/pick_username/") + + cookieheader = cm.exception.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)) + + session_id = m.group(1).decode("ascii") + self.assertIn( + session_id, username_mapping_sessions, "session id not found in map" ) - - def test_existing_user(self): - provider, config = create_mapping_provider() - response = _make_test_saml_response(config, "wibble%@wobble.com", None) - - attribute_dict = provider.saml_response_to_user_attributes(response) - - # Simulate a failure on the first attempt - attribute_dict = provider.saml_response_to_user_attributes(response, failures=1) - - self.assertEqual(attribute_dict["mxid_localpart"], "wibble1") - self.assertEqual(attribute_dict["displayname"], None) + session = username_mapping_sessions[session_id] + self.assertEqual(session.remote_user_id, 123435) + self.assertEqual(session.displayname, "Jonny") + self.assertEqual(session.client_redirect_url, "http://client/") + + # the expiry time should be about 15 minutes away + expected_expiry = (time.time() + 15 * 60) * 1000 + self.assertGreaterEqual(session.expiry_time_ms, expected_expiry - 1000) + self.assertLessEqual(session.expiry_time_ms, expected_expiry + 1000) diff --git a/tox.ini b/tox.ini index 262f7b3..6e1367a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,25 +1,33 @@ [tox] -envlist = packaging, pep8 +envlist = packaging, lint, tests [testenv] setenv = PYTHONDONTWRITEBYTECODE = no_byte_code - PYTHONPATH = . [testenv:tests] +deps = + git+git://github.com/matrix-org/synapse@rav/mozilla_username_hacks#egg=matrix-synapse + commands = python -m unittest discover [testenv:packaging] +skip_install = True deps = check-manifest commands = check-manifest -[testenv:pep8] +[testenv:lint] skip_install = True basepython = python3 deps = flake8 + # We pin so that our tests don't start failing on new releases of black. + black==19.10b0 + isort commands = - flake8 saml_mapping_provider.py tests/* + python -m black --check --diff . + flake8 matrix_synapse_saml_mozilla tests + isort -c -df -sp setup.cfg -rc matrix_synapse_saml_mozilla tests