Browse Source

Support MSC2140 register/terms endpoints

 * Account endpoints for registering / logging out
 * Terms endpoints to get & accept terms
 * Deferjsonwrap used in the register endpoint
David Baker 4 years ago
parent
commit
aa34363d56

+ 67 - 0
sydent/db/accounts.py

@@ -0,0 +1,67 @@
+# -*- coding: utf-8 -*-
+
+# Copyright 2019 The Matrix.org Foundation C.I.C.
+#
+# 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 sydent.users.accounts import Account
+
+class AccountStore:
+    def __init__(self, sydent):
+        self.sydent = sydent
+
+    def getAccountByToken(self, token):
+        cur = self.sydent.db.cursor()
+        res = cur.execute("select a.user_id, a.created_ts, a.consent_version from accounts a, tokens t "
+                          "where t.user_id = a.user_id and t.token = ?", (token,))
+
+        row = res.fetchone()
+        if row is None:
+            return None
+
+        return Account(row[0], row[1], row[2])
+
+    def storeAccount(self, user_id, creation_ts, consent_version):
+        cur = self.sydent.db.cursor()
+        res = cur.execute(
+            "insert or ignore into accounts (user_id, created_ts, consent_version) "
+            "values (?, ?, ?)",
+            (user_id, creation_ts, consent_version),
+        )
+        self.sydent.db.commit()
+
+    def setConsentVersion(self, user_id, consent_version):
+        cur = self.sydent.db.cursor()
+        res = cur.execute(
+            "update accounts set consent_version = ? where user_id = ?",
+            (consent_version, user_id),
+        )
+        self.sydent.db.commit()
+
+    def addToken(self, user_id, token):
+        cur = self.sydent.db.cursor()
+        res = cur.execute(
+            "insert into tokens (user_id, token) values (?, ?)",
+            (user_id, token),
+        )
+        self.sydent.db.commit()
+
+    def delToken(self, token):
+        cur = self.sydent.db.cursor()
+        res = cur.execute(
+            "delete from tokens where token = ?",
+            (token,),
+        )
+        deleted = cur.rowcount
+        self.sydent.db.commit()
+        return deleted

+ 10 - 0
sydent/db/sqlitedb.py

@@ -176,6 +176,16 @@ class SqliteDatabase:
             logger.info("v2 -> v3 schema migration complete")
             self._setSchemaVersion(3)
 
+        if curVer < 4:
+            cur = self.db.cursor()
+            cur.execute("CREATE TABLE accounts(user_id TEXT NOT NULL PRIMARY KEY, created_ts BIGINT NOT NULL, consent_version TEXT)")
+            cur.execute("CREATE TABLE tokens(token TEXT NOT NULL PRIMARY KEY, user_id TEXT NOT NULL)")
+            cur.execute("CREATE TABLE accepted_terms_urls(user_id TEXT NOT NULL, url TEXT NOT NULL)")
+            cur.execute("CREATE UNIQUE INDEX accepted_terms_urls_idx ON accepted_terms_urls (user_id, url)")
+            self.db.commit()
+            logger.info("v3 -> v4 schema migration complete")
+            self._setSchemaVersion(4)
+
     def _getSchemaVersion(self):
         cur = self.db.cursor()
         res = cur.execute("PRAGMA user_version");

+ 41 - 0
sydent/db/terms.py

@@ -0,0 +1,41 @@
+# -*- coding: utf-8 -*-
+
+# Copyright 2019 The Matrix.org Foundation C.I.C.
+#
+# 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.
+
+class TermsStore:
+    def __init__(self, sydent):
+        self.sydent = sydent
+
+    def getAgreedUrls(self, user_id):
+        cur = self.sydent.db.cursor()
+        res = cur.execute(
+            "select url from accepted_terms_urls "
+            "where user_id = ?", (user_id,),
+        )
+
+        urls = []
+        for row in res.fetchall():
+            urls.append(row[0])
+
+        return urls
+
+    def addAgreedUrls(self, user_id, urls):
+        cur = self.sydent.db.cursor()
+        for u in urls:
+            res = cur.execute(
+                "insert or ignore into accepted_terms_urls (user_id, url) values (?, ?)",
+                (user_id, u),
+            )
+        self.sydent.db.commit()

