diff test/rest_common.py @ 5878:1b57d8f3eb97

Add rudimentery experiment JSON Web Token (jwt) support issue2551061: Add rudimentary experimental support for JSON Web Tokens to allow delegation of limited access rights to third parties. See doc/rest.txt for details and intent.
author John Rouillard <rouilj@ieee.org>
date Fri, 27 Sep 2019 20:38:31 -0400
parents 6630baff5f68
children 94a7669677ae
line wrap: on
line diff
--- a/test/rest_common.py	Mon Sep 09 19:39:08 2019 +0200
+++ b/test/rest_common.py	Fri Sep 27 20:38:31 2019 -0400
@@ -5,7 +5,27 @@
 import cgi
 
 from time import sleep
-from datetime import datetime
+from datetime import datetime, timedelta
+
+try:
+    from datetime import timezone
+    myutc = timezone.utc
+except ImportError:
+    # python 2
+    from datetime import tzinfo
+    ZERO = timedelta(0)
+    class UTC(tzinfo):
+        """UTC"""
+        def utcoffset(self, dt):
+            return ZERO
+        
+        def tzname(self, dt):
+            return "UTC"
+        
+        def dst(self, dt):
+            return ZERO
+
+    myutc = UTC()
 
 from roundup.cgi.exceptions import *
 from roundup.hyperdb import HyperdbValueError
@@ -27,6 +47,17 @@
 from io import BytesIO
 import json
 
+from copy import copy
+
+try:
+    import jwt
+    skip_jwt = lambda func, *args, **kwargs: func
+except ImportError:
+    from .pytest_patcher import mark_class
+    jwt=None 
+    skip_jwt = mark_class(pytest.mark.skip(
+        reason='Skipping JWT tests: jwt library not available'))
+    
 NEEDS_INSTANCE = 1
 
 
@@ -38,7 +69,10 @@
     def setUp(self):
         self.dirname = '_test_rest'
         # set up and open a tracker
-        self.instance = setupTracker(self.dirname, self.backend)
+        # Set optimize=True as code under test (Client.main()::determine_user)
+        # will close and re-open the database on user changes. This wipes
+        # out additions to the schema needed for testing.
+        self.instance = setupTracker(self.dirname, self.backend, optimize=True)
 
         # open the database
         self.db = self.instance.open('admin')
@@ -60,6 +94,8 @@
             roles='User'
         )
 
+        self.db.user.set('1', address="admin@admin.com")
+        self.db.user.set('2', address="anon@admin.com")
         self.db.commit()
         self.db.close()
         self.db = self.instance.open('joe')
@@ -67,6 +103,91 @@
         p = self.db.security.addPermission(name='Retire', klass='issue')
         self.db.security.addPermissionToRole('User', p)
 
