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 *.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
|
||||
|
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 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
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue