diff --git a/synapse/config/server.py b/synapse/config/server.py index 4d12d49857..0b1b2fbc9d 100644 --- a/synapse/config/server.py +++ b/synapse/config/server.py @@ -117,6 +117,11 @@ class ServerConfig(Config): self.content_addr = content_addr + client_addr = config.get("client_addr") + if not client_addr: + client_addr = content_addr + self.client_addr = client_addr + def default_config(self, server_name, **kwargs): if ":" in server_name: bind_port = int(server_name.split(":")[1]) @@ -140,6 +145,9 @@ class ServerConfig(Config): # Whether to serve a web client from the HTTP/HTTPS root resource. web_client: True + # URL clients can use to talk to the server. + client_addr: "https://%(server_name)s:%(bind_port)s" + # Set the soft limit on the number of file descriptors synapse can use # Zero is used to indicate synapse should set the soft limit to the # hard limit. diff --git a/synapse/handlers/auth.py b/synapse/handlers/auth.py index 793b3fcd8b..ff69c83cde 100644 --- a/synapse/handlers/auth.py +++ b/synapse/handlers/auth.py @@ -290,11 +290,74 @@ class AuthHandler(BaseHandler): user_id, password_hash = yield self._find_user_id_and_pwd_hash(user_id) self._check_password(user_id, password, password_hash) + res = yield self._issue_tokens(user_id) + defer.returnValue(res) + + @defer.inlineCallbacks + def _issue_tokens(self, user_id): logger.info("Logging in user %s", user_id) access_token = yield self.issue_access_token(user_id) refresh_token = yield self.issue_refresh_token(user_id) defer.returnValue((user_id, access_token, refresh_token)) + @defer.inlineCallbacks + def do_short_term_token_login(self, token, user_id): + macaroon_exact_caveats = [ + "gen = 1", + "type = st_login", + "user_id = %s" % (user_id,) + ] + + macaroon_general_caveats = [ + self._verify_macaroon_expiry + ] + + try: + macaroon = pymacaroons.Macaroon.deserialize(token) + + v = pymacaroons.Verifier() + for exact_caveat in macaroon_exact_caveats: + v.satisfy_exact(exact_caveat) + + for general_caveat in macaroon_general_caveats: + v.satisfy_general(general_caveat) + + verified = v.verify(macaroon, self.hs.config.macaroon_secret_key) + if not verified: + raise LoginError(403, "Invalid token", errcode=Codes.FORBIDDEN) + + user_id, access_token, refresh_token = yield self._issue_tokens( + user_id=user_id, + ) + + result = { + "user_id": user_id, # may have changed + "access_token": access_token, + "refresh_token": refresh_token, + "home_server": self.hs.hostname, + } + + defer.returnValue(result) + except (pymacaroons.exceptions.MacaroonException, TypeError, ValueError): + raise LoginError(403, "Invalid token", errcode=Codes.FORBIDDEN) + + def _verify_macaroon_expiry(self, caveat): + prefix = "time < " + if not caveat.startswith(prefix): + return False + + expiry = int(caveat[len(prefix):]) + now = self.hs.get_clock().time_msec() + return now < expiry + + def make_short_term_token(self, user_id): + macaroon = self._generate_base_macaroon(user_id) + macaroon.add_first_party_caveat("type = st_login") + now = self.hs.get_clock().time_msec() + expiry = now + (60 * 1000) + macaroon.add_first_party_caveat("time < %d" % (expiry,)) + return macaroon.serialize() + @defer.inlineCallbacks def _find_user_id_and_pwd_hash(self, user_id): """Checks to see if a user with the given id exists. Will check case diff --git a/synapse/rest/client/v1/login.py b/synapse/rest/client/v1/login.py index e580f71964..146b9b510e 100644 --- a/synapse/rest/client/v1/login.py +++ b/synapse/rest/client/v1/login.py @@ -27,6 +27,8 @@ from saml2 import BINDING_HTTP_POST from saml2 import config from saml2.client import Saml2Client +import pymacaroons + logger = logging.getLogger(__name__) @@ -35,6 +37,7 @@ class LoginRestServlet(ClientV1RestServlet): PATTERN = client_path_pattern("/login$") PASS_TYPE = "m.login.password" SAML2_TYPE = "m.login.saml2" + TOKEN_TYPE = "m.login.token" def __init__(self, hs): super(LoginRestServlet, self).__init__(hs) @@ -42,7 +45,10 @@ class LoginRestServlet(ClientV1RestServlet): self.saml2_enabled = hs.config.saml2_enabled def on_GET(self, request): - flows = [{"type": LoginRestServlet.PASS_TYPE}] + flows = [ + {"type": LoginRestServlet.PASS_TYPE}, + {"type": LoginRestServlet.TOKEN_TYPE} + ] if self.saml2_enabled: flows.append({"type": LoginRestServlet.SAML2_TYPE}) return (200, {"flows": flows}) @@ -67,6 +73,12 @@ class LoginRestServlet(ClientV1RestServlet): "uri": "%s%s" % (self.idp_redirect_url, relay_state) } defer.returnValue((200, result)) + elif login_submission["type"] == LoginRestServlet.TOKEN_TYPE: + auth_handler = self.handlers.auth_handler + token = login_submission["token"] + user_id = login_submission["user"] + result = yield auth_handler.do_short_term_token_login(token, user_id) + defer.returnValue(result) else: raise SynapseError(400, "Bad login type.") except KeyError: @@ -100,6 +112,60 @@ class LoginRestServlet(ClientV1RestServlet): defer.returnValue((200, result)) + @defer.inlineCallbacks + def do_short_term_token_login(self, login_submission): + token = login_submission["token"] + user_id = login_submission["user"] + + macaroon_exact_caveats = [ + "gen = 1", + "type = st_login", + "user_id = %s" % (user_id,) + ] + + macaroon_general_caveats = [ + self._verify_macaroon_expiry + ] + + try: + macaroon = pymacaroons.Macaroon.deserialize(token) + + v = pymacaroons.Verifier() + for exact_caveat in macaroon_exact_caveats: + v.satisfy_exact(exact_caveat) + + for general_caveat in macaroon_general_caveats: + v.satisfy_general(general_caveat) + + verified = v.verify(macaroon, self.hs.config.macaroon_secret_key) + if not verified: + raise SynapseError(400, "Invalid token.") + + auth_handler = self.handlers.auth_handler + user_id, access_token, refresh_token = yield auth_handler.issue_tokens( + user_id=user_id, + ) + + result = { + "user_id": user_id, # may have changed + "access_token": access_token, + "refresh_token": refresh_token, + "home_server": self.hs.hostname, + } + + defer.returnValue(result) + except (pymacaroons.exceptions.MacaroonException, TypeError, ValueError): + raise SynapseError(400, "Invalid token.") + + def _verify_macaroon_expiry(self, caveat): + prefix = "time < " + if not caveat.startswith(prefix): + return False + + expiry = int(caveat[len(prefix):]) + now = self.hs.get_clock().time_msec() + return now < expiry + class LoginFallbackRestServlet(ClientV1RestServlet): PATTERN = client_path_pattern("/login/fallback$") diff --git a/synapse/rest/media/v1/login_qr_resource.py b/synapse/rest/media/v1/login_qr_resource.py new file mode 100644 index 0000000000..55708b2852 --- /dev/null +++ b/synapse/rest/media/v1/login_qr_resource.py @@ -0,0 +1,95 @@ +# Copyright 2015 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 twisted.web.resource import Resource +from twisted.web.server import NOT_DONE_YET +from twisted.internet import defer, threads + +from synapse.api.errors import CodeMessageException + +import simplejson +import logging + +from unpaddedbase64 import encode_base64 +from hashlib import sha256 +from OpenSSL import crypto + +logger = logging.getLogger(__name__) + + +class LoginQRResource(Resource): + isLeaf = True + + def __init__(self, hs): + Resource.__init__(self) + self.hs = hs + self.auth = hs.get_auth() + self.handlers = hs.get_handlers() + self.config = hs.get_config() + + def render_GET(self, request): + self._async_render_GET(request) + return NOT_DONE_YET + + @defer.inlineCallbacks + def _async_render_GET(self, request): + try: + auth_user, _ = yield self.auth.get_user_by_req(request) + image = yield self.make_short_term_qr_code(auth_user.to_string()) + request.setHeader(b"Content-Type", b"image/png") + + image.save(request) + request.finish() + except CodeMessageException as e: + logger.info("Returning: %s", e) + request.setResponseCode(e.code) + request.finish() + except Exception: + logger.exception("Exception while generating token") + request.setResponseCode(500) + request.finish() + + @defer.inlineCallbacks + def make_short_term_qr_code(self, user_id): + h = self.handlers.auth_handler + token = h.make_short_term_token(user_id) + + x509_certificate_bytes = crypto.dump_certificate( + crypto.FILETYPE_ASN1, + self.config.tls_certificate + ) + + sha256_fingerprint = sha256(x509_certificate_bytes).digest() + + def gen(): + import qrcode + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_L, + box_size=5, + ) + qr.add_data(simplejson.dumps({ + "user_id": user_id, + "token": token, + "homeserver_url": self.config.client_addr, + "fingerprints": [{ + "hash_type": "SHA256", + "bytes": encode_base64(sha256_fingerprint), + }], + })) + qr.make(fit=True) + return qr.make_image() + + res = yield threads.deferToThread(gen) + defer.returnValue(res) diff --git a/synapse/rest/media/v1/media_repository.py b/synapse/rest/media/v1/media_repository.py index 9ca4d884dd..5dcd7b659c 100644 --- a/synapse/rest/media/v1/media_repository.py +++ b/synapse/rest/media/v1/media_repository.py @@ -17,6 +17,7 @@ from .upload_resource import UploadResource from .download_resource import DownloadResource from .thumbnail_resource import ThumbnailResource from .identicon_resource import IdenticonResource +from .login_qr_resource import LoginQRResource from .filepath import MediaFilePaths from twisted.web.resource import Resource @@ -78,3 +79,4 @@ class MediaRepositoryResource(Resource): self.putChild("download", DownloadResource(hs, filepaths)) self.putChild("thumbnail", ThumbnailResource(hs, filepaths)) self.putChild("identicon", IdenticonResource()) + self.putChild("login_qr", LoginQRResource(hs))