mirror of https://github.com/watcha-fr/synapse
Merge pull request #3213 from matrix-org/rav/consent_handler
ConsentResource to gather policy consent from userspull/14/head
commit
8030a825c8
@ -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> |
@ -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 |
@ -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") |
@ -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; |
Loading…
Reference in new issue