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.master
parent
ccbb42d66b
commit
f85ec19465
@ -1,6 +1,10 @@
|
|||||||
include *.in
|
include *.in
|
||||||
include *.py
|
|
||||||
include LICENSE
|
include LICENSE
|
||||||
include tox.ini
|
include tox.ini
|
||||||
include requirements.txt
|
include requirements.txt
|
||||||
|
|
||||||
|
prune doc
|
||||||
|
|
||||||
|
recursive-include matrix_synapse_saml_mozilla *.py
|
||||||
|
graft matrix_synapse_saml_mozilla/res
|
||||||
recursive-include tests *.py
|
recursive-include tests *.py
|
||||||
|
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 33 KiB |
@ -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=<clienturl>--""
|
||||||
|
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=<SAML request>
|
||||||
|
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=<response>
|
||||||
|
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=<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=<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": "<token>"}
|
||||||
|
activate HS
|
||||||
|
Riot<--HS:""access token"" etc
|
||||||
|
deactivate HS
|
||||||
|
|
||||||
|
|
@ -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"]
|
@ -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)
|
@ -0,0 +1,20 @@
|
|||||||
|
<!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">
|
||||||
|
<input type="text" name="username" id="field-username" autofocus="">
|
||||||
|
<label for="field-username">
|
||||||
|
<span><span aria-hidden="true">Please pick your </span>username</span>
|
||||||
|
</label>
|
||||||
|
<input type="button" class="button button--full-width" id="button-submit" value="Submit">
|
||||||
|
</form>
|
||||||
|
<div role=alert class="tooltip hidden" id="message"></div>
|
||||||
|
<script src="script.js"></script>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -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 = "" +
|
||||||
|
"<code>a-z</code>, " +
|
||||||
|
"<code>0-9</code>, " +
|
||||||
|
"<code>.</code>, " +
|
||||||
|
"<code>_</code>, " +
|
||||||
|
"<code>-</code>, " +
|
||||||
|
"<code>/</code>, " +
|
||||||
|
"<code>=</code>";
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
@ -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; }
|
@ -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 = """<!DOCTYPE html>
|
||||||
|
<html lang=en>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Error {code}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>{msg}</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
@ -1,16 +1,15 @@
|
|||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
from matrix_synapse_saml_mozilla import SamlMappingProvider
|
from matrix_synapse_saml_mozilla import SamlMappingProvider
|
||||||
|
|
||||||
|
|
||||||
def create_mapping_provider() -> Tuple[SamlMappingProvider, dict]:
|
def create_mapping_provider() -> Tuple[SamlMappingProvider, dict]:
|
||||||
# Default configuration
|
# Default configuration
|
||||||
config_dict = {
|
config_dict = {}
|
||||||
"mxid_source_attribute": "uid"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Convert the config dictionary to a SamlMappingProvider.SamlConfig object
|
# Convert the config dictionary to a SamlMappingProvider.SamlConfig object
|
||||||
config = SamlMappingProvider.parse_config(config_dict)
|
config = SamlMappingProvider.parse_config(config_dict)
|
||||||
|
|
||||||
# Create a new instance of the provider with the specified config
|
# Create a new instance of the provider with the specified config
|
||||||
# Return the config dict as well for other test methods to use
|
# Return the config dict as well for other test methods to use
|
||||||
return SamlMappingProvider(config), config_dict
|
return SamlMappingProvider(config, None), config_dict
|
||||||
|
@ -1,25 +1,33 @@
|
|||||||
[tox]
|
[tox]
|
||||||
envlist = packaging, pep8
|
envlist = packaging, lint, tests
|
||||||
|
|
||||||
[testenv]
|
[testenv]
|
||||||
setenv =
|
setenv =
|
||||||
PYTHONDONTWRITEBYTECODE = no_byte_code
|
PYTHONDONTWRITEBYTECODE = no_byte_code
|
||||||
PYTHONPATH = .
|
|
||||||
|
|
||||||
[testenv:tests]
|
[testenv:tests]
|
||||||
|
deps =
|
||||||
|
git+git://github.com/matrix-org/synapse@rav/mozilla_username_hacks#egg=matrix-synapse
|
||||||
|
|
||||||
commands =
|
commands =
|
||||||
python -m unittest discover
|
python -m unittest discover
|
||||||
|
|
||||||
[testenv:packaging]
|
[testenv:packaging]
|
||||||
|
skip_install = True
|
||||||
deps =
|
deps =
|
||||||
check-manifest
|
check-manifest
|
||||||
commands =
|
commands =
|
||||||
check-manifest
|
check-manifest
|
||||||
|
|
||||||
[testenv:pep8]
|
[testenv:lint]
|
||||||
skip_install = True
|
skip_install = True
|
||||||
basepython = python3
|
basepython = python3
|
||||||
deps =
|
deps =
|
||||||
flake8
|
flake8
|
||||||
|
# We pin so that our tests don't start failing on new releases of black.
|
||||||
|
black==19.10b0
|
||||||
|
isort
|
||||||
commands =
|
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
|
||||||
|
Loading…
Reference in New Issue