From f294e327c7ec623f3f6a77dac1c1fc989a22a551 Mon Sep 17 00:00:00 2001 From: Max Klenk Date: Tue, 10 Mar 2020 15:41:23 +0100 Subject: [PATCH 1/4] add oauth2 code flow handling --- docs/sample_config.yaml | 9 +++ synapse/config/_base.pyi | 2 + synapse/config/homeserver.py | 2 + synapse/config/oauth2.py | 55 +++++++++++++++ synapse/rest/client/v1/login.py | 106 +++++++++++++++++++++++++++++ tests/rest/client/v1/test_login.py | 99 +++++++++++++++++++++++++++ 6 files changed, 273 insertions(+) create mode 100644 synapse/config/oauth2.py diff --git a/docs/sample_config.yaml b/docs/sample_config.yaml index 6f6f6fd54b5a..dd118a1d2367 100644 --- a/docs/sample_config.yaml +++ b/docs/sample_config.yaml @@ -1359,6 +1359,15 @@ saml2_config: # #required_attributes: # # name: value +# Enable OAuth2 for registration and login. +# +#oauth2_config: +# enabled: true +# server_authorization_url: "https://oauth.server.com/oauth2/authorize" +# server_token_url: "https://oauth.server.com/oauth2/token" +# server_userinfo_url: "https://oauth.server.com/oauth2/userinfo" +# client_id: "FORM_OAUTH_SERVER" +# client_secret: "FORM_OAUTH_SERVER" # Additional settings to use with single-sign on systems such as SAML2 and CAS. # diff --git a/synapse/config/_base.pyi b/synapse/config/_base.pyi index 3053fc9d27e5..49aa259aeaeb 100644 --- a/synapse/config/_base.pyi +++ b/synapse/config/_base.pyi @@ -5,6 +5,7 @@ from synapse.config import ( appservice, captcha, cas, + oauth2, consent_config, database, emailconfig, @@ -58,6 +59,7 @@ class RootConfig: key: key.KeyConfig saml2: saml2_config.SAML2Config cas: cas.CasConfig + oauth2: oauth2.OAuth2Config sso: sso.SSOConfig jwt: jwt_config.JWTConfig password: password.PasswordConfig diff --git a/synapse/config/homeserver.py b/synapse/config/homeserver.py index b4bca08b20aa..6c32be15b654 100644 --- a/synapse/config/homeserver.py +++ b/synapse/config/homeserver.py @@ -19,6 +19,7 @@ from .appservice import AppServiceConfig from .captcha import CaptchaConfig from .cas import CasConfig +from .oauth2 import OAuth2Config from .consent_config import ConsentConfig from .database import DatabaseConfig from .emailconfig import EmailConfig @@ -66,6 +67,7 @@ class HomeServerConfig(RootConfig): KeyConfig, SAML2Config, CasConfig, + OAuth2Config, SSOConfig, JWTConfig, PasswordConfig, diff --git a/synapse/config/oauth2.py b/synapse/config/oauth2.py new file mode 100644 index 000000000000..83e6099c91c1 --- /dev/null +++ b/synapse/config/oauth2.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Copyright 2015, 2016 OpenMarket 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 + + +class OAuth2Config(Config): + """OAuth2 Configuration + + oauth_server_url: URL of OAuth2 server + """ + + section = "oauth2" + + def read_config(self, config, **kwargs): + oauth2_config = config.get("oauth2_config", None) + if oauth2_config: + self.oauth2_enabled = oauth2_config.get("enabled", True) + self.oauth2_server_authorization_url = oauth2_config["server_authorization_url"] + self.oauth2_server_token_url = oauth2_config["server_token_url"] + self.oauth2_server_userinfo_url = oauth2_config["server_userinfo_url"] + self.oauth2_client_id = oauth2_config["client_id"] + self.oauth2_client_secret = oauth2_config["client_secret"] + else: + self.oauth2_enabled = False + self.oauth2_server_authorization_url = None + self.oauth2_server_token_url = None + self.oauth2_server_userinfo_url = None + self.oauth2_client_id = None + self.oauth2_client_secret = None + + def generate_config_section(self, config_dir_path, server_name, **kwargs): + return """ + # Enable OAuth2 for registration and login. + # + #oauth_config: + # enabled: true + # server_authorization_url: "https://oauth.server.com/oauth2/authorize" + # server_token_url: "https://oauth.server.com/oauth2/token" + # server_userinfo_url: "https://oauth.server.com/oauth2/userinfo" + # client_id: "FORM_OAUTH_SERVER" + # client_secret: "FORM_OAUTH_SERVER" + """ diff --git a/synapse/rest/client/v1/login.py b/synapse/rest/client/v1/login.py index d0d4999795c8..221c01c8b77d 100644 --- a/synapse/rest/client/v1/login.py +++ b/synapse/rest/client/v1/login.py @@ -87,6 +87,7 @@ def __init__(self, hs): self.jwt_secret = hs.config.jwt_secret self.jwt_algorithm = hs.config.jwt_algorithm self.saml2_enabled = hs.config.saml2_enabled + self.oauth2_enabled = hs.config.oauth2_enabled self.cas_enabled = hs.config.cas_enabled self.auth_handler = self.hs.get_auth_handler() self.registration_handler = hs.get_registration_handler() @@ -104,6 +105,9 @@ def on_GET(self, request): if self.saml2_enabled: flows.append({"type": LoginRestServlet.SSO_TYPE}) flows.append({"type": LoginRestServlet.TOKEN_TYPE}) + if self.oauth2_enabled: + flows.append({"type": LoginRestServlet.SSO_TYPE}) + flows.append({"type": LoginRestServlet.TOKEN_TYPE}) if self.cas_enabled: flows.append({"type": LoginRestServlet.SSO_TYPE}) @@ -425,6 +429,105 @@ def get_sso_url(self, client_redirect_url): raise NotImplementedError() +class OAuth2RedirectServlet(BaseSSORedirectServlet): + def __init__(self, hs): + super(OAuth2RedirectServlet, self).__init__() + self.public_baseurl = hs.config.public_baseurl + self.oauth2_server_authorization_url = hs.config.oauth2_server_authorization_url + self.oauth2_client_id = hs.config.oauth2_client_id + self.oauth2_scope = "openid" + self.oauth2_response_type = "code" + self.oauth2_response_mode = "query" + self.oauth2_nonce = "dajmpe2p1x5" # TODO(Max): generate random + + def get_sso_url(self, client_redirect_url): + # required to get back to the client later TODO(Max): store + client_redirect_url_param = urllib.parse.urlencode( + {b"redirectUrl": client_redirect_url} + ).encode("ascii") + + # redirect to synapse to generate synapse token + redirect_uri = self.public_baseurl + "_matrix/client/r0/login/oauth/response" + + service_param = urllib.parse.urlencode( + { + b"redirect_uri": redirect_uri, + b"client_id": self.oauth2_client_id, + b"scope": self.oauth2_scope, + b"response_type": self.oauth2_response_type, + b"response_mode": self.oauth2_response_mode, + b"nonce": self.oauth2_nonce, + } + ).encode("ascii") + return b"%s?%s" % (self.oauth2_server_authorization_url.encode("ascii"), service_param) + +class OAuth2ResponseServlet(RestServlet): + PATTERNS = client_patterns("/login/oauth/response", v1=True) + + def __init__(self, hs): + super(OAuth2ResponseServlet, self).__init__() + self.oauth2_server_token_url = hs.config.oauth2_server_token_url + self.oauth2_server_userinfo_url = hs.config.oauth2_server_userinfo_url + self.oauth2_client_id = hs.config.oauth2_client_id + self.oauth2_client_secret = hs.config.oauth2_client_secret + self._sso_auth_handler = SSOAuthHandler(hs) + self._http_client = hs.get_proxied_http_client() + + async def on_GET(self, request): + oauth2_code = parse_string(request, "code", required=True) + #oauth2_scope = parse_string(request, "scope", required=False) + #oauth2_state = parse_string(request, "state", required=False) # optional CSRF token + + access_token = await self.get_access_token(oauth2_code) + userinfo = await self.get_userinfo(access_token) + + user = "tv" + userinfo.get('sub') + displayname = userinfo.get('preferred_username') + + result = await self._sso_auth_handler.on_successful_auth( + user, request, "https://riot.im/develop/", displayname + ) + return result + + + async def get_access_token(self, oauth2_code): + # TODO(Max): get stored? + redirect_uri = "http://localhost:8008/_matrix/client/r0/login/oauth/response" + args = { + "client_id": self.oauth2_client_id, + "client_secret": self.oauth2_client_secret, + "code": oauth2_code, + "grant_type": "authorization_code", + "redirect_uri": redirect_uri, + } + + try: + body = await self._http_client.post_urlencoded_get_json(self.oauth2_server_token_url, args) + except PartialDownloadError as pde: + # Twisted raises this error if the connection is closed, + # even if that's being used old-http style to signal end-of-data + body = pde.response + + logging.warning(body) + access_token = body.get('access_token') + return access_token + + async def get_userinfo(self, access_token): + headers = { + "Authorization": ["Bearer "+ access_token], + } + + try: + userinfo = await self._http_client.get_json(self.oauth2_server_userinfo_url, {}, headers) + except PartialDownloadError as pde: + # Twisted raises this error if the connection is closed, + # even if that's being used old-http style to signal end-of-data + userinfo = pde.response + + logging.warning(userinfo) + return userinfo + + class CasRedirectServlet(BaseSSORedirectServlet): def __init__(self, hs): super(CasRedirectServlet, self).__init__() @@ -600,5 +703,8 @@ def register_servlets(hs, http_server): if hs.config.cas_enabled: CasRedirectServlet(hs).register(http_server) CasTicketServlet(hs).register(http_server) + elif hs.config.oauth2_enabled: + OAuth2RedirectServlet(hs).register(http_server) + OAuth2ResponseServlet(hs).register(http_server) elif hs.config.saml2_enabled: SAMLRedirectServlet(hs).register(http_server) diff --git a/tests/rest/client/v1/test_login.py b/tests/rest/client/v1/test_login.py index da2c9bfa1e57..720b250f5c13 100644 --- a/tests/rest/client/v1/test_login.py +++ b/tests/rest/client/v1/test_login.py @@ -363,3 +363,102 @@ def test_cas_redirect_whitelisted(self): self.assertEqual(channel.code, 302) location_headers = channel.headers.getRawHeaders("Location") self.assertEqual(location_headers[0][: len(redirect_url)], redirect_url) + + +class OAuthRedirectConfirmTestCase(unittest.HomeserverTestCase): + + servlets = [ + login.register_servlets, + ] + + def make_homeserver(self, reactor, clock): + self.base_url = "https://matrix.goodserver.com/" + self.redirect_path = "_synapse/client/login/sso/redirect/confirm" + + config = self.default_config() + config["oauth2_config"] = { + "enabled": True, + "server_url": "https://fake.test", + "service_url": "https://matrix.goodserver.com:8448", + } + + async def get_raw(uri, args): + """Return an example response payload from a call to the `/proxyValidate` + endpoint of a CAS server, copied from + https://apereo.github.io/cas/5.0.x/protocol/CAS-Protocol-V2-Specification.html#26-proxyvalidate-cas-20 + + This needs to be returned by an async function (as opposed to set as the + mock's return value) because the corresponding Synapse code awaits on it. + """ + return """ + {} + """ + + mocked_http_client = Mock(spec=["get_raw"]) + mocked_http_client.get_raw.side_effect = get_raw + + self.hs = self.setup_test_homeserver( + config=config, proxied_http_client=mocked_http_client, + ) + + return self.hs + + def test_oauth_redirect_confirm(self): + """Tests that the SSO login flow serves a confirmation page before redirecting a + user to the redirect URL. + """ + base_url = "/_matrix/client/r0/login/sso/redirect" + redirect_url = "https://dodgy-site.com/" + + url_parts = list(urllib.parse.urlparse(base_url)) + query = dict(urllib.parse.parse_qsl(url_parts[4])) + query.update({"redirectUrl": redirect_url}) + query.update({"ticket": "ticket"}) + url_parts[4] = urllib.parse.urlencode(query) + oauth_url = urllib.parse.urlunparse(url_parts) + + # Get Synapse to call the fake CAS and serve the template. + request, channel = self.make_request("GET", oauth_url) + self.render(request) + + # Test that the response is HTML. + self.assertEqual(channel.code, 200) + content_type_header_value = "" + for header in channel.result.get("headers", []): + if header[0] == b"Content-Type": + content_type_header_value = header[1].decode("utf8") + + self.assertTrue(content_type_header_value.startswith("text/html")) + + # Test that the body isn't empty. + self.assertTrue(len(channel.result["body"]) > 0) + + # And that it contains our redirect link + self.assertIn(redirect_url, channel.result["body"].decode("UTF-8")) + + @override_config( + { + "sso": { + "client_whitelist": [ + "https://legit-site.com/", + "https://other-site.com/", + ] + } + } + ) + def test_oauth_redirect_whitelisted(self): + """Tests that the SSO login flow serves a redirect to a whitelisted url + """ + redirect_url = "https://legit-site.com/" + oauth_url = ( + "/_matrix/client/r0/login/sso/redirect?redirectUrl=%s" + % (urllib.parse.quote(redirect_url)) + ) + + # Get Synapse to call the fake CAS and serve the template. + request, channel = self.make_request("GET", oauth_url) + self.render(request) + + self.assertEqual(channel.code, 302) + location_headers = channel.headers.getRawHeaders("Location") + self.assertEqual(location_headers[0][: len(redirect_url)], redirect_url) From 9a8fc873cb039e85efb395b98ab774ac44accfe8 Mon Sep 17 00:00:00 2001 From: Max Klenk Date: Tue, 10 Mar 2020 17:38:24 +0100 Subject: [PATCH 2/4] add oauth state validation --- synapse/config/_base.pyi | 2 +- synapse/config/homeserver.py | 2 +- synapse/config/oauth2.py | 4 +- synapse/handlers/oauth2_handler.py | 154 +++++++++++++++++++++++++++++ synapse/rest/client/v1/login.py | 93 ++--------------- synapse/server.py | 6 ++ tests/rest/client/v1/test_login.py | 84 ++++------------ 7 files changed, 189 insertions(+), 156 deletions(-) create mode 100644 synapse/handlers/oauth2_handler.py diff --git a/synapse/config/_base.pyi b/synapse/config/_base.pyi index 49aa259aeaeb..be4620b55623 100644 --- a/synapse/config/_base.pyi +++ b/synapse/config/_base.pyi @@ -5,7 +5,6 @@ from synapse.config import ( appservice, captcha, cas, - oauth2, consent_config, database, emailconfig, @@ -14,6 +13,7 @@ from synapse.config import ( key, logger, metrics, + oauth2, password, password_auth_providers, push, diff --git a/synapse/config/homeserver.py b/synapse/config/homeserver.py index 6c32be15b654..5d64a12c9629 100644 --- a/synapse/config/homeserver.py +++ b/synapse/config/homeserver.py @@ -19,7 +19,6 @@ from .appservice import AppServiceConfig from .captcha import CaptchaConfig from .cas import CasConfig -from .oauth2 import OAuth2Config from .consent_config import ConsentConfig from .database import DatabaseConfig from .emailconfig import EmailConfig @@ -28,6 +27,7 @@ from .key import KeyConfig from .logger import LoggingConfig from .metrics import MetricsConfig +from .oauth2 import OAuth2Config from .password import PasswordConfig from .password_auth_providers import PasswordAuthProviderConfig from .push import PushConfig diff --git a/synapse/config/oauth2.py b/synapse/config/oauth2.py index 83e6099c91c1..82355c321944 100644 --- a/synapse/config/oauth2.py +++ b/synapse/config/oauth2.py @@ -28,7 +28,9 @@ def read_config(self, config, **kwargs): oauth2_config = config.get("oauth2_config", None) if oauth2_config: self.oauth2_enabled = oauth2_config.get("enabled", True) - self.oauth2_server_authorization_url = oauth2_config["server_authorization_url"] + self.oauth2_server_authorization_url = oauth2_config[ + "server_authorization_url" + ] self.oauth2_server_token_url = oauth2_config["server_token_url"] self.oauth2_server_userinfo_url = oauth2_config["server_userinfo_url"] self.oauth2_client_id = oauth2_config["client_id"] diff --git a/synapse/handlers/oauth2_handler.py b/synapse/handlers/oauth2_handler.py new file mode 100644 index 000000000000..d6a3099ba25c --- /dev/null +++ b/synapse/handlers/oauth2_handler.py @@ -0,0 +1,154 @@ +# -*- 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. +import logging + +from six.moves import urllib + +from twisted.web.client import PartialDownloadError + +from synapse.api.errors import Codes, LoginError +from synapse.http.servlet import parse_string +from synapse.rest.client.v1.login import SSOAuthHandler +from synapse.util.caches.expiringcache import ExpiringCache +from synapse.util.stringutils import random_string + +logger = logging.getLogger(__name__) + + +class OAuth2Handler: + def __init__(self, hs): + # config + self.public_baseurl = hs.config.public_baseurl.encode("ascii") + self.oauth2_server_authorization_url = hs.config.oauth2_server_authorization_url.encode( + "ascii" + ) + self.oauth2_server_token_url = hs.config.oauth2_server_token_url + self.oauth2_server_userinfo_url = hs.config.oauth2_server_userinfo_url + self.oauth2_client_id = hs.config.oauth2_client_id + self.oauth2_client_secret = hs.config.oauth2_client_secret + self.oauth2_scope = "openid" + self.oauth2_response_type = "code" + self.oauth2_response_mode = "query" + + # state + self.nonces = ExpiringCache( + cache_name="oauth_nonces", + clock=hs.get_clock(), + expiry_ms=5 * 60 * 1000, # 5 minutes + reset_expiry_on_get=False, + ) + + # tools + self._sso_auth_handler = SSOAuthHandler(hs) + self._http_client = hs.get_proxied_http_client() + + def handle_redirect_request(self, client_redirect_url): + """Handle an incoming request to /login/sso/redirect + + Args: + client_redirect_url (bytes): the URL that we should redirect the + client to when everything is done + + Returns: + bytes: URL to redirect to + """ + + oauth2_nonce = random_string(12) + self.nonces[oauth2_nonce] = {"redirectUrl": client_redirect_url} + + service_param = urllib.parse.urlencode( + { + b"redirect_uri": self.get_server_redirect_url(), + b"client_id": self.oauth2_client_id, + b"scope": self.oauth2_scope, + b"response_type": self.oauth2_response_type, + b"response_mode": self.oauth2_response_mode, + b"state": oauth2_nonce, + } + ).encode("ascii") + return b"%s?%s" % (self.oauth2_server_authorization_url, service_param) + + async def handle_oauth2_response(self, request): + """Handle an incoming request to /_matrix/oauth2/response + + Args: + request (SynapseRequest): the incoming request from the browser. We'll + respond to it with a redirect. + + Returns: + Deferred[none]: Completes once we have handled the request. + """ + oauth2_code = parse_string(request, "code", required=True) + oauth2_state = parse_string(request, "state", required=False) + + # validate state + if oauth2_state not in self.nonces: + raise LoginError( + 400, "Invalid or expire state passed", errcode=Codes.UNAUTHORIZED + ) + + client_redirect_url = self.nonces[oauth2_state].pop("redirectUrl").decode() + logging.warning(client_redirect_url) + + access_token = await self.get_access_token(oauth2_code) + userinfo = await self.get_userinfo(access_token) + + user = "sso_" + userinfo.get("sub") + displayname = userinfo.get("preferred_username") + + result = await self._sso_auth_handler.on_successful_auth( + user, request, client_redirect_url, displayname + ) + return result + + async def get_access_token(self, oauth2_code): + args = { + "client_id": self.oauth2_client_id, + "client_secret": self.oauth2_client_secret, + "code": oauth2_code, + "grant_type": "authorization_code", + "redirect_uri": self.get_server_redirect_url(), + } + + try: + body = await self._http_client.post_urlencoded_get_json( + self.oauth2_server_token_url, args + ) + except PartialDownloadError as pde: + # Twisted raises this error if the connection is closed, + # even if that's being used old-http style to signal end-of-data + body = pde.response + + access_token = body.get("access_token") + return access_token + + async def get_userinfo(self, access_token): + headers = { + "Authorization": ["Bearer " + access_token], + } + + try: + userinfo = await self._http_client.get_json( + self.oauth2_server_userinfo_url, {}, headers + ) + except PartialDownloadError as pde: + # Twisted raises this error if the connection is closed, + # even if that's being used old-http style to signal end-of-data + userinfo = pde.response + + return userinfo + + def get_server_redirect_url(self): + return self.public_baseurl + b"_matrix/client/r0/login/oauth/response" diff --git a/synapse/rest/client/v1/login.py b/synapse/rest/client/v1/login.py index 221c01c8b77d..ccaf3300e238 100644 --- a/synapse/rest/client/v1/login.py +++ b/synapse/rest/client/v1/login.py @@ -430,102 +430,23 @@ def get_sso_url(self, client_redirect_url): class OAuth2RedirectServlet(BaseSSORedirectServlet): + PATTERNS = client_patterns("/login/sso/redirect", v1=True) + def __init__(self, hs): - super(OAuth2RedirectServlet, self).__init__() - self.public_baseurl = hs.config.public_baseurl - self.oauth2_server_authorization_url = hs.config.oauth2_server_authorization_url - self.oauth2_client_id = hs.config.oauth2_client_id - self.oauth2_scope = "openid" - self.oauth2_response_type = "code" - self.oauth2_response_mode = "query" - self.oauth2_nonce = "dajmpe2p1x5" # TODO(Max): generate random + self._oauth2_handler = hs.get_oauth2_handler() def get_sso_url(self, client_redirect_url): - # required to get back to the client later TODO(Max): store - client_redirect_url_param = urllib.parse.urlencode( - {b"redirectUrl": client_redirect_url} - ).encode("ascii") + return self._oauth2_handler.handle_redirect_request(client_redirect_url) - # redirect to synapse to generate synapse token - redirect_uri = self.public_baseurl + "_matrix/client/r0/login/oauth/response" - - service_param = urllib.parse.urlencode( - { - b"redirect_uri": redirect_uri, - b"client_id": self.oauth2_client_id, - b"scope": self.oauth2_scope, - b"response_type": self.oauth2_response_type, - b"response_mode": self.oauth2_response_mode, - b"nonce": self.oauth2_nonce, - } - ).encode("ascii") - return b"%s?%s" % (self.oauth2_server_authorization_url.encode("ascii"), service_param) class OAuth2ResponseServlet(RestServlet): PATTERNS = client_patterns("/login/oauth/response", v1=True) def __init__(self, hs): - super(OAuth2ResponseServlet, self).__init__() - self.oauth2_server_token_url = hs.config.oauth2_server_token_url - self.oauth2_server_userinfo_url = hs.config.oauth2_server_userinfo_url - self.oauth2_client_id = hs.config.oauth2_client_id - self.oauth2_client_secret = hs.config.oauth2_client_secret - self._sso_auth_handler = SSOAuthHandler(hs) - self._http_client = hs.get_proxied_http_client() - - async def on_GET(self, request): - oauth2_code = parse_string(request, "code", required=True) - #oauth2_scope = parse_string(request, "scope", required=False) - #oauth2_state = parse_string(request, "state", required=False) # optional CSRF token - - access_token = await self.get_access_token(oauth2_code) - userinfo = await self.get_userinfo(access_token) - - user = "tv" + userinfo.get('sub') - displayname = userinfo.get('preferred_username') - - result = await self._sso_auth_handler.on_successful_auth( - user, request, "https://riot.im/develop/", displayname - ) - return result - + self._oauth2_handler = hs.get_oauth2_handler() - async def get_access_token(self, oauth2_code): - # TODO(Max): get stored? - redirect_uri = "http://localhost:8008/_matrix/client/r0/login/oauth/response" - args = { - "client_id": self.oauth2_client_id, - "client_secret": self.oauth2_client_secret, - "code": oauth2_code, - "grant_type": "authorization_code", - "redirect_uri": redirect_uri, - } - - try: - body = await self._http_client.post_urlencoded_get_json(self.oauth2_server_token_url, args) - except PartialDownloadError as pde: - # Twisted raises this error if the connection is closed, - # even if that's being used old-http style to signal end-of-data - body = pde.response - - logging.warning(body) - access_token = body.get('access_token') - return access_token - - async def get_userinfo(self, access_token): - headers = { - "Authorization": ["Bearer "+ access_token], - } - - try: - userinfo = await self._http_client.get_json(self.oauth2_server_userinfo_url, {}, headers) - except PartialDownloadError as pde: - # Twisted raises this error if the connection is closed, - # even if that's being used old-http style to signal end-of-data - userinfo = pde.response - - logging.warning(userinfo) - return userinfo + def on_GET(self, request): + return self._oauth2_handler.handle_oauth2_response(request) class CasRedirectServlet(BaseSSORedirectServlet): diff --git a/synapse/server.py b/synapse/server.py index fd2f69e92861..206bb1791127 100644 --- a/synapse/server.py +++ b/synapse/server.py @@ -197,6 +197,7 @@ def build_DEPENDENCY(self) "registration_handler", "account_validity_handler", "saml_handler", + "oauth2_handler", "event_client_serializer", "storage", ] @@ -530,6 +531,11 @@ def build_saml_handler(self): return SamlHandler(self) + def build_oauth2_handler(self): + from synapse.handlers.oauth2_handler import OAuth2Handler + + return OAuth2Handler(self) + def build_event_client_serializer(self): return EventClientSerializer(self) diff --git a/tests/rest/client/v1/test_login.py b/tests/rest/client/v1/test_login.py index 720b250f5c13..92ddbba1999b 100644 --- a/tests/rest/client/v1/test_login.py +++ b/tests/rest/client/v1/test_login.py @@ -365,7 +365,7 @@ def test_cas_redirect_whitelisted(self): self.assertEqual(location_headers[0][: len(redirect_url)], redirect_url) -class OAuthRedirectConfirmTestCase(unittest.HomeserverTestCase): +class OAuth2RedirectConfirmTestCase(unittest.HomeserverTestCase): servlets = [ login.register_servlets, @@ -373,47 +373,31 @@ class OAuthRedirectConfirmTestCase(unittest.HomeserverTestCase): def make_homeserver(self, reactor, clock): self.base_url = "https://matrix.goodserver.com/" - self.redirect_path = "_synapse/client/login/sso/redirect/confirm" + self.server_authorization_url = "https://auth.server/oauth2/authorize" config = self.default_config() + config["public_baseurl"] = self.base_url config["oauth2_config"] = { "enabled": True, - "server_url": "https://fake.test", - "service_url": "https://matrix.goodserver.com:8448", + "server_authorization_url": self.server_authorization_url, + "server_token_url": "https://auth.server/oauth2/token", + "server_userinfo_url": "https://auth.server/oauth2/userinfo", + "client_id": "client_id", + "client_secret": "client_secret", } - async def get_raw(uri, args): - """Return an example response payload from a call to the `/proxyValidate` - endpoint of a CAS server, copied from - https://apereo.github.io/cas/5.0.x/protocol/CAS-Protocol-V2-Specification.html#26-proxyvalidate-cas-20 - - This needs to be returned by an async function (as opposed to set as the - mock's return value) because the corresponding Synapse code awaits on it. - """ - return """ - {} - """ - - mocked_http_client = Mock(spec=["get_raw"]) - mocked_http_client.get_raw.side_effect = get_raw - - self.hs = self.setup_test_homeserver( - config=config, proxied_http_client=mocked_http_client, - ) - + self.hs = self.setup_test_homeserver(config=config,) return self.hs - def test_oauth_redirect_confirm(self): - """Tests that the SSO login flow serves a confirmation page before redirecting a - user to the redirect URL. + def test_oauth2_redirect(self): + """Tests that the SSO login flow redirects to login server. """ base_url = "/_matrix/client/r0/login/sso/redirect" - redirect_url = "https://dodgy-site.com/" + redirect_url = "https://my-client.com/" url_parts = list(urllib.parse.urlparse(base_url)) query = dict(urllib.parse.parse_qsl(url_parts[4])) query.update({"redirectUrl": redirect_url}) - query.update({"ticket": "ticket"}) url_parts[4] = urllib.parse.urlencode(query) oauth_url = urllib.parse.urlunparse(url_parts) @@ -421,44 +405,10 @@ def test_oauth_redirect_confirm(self): request, channel = self.make_request("GET", oauth_url) self.render(request) - # Test that the response is HTML. - self.assertEqual(channel.code, 200) - content_type_header_value = "" - for header in channel.result.get("headers", []): - if header[0] == b"Content-Type": - content_type_header_value = header[1].decode("utf8") - - self.assertTrue(content_type_header_value.startswith("text/html")) - - # Test that the body isn't empty. - self.assertTrue(len(channel.result["body"]) > 0) - - # And that it contains our redirect link - self.assertIn(redirect_url, channel.result["body"].decode("UTF-8")) - - @override_config( - { - "sso": { - "client_whitelist": [ - "https://legit-site.com/", - "https://other-site.com/", - ] - } - } - ) - def test_oauth_redirect_whitelisted(self): - """Tests that the SSO login flow serves a redirect to a whitelisted url - """ - redirect_url = "https://legit-site.com/" - oauth_url = ( - "/_matrix/client/r0/login/sso/redirect?redirectUrl=%s" - % (urllib.parse.quote(redirect_url)) - ) - - # Get Synapse to call the fake CAS and serve the template. - request, channel = self.make_request("GET", oauth_url) - self.render(request) - + # Test that the response is a redirect. self.assertEqual(channel.code, 302) location_headers = channel.headers.getRawHeaders("Location") - self.assertEqual(location_headers[0][: len(redirect_url)], redirect_url) + self.assertEqual( + location_headers[0][: len(self.server_authorization_url)], + self.server_authorization_url, + ) From 8474b3235ee9b1acd9314800c19ebf75ff16a40b Mon Sep 17 00:00:00 2001 From: Max Klenk Date: Tue, 10 Mar 2020 18:30:41 +0100 Subject: [PATCH 3/4] add changelog file --- changelog.d/7059.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7059.feature diff --git a/changelog.d/7059.feature b/changelog.d/7059.feature new file mode 100644 index 000000000000..92b2e4b4b2d1 --- /dev/null +++ b/changelog.d/7059.feature @@ -0,0 +1 @@ +It is now possible to register and login in using the OAuth2 Authorization Code Flow. Contributed by Max Klenk. From bbd039571f6be09eb3644fc0681e7b2a177c2de1 Mon Sep 17 00:00:00 2001 From: Max Klenk Date: Tue, 10 Mar 2020 19:33:42 +0100 Subject: [PATCH 4/4] generate sample_config --- docs/sample_config.yaml | 3 +++ synapse/config/oauth2.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/sample_config.yaml b/docs/sample_config.yaml index dd118a1d2367..64c38c594c6a 100644 --- a/docs/sample_config.yaml +++ b/docs/sample_config.yaml @@ -1359,6 +1359,8 @@ saml2_config: # #required_attributes: # # name: value + + # Enable OAuth2 for registration and login. # #oauth2_config: @@ -1369,6 +1371,7 @@ saml2_config: # client_id: "FORM_OAUTH_SERVER" # client_secret: "FORM_OAUTH_SERVER" + # Additional settings to use with single-sign on systems such as SAML2 and CAS. # sso: diff --git a/synapse/config/oauth2.py b/synapse/config/oauth2.py index 82355c321944..a80957e4d784 100644 --- a/synapse/config/oauth2.py +++ b/synapse/config/oauth2.py @@ -47,7 +47,7 @@ def generate_config_section(self, config_dir_path, server_name, **kwargs): return """ # Enable OAuth2 for registration and login. # - #oauth_config: + #oauth2_config: # enabled: true # server_authorization_url: "https://oauth.server.com/oauth2/authorize" # server_token_url: "https://oauth.server.com/oauth2/token"