+        # add set of roles for testing jwt's.
+        self.db.security.addRole(name="User:email",
+                        description="allow email by jwt")
+        # allow the jwt to access everybody's email addresses.
+        # this makes it easier to differentiate between User and
+        # User:email roles by accessing the /rest/data/user
+        # endpoint
+        jwt_perms = self.db.security.addPermission(name='View',
+                       klass='user',
+                       properties=('id', 'realname', 'address', 'username'),
+                       description="Allow jwt access to email",
+                       props_only=False)
+        self.db.security.addPermissionToRole("User:email", jwt_perms)
+
+        if jwt:
+            # must be 32 chars in length minimum (I think this is at least
+            # 256 bits of data)
+
+            secret = "TestingTheJwtSecretTestingTheJwtSecret"
+            self.db.config['WEB_JWT_SECRET'] = secret
+
+            # generate all timestamps in UTC.
+            base_datetime = datetime(1970,1,1, tzinfo=myutc)
+
+            # A UTC timestamp for now.
+            dt = datetime.now(myutc)
+            now_ts = int((dt - base_datetime).total_seconds())
+
+            # one good for a minute
+            dt = dt + timedelta(seconds=60)
+            plus1min_ts = int((dt - base_datetime).total_seconds())
+
+            # one that expired a minute ago
+            dt = dt - timedelta(seconds=120)
+            expired_ts = int((dt - base_datetime).total_seconds())
+
+            # claims match what cgi/client.py::determine_user
+            # is looking for
+            claim= { 'sub': self.db.getuid(),
+                     'iss': self.db.config.TRACKER_WEB,
+                     'aud': self.db.config.TRACKER_WEB,
+                     'roles': [ 'User' ],
+                     'iat': now_ts,
+                     'exp': plus1min_ts,
+            }
+            
+            self.jwt = {}
+            self.claim = {}
+            # generate invalid claim with expired timestamp
+            self.claim['expired'] = copy(claim)
+            self.claim['expired']['exp'] = expired_ts
+            self.jwt['expired'] = b2s(jwt.encode(self.claim['expired'], secret,
+                                             algorithm='HS256'))
+         
+            # generate valid claim with user role
+            self.claim['user'] = copy(claim)
+            self.claim['user']['exp'] = plus1min_ts
+            self.jwt['user'] = b2s(jwt.encode(self.claim['user'], secret,
+                                          algorithm='HS256'))
+            # generate invalid claim bad issuer
+            self.claim['badiss'] = copy(claim)
+            self.claim['badiss']['iss'] = "http://someissuer/bugs"
+            self.jwt['badiss'] = b2s(jwt.encode(self.claim['badiss'], secret,
+                                          algorithm='HS256'))
+            # generate invalid claim bad aud(ience)
+            self.claim['badaud'] = copy(claim)
+            self.claim['badaud']['aud'] = "http://someaudience/bugs"
+            self.jwt['badaud'] = b2s(jwt.encode(self.claim['badaud'], secret,
+                                          algorithm='HS256'))            
+            # generate invalid claim bad sub(ject)
+            self.claim['badsub'] = copy(claim)
+            self.claim['badsub']['sub'] = str("99")
+            self.jwt['badsub'] = b2s(jwt.encode(self.claim['badsub'], secret,
+                                          algorithm='HS256'))            
+            # generate invalid claim bad roles
+            self.claim['badroles'] = copy(claim)
+            self.claim['badroles']['roles'] = [ "badrole1", "badrole2" ]
+            self.jwt['badroles'] = b2s(jwt.encode(self.claim['badroles'], secret,
+                                          algorithm='HS256'))            
+            # generate valid claim with limited user:email role
+            self.claim['user:email'] = copy(claim)
+            self.claim['user:email']['roles'] = [ "user:email" ]
+            self.jwt['user:email'] = b2s(jwt.encode(self.claim['user:email'], secret,
+                                          algorithm='HS256'))
+
         self.db.tx_Source = 'web'
 
         self.db.issue.addprop(tx_Source=hyperdb.String())
@@ -2758,6 +2879,388 @@
         self.assertEqual(len(results['attributes']['nosy']), 0)
         self.assertListEqual(results['attributes']['nosy'], [])
 