+ 63 - 0
sydent/http/auth.py

@@ -0,0 +1,63 @@
+# -*- coding: utf-8 -*-
+
+# Copyright 2019 The Matrix.org Foundation C.I.C.
+#
+# 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 logging
+
+import twisted.internet.ssl
+
+from sydent.db.accounts import AccountStore
+from sydent.terms.terms import get_terms
+from sydent.http.servlets import MatrixRestError
+
+
+logger = logging.getLogger(__name__)
+
+def tokenFromRequest(request):
+    token = None
+    # check for Authorization header first
+    authHeader = request.getHeader('Authorization')
+    if authHeader is not None and authHeader.startswith('Bearer '):
+        token = authHeader[len("Bearer "):]
+
+    # no? try access_token query param
+    if token is None and 'access_token' in request.args:
+        token = request.args['access_token'][0]
+
+    return token
+
+def authIfV2(sydent, request, requireTermsAgreed=True):
+    if request.path.startswith('/_matrix/identity/v2'):
+        token = tokenFromRequest(request)
+
+        if token is None:
+            raise MatrixRestError(403, "M_UNAUTHORIZED", "Unauthorized")
+
+        accountStore = AccountStore(sydent)
+
+        account = accountStore.getAccountByToken(token)
+        if account is None:
+            raise MatrixRestError(403, "M_UNAUTHORIZED", "Unauthorized")
+
+        if requireTermsAgreed:
+            terms = get_terms(sydent)
+            if (
+                terms.getMasterVersion() is not None and
+                account.consentVersion != terms.getMasterVersion()
+            ):
+                raise MatrixRestError(403, "M_TERMS_NOT_SIGNED", "Terms not signed")
+
+        return account
+    return None

+ 18 - 0
sydent/http/httpserver.py

@@ -2,6 +2,7 @@
 
 # Copyright 2014 OpenMarket Ltd
 # Copyright 2018 New Vector Ltd
+# Copyright 2019 The Matrix.org Foundation C.I.C.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -98,6 +99,23 @@ class ClientApiHttpServer:
 
         v1.putChild('sign-ed25519', self.sydent.servlets.blindlySignStuffServlet)
 
+        # v2
+        # note v2 loses the /api so goes on 'identity' not 'api'
+        identity.putChild('v2', v2)
+
+        # v2 exclusive APIs
+        v2.putChild('terms', self.sydent.servlets.termsServlet)
+        account = self.sydent.servlets.accountServlet
+        v2.putChild('account', account)
+        account.putChild('register', self.sydent.servlets.registerServlet)
+        account.putChild('logout', self.sydent.servlets.logoutServlet)
+
+        # v2 versions of existing APIs
+        v2.putChild('validate', validate)
+        v2.putChild('pubkey', pubkey)
+        v2.putChild('3pid', threepid)
+        v2.putChild('store-invite', self.sydent.servlets.storeInviteServlet)
+        v2.putChild('sign-ed25519', self.sydent.servlets.blindlySignStuffServlet)
         v2.putChild('lookup', lookup_v2)
         v2.putChild('hash_details', hash_details)
 

+ 39 - 5
sydent/http/servlets/__init__.py

@@ -39,25 +39,32 @@ def get_args(request, required_args):
     """
     Helper function to get arguments for an HTTP request.
     Currently takes args from the top level keys of a json object or
-    www-form-urlencoded for backwards compatability.
+    www-form-urlencoded for backwards compatability on v1 endpoints only.
     Returns a tuple (error, args) where if error is non-null,
     the request is malformed. Otherwise, args contains the
     parameters passed.
     """
+    v1_path = request.path.startswith('/_matrix/identity/v1')
+
     args = None
