Merge pull request #4004 from matrix-org/travis/login-terms

Add m.login.terms to the registration flow
This commit is contained in:
Travis Ralston 2018-11-01 11:03:38 -06:00 committed by GitHub
commit c68aab1536
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 274 additions and 24 deletions

1
changelog.d/4004.feature Normal file
View File

@ -0,0 +1 @@
Add `m.login.terms` to the registration flow when consent tracking is enabled. **This makes the template arguments conditionally optional on a new `public_version` variable - update your privacy templates to support this.**

View File

@ -31,7 +31,7 @@ Note that the templates must be stored under a name giving the language of the
template - currently this must always be `en` (for "English"); template - currently this must always be `en` (for "English");
internationalisation support is intended for the future. internationalisation support is intended for the future.
The template for the policy itself should be versioned and named according to The template for the policy itself should be versioned and named according to
the version: for example `1.0.html`. The version of the policy which the user the version: for example `1.0.html`. The version of the policy which the user
has agreed to is stored in the database. has agreed to is stored in the database.
@ -81,9 +81,9 @@ should be a matter of `pip install Jinja2`. On debian, try `apt-get install
python-jinja2`. python-jinja2`.
Once this is complete, and the server has been restarted, try visiting Once this is complete, and the server has been restarted, try visiting
`https://<server>/_matrix/consent`. If correctly configured, this should give `https://<server>/_matrix/consent`. If correctly configured, you should see a
an error "Missing string query parameter 'u'". It is now possible to manually default policy document. It is now possible to manually construct URIs where
construct URIs where users can give their consent. users can give their consent.
### Constructing the consent URI ### Constructing the consent URI
@ -106,6 +106,11 @@ query parameters:
`https://<server>/_matrix/consent?u=<user>&h=68a152465a4d...`. `https://<server>/_matrix/consent?u=<user>&h=68a152465a4d...`.
Note that not providing a `u` parameter will be interpreted as wanting to view
the document from an unauthenticated perspective, such as prior to registration.
Therefore, the `h` parameter is not required in this scenario.
Sending users a server notice asking them to agree to the policy Sending users a server notice asking them to agree to the policy
---------------------------------------------------------------- ----------------------------------------------------------------

View File

@ -12,12 +12,15 @@
<p> <p>
All your base are belong to us. All your base are belong to us.
</p> </p>
<form method="post" action="consent"> {% if not public_version %}
<input type="hidden" name="v" value="{{version}}"/> <!-- The variables used here are only provided when the 'u' param is given to the homeserver -->
<input type="hidden" name="u" value="{{user}}"/> <form method="post" action="consent">
<input type="hidden" name="h" value="{{userhmac}}"/> <input type="hidden" name="v" value="{{version}}"/>
<input type="submit" value="Sure thing!"/> <input type="hidden" name="u" value="{{user}}"/>
</form> <input type="hidden" name="h" value="{{userhmac}}"/>
<input type="submit" value="Sure thing!"/>
</form>
{% endif %}
{% endif %} {% endif %}
</body> </body>
</html> </html>

View File

@ -51,6 +51,7 @@ class LoginType(object):
EMAIL_IDENTITY = u"m.login.email.identity" EMAIL_IDENTITY = u"m.login.email.identity"
MSISDN = u"m.login.msisdn" MSISDN = u"m.login.msisdn"
RECAPTCHA = u"m.login.recaptcha" RECAPTCHA = u"m.login.recaptcha"
TERMS = u"m.login.terms"
DUMMY = u"m.login.dummy" DUMMY = u"m.login.dummy"
# Only for C/S API v1 # Only for C/S API v1

View File

