Hopefully there are enough comments and docs in this that it makes sense on its own.tags/v0.30.0-rc1
@@ -0,0 +1,23 @@ | |||
If enabling the 'consent' resource in synapse, you will need some templates | |||
for the HTML to be served to the user. This directory contains very simple | |||
examples of the sort of thing that can be done. | |||
You'll need to add this sort of thing to your homeserver.yaml: | |||
``` | |||
form_secret: <unique but arbitrary secret> | |||
user_consent: | |||
template_dir: docs/privacy_policy_templates | |||
default_version: 1.0 | |||
``` | |||
You should then be able to enable the `consent` resource under a `listener` | |||
entry. For example: | |||
``` | |||
listeners: | |||
- port: 8008 | |||
resources: | |||
- names: [client, consent] | |||
``` |
@@ -0,0 +1,17 @@ | |||
<!doctype html> | |||
<html lang="en"> | |||
<head> | |||
<title>Matrix.org Privacy policy</title> | |||
</head> | |||
<body> | |||
<p> | |||
All your base are belong to us. | |||
</p> | |||
<form method="post" action="consent"> | |||
<input type="hidden" name="v" value="{{version}}"/> | |||
<input type="hidden" name="u" value="{{user}}"/> | |||
<input type="hidden" name="h" value="{{userhmac}}"/> | |||
<input type="submit" value="Sure thing!"/> | |||
</form> | |||
</body> | |||
</html> |
@@ -0,0 +1,11 @@ | |||
<!doctype html> | |||
<html lang="en"> | |||
<head> | |||
<title>Matrix.org Privacy policy</title> | |||
</head> | |||
<body> | |||
<p> | |||
Sweet. | |||
</p> | |||
</body> | |||
</html> |
@@ -41,6 +41,7 @@ from synapse.python_dependencies import CONDITIONAL_REQUIREMENTS, \ | |||
from synapse.replication.http import ReplicationRestResource, REPLICATION_PREFIX | |||
from synapse.replication.tcp.resource import ReplicationStreamProtocolFactory | |||
from synapse.rest import ClientRestResource | |||
from synapse.rest.consent.consent_resource import ConsentResource | |||
from synapse.rest.key.v1.server_key_resource import LocalKey | |||
from synapse.rest.key.v2 import KeyApiV2Resource | |||
from synapse.rest.media.v0.content_repository import ContentRepoResource | |||
@@ -182,6 +183,14 @@ class SynapseHomeServer(HomeServer): | |||
"/_matrix/client/versions": client_resource, | |||
}) | |||
if name == "consent": | |||
consent_resource = ConsentResource(self) | |||
if compress: | |||
consent_resource = gz_wrap(consent_resource) | |||
resources.update({ | |||
"/_matrix/consent": consent_resource, | |||
}) | |||
if name == "federation": | |||
resources.update({ | |||
FEDERATION_PREFIX: TransportLayerServer(self), | |||
@@ -12,3 +12,9 @@ | |||
# 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 ConfigError | |||
# export ConfigError if somebody does import * | |||
# this is largely a fudge to stop PEP8 moaning about the import | |||
__all__ = ["ConfigError"] |
@@ -0,0 +1,42 @@ | |||
# -*- 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 | |||
DEFAULT_CONFIG = """\ | |||
# User Consent configuration | |||
# | |||
# uncomment and configure if enabling the 'consent' resource under 'listeners'. | |||
# | |||
# 'template_dir' gives the location of the templates for the HTML forms. | |||
# This directory should contain one subdirectory per language (eg, 'en', 'fr'), | |||
# and each language directory should contain the policy document (named as | |||
# '<version>.html') and a success page (success.html). | |||
# | |||
# 'default_version' gives the version of the policy document to serve up if | |||
# there is no 'v' parameter. | |||
# | |||
# user_consent: | |||
# template_dir: res/templates/privacy | |||
# default_version: 1.0 | |||
""" | |||
class ConsentConfig(Config): | |||
def read_config(self, config): | |||
self.consent_config = config.get("user_consent") | |||
def default_config(self, **kwargs): | |||
return DEFAULT_CONFIG |
@@ -1,5 +1,6 @@ | |||
# -*- coding: utf-8 -*- | |||
# Copyright 2014-2016 OpenMarket Ltd | |||
# 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. | |||
@@ -12,7 +13,6 @@ | |||
# 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 .tls import TlsConfig | |||
from .server import ServerConfig | |||
from .logger import LoggingConfig | |||
@@ -37,6 +37,7 @@ from .push import PushConfig | |||
from .spam_checker import SpamCheckerConfig | |||
from .groups import GroupsConfig | |||
from .user_directory import UserDirectoryConfig | |||
from .consent_config import ConsentConfig | |||
class HomeServerConfig(TlsConfig, ServerConfig, DatabaseConfig, LoggingConfig, | |||
@@ -45,12 +46,13 @@ class HomeServerConfig(TlsConfig, ServerConfig, DatabaseConfig, LoggingConfig, | |||
AppServiceConfig, KeyConfig, SAML2Config, CasConfig, | |||
JWTConfig, PasswordConfig, EmailConfig, | |||
WorkerConfig, PasswordAuthProviderConfig, PushConfig, | |||
SpamCheckerConfig, GroupsConfig, UserDirectoryConfig,): | |||
SpamCheckerConfig, GroupsConfig, UserDirectoryConfig, | |||
ConsentConfig): | |||
pass | |||
if __name__ == '__main__': | |||
import sys | |||
sys.stdout.write( | |||
HomeServerConfig().generate_config(sys.argv[1], sys.argv[2])[0] | |||
HomeServerConfig().generate_config(sys.argv[1], sys.argv[2], True)[0] | |||
) |
@@ -59,14 +59,20 @@ class KeyConfig(Config): | |||
self.expire_access_token = config.get("expire_access_token", False) | |||
# a secret which is used to calculate HMACs for form values, to stop | |||
# falsification of values | |||
self.form_secret = config.get("form_secret", None) | |||
def default_config(self, config_dir_path, server_name, is_generating_file=False, | |||
**kwargs): | |||
base_key_name = os.path.join(config_dir_path, server_name) | |||
if is_generating_file: | |||
macaroon_secret_key = random_string_with_symbols(50) | |||
form_secret = '"%s"' % random_string_with_symbols(50) | |||
else: | |||
macaroon_secret_key = None | |||
form_secret = 'null' | |||
return """\ | |||
macaroon_secret_key: "%(macaroon_secret_key)s" | |||
@@ -74,6 +80,10 @@ class KeyConfig(Config): | |||
# Used to enable access token expiration. | |||
expire_access_token: False | |||
# a secret which is used to calculate HMACs for form values, to stop | |||
# falsification of values | |||
form_secret: %(form_secret)s | |||
## Signing Keys ## | |||
# Path to the signing key to sign messages with | |||
@@ -13,7 +13,8 @@ | |||
# 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 cgi | |||
from six.moves import http_client | |||
from synapse.api.errors import ( | |||
cs_exception, SynapseError, CodeMessageException, UnrecognizedRequestError, Codes | |||
@@ -44,6 +45,18 @@ import simplejson | |||
logger = logging.getLogger(__name__) | |||
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_json_request_handler(h): | |||
"""Wraps a request handler method with exception handling. | |||
@@ -104,6 +117,65 @@ def wrap_json_request_handler(h): | |||
return wrap_request_handler_with_logging(wrapped_request_handler) | |||
def wrap_html_request_handler(h): | |||
"""Wraps a request handler method with exception handling. | |||
Also adds logging as per wrap_request_handler_with_logging. | |||
The handler method must have a signature of "handle_foo(self, request)", | |||
where "self" must have a "clock" attribute (and "request" must be a | |||
SynapseRequest). | |||
""" | |||
def wrapped_request_handler(self, request): | |||
d = defer.maybeDeferred(h, self, request) | |||
d.addErrback(_return_html_error, request) | |||
return d | |||
return wrap_request_handler_with_logging(wrapped_request_handler) | |||
def _return_html_error(f, request): | |||
"""Sends an HTML error page corresponding to the given failure | |||
Args: | |||
f (twisted.python.failure.Failure): | |||
request (twisted.web.iweb.IRequest): | |||
""" | |||
if f.check(CodeMessageException): | |||
cme = f.value | |||
code = cme.code | |||
msg = cme.msg | |||
if isinstance(cme, SynapseError): | |||
logger.info( | |||
"%s SynapseError: %s - %s", request, code, msg | |||
) | |||
else: | |||
logger.error( | |||
"Failed handle request %r: %s", | |||
request, | |||
f.getTraceback().rstrip(), | |||
) | |||
else: | |||
code = http_client.INTERNAL_SERVER_ERROR | |||
msg = "Internal server error" | |||
logger.error( | |||
"Failed handle request %r: %s", | |||
request, | |||
f.getTraceback().rstrip(), | |||
) | |||
body = HTML_ERROR_TEMPLATE.format( | |||
code=code, msg=cgi.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) | |||
finish_request(request) | |||
def wrap_request_handler_with_logging(h): | |||
"""Wraps a request handler to provide logging and metrics | |||
@@ -134,7 +206,7 @@ def wrap_request_handler_with_logging(h): | |||
servlet_name = self.__class__.__name__ | |||
with request.processing(servlet_name): | |||
with PreserveLoggingContext(request_context): | |||
d = h(self, request) | |||
d = defer.maybeDeferred(h, self, request) | |||
# record the arrival of the request *after* | |||
# dispatching to the handler, so that the handler | |||
@@ -0,0 +1,210 @@ | |||
# -*- 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 hashlib import sha256 | |||
import hmac | |||
import logging | |||
from os import path | |||
from six.moves import http_client | |||
import jinja2 | |||
from jinja2 import TemplateNotFound | |||
from twisted.internet import defer | |||
from twisted.web.resource import Resource | |||
from twisted.web.server import NOT_DONE_YET | |||
from synapse.api.errors import NotFoundError, SynapseError, StoreError | |||
from synapse.config import ConfigError | |||
from synapse.http.server import ( | |||
finish_request, | |||
wrap_html_request_handler, | |||
) | |||
from synapse.http.servlet import parse_string | |||
from synapse.types import UserID | |||
# language to use for the templates. TODO: figure this out from Accept-Language | |||
TEMPLATE_LANGUAGE = "en" | |||
logger = logging.getLogger(__name__) | |||
# use hmac.compare_digest if we have it (python 2.7.7), else just use equality | |||
if hasattr(hmac, "compare_digest"): | |||
compare_digest = hmac.compare_digest | |||
else: | |||
def compare_digest(a, b): | |||
return a == b | |||
class ConsentResource(Resource): | |||
"""A twisted Resource to display a privacy policy and gather consent to it | |||
When accessed via GET, returns the privacy policy via a template. | |||
When accessed via POST, records the user's consent in the database and | |||
displays a success page. | |||
The config should include a template_dir setting which contains templates | |||
for the HTML. The directory should contain one subdirectory per language | |||
(eg, 'en', 'fr'), and each language directory should contain the policy | |||
document (named as '<version>.html') and a success page (success.html). | |||
Both forms take a set of parameters from the browser. For the POST form, | |||
these are normally sent as form parameters (but may be query-params); for | |||
GET requests they must be query params. These are: | |||
u: the complete mxid, or the localpart of the user giving their | |||
consent. Required for both GET (where it is used as an input to the | |||
template) and for POST (where it is used to find the row in the db | |||
to update). | |||
h: hmac_sha256(secret, u), where 'secret' is the privacy_secret in the | |||
config file. If it doesn't match, the request is 403ed. | |||
v: the version of the privacy policy being agreed to. | |||
For GET: optional, and defaults to whatever was set in the config | |||
file. Used to choose the version of the policy to pick from the | |||
templates directory. | |||
For POST: required; gives the value to be recorded in the database | |||
against the user. | |||
""" | |||
def __init__(self, hs): | |||
""" | |||
Args: | |||
hs (synapse.server.HomeServer): homeserver | |||
""" | |||
Resource.__init__(self) | |||
self.hs = hs | |||
self.store = hs.get_datastore() | |||
# this is required by the request_handler wrapper | |||
self.clock = hs.get_clock() | |||
consent_config = hs.config.consent_config | |||
if consent_config is None: | |||
raise ConfigError( | |||
"Consent resource is enabled but user_consent section is " | |||
"missing in config file.", | |||
) | |||
# daemonize changes the cwd to /, so make the path absolute now. | |||
consent_template_directory = path.abspath( | |||
consent_config["template_dir"], | |||
) | |||
if not path.isdir(consent_template_directory): | |||
raise ConfigError( | |||
"Could not find template directory '%s'" % ( | |||
consent_template_directory, | |||
), | |||
) | |||
loader = jinja2.FileSystemLoader(consent_template_directory) | |||
self._jinja_env = jinja2.Environment(loader=loader) | |||
self._default_consent_verison = consent_config["default_version"] | |||
if hs.config.form_secret is None: | |||
raise ConfigError( | |||
"Consent resource is enabled but form_secret is not set in " | |||
"config file. It should be set to an arbitrary secret string.", | |||
) | |||
self._hmac_secret = hs.config.form_secret.encode("utf-8") | |||
def render_GET(self, request): | |||
self._async_render_GET(request) | |||
return NOT_DONE_YET | |||
@wrap_html_request_handler | |||
def _async_render_GET(self, request): | |||
""" | |||
Args: | |||
request (twisted.web.http.Request): | |||
""" | |||
version = parse_string(request, "v", | |||
default=self._default_consent_verison) | |||
username = parse_string(request, "u", required=True) | |||
userhmac = parse_string(request, "h", required=True) | |||
self._check_hash(username, userhmac) | |||
try: | |||
self._render_template( | |||
request, "%s.html" % (version,), | |||
user=username, userhmac=userhmac, version=version, | |||
) | |||
except TemplateNotFound: | |||
raise NotFoundError("Unknown policy version") | |||
def render_POST(self, request): | |||
self._async_render_POST(request) | |||
return NOT_DONE_YET | |||
@wrap_html_request_handler | |||
@defer.inlineCallbacks | |||
def _async_render_POST(self, request): | |||
""" | |||
Args: | |||
request (twisted.web.http.Request): | |||
""" | |||
version = parse_string(request, "v", required=True) | |||
username = parse_string(request, "u", required=True) | |||
userhmac = parse_string(request, "h", required=True) | |||
self._check_hash(username, userhmac) | |||
if username.startswith('@'): | |||
qualified_user_id = username | |||
else: | |||
qualified_user_id = UserID(username, self.hs.hostname).to_string() | |||
try: | |||
yield self.store.user_set_consent_version(qualified_user_id, version) | |||
except StoreError as e: | |||
if e.code != 404: | |||
raise | |||
raise NotFoundError("Unknown user") | |||
try: | |||
self._render_template(request, "success.html") | |||
except TemplateNotFound: | |||
raise NotFoundError("success.html not found") | |||
def _render_template(self, request, template_name, **template_args): | |||
# get_template checks for ".." so we don't need to worry too much | |||
# about path traversal here. | |||
template_html = self._jinja_env.get_template( | |||
path.join(TEMPLATE_LANGUAGE, template_name) | |||
) | |||
html_bytes = template_html.render(**template_args).encode("utf8") | |||
request.setHeader(b"Content-Type", b"text/html; charset=utf-8") | |||
request.setHeader(b"Content-Length", b"%i" % len(html_bytes)) | |||
request.write(html_bytes) | |||
finish_request(request) | |||
def _check_hash(self, userid, userhmac): | |||
want_mac = hmac.new( | |||
key=self._hmac_secret, | |||
msg=userid, | |||
digestmod=sha256, | |||
).hexdigest() | |||
if not compare_digest(want_mac, userhmac): | |||
raise SynapseError(http_client.FORBIDDEN, "HMAC incorrect") |
@@ -97,6 +97,9 @@ class HomeServer(object): | |||
which must be implemented by the subclass. This code may call any of the | |||
required "get" methods on the instance to obtain the sub-dependencies that | |||
one requires. | |||
Attributes: | |||
config (synapse.config.homeserver.HomeserverConfig): | |||
""" | |||
DEPENDENCIES = [ | |||
@@ -286,6 +286,24 @@ class RegistrationStore(RegistrationWorkerStore, | |||
"user_set_password_hash", user_set_password_hash_txn | |||
) | |||
def user_set_consent_version(self, user_id, consent_version): | |||
"""Updates the user table to record privacy policy consent | |||
Args: | |||
user_id (str): full mxid of the user to update | |||
consent_version (str): version of the policy the user has consented | |||
to | |||
Raises: | |||
StoreError(404) if user not found | |||
""" | |||
return self._simple_update_one( | |||
table='users', | |||
keyvalues={'name': user_id, }, | |||
updatevalues={'consent_version': consent_version, }, | |||
desc="user_set_consent_version" | |||
) | |||
def user_delete_access_tokens(self, user_id, except_token_id=None, | |||
device_id=None): | |||
""" | |||
@@ -0,0 +1,18 @@ | |||
/* 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. | |||
*/ | |||
/* record the version of the privacy policy the user has consented to | |||
*/ | |||
ALTER TABLE users ADD COLUMN consent_version TEXT; |