+    # for v1 paths, only look for json args if content type is json
     if (
-        request.requestHeaders.hasHeader('Content-Type') and
-        request.requestHeaders.getRawHeaders('Content-Type')[0].startswith('application/json')
+        request.method == 'POST' and (
+            not v1_path or (
+                request.requestHeaders.hasHeader('Content-Type') and
+                request.requestHeaders.getRawHeaders('Content-Type')[0].startswith('application/json')
+            )
+        )
     ):
         try:
             args = json.load(request.content)
         except ValueError:
             raise MatrixRestError(400, 'M_BAD_JSON', 'Malformed JSON')
 
-    # If we didn't get anything from that, try the request args
+    # If we didn't get anything from that, and it's a v1 api path, try the request args
     # (riot-web's usage of the ed25519 sign servlet currently involves
     # sending the params in the query string with a json body of 'null')
-    if args is None:
+    if args is None and (v1_path or request.method == 'GET'):
         args = copy.copy(request.args)
         # Twisted supplies everything as an array because it's valid to
         # supply the same params multiple times with www-form-urlencoded
@@ -67,6 +74,8 @@ def get_args(request, required_args):
         for k, v in args.items():
             if isinstance(v, list) and len(v) == 1:
                 args[k] = v[0]
+    elif args is None:
+        args = {}
 
     missing = []
     for a in required_args:
@@ -100,6 +109,31 @@ def jsonwrap(f):
             })
     return inner
 
+def deferjsonwrap(f):
+    def reqDone(resp, request):
+        request.setResponseCode(200)
+        request.write(json.dumps(resp).encode("UTF-8"))
+        request.finish()
+
+    def reqErr(failure, request):
+        if failure.check(MatrixRestError) is not None:
+            request.setResponseCode(failure.value.httpStatus)
+            request.write(json.dumps({'errcode': failure.value.errcode, 'error': failure.value.error}))
+        else:
+            logger.error("Request processing failed: %r, %s", failure, failure.getTraceback())
+            request.setResponseCode(500)
+            request.write(json.dumps({'errcode': 'M_UNKNOWN', 'error': 'Internal Server Error'}))
+        request.finish()
+
+    def inner(*args, **kwargs):
+        request = args[1]
+
+        d = defer.maybeDeferred(f, *args, **kwargs)
+        d.addCallback(reqDone, request)
+        d.addErrback(reqErr, request)
+        return server.NOT_DONE_YET
+    return inner
+
 def send_cors(request):
     request.setHeader(b"Content-Type", b"application/json")
     request.setHeader("Access-Control-Allow-Origin", "*")

+ 50 - 0
sydent/http/servlets/accountservlet.py

@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+
+# Copyright 2019 The Matrix.org Foundation C.I.C.
+#
+# 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 twisted.web.resource import Resource
+
+from sydent.http.servlets import get_args, jsonwrap, send_cors
+from sydent.users.tokens import issueToken
+from sydent.http.auth import authIfV2
+
+
+class AccountServlet(Resource):
+    isLeaf = False
+
+    def __init__(self, syd):
+        Resource.__init__(self)
+        self.sydent = syd
+
+    @jsonwrap
+    def render_GET(self, request):
+        """
+        Return information about the user's account
+        (essentially just a 'who am i')
+        """
+        send_cors(request)
+
+        account = authIfV2(self.sydent, request)
+
+        return {
+            "user_id": account.userId,
+        }
+
+    @jsonwrap
+    def render_OPTIONS(self, request):
+        send_cors(request)
+        request.setResponseCode(200)
+        return {}
+

+ 55 - 0
sydent/http/servlets/logoutservlet.py