@ -59,6 +59,7 @@ class AuthHandler(BaseHandler):
LoginType.EMAIL_IDENTITY: self._check_email_identity, LoginType.EMAIL_IDENTITY: self._check_email_identity,
LoginType.MSISDN: self._check_msisdn, LoginType.MSISDN: self._check_msisdn,
LoginType.DUMMY: self._check_dummy_auth, LoginType.DUMMY: self._check_dummy_auth,
LoginType.TERMS: self._check_terms_auth,
} }
self.bcrypt_rounds = hs.config.bcrypt_rounds self.bcrypt_rounds = hs.config.bcrypt_rounds
@ -431,6 +432,9 @@ class AuthHandler(BaseHandler):
def _check_dummy_auth(self, authdict, _): def _check_dummy_auth(self, authdict, _):
return defer.succeed(True) return defer.succeed(True)
def _check_terms_auth(self, authdict, _):
return defer.succeed(True)
@defer.inlineCallbacks @defer.inlineCallbacks
def _check_threepid(self, medium, authdict): def _check_threepid(self, medium, authdict):
if 'threepid_creds' not in authdict: if 'threepid_creds' not in authdict:
@ -462,6 +466,22 @@ class AuthHandler(BaseHandler):
def _get_params_recaptcha(self): def _get_params_recaptcha(self):
return {"public_key": self.hs.config.recaptcha_public_key} return {"public_key": self.hs.config.recaptcha_public_key}
def _get_params_terms(self):
return {
"policies": {
"privacy_policy": {
"version": self.hs.config.user_consent_version,
"en": {
"name": "Privacy Policy",
"url": "%s/_matrix/consent?v=%s" % (
self.hs.config.public_baseurl,
self.hs.config.user_consent_version,
),
},
},
},
}
def _auth_dict_for_flows(self, flows, session): def _auth_dict_for_flows(self, flows, session):
public_flows = [] public_flows = []
for f in flows: for f in flows:
@ -469,6 +489,7 @@ class AuthHandler(BaseHandler):
get_params = { get_params = {
LoginType.RECAPTCHA: self._get_params_recaptcha, LoginType.RECAPTCHA: self._get_params_recaptcha,
LoginType.TERMS: self._get_params_terms,
} }
params = {} params = {}

View File

