mirror of
https://git.anonymousland.org/anonymousland/synapse-product.git
synced 2024-10-01 08:25:44 -04:00
Merge pull request #4004 from matrix-org/travis/login-terms
Add m.login.terms to the registration flow
This commit is contained in:
commit
c68aab1536
1
changelog.d/4004.feature
Normal file
1
changelog.d/4004.feature
Normal 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.**
|
@ -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
|
||||||
----------------------------------------------------------------
|
----------------------------------------------------------------
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
|
@ -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 = {}
|
||||||
|
@ -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")
|
||||||
|
@ -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, _):
|
||||||
|
@ -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
123
tests/test_terms_auth.py
Normal 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)
|
Loading…
Reference in New Issue
Block a user