@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+
+# Copyright 2019 The Matrix.org Foundation C.I.C.
+#
+# 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 twisted.web.resource import Resource
+
+import logging
+
+from sydent.http.servlets import get_args, jsonwrap, send_cors, MatrixRestError
+from sydent.db.accounts import AccountStore
+from sydent.http.auth import authIfV2, tokenFromRequest
+
+
+logger = logging.getLogger(__name__)
+
+
+class LogoutServlet(Resource):
+    isLeaf = True
+
+    def __init__(self, syd):
+        self.sydent = syd
+
+    @jsonwrap
+    def render_POST(self, request):
+        """
+        Invalidate the given access token
+        """
+        send_cors(request)
+
+        account = authIfV2(self.sydent, request, False)
+
+        token = tokenFromRequest(request)
+
+        accountStore = AccountStore(self.sydent)
+        accountStore.delToken(token)
+        return {}
+
+    @jsonwrap
+    def render_OPTIONS(self, request):
+        send_cors(request)
+        request.setResponseCode(200)
+        return {}
+

+ 69 - 0
sydent/http/servlets/registerservlet.py

@@ -0,0 +1,69 @@
+# -*- coding: utf-8 -*-
+
+# Copyright 2019 The Matrix.org Foundation C.I.C.
+#
+# 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 twisted.web.resource import Resource
+from twisted.internet import defer
+
+import logging
+import json
+import urllib
+
+from sydent.http.servlets import get_args, jsonwrap, deferjsonwrap, send_cors
+from sydent.http.httpclient import FederationHttpClient
+from sydent.users.tokens import issueToken
+
+
+logger = logging.getLogger(__name__)
+
+
+class RegisterServlet(Resource):
+    isLeaf = True
+
+    def __init__(self, syd):
+        self.sydent = syd
+
+    @deferjsonwrap
+    @defer.inlineCallbacks
+    def render_POST(self, request):
+        """
+        Register with the Identity Server
+        """
+        send_cors(request)
+
+        args = get_args(request, ('matrix_server_name', 'access_token'))
+
+        client = FederationHttpClient(self.sydent)
+        result = yield client.get_json(
+            "matrix://%s/_matrix/federation/v1/openid/userinfo?access_token=%s" % (
+                args['matrix_server_name'], urllib.quote(args['access_token']),
+            ),
+        )
+        if 'sub' not in result:
+            raise Exception("Invalid response from Homeserver")
+
+        user_id = result['sub']
+        tok = yield issueToken(self.sydent, user_id)
+
+        defer.returnValue({
+            "access_token": tok,
+        })
+
+    @jsonwrap
+    def render_OPTIONS(self, request):
+        send_cors(request)
+        request.setResponseCode(200)
+        return {}
+

+ 87 - 0
sydent/http/servlets/termsservlet.py

@@ -0,0 +1,87 @@
+# -*- coding: utf-8 -*-
+
+# Copyright 2019 The Matrix.org Foundation C.I.C.
+#
+# 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 twisted.web.resource import Resource
+
+import logging
+import json
+
+from sydent.http.servlets import get_args, jsonwrap, send_cors
+from sydent.terms.terms import get_terms
+from sydent.http.auth import authIfV2
+from sydent.db.terms import TermsStore
+from sydent.db.accounts import AccountStore
+
+
+logger = logging.getLogger(__name__)
+
+
+class TermsServlet(Resource):
+    isLeaf = True
+
+    def __init__(self, syd):
+        self.sydent = syd
+
+    @jsonwrap
+    def render_GET(self, request):
+        """
+        Get the terms that must be agreed to in order to use this service
+        Returns: Object describing the terms that require agreement
+        """
+        send_cors(request)
+
+        terms = get_terms(self.sydent)
+
+        return terms.getForClient()
+
+    @jsonwrap
+    def render_POST(self, request):
+        """
+        Mark a set of terms and conditions as having been agreed to
+        """
+        send_cors(request)
+
+        account = authIfV2(self.sydent, request, False)
+
+        args = get_args(request, ("user_accepts",))
+
+        user_accepts = args["user_accepts"]
+
+        terms = get_terms(self.sydent)
+        unknown_urls = list(set(user_accepts) - terms.getUrlSet())
+        if len(unknown_urls) > 0:
+            return {
+                "errcode": "M_UNKNOWN",
+                "error": "Unrecognised URLs: %s" % (', '.join(unknown_urls),),
+            }
+
+        termsStore = TermsStore(self.sydent)
+        termsStore.addAgreedUrls(account.userId, user_accepts)
+
+        all_accepted_urls = termsStore.getAgreedUrls(account.userId)
+
+        if terms.urlListIsSufficient(all_accepted_urls):
+            accountStore = AccountStore(self.sydent)
+            accountStore.setConsentVersion(account.userId, terms.getMasterVersion())
+
+        return {}
+
+    @jsonwrap
+    def render_OPTIONS(self, request):
+        send_cors(request)
+        request.setResponseCode(200)
+        return {}
+