@ -68,6 +68,29 @@ function captchaDone() {
</html> </html>
""" """
TERMS_TEMPLATE = """
<html>
<head>
<title>Authentication</title>
<meta name='viewport' content='width=device-width, initial-scale=1,
user-scalable=no, minimum-scale=1.0, maximum-scale=1.0'>
<link rel="stylesheet" href="/_matrix/static/client/register/style.css">
</head>
<body>
<form id="registrationForm" method="post" action="%(myurl)s">
<div>
<p>
Please click the button below if you agree to the
<a href="%(terms_url)s">privacy policy of this homeserver.</a>
</p>
<input type="hidden" name="session" value="%(session)s" />
<input type="submit" value="Agree" />
</div>
</form>
</body>
</html>
"""
SUCCESS_TEMPLATE = """ SUCCESS_TEMPLATE = """
<html> <html>
<head> <head>
@ -130,6 +153,27 @@ class AuthRestServlet(RestServlet):
request.setHeader(b"Content-Type", b"text/html; charset=utf-8") request.setHeader(b"Content-Type", b"text/html; charset=utf-8")
request.setHeader(b"Content-Length", b"%d" % (len(html_bytes),)) request.setHeader(b"Content-Length", b"%d" % (len(html_bytes),))
request.write(html_bytes)
finish_request(request)
defer.returnValue(None)
elif stagetype == LoginType.TERMS:
session = request.args['session'][0]
html = TERMS_TEMPLATE % {
'session': session,
'terms_url': "%s/_matrix/consent?v=%s" % (
self.hs.config.public_baseurl,
self.hs.config.user_consent_version,
),
'myurl': "%s/auth/%s/fallback/web" % (
CLIENT_V2_ALPHA_PREFIX, LoginType.TERMS
),
}
html_bytes = html.encode("utf8")
request.setResponseCode(200)
request.setHeader(b"Content-Type", b"text/html; charset=utf-8")
request.setHeader(b"Content-Length", b"%d" % (len(html_bytes),))
request.write(html_bytes) request.write(html_bytes)
finish_request(request) finish_request(request)
defer.returnValue(None) defer.returnValue(None)
@ -139,7 +183,7 @@ class AuthRestServlet(RestServlet):
@defer.inlineCallbacks @defer.inlineCallbacks
def on_POST(self, request, stagetype): def on_POST(self, request, stagetype):
yield yield
if stagetype == "m.login.recaptcha": if stagetype == LoginType.RECAPTCHA:
if ('g-recaptcha-response' not in request.args or if ('g-recaptcha-response' not in request.args or
len(request.args['g-recaptcha-response'])) == 0: len(request.args['g-recaptcha-response'])) == 0:
raise SynapseError(400, "No captcha response supplied") raise SynapseError(400, "No captcha response supplied")
@ -178,6 +222,41 @@ class AuthRestServlet(RestServlet):
request.write(html_bytes) request.write(html_bytes)
finish_request(request) finish_request(request)
defer.returnValue(None)
elif stagetype == LoginType.TERMS:
if ('session' not in request.args or
len(request.args['session'])) == 0:
raise SynapseError(400, "No session supplied")
session = request.args['session'][0]
authdict = {'session': session}
success = yield self.auth_handler.add_oob_auth(
LoginType.TERMS,
authdict,
self.hs.get_ip_from_request(request)
)
if success:
html = SUCCESS_TEMPLATE
else:
html = TERMS_TEMPLATE % {
'session': session,
'terms_url': "%s/_matrix/consent?v=%s" % (
self.hs.config.public_baseurl,
self.hs.config.user_consent_version,
),
'myurl': "%s/auth/%s/fallback/web" % (
CLIENT_V2_ALPHA_PREFIX, LoginType.TERMS
),
}
html_bytes = html.encode("utf8")
request.setResponseCode(200)
request.setHeader(b"Content-Type", b"text/html; charset=utf-8")
request.setHeader(b"Content-Length", b"%d" % (len(html_bytes),))
request.write(html_bytes)
finish_request(request)
defer.returnValue(None) defer.returnValue(None)
else: else:
raise SynapseError(404, "Unknown auth stage type") raise SynapseError(404, "Unknown auth stage type")

View File

@ -359,6 +359,13 @@ class RegisterRestServlet(RestServlet):
[LoginType.MSISDN, LoginType.EMAIL_IDENTITY] [LoginType.MSISDN, LoginType.EMAIL_IDENTITY]
]) ])
# Append m.login.terms to all flows if we're requiring consent
if self.hs.config.block_events_without_consent_error is not None:
new_flows = []
for flow in flows:
flow.append(LoginType.TERMS)
flows.extend(new_flows)
auth_result, params, session_id = yield self.auth_handler.check_auth( auth_result, params, session_id = yield self.auth_handler.check_auth(
flows, body, self.hs.get_ip_from_request(request) flows, body, self.hs.get_ip_from_request(request)
) )
@ -445,6 +452,12 @@ class RegisterRestServlet(RestServlet):
params.get("bind_msisdn") params.get("bind_msisdn")
) )
if auth_result and LoginType.TERMS in auth_result:
logger.info("%s has consented to the privacy policy" % registered_user_id)
yield self.store.user_set_consent_version(
registered_user_id, self.hs.config.user_consent_version,
)
defer.returnValue((200, return_dict)) defer.returnValue((200, return_dict))
def on_OPTIONS(self, _): def on_OPTIONS(self, _):

View File

