1# Copyright 2018 New Vector Ltd
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#     http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15import hmac
16import logging
17from hashlib import sha256
18from http import HTTPStatus
19from os import path
20from typing import TYPE_CHECKING, Any, Dict, List
21
22import jinja2
23from jinja2 import TemplateNotFound
24
25from twisted.web.server import Request
26
27from synapse.api.errors import NotFoundError, StoreError, SynapseError
28from synapse.config import ConfigError
29from synapse.http.server import DirectServeHtmlResource, respond_with_html
30from synapse.http.servlet import parse_bytes_from_args, parse_string
31from synapse.types import UserID
32
33if TYPE_CHECKING:
34    from synapse.server import HomeServer
35
36# language to use for the templates. TODO: figure this out from Accept-Language
37TEMPLATE_LANGUAGE = "en"
38
39logger = logging.getLogger(__name__)
40
41
42class ConsentResource(DirectServeHtmlResource):
43    """A twisted Resource to display a privacy policy and gather consent to it
44
45    When accessed via GET, returns the privacy policy via a template.
46
47    When accessed via POST, records the user's consent in the database and
48    displays a success page.
49
50    The config should include a template_dir setting which contains templates
51    for the HTML. The directory should contain one subdirectory per language
52    (eg, 'en', 'fr'), and each language directory should contain the policy
53    document (named as '<version>.html') and a success page (success.html).
54
55    Both forms take a set of parameters from the browser. For the POST form,
56    these are normally sent as form parameters (but may be query-params); for
57    GET requests they must be query params. These are:
58
59        u: the complete mxid, or the localpart of the user giving their
60           consent. Required for both GET (where it is used as an input to the
61           template) and for POST (where it is used to find the row in the db
62           to update).
63
64        h: hmac_sha256(secret, u), where 'secret' is the privacy_secret in the
65           config file. If it doesn't match, the request is 403ed.
66
67        v: the version of the privacy policy being agreed to.
68
69           For GET: optional, and defaults to whatever was set in the config
70           file. Used to choose the version of the policy to pick from the
71           templates directory.
72
73           For POST: required; gives the value to be recorded in the database
74           against the user.
75    """
76
77    def __init__(self, hs: "HomeServer"):
78        super().__init__()
79
80        self.hs = hs
81        self.store = hs.get_datastore()
82        self.registration_handler = hs.get_registration_handler()
83
84        # this is required by the request_handler wrapper
85        self.clock = hs.get_clock()
86
87        # Consent must be configured to create this resource.
88        default_consent_version = hs.config.consent.user_consent_version
89        consent_template_directory = hs.config.consent.user_consent_template_dir
90        if default_consent_version is None or consent_template_directory is None:
91            raise ConfigError(
92                "Consent resource is enabled but user_consent section is "
93                "missing in config file."
94            )
95        self._default_consent_version = default_consent_version
96
97        # TODO: switch to synapse.util.templates.build_jinja_env
98        loader = jinja2.FileSystemLoader(consent_template_directory)
99        self._jinja_env = jinja2.Environment(
100            loader=loader, autoescape=jinja2.select_autoescape(["html", "htm", "xml"])
101        )
102
103        if hs.config.key.form_secret is None:
104            raise ConfigError(
105                "Consent resource is enabled but form_secret is not set in "
106                "config file. It should be set to an arbitrary secret string."
107            )
108
109        self._hmac_secret = hs.config.key.form_secret.encode("utf-8")
110
111    async def _async_render_GET(self, request: Request) -> None:
112        version = parse_string(request, "v", default=self._default_consent_version)
113        username = parse_string(request, "u", default="")
114        userhmac = None
115        has_consented = False
116        public_version = username == ""
117        if not public_version:
118            args: Dict[bytes, List[bytes]] = request.args  # type: ignore
119            userhmac_bytes = parse_bytes_from_args(args, "h", required=True)
120
121            self._check_hash(username, userhmac_bytes)
122
123            if username.startswith("@"):
124                qualified_user_id = username
125            else:
126                qualified_user_id = UserID(username, self.hs.hostname).to_string()
127
128            u = await self.store.get_user_by_id(qualified_user_id)
129            if u is None:
130                raise NotFoundError("Unknown user")
131
132            has_consented = u["consent_version"] == version
133            userhmac = userhmac_bytes.decode("ascii")
134
135        try:
136            self._render_template(
137                request,
138                "%s.html" % (version,),
139                user=username,
140                userhmac=userhmac,
141                version=version,
142                has_consented=has_consented,
143                public_version=public_version,
144            )
145        except TemplateNotFound:
146            raise NotFoundError("Unknown policy version")
147
148    async def _async_render_POST(self, request: Request) -> None:
149        version = parse_string(request, "v", required=True)
150        username = parse_string(request, "u", required=True)
151        args: Dict[bytes, List[bytes]] = request.args  # type: ignore
152        userhmac = parse_bytes_from_args(args, "h", required=True)
153
154        self._check_hash(username, userhmac)
155
156        if username.startswith("@"):
157            qualified_user_id = username
158        else:
159            qualified_user_id = UserID(username, self.hs.hostname).to_string()
160
161        try:
162            await self.store.user_set_consent_version(qualified_user_id, version)
163        except StoreError as e:
164            if e.code != 404:
165                raise
166            raise NotFoundError("Unknown user")
167        await self.registration_handler.post_consent_actions(qualified_user_id)
168
169        try:
170            self._render_template(request, "success.html")
171        except TemplateNotFound:
172            raise NotFoundError("success.html not found")
173
174    def _render_template(
175        self, request: Request, template_name: str, **template_args: Any
176    ) -> None:
177        # get_template checks for ".." so we don't need to worry too much
178        # about path traversal here.
179        template_html = self._jinja_env.get_template(
180            path.join(TEMPLATE_LANGUAGE, template_name)
181        )
182        html = template_html.render(**template_args)
183        respond_with_html(request, 200, html)
184
185    def _check_hash(self, userid: str, userhmac: bytes) -> None:
186        """
187        Args:
188            userid:
189            userhmac:
190
191        Raises:
192              SynapseError if the hash doesn't match
193
194        """
195        want_mac = (
196            hmac.new(
197                key=self._hmac_secret, msg=userid.encode("utf-8"), digestmod=sha256
198            )
199            .hexdigest()
200            .encode("ascii")
201        )
202
203        if not hmac.compare_digest(want_mac, userhmac):
204            raise SynapseError(HTTPStatus.FORBIDDEN, "HMAC incorrect")
205