Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Implement SAML2 authentication #4267

Merged
merged 8 commits into from
Dec 7, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/4264.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix CAS login when username is not valid in an MXID
1 change: 1 addition & 0 deletions changelog.d/4267.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Rework SAML2 authentication
4 changes: 4 additions & 0 deletions synapse/app/homeserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,10 @@ def _configure_named_resource(self, name, compress=False):
"/.well-known/matrix/client": WellKnownResource(self),
})

if self.get_config().saml2_enabled:
from synapse.rest.saml2 import SAML2Resource
resources["/_matrix/saml2"] = SAML2Resource(self)

if name == "consent":
from synapse.rest.consent.consent_resource import ConsentResource
consent_resource = ConsentResource(self)
Expand Down
3 changes: 2 additions & 1 deletion synapse/config/homeserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from .registration import RegistrationConfig
from .repository import ContentRepositoryConfig
from .room_directory import RoomDirectoryConfig
from .saml2_config import SAML2Config
from .server import ServerConfig
from .server_notices_config import ServerNoticesConfig
from .spam_checker import SpamCheckerConfig
Expand All @@ -44,7 +45,7 @@
class HomeServerConfig(TlsConfig, ServerConfig, DatabaseConfig, LoggingConfig,
RatelimitConfig, ContentRepositoryConfig, CaptchaConfig,
VoipConfig, RegistrationConfig, MetricsConfig, ApiConfig,
AppServiceConfig, KeyConfig, CasConfig,
AppServiceConfig, KeyConfig, SAML2Config, CasConfig,
JWTConfig, PasswordConfig, EmailConfig,
WorkerConfig, PasswordAuthProviderConfig, PushConfig,
SpamCheckerConfig, GroupsConfig, UserDirectoryConfig,
Expand Down
110 changes: 110 additions & 0 deletions synapse/config/saml2_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# -*- coding: utf-8 -*-
# Copyright 2018 New Vector Ltd.
#
# 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 ._base import Config, ConfigError


class SAML2Config(Config):
def read_config(self, config):
self.saml2_enabled = False

saml2_config = config.get("saml2_config")

if not saml2_config or not saml2_config.get("enabled", True):
return

self.saml2_enabled = True

import saml2.config
self.saml2_sp_config = saml2.config.SPConfig()
self.saml2_sp_config.load(self._default_saml_config_dict())
self.saml2_sp_config.load(saml2_config.get("sp_config", {}))

config_path = saml2_config.get("config_path", None)
if config_path is not None:
self.saml2_sp_config.load_file(config_path)

def _default_saml_config_dict(self):
import saml2

public_baseurl = self.public_baseurl
if public_baseurl is None:
raise ConfigError(
"saml2_config requires a public_baseurl to be set"
)

metadata_url = public_baseurl + "_matrix/saml2/metadata.xml"
response_url = public_baseurl + "_matrix/saml2/authn_response"
return {
"entityid": metadata_url,

"service": {
"sp": {
"endpoints": {
"assertion_consumer_service": [
(response_url, saml2.BINDING_HTTP_POST),
],
},
"required_attributes": ["uid"],
"optional_attributes": ["mail", "surname", "givenname"],
},
}
}

def default_config(self, config_dir_path, server_name, **kwargs):
return """
# Enable SAML2 for registration and login. Uses pysaml2.
#
# saml2_config:
#
# # The following is the configuration for the pysaml2 Service Provider.
# # See pysaml2 docs for format of config.
# #
# # Default values will be used for the 'entityid' and 'service' settings,
# # so it is not normally necessary to specify them unless you need to
# # override them.
#
# sp_config:
# # point this to the IdP's metadata. You can use either a local file or
# # (preferably) a URL.
# metadata:
# # local: ["saml2/idp.xml"]
# remote:
# - url: https://our_idp/metadata.xml
#
# # The following is just used to generate our metadata xml, and you
# # may well not need it, depending on your setup. Alternatively you
# # may need a whole lot more detail - see the pysaml2 docs!
#
# description: ["My awesome SP", "en"]
# name: ["Test SP", "en"]
#
# organization:
# name: Example com
# display_name:
# - ["Example co", "en"]
# url: "http://example.com"
#
# contact_person:
# - given_name: Bob
# sur_name: "the Sysadmin"
# email_address": ["admin@example.com"]
# contact_type": technical
#
# # Instead of putting the config inline as above, you can specify a
# # separate pysaml2 configuration file:
# #
# # config_path: "%(config_dir_path)s/sp_conf.py"
""" % {"config_dir_path": config_dir_path}
13 changes: 11 additions & 2 deletions synapse/handlers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -563,10 +563,10 @@ def check_user_exists(self, user_id):
insensitively, but return None if there are multiple inexact matches.

Args:
(str) user_id: complete @user:id
(unicode|bytes) user_id: complete @user:id

Returns:
defer.Deferred: (str) canonical_user_id, or None if zero or
defer.Deferred: (unicode) canonical_user_id, or None if zero or
multiple matches
"""
res = yield self._find_user_id_and_pwd_hash(user_id)
Expand Down Expand Up @@ -954,6 +954,15 @@ def generate_access_token(self, user_id, extra_caveats=None):
return macaroon.serialize()

def generate_short_term_login_token(self, user_id, duration_in_ms=(2 * 60 * 1000)):
"""

Args:
user_id (unicode):
duration_in_ms (int):

Returns:
unicode
"""
macaroon = self._generate_base_macaroon(user_id)
macaroon.add_first_party_caveat("type = login")
now = self.hs.get_clock().time_msec()
Expand Down
5 changes: 4 additions & 1 deletion synapse/python_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,10 @@
},
"postgres": {
"psycopg2>=2.6": ["psycopg2"]
}
},
"saml2": {
"pysaml2>=4.5.0": ["saml2"],
},
}


Expand Down
105 changes: 76 additions & 29 deletions synapse/rest/client/v1/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,12 @@

from synapse.api.errors import Codes, LoginError, SynapseError
from synapse.http.server import finish_request
from synapse.http.servlet import RestServlet, parse_json_object_from_request
from synapse.types import UserID
from synapse.http.servlet import (
RestServlet,
parse_json_object_from_request,
parse_string,
)
from synapse.types import UserID, map_username_to_mxid_localpart
from synapse.util.msisdn import phone_number_to_msisdn

from .base import ClientV1RestServlet, client_path_patterns
Expand Down Expand Up @@ -358,17 +362,15 @@ def __init__(self, hs):
self.cas_server_url = hs.config.cas_server_url
self.cas_service_url = hs.config.cas_service_url
self.cas_required_attributes = hs.config.cas_required_attributes
self.auth_handler = hs.get_auth_handler()
self.handlers = hs.get_handlers()
self.macaroon_gen = hs.get_macaroon_generator()
self._sso_auth_handler = SSOAuthHandler(hs)

@defer.inlineCallbacks
def on_GET(self, request):
client_redirect_url = request.args[b"redirectUrl"][0]
client_redirect_url = parse_string(request, "redirectUrl", required=True)
http_client = self.hs.get_simple_http_client()
uri = self.cas_server_url + "/proxyValidate"
args = {
"ticket": request.args[b"ticket"][0].decode('ascii'),
"ticket": parse_string(request, "ticket", required=True),
"service": self.cas_service_url
}
try:
Expand All @@ -380,7 +382,6 @@ def on_GET(self, request):
result = yield self.handle_cas_response(request, body, client_redirect_url)
defer.returnValue(result)

@defer.inlineCallbacks
def handle_cas_response(self, request, cas_response_body, client_redirect_url):
user, attributes = self.parse_cas_response(cas_response_body)

Expand All @@ -396,28 +397,9 @@ def handle_cas_response(self, request, cas_response_body, client_redirect_url):
if required_value != actual_value:
raise LoginError(401, "Unauthorized", errcode=Codes.UNAUTHORIZED)

user_id = UserID(user, self.hs.hostname).to_string()
auth_handler = self.auth_handler
registered_user_id = yield auth_handler.check_user_exists(user_id)
if not registered_user_id:
registered_user_id, _ = (
yield self.handlers.registration_handler.register(localpart=user)
)

login_token = self.macaroon_gen.generate_short_term_login_token(
registered_user_id
return self._sso_auth_handler.on_successful_auth(
user, request, client_redirect_url,
)
redirect_url = self.add_login_token_to_redirect_url(client_redirect_url,
login_token)
request.redirect(redirect_url)
finish_request(request)

def add_login_token_to_redirect_url(self, 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).encode('ascii')
return urllib.parse.urlunparse(url_parts)

def parse_cas_response(self, cas_response_body):
user = None
Expand Down Expand Up @@ -452,6 +434,71 @@ def parse_cas_response(self, cas_response_body):
return user, attributes


class SSOAuthHandler(object):
"""
Utility class for Resources and Servlets which handle the response from a SSO
service

Args:
hs (synapse.server.HomeServer)
"""
def __init__(self, hs):
self._hostname = hs.hostname
self._auth_handler = hs.get_auth_handler()
self._registration_handler = hs.get_handlers().registration_handler
self._macaroon_gen = hs.get_macaroon_generator()

@defer.inlineCallbacks
def on_successful_auth(
self, username, request, client_redirect_url,
):
"""Called once the user has successfully authenticated with the SSO.

Registers the user if necessary, and then returns a redirect (with
a login token) to the client.

Args:
username (unicode|bytes): the remote user id. We'll map this onto
something sane for a MXID localpath.

request (SynapseRequest): the incoming request from the browser. We'll
respond to it with a redirect.

client_redirect_url (unicode): the redirect_url the client gave us when
it first started the process.

Returns:
Deferred[none]: Completes once we have handled the request.
"""
localpart = map_username_to_mxid_localpart(username)
user_id = UserID(localpart, self._hostname).to_string()
registered_user_id = yield self._auth_handler.check_user_exists(user_id)
if not registered_user_id:
registered_user_id, _ = (
yield self._registration_handler.register(
localpart=localpart,
generate_token=False,
)
)

login_token = self._macaroon_gen.generate_short_term_login_token(
registered_user_id
)
redirect_url = self._add_login_token_to_redirect_url(
client_redirect_url, login_token
)
request.redirect(redirect_url)
finish_request(request)

@staticmethod
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 register_servlets(hs, http_server):
LoginRestServlet(hs).register(http_server)
if hs.config.cas_enabled:
Expand Down
29 changes: 29 additions & 0 deletions synapse/rest/saml2/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
# Copyright 2018 New Vector Ltd
#
# 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 twisted.web.resource import Resource

from synapse.rest.saml2.metadata_resource import SAML2MetadataResource
from synapse.rest.saml2.response_resource import SAML2ResponseResource

logger = logging.getLogger(__name__)


class SAML2Resource(Resource):
def __init__(self, hs):
Resource.__init__(self)
self.putChild(b"metadata.xml", SAML2MetadataResource(hs))
self.putChild(b"authn_response", SAML2ResponseResource(hs))
36 changes: 36 additions & 0 deletions synapse/rest/saml2/metadata_resource.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Copyright 2018 New Vector Ltd
#
# 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 saml2.metadata

from twisted.web.resource import Resource


class SAML2MetadataResource(Resource):
"""A Twisted web resource which renders the SAML metadata"""

isLeaf = 1

def __init__(self, hs):
Resource.__init__(self)
self.sp_config = hs.config.saml2_sp_config

def render_GET(self, request):
metadata_xml = saml2.metadata.create_metadata_string(
configfile=None, config=self.sp_config,
)
request.setHeader(b"Content-Type", b"text/xml; charset=utf-8")
return metadata_xml
Loading