@ -137,27 +137,31 @@ class ConsentResource(Resource):
request (twisted.web.http.Request): request (twisted.web.http.Request):
""" """
version = parse_string(request, "v", version = parse_string(request, "v", default=self._default_consent_version)
default=self._default_consent_version) username = parse_string(request, "u", required=False, default="")
username = parse_string(request, "u", required=True) userhmac = None
userhmac = parse_string(request, "h", required=True, encoding=None) has_consented = False
public_version = username != ""
if not public_version:
userhmac = parse_string(request, "h", required=True, encoding=None)
self._check_hash(username, userhmac) self._check_hash(username, userhmac)
if username.startswith('@'): if username.startswith('@'):
qualified_user_id = username qualified_user_id = username
else: else:
qualified_user_id = UserID(username, self.hs.hostname).to_string() qualified_user_id = UserID(username, self.hs.hostname).to_string()
u = yield self.store.get_user_by_id(qualified_user_id) u = yield self.store.get_user_by_id(qualified_user_id)
if u is None: if u is None:
raise NotFoundError("Unknown user") raise NotFoundError("Unknown user")
has_consented = u["consent_version"] == version
try: try:
self._render_template( self._render_template(
request, "%s.html" % (version,), request, "%s.html" % (version,),
user=username, userhmac=userhmac, version=version, user=username, userhmac=userhmac, version=version,
has_consented=(u["consent_version"] == version), has_consented=has_consented, public_version=public_version,
) )
except TemplateNotFound: except TemplateNotFound:
raise NotFoundError("Unknown policy version") raise NotFoundError("Unknown policy version")

123
tests/test_terms_auth.py Normal file
View File

@ -0,0 +1,123 @@
# 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.
import json
import six
from mock import Mock
from twisted.test.proto_helpers import MemoryReactorClock
from synapse.rest.client.v2_alpha.register import register_servlets
from synapse.util import Clock
from tests import unittest
from tests.server import make_request
class TermsTestCase(unittest.HomeserverTestCase):
servlets = [register_servlets]
def prepare(self, reactor, clock, hs):
self.clock = MemoryReactorClock()
self.hs_clock = Clock(self.clock)
self.url = "/_matrix/client/r0/register"
self.registration_handler = Mock()
self.auth_handler = Mock()
self.device_handler = Mock()
hs.config.enable_registration = True
hs.config.registrations_require_3pid = []
hs.config.auto_join_rooms = []
hs.config.enable_registration_captcha = False
def test_ui_auth(self):
self.hs.config.block_events_without_consent_error = True
self.hs.config.public_baseurl = "https://example.org"
self.hs.config.user_consent_version = "1.0"
# Do a UI auth request
request, channel = self.make_request(b"POST", self.url, b"{}")
self.render(request)
self.assertEquals(channel.result["code"], b"401", channel.result)
self.assertTrue(channel.json_body is not None)
self.assertIsInstance(channel.json_body["session"], six.text_type)
self.assertIsInstance(channel.json_body["flows"], list)
for flow in channel.json_body["flows"]:
self.assertIsInstance(flow["stages"], list)
self.assertTrue(len(flow["stages"]) > 0)
self.assertEquals(flow["stages"][-1], "m.login.terms")
expected_params = {
"m.login.terms": {
"policies": {
"privacy_policy": {
"en": {
"name": "Privacy Policy",
"url": "https://example.org/_matrix/consent?v=1.0",
},
"version": "1.0"
},
},
},
}
self.assertIsInstance(channel.json_body["params"], dict)
self.assertDictContainsSubset(channel.json_body["params"], expected_params)
# We have to complete the dummy auth stage before completing the terms stage
request_data = json.dumps(
{
"username": "kermit",
"password": "monkey",
"auth": {
"session": channel.json_body["session"],
"type": "m.login.dummy",
},
}
)
self.registration_handler.check_username = Mock(return_value=True)
request, channel = make_request(b"POST", self.url, request_data)
self.render(request)
# We don't bother checking that the response is correct - we'll leave that to
# other tests. We just want to make sure we're on the right path.
self.assertEquals(channel.result["code"], b"401", channel.result)
# Finish the UI auth for terms
request_data = json.dumps(
{
"username": "kermit",
"password": "monkey",
"auth": {
"session": channel.json_body["session"],
"type": "m.login.terms",
},
}
)
request, channel = make_request(b"POST", self.url, request_data)
self.render(request)
# We're interested in getting a response that looks like a successful
# registration, not so much that the details are exactly what we want.
self.assertEquals(channel.result["code"], b"200", channel.result)
self.assertTrue(channel.json_body is not None)
self.assertIsInstance(channel.json_body["user_id"], six.text_type)
self.assertIsInstance(channel.json_body["access_token"], six.text_type)
self.assertIsInstance(channel.json_body["device_id"], six.text_type)