+ 10 - 0
sydent/sydent.py

@@ -2,6 +2,7 @@
 
 # Copyright 2014 OpenMarket Ltd
 # Copyright 2018 New Vector Ltd
+# Copyright 2019 The Matrix.org Foundation C.I.C.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -35,6 +36,7 @@ from http.httpserver import (
 from http.httpsclient import ReplicationHttpsClient
 from http.servlets.blindlysignstuffservlet import BlindlySignStuffServlet
 from http.servlets.pubkeyservlets import EphemeralPubkeyIsValidServlet, PubkeyIsValidServlet
+from http.servlets.termsservlet import TermsServlet
 from validators.emailvalidator import EmailValidator
 from validators.msisdnvalidator import MsisdnValidator
 from hs_federation.verifier import Verifier
@@ -57,6 +59,9 @@ from http.servlets.replication import ReplicationPushServlet
 from http.servlets.getvalidated3pidservlet import GetValidated3pidServlet
 from http.servlets.store_invite_servlet import StoreInviteServlet
 from http.servlets.v1_servlet import V1Servlet
+from http.servlets.accountservlet import AccountServlet
+from http.servlets.registerservlet import RegisterServlet
+from http.servlets.logoutservlet import LogoutServlet
 from http.servlets.v2_servlet import V2Servlet
 
 from db.valsession import ThreePidValSessionStore