+    @skip_jwt
+    def test_expired_jwt(self):
+        # self.dummy_client.main() closes database, so
+        # we need a new test with setup called for each test
+        out = []
+        def wh(s):
+            out.append(s)
+
+        secret = self.db.config.WEB_JWT_SECRET
+
+        # verify library and tokens are correct
+        self.assertRaises(jwt.exceptions.InvalidTokenError,
+                          jwt.decode, self.jwt['expired'],
+                          secret,  algorithms=['HS256'],
+                          audience=self.db.config.TRACKER_WEB,
+                          issuer=self.db.config.TRACKER_WEB)
+
+        result = jwt.decode(self.jwt['user'],
+                            secret,  algorithms=['HS256'],
+                            audience=self.db.config.TRACKER_WEB,
+                            issuer=self.db.config.TRACKER_WEB)
+        self.assertEqual(self.claim['user'],result)
+
+        result = jwt.decode(self.jwt['user:email'],
+                            secret,  algorithms=['HS256'],
+                            audience=self.db.config.TRACKER_WEB,
+                            issuer=self.db.config.TRACKER_WEB)
+        self.assertEqual(self.claim['user:email'],result)
+
+        # set environment for all jwt tests
+        env = {
+            'PATH_INFO': 'rest/data/user',
+            'HTTP_HOST': 'localhost',
+            'TRACKER_NAME': 'rounduptest',
+            "REQUEST_METHOD": "GET"
+        }
+        self.dummy_client = client.Client(self.instance, MockNull(), env,
+                                          [], None)
+        self.dummy_client.db = self.db
+        self.dummy_client.request.headers.get = self.get_header
+        self.empty_form = cgi.FieldStorage()
+        self.terse_form = cgi.FieldStorage()
+        self.terse_form.list = [
+            cgi.MiniFieldStorage('@verbose', '0'),
+        ]
+        self.dummy_client.form = cgi.FieldStorage()
+        self.dummy_client.form.list = [
+            cgi.MiniFieldStorage('@fields', 'username,address'),
+        ]
+        # accumulate json output for further analysis
+        self.dummy_client.write = wh
+
+        # set up for expired token first
+        env['HTTP_AUTHORIZATION'] = 'bearer %s'%self.jwt['expired']
+        self.dummy_client.main()
+
+        # this will be the admin still as auth failed
+        self.assertEqual('1', self.db.getuid())
+        self.assertEqual(out[0], b'Invalid Login - Signature has expired')
+        del(out[0])
+
+
+    @skip_jwt
+    def test_user_jwt(self):
+        # self.dummy_client.main() closes database, so
+        # we need a new test with setup called for each test
+        out = []
+        def wh(s):
+            out.append(s)
+
+        secret = self.db.config.WEB_JWT_SECRET
+
+        # verify library and tokens are correct
+        self.assertRaises(jwt.exceptions.InvalidTokenError,
+                          jwt.decode, self.jwt['expired'],
+                          secret,  algorithms=['HS256'],
+                          audience=self.db.config.TRACKER_WEB,
+                          issuer=self.db.config.TRACKER_WEB)
+
+        result = jwt.decode(self.jwt['user'],
+                            secret,  algorithms=['HS256'],
+                            audience=self.db.config.TRACKER_WEB,
+                            issuer=self.db.config.TRACKER_WEB)
+        self.assertEqual(self.claim['user'],result)
+
+        result = jwt.decode(self.jwt['user:email'],
+                            secret,  algorithms=['HS256'],
+                            audience=self.db.config.TRACKER_WEB,
+                            issuer=self.db.config.TRACKER_WEB)
+        self.assertEqual(self.claim['user:email'],result)
+
+        # set environment for all jwt tests
+        env = {
+            'PATH_INFO': 'rest/data/user',
+            'HTTP_HOST': 'localhost',
+            'TRACKER_NAME': 'rounduptest',
+            "REQUEST_METHOD": "GET"
+        }
+        self.dummy_client = client.Client(self.instance, MockNull(), env,
+                                          [], None)
+        self.dummy_client.db = self.db
+        self.dummy_client.request.headers.get = self.get_header
+        self.empty_form = cgi.FieldStorage()
+        self.terse_form = cgi.FieldStorage()
+        self.terse_form.list = [
+            cgi.MiniFieldStorage('@verbose', '0'),
+        ]
+        self.dummy_client.form = cgi.FieldStorage()
+        self.dummy_client.form.list = [
+            cgi.MiniFieldStorage('@fields', 'username,address'),
+        ]
+        # accumulate json output for further analysis
+        self.dummy_client.write = wh
+
+        # set up for standard user role token
+        env['HTTP_AUTHORIZATION'] = 'bearer %s'%self.jwt['user']
+        self.dummy_client.main()
+        print(out[0])
+        json_dict = json.loads(b2s(out[0]))
+        print(json_dict)
+        # user will be joe id 3 as auth works
+        self.assertTrue('3', self.db.getuid())
+        # there should be three items in the collection admin, anon, and joe
+        self.assertEqual(3, len(json_dict['data']['collection']))
+        # since this token has no access to email addresses, only joe
+        # should have email addresses. Order is by id by default.
+        self.assertFalse('address' in json_dict['data']['collection'][0])
+        self.assertFalse('address' in json_dict['data']['collection'][1])
+        self.assertTrue('address' in json_dict['data']['collection'][2])
+        del(out[0])
+        self.db.setCurrentUser('admin')
+
+    @skip_jwt
+    def test_user_email_jwt(self):
+        # self.dummy_client.main() closes database, so
+        # we need a new test with setup called for each test
+        out = []
+        def wh(s):
+            out.append(s)
+
+        secret = self.db.config.WEB_JWT_SECRET
+
+        # verify library and tokens are correct
+        self.assertRaises(jwt.exceptions.InvalidTokenError,
+                          jwt.decode, self.jwt['expired'],
+                          secret,  algorithms=['HS256'],
+                          audience=self.db.config.TRACKER_WEB,
+                          issuer=self.db.config.TRACKER_WEB)
+
+        result = jwt.decode(self.jwt['user'],
+                            secret,  algorithms=['HS256'],
+                            audience=self.db.config.TRACKER_WEB,
+                            issuer=self.db.config.TRACKER_WEB)
+        self.assertEqual(self.claim['user'],result)
+
+        result = jwt.decode(self.jwt['user:email'],
+                            secret,  algorithms=['HS256'],
+                            audience=self.db.config.TRACKER_WEB,
+                            issuer=self.db.config.TRACKER_WEB)
+        self.assertEqual(self.claim['user:email'],result)
+
+        # set environment for all jwt tests
+        env = {
+            'PATH_INFO': 'rest/data/user',
+            'HTTP_HOST': 'localhost',
+            'TRACKER_NAME': 'rounduptest',
+            "REQUEST_METHOD": "GET"
+        }
+        self.dummy_client = client.Client(self.instance, MockNull(), env,
+                                          [], None)
+        self.dummy_client.db = self.db
+        self.dummy_client.request.headers.get = self.get_header
+        self.empty_form = cgi.FieldStorage()
+        self.terse_form = cgi.FieldStorage()
+        self.terse_form.list = [
+            cgi.MiniFieldStorage('@verbose', '0'),
+        ]
+        self.dummy_client.form = cgi.FieldStorage()
+        self.dummy_client.form.list = [
+            cgi.MiniFieldStorage('@fields', 'username,address'),
+        ]
+        # accumulate json output for further analysis
+        self.dummy_client.write = wh
+
+        # set up for limited user:email role token
+        env['HTTP_AUTHORIZATION'] = 'bearer %s'%self.jwt['user:email']
+        self.dummy_client.main()
+        json_dict = json.loads(b2s(out[0]))
+        print(json_dict)
+        # user will be joe id 3 as auth works
+        self.assertTrue('3', self.db.getuid())
+        # there should be three items in the collection admin, anon, and joe
+        self.assertEqual(3, len(json_dict['data']['collection']))
+        # However this token has access to email addresses, so all three
+        # should have email addresses. Order is by id by default.
+        self.assertTrue('address' in json_dict['data']['collection'][0])
+        self.assertTrue('address' in json_dict['data']['collection'][1])
+        self.assertTrue('address' in json_dict['data']['collection'][2])
+
+
+    @skip_jwt
+    def test_disabled_jwt(self):
+        # self.dummy_client.main() closes database, so
+        # we need a new test with setup called for each test
+        out = []
+        def wh(s):
+            out.append(s)
+
+        # set environment for all jwt tests
+        env = {
+            'PATH_INFO': 'rest/data/user',
+            'HTTP_HOST': 'localhost',
+            'TRACKER_NAME': 'rounduptest',
+            "REQUEST_METHOD": "GET"
+        }
+        self.dummy_client = client.Client(self.instance, MockNull(), env,
+                                          [], None)
+        self.dummy_client.db = self.db
+        self.dummy_client.request.headers.get = self.get_header
+        self.empty_form = cgi.FieldStorage()
+        self.terse_form = cgi.FieldStorage()
+        self.terse_form.list = [
+            cgi.MiniFieldStorage('@verbose', '0'),
+        ]
+        self.dummy_client.form = cgi.FieldStorage()
+        self.dummy_client.form.list = [
+            cgi.MiniFieldStorage('@fields', 'username,address'),
+        ]
+        # accumulate json output for further analysis
+        self.dummy_client.write = wh
+        # disable jwt validation by making secret too short
+        # use the default value for this in configure.py.
+        self.db.config['WEB_JWT_SECRET'] = "disabled"
+        env['HTTP_AUTHORIZATION'] = 'bearer %s'%self.jwt['user']
+        self.dummy_client.main()
+        # user will be 1 as there is no auth
+        self.assertTrue('1', self.db.getuid())
+        self.assertEqual(out[0], b'Invalid Login - Support for jwt disabled by admin.')
+
+    @skip_jwt
+    def test_bad_issue_jwt(self):
+        # self.dummy_client.main() closes database, so
+        # we need a new test with setup called for each test
+        out = []
+        def wh(s):
+            out.append(s)
+
+        # set environment for all jwt tests
+        env = {
+            'PATH_INFO': 'rest/data/user',
+            'HTTP_HOST': 'localhost',
+            'TRACKER_NAME': 'rounduptest',
+            "REQUEST_METHOD": "GET"
+        }
+        self.dummy_client = client.Client(self.instance, MockNull(), env,
+                                          [], None)
+        self.dummy_client.db = self.db
+        self.dummy_client.request.headers.get = self.get_header
+        self.empty_form = cgi.FieldStorage()
+        self.terse_form = cgi.FieldStorage()
+        self.terse_form.list = [
+            cgi.MiniFieldStorage('@verbose', '0'),
+        ]
+        self.dummy_client.form = cgi.FieldStorage()
+        self.dummy_client.form.list = [
+            cgi.MiniFieldStorage('@fields', 'username,address'),
+        ]
+        # accumulate json output for further analysis
+        self.dummy_client.write = wh
+        env['HTTP_AUTHORIZATION'] = 'bearer %s'%self.jwt['badiss']
+        self.dummy_client.main()
+        # user will be 1 as there is no auth
+        self.assertTrue('1', self.db.getuid())
+        self.assertEqual(out[0], b'Invalid Login - Invalid issuer')
+
+    @skip_jwt
+    def test_bad_audience_jwt(self):
+        # self.dummy_client.main() closes database, so
+        # we need a new test with setup called for each test
+        out = []
+        def wh(s):
+            out.append(s)
+
+        # set environment for all jwt tests
+        env = {
+            'PATH_INFO': 'rest/data/user',
+            'HTTP_HOST': 'localhost',
+            'TRACKER_NAME': 'rounduptest',
+            "REQUEST_METHOD": "GET"
+        }
+        self.dummy_client = client.Client(self.instance, MockNull(), env,
+                                          [], None)
+        self.dummy_client.db = self.db
+        self.dummy_client.request.headers.get = self.get_header
+        self.empty_form = cgi.FieldStorage()
+        self.terse_form = cgi.FieldStorage()
+        self.terse_form.list = [
+            cgi.MiniFieldStorage('@verbose', '0'),
+        ]
+        self.dummy_client.form = cgi.FieldStorage()
+        self.dummy_client.form.list = [
+            cgi.MiniFieldStorage('@fields', 'username,address'),
+        ]
+        # accumulate json output for further analysis
+        self.dummy_client.write = wh
+        env['HTTP_AUTHORIZATION'] = 'bearer %s'%self.jwt['badaud']
+        self.dummy_client.main()
+        # user will be 1 as there is no auth
+        self.assertTrue('1', self.db.getuid())
+        self.assertEqual(out[0], b'Invalid Login - Invalid audience')
+
+    @skip_jwt
+    def test_bad_roles_jwt(self):
+        # self.dummy_client.main() closes database, so
+        # we need a new test with setup called for each test
+        out = []
+        def wh(s):
+            out.append(s)
+
+        # set environment for all jwt tests
+        env = {
+            'PATH_INFO': 'rest/data/user',
+            'HTTP_HOST': 'localhost',
+            'TRACKER_NAME': 'rounduptest',
+            "REQUEST_METHOD": "GET"
+        }
+        self.dummy_client = client.Client(self.instance, MockNull(), env,
+                                          [], None)
+        self.dummy_client.db = self.db
+        self.dummy_client.request.headers.get = self.get_header
+        self.empty_form = cgi.FieldStorage()
+        self.terse_form = cgi.FieldStorage()
+        self.terse_form.list = [
+            cgi.MiniFieldStorage('@verbose', '0'),
+        ]
+        self.dummy_client.form = cgi.FieldStorage()
+        self.dummy_client.form.list = [
+            cgi.MiniFieldStorage('@fields', 'username,address'),
+        ]
+        # accumulate json output for further analysis
+        self.dummy_client.write = wh
+        env['HTTP_AUTHORIZATION'] = 'bearer %s'%self.jwt['badroles']
+        self.dummy_client.main()
+        # user will be 1 as there is no auth
+        self.assertTrue('1', self.db.getuid())
+        self.assertEqual(out[0], b'Invalid Login - Token roles are invalid.')
+
+    @skip_jwt
+    def test_bad_subject_jwt(self):
+        # self.dummy_client.main() closes database, so
+        # we need a new test with setup called for each test
+        out = []
+        def wh(s):
+            out.append(s)
+
+        # set environment for all jwt tests
+        env = {
+            'PATH_INFO': 'rest/data/user',
+            'HTTP_HOST': 'localhost',
+            'TRACKER_NAME': 'rounduptest',
+            "REQUEST_METHOD": "GET"
+        }
+        self.dummy_client = client.Client(self.instance, MockNull(), env,
+                                          [], None)
+        self.dummy_client.db = self.db
+        self.dummy_client.request.headers.get = self.get_header
+        self.empty_form = cgi.FieldStorage()
+        self.terse_form = cgi.FieldStorage()
+        self.terse_form.list = [
+            cgi.MiniFieldStorage('@verbose', '0'),
+        ]
+        self.dummy_client.form = cgi.FieldStorage()
+        self.dummy_client.form.list = [
+            cgi.MiniFieldStorage('@fields', 'username,address'),
+        ]
+        # accumulate json output for further analysis
+        self.dummy_client.write = wh
+        env['HTTP_AUTHORIZATION'] = 'bearer %s'%self.jwt['badsub']
+        self.dummy_client.main()
+        # user will be 1 as there is no auth
+        self.assertTrue('1', self.db.getuid())
+        self.assertEqual(out[0], b'Invalid Login - Token subject is invalid.')
 
 def get_obj(path, id):
     return {

Roundup Issue Tracker: http://roundup-tracker.org/