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