@@ -74,6 +79,7 @@ CONFIG_DEFAULTS = {
         'log.path': '',
         'log.level': 'INFO',
         'pidfile.path': 'sydent.pid',
+        'terms.path': '',
 
         # The following can be added to your local config file to enable prometheus
         # support.
@@ -225,6 +231,10 @@ class Sydent:
         self.servlets.getValidated3pid = GetValidated3pidServlet(self)
         self.servlets.storeInviteServlet = StoreInviteServlet(self)
         self.servlets.blindlySignStuffServlet = BlindlySignStuffServlet(self)
+        self.servlets.termsServlet = TermsServlet(self)
+        self.servlets.accountServlet = AccountServlet(self)
+        self.servlets.registerServlet = RegisterServlet(self)
+        self.servlets.logoutServlet = LogoutServlet(self)
 
         self.threepidBinder = ThreepidBinder(self)
 

+ 0 - 0
sydent/terms/__init__.py


+ 90 - 0
sydent/terms/terms.py

@@ -0,0 +1,90 @@
+# -*- coding: utf-8 -*-
+
+# Copyright 2019 The Matrix.org Foundation C.I.C.
+#
+# 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 logging
+import yaml
+
+
+logger = logging.getLogger(__name__)
+
+
+class Terms(object):
+    def __init__(self, yamlObj):
+        self._rawTerms = yamlObj
+
+    def getMasterVersion(self):
+        return None if self._rawTerms is None else self._rawTerms['master_version']
+
+    def getForClient(self):
+        policies = {}
+        if self._rawTerms is not None:
+            for docName, doc in self._rawTerms['docs'].items():
+                policies[docName] = {
+                    'version': doc['version'],
+                }
+                for langName, lang in doc['langs'].items():
+                    policies[docName][langName] = lang
+        return { 'policies': policies }
+
+    def getUrlSet(self):
+        urls = set()
+        if self._rawTerms is not None:
+            for docName, doc in self._rawTerms['docs'].items():
+                for langName, lang in doc['langs'].items():
+                    urls.add(lang['url'])
+        return urls
+
+    def urlListIsSufficient(self, urls):
+        agreed = set()
+        urlset = set(urls)
+
+        if self._rawTerms is not None:
+            for docName, doc in self._rawTerms['docs'].items():
+                for _, lang in doc['langs'].items():
+                    if lang['url'] in urlset:
+                        agreed.add(docName)
+                        break
+
+        required = set(self._rawTerms['docs'].keys())
+        return agreed == required
+
+def get_terms(sydent):
+    try:
+        termsYaml = None
+        termsPath = sydent.cfg.get('general', 'terms.path')
+        if termsPath == '':
+            return Terms(None)
+
+        with open(termsPath) as fp:
+            termsYaml = yaml.full_load(fp)
+        if 'master_version' not in termsYaml:
+            raise Exception("No master version")
+        if 'docs' not in termsYaml:
+            raise Exception("No 'docs' key in terms")
+        for docName, doc in termsYaml['docs'].items():
+            if 'version' not in doc:
+                raise Exception("'%s' has no version" % (docName,))
+            if 'langs' not in doc:
+                raise Exception("'%s' has no langs" % (docName,))
+            for langKey, lang in doc['langs'].items():
+                if 'name' not in lang:
+                    raise Exception("lang '%s' of doc %s has no name" % (langKey, docName))
+                if 'url' not in lang:
+                    raise Exception("lang '%s' of doc %s has no url" % (langKey, docName))
+
+        return Terms(termsYaml)
+    except Exception:
+        logger.exception("Couldn't read terms file '%s'", sydent.cfg.get('general', 'terms.path'))

+ 0 - 0
sydent/users/__init__.py


+ 20 - 0
sydent/users/accounts.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Copyright 2019 The Matrix.org Foundation C.I.C.
+#
+# 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.
+
+class Account(object):
+    def __init__(self, user_id, creation_ts, consent_version):
+        self.userId = user_id
+        self.creationTs = creation_ts;
+        self.consentVersion = consent_version

+ 31 - 0
sydent/users/tokens.py

@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+# Copyright 2019 The Matrix.org Foundation C.I.C.
+#
+# 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 logging
+import time
+
+from sydent.util.tokenutils import generateAlphanumericTokenOfLength
+from sydent.db.accounts import AccountStore
+
+logger = logging.getLogger(__name__)
+
+def issueToken(sydent, user_id):
+    accountStore = AccountStore(sydent)
+    accountStore.storeAccount(user_id, int(time.time() * 1000), None)
+
+    new_token = generateAlphanumericTokenOfLength(64)
+    accountStore.addToken(user_id, new_token)
+
+    return new_token

+ 23 - 0
sydent/util/users.py

@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+# Copyright 2019 The Matrix.org Foundation C.I.C.
+#
+# 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 logging
+import time
+
+import attr
+
+logger = logging.getLogger(__name__)
+
+def getUserByToken(sydent, token):

+ 20 - 0
terms.sample.yaml

@@ -0,0 +1,20 @@
+master_version: "master_1_1"
+docs:
+  terms_of_service:
+    version: "2.0"
+    langs:
+      en:
+        name: "Terms of Service"
+        url: "https://example.org/somewhere/terms-2.0-en.html"
+      fr:
+        name: "Conditions d'utilisation"
+        url: "https://example.org/somewhere/terms-2.0-fr.html"
+  privacy_policy:
+    version: "1.2"
+    langs:
+      en:
+        name: "Privacy Policy"
+        url: "https://example.org/somewhere/privacy-1.2-en.html"
+      fr:
+        name: "Politique de confidentialité"
+        url: "https://example.org/somewhere/privacy-1.2-fr.html"