comparison 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
comparison
equal deleted inserted replaced
5877:08b241c9fea4 5878:1b57d8f3eb97
3 import shutil 3 import shutil
4 import errno 4 import errno
5 import cgi 5 import cgi
6 6
7 from time import sleep 7 from time import sleep
8 from datetime import datetime 8 from datetime import datetime, timedelta
9
10 try:
11 from datetime import timezone
12 myutc = timezone.utc
13 except ImportError:
14 # python 2
15 from datetime import tzinfo
16 ZERO = timedelta(0)
17 class UTC(tzinfo):
18 """UTC"""
19 def utcoffset(self, dt):
20 return ZERO
21
22 def tzname(self, dt):
23 return "UTC"
24
25 def dst(self, dt):
26 return ZERO
27
28 myutc = UTC()
9 29
10 from roundup.cgi.exceptions import * 30 from roundup.cgi.exceptions import *
11 from roundup.hyperdb import HyperdbValueError 31 from roundup.hyperdb import HyperdbValueError
12 from roundup.exceptions import * 32 from roundup.exceptions import *
13 from roundup import password, hyperdb 33 from roundup import password, hyperdb
25 from .mocknull import MockNull 45 from .mocknull import MockNull
26 46
27 from io import BytesIO 47 from io import BytesIO
28 import json 48 import json
29 49
50 from copy import copy
51
52 try:
53 import jwt
54 skip_jwt = lambda func, *args, **kwargs: func
55 except ImportError:
56 from .pytest_patcher import mark_class
57 jwt=None
58 skip_jwt = mark_class(pytest.mark.skip(
59 reason='Skipping JWT tests: jwt library not available'))
60
30 NEEDS_INSTANCE = 1 61 NEEDS_INSTANCE = 1
31 62
32 63
33 class TestCase(): 64 class TestCase():
34 65
36 url_pfx = 'http://tracker.example/cgi-bin/roundup.cgi/bugs/rest/data/' 67 url_pfx = 'http://tracker.example/cgi-bin/roundup.cgi/bugs/rest/data/'
37 68
38 def setUp(self): 69 def setUp(self):
39 self.dirname = '_test_rest' 70 self.dirname = '_test_rest'
40 # set up and open a tracker 71 # set up and open a tracker
41 self.instance = setupTracker(self.dirname, self.backend) 72 # Set optimize=True as code under test (Client.main()::determine_user)
73 # will close and re-open the database on user changes. This wipes
74 # out additions to the schema needed for testing.
75 self.instance = setupTracker(self.dirname, self.backend, optimize=True)
42 76
43 # open the database 77 # open the database
44 self.db = self.instance.open('admin') 78 self.db = self.instance.open('admin')
45 79
46 # Create the Otk db. 80 # Create the Otk db.
58 address='random@home.org', 92 address='random@home.org',
59 realname='Joe Random', 93 realname='Joe Random',
60 roles='User' 94 roles='User'
61 ) 95 )
62 96
97 self.db.user.set('1', address="admin@admin.com")
98 self.db.user.set('2', address="anon@admin.com")
63 self.db.commit() 99 self.db.commit()
64 self.db.close() 100 self.db.close()
65 self.db = self.instance.open('joe') 101 self.db = self.instance.open('joe')
66 # Allow joe to retire 102 # Allow joe to retire
67 p = self.db.security.addPermission(name='Retire', klass='issue') 103 p = self.db.security.addPermission(name='Retire', klass='issue')
68 self.db.security.addPermissionToRole('User', p) 104 self.db.security.addPermissionToRole('User', p)
105
106 # add set of roles for testing jwt's.
107 self.db.security.addRole(name="User:email",
108 description="allow email by jwt")
109 # allow the jwt to access everybody's email addresses.
110 # this makes it easier to differentiate between User and
111 # User:email roles by accessing the /rest/data/user
112 # endpoint
113 jwt_perms = self.db.security.addPermission(name='View',
114 klass='user',
115 properties=('id', 'realname', 'address', 'username'),
116 description="Allow jwt access to email",
117 props_only=False)
118 self.db.security.addPermissionToRole("User:email", jwt_perms)
119
120 if jwt:
121 # must be 32 chars in length minimum (I think this is at least
122 # 256 bits of data)
123
124 secret = "TestingTheJwtSecretTestingTheJwtSecret"
125 self.db.config['WEB_JWT_SECRET'] = secret
126
127 # generate all timestamps in UTC.
128 base_datetime = datetime(1970,1,1, tzinfo=myutc)
129
130 # A UTC timestamp for now.
131 dt = datetime.now(myutc)
132 now_ts = int((dt - base_datetime).total_seconds())
133
134 # one good for a minute
135 dt = dt + timedelta(seconds=60)
136 plus1min_ts = int((dt - base_datetime).total_seconds())
137
138 # one that expired a minute ago
139 dt = dt - timedelta(seconds=120)
140 expired_ts = int((dt - base_datetime).total_seconds())
141
142 # claims match what cgi/client.py::determine_user
143 # is looking for
144 claim= { 'sub': self.db.getuid(),
145 'iss': self.db.config.TRACKER_WEB,
146 'aud': self.db.config.TRACKER_WEB,
147 'roles': [ 'User' ],
148 'iat': now_ts,
149 'exp': plus1min_ts,
150 }
151
152 self.jwt = {}
153 self.claim = {}
154 # generate invalid claim with expired timestamp
155 self.claim['expired'] = copy(claim)
156 self.claim['expired']['exp'] = expired_ts
157 self.jwt['expired'] = b2s(jwt.encode(self.claim['expired'], secret,
158 algorithm='HS256'))
159
160 # generate valid claim with user role
161 self.claim['user'] = copy(claim)
162 self.claim['user']['exp'] = plus1min_ts
163 self.jwt['user'] = b2s(jwt.encode(self.claim['user'], secret,
164 algorithm='HS256'))
165 # generate invalid claim bad issuer
166 self.claim['badiss'] = copy(claim)
167 self.claim['badiss']['iss'] = "http://someissuer/bugs"
168 self.jwt['badiss'] = b2s(jwt.encode(self.claim['badiss'], secret,
169 algorithm='HS256'))
170 # generate invalid claim bad aud(ience)
171 self.claim['badaud'] = copy(claim)
172 self.claim['badaud']['aud'] = "http://someaudience/bugs"
173 self.jwt['badaud'] = b2s(jwt.encode(self.claim['badaud'], secret,
174 algorithm='HS256'))
175 # generate invalid claim bad sub(ject)
176 self.claim['badsub'] = copy(claim)
177 self.claim['badsub']['sub'] = str("99")
178 self.jwt['badsub'] = b2s(jwt.encode(self.claim['badsub'], secret,
179 algorithm='HS256'))
180 # generate invalid claim bad roles
181 self.claim['badroles'] = copy(claim)
182 self.claim['badroles']['roles'] = [ "badrole1", "badrole2" ]
183 self.jwt['badroles'] = b2s(jwt.encode(self.claim['badroles'], secret,
184 algorithm='HS256'))
185 # generate valid claim with limited user:email role
186 self.claim['user:email'] = copy(claim)
187 self.claim['user:email']['roles'] = [ "user:email" ]
188 self.jwt['user:email'] = b2s(jwt.encode(self.claim['user:email'], secret,
189 algorithm='HS256'))
69 190
70 self.db.tx_Source = 'web' 191 self.db.tx_Source = 'web'
71 192
72 self.db.issue.addprop(tx_Source=hyperdb.String()) 193 self.db.issue.addprop(tx_Source=hyperdb.String())
73 self.db.issue.addprop(anint=hyperdb.Integer()) 194 self.db.issue.addprop(anint=hyperdb.Integer())
2756 results = results['data'] 2877 results = results['data']
2757 self.assertEqual(self.dummy_client.response_code, 200) 2878 self.assertEqual(self.dummy_client.response_code, 200)
2758 self.assertEqual(len(results['attributes']['nosy']), 0) 2879 self.assertEqual(len(results['attributes']['nosy']), 0)
2759 self.assertListEqual(results['attributes']['nosy'], []) 2880 self.assertListEqual(results['attributes']['nosy'], [])
2760 2881
2882 @skip_jwt
2883 def test_expired_jwt(self):
2884 # self.dummy_client.main() closes database, so
2885 # we need a new test with setup called for each test
2886 out = []
2887 def wh(s):
2888 out.append(s)
2889
2890 secret = self.db.config.WEB_JWT_SECRET
2891
2892 # verify library and tokens are correct
2893 self.assertRaises(jwt.exceptions.InvalidTokenError,
2894 jwt.decode, self.jwt['expired'],
2895 secret, algorithms=['HS256'],
2896 audience=self.db.config.TRACKER_WEB,
2897 issuer=self.db.config.TRACKER_WEB)
2898
2899 result = jwt.decode(self.jwt['user'],
2900 secret, algorithms=['HS256'],
2901 audience=self.db.config.TRACKER_WEB,
2902 issuer=self.db.config.TRACKER_WEB)
2903 self.assertEqual(self.claim['user'],result)
2904
2905 result = jwt.decode(self.jwt['user:email'],
2906 secret, algorithms=['HS256'],
2907 audience=self.db.config.TRACKER_WEB,
2908 issuer=self.db.config.TRACKER_WEB)
2909 self.assertEqual(self.claim['user:email'],result)
2910
2911 # set environment for all jwt tests
2912 env = {
2913 'PATH_INFO': 'rest/data/user',
2914 'HTTP_HOST': 'localhost',
2915 'TRACKER_NAME': 'rounduptest',
2916 "REQUEST_METHOD": "GET"
2917 }
2918 self.dummy_client = client.Client(self.instance, MockNull(), env,
2919 [], None)
2920 self.dummy_client.db = self.db
2921 self.dummy_client.request.headers.get = self.get_header
2922 self.empty_form = cgi.FieldStorage()
2923 self.terse_form = cgi.FieldStorage()
2924 self.terse_form.list = [
2925 cgi.MiniFieldStorage('@verbose', '0'),
2926 ]
2927 self.dummy_client.form = cgi.FieldStorage()
2928 self.dummy_client.form.list = [
2929 cgi.MiniFieldStorage('@fields', 'username,address'),
2930 ]
2931 # accumulate json output for further analysis
2932 self.dummy_client.write = wh
2933
2934 # set up for expired token first
2935 env['HTTP_AUTHORIZATION'] = 'bearer %s'%self.jwt['expired']
2936 self.dummy_client.main()
2937
2938 # this will be the admin still as auth failed
2939 self.assertEqual('1', self.db.getuid())
2940 self.assertEqual(out[0], b'Invalid Login - Signature has expired')
2941 del(out[0])
2942
2943
2944 @skip_jwt
2945 def test_user_jwt(self):
2946 # self.dummy_client.main() closes database, so
2947 # we need a new test with setup called for each test
2948 out = []
2949 def wh(s):
2950 out.append(s)
2951
2952 secret = self.db.config.WEB_JWT_SECRET
2953
2954 # verify library and tokens are correct
2955 self.assertRaises(jwt.exceptions.InvalidTokenError,
2956 jwt.decode, self.jwt['expired'],
2957 secret, algorithms=['HS256'],
2958 audience=self.db.config.TRACKER_WEB,
2959 issuer=self.db.config.TRACKER_WEB)
2960
2961 result = jwt.decode(self.jwt['user'],
2962 secret, algorithms=['HS256'],
2963 audience=self.db.config.TRACKER_WEB,
2964 issuer=self.db.config.TRACKER_WEB)
2965 self.assertEqual(self.claim['user'],result)
2966
2967 result = jwt.decode(self.jwt['user:email'],
2968 secret, algorithms=['HS256'],
2969 audience=self.db.config.TRACKER_WEB,
2970 issuer=self.db.config.TRACKER_WEB)
2971 self.assertEqual(self.claim['user:email'],result)
2972
2973 # set environment for all jwt tests
2974 env = {
2975 'PATH_INFO': 'rest/data/user',
2976 'HTTP_HOST': 'localhost',
2977 'TRACKER_NAME': 'rounduptest',
2978 "REQUEST_METHOD": "GET"
2979 }
2980 self.dummy_client = client.Client(self.instance, MockNull(), env,
2981 [], None)
2982 self.dummy_client.db = self.db
2983 self.dummy_client.request.headers.get = self.get_header
2984 self.empty_form = cgi.FieldStorage()
2985 self.terse_form = cgi.FieldStorage()
2986 self.terse_form.list = [
2987 cgi.MiniFieldStorage('@verbose', '0'),
2988 ]
2989 self.dummy_client.form = cgi.FieldStorage()
2990 self.dummy_client.form.list = [
2991 cgi.MiniFieldStorage('@fields', 'username,address'),
2992 ]
2993 # accumulate json output for further analysis
2994 self.dummy_client.write = wh
2995
2996 # set up for standard user role token
2997 env['HTTP_AUTHORIZATION'] = 'bearer %s'%self.jwt['user']
2998 self.dummy_client.main()
2999 print(out[0])
3000 json_dict = json.loads(b2s(out[0]))
3001 print(json_dict)
3002 # user will be joe id 3 as auth works
3003 self.assertTrue('3', self.db.getuid())
3004 # there should be three items in the collection admin, anon, and joe
3005 self.assertEqual(3, len(json_dict['data']['collection']))
3006 # since this token has no access to email addresses, only joe
3007 # should have email addresses. Order is by id by default.
3008 self.assertFalse('address' in json_dict['data']['collection'][0])
3009 self.assertFalse('address' in json_dict['data']['collection'][1])
3010 self.assertTrue('address' in json_dict['data']['collection'][2])
3011 del(out[0])
3012 self.db.setCurrentUser('admin')
3013
3014 @skip_jwt
3015 def test_user_email_jwt(self):
3016 # self.dummy_client.main() closes database, so
3017 # we need a new test with setup called for each test
3018 out = []
3019 def wh(s):
3020 out.append(s)
3021
3022 secret = self.db.config.WEB_JWT_SECRET
3023
3024 # verify library and tokens are correct
3025 self.assertRaises(jwt.exceptions.InvalidTokenError,
3026 jwt.decode, self.jwt['expired'],
3027 secret, algorithms=['HS256'],
3028 audience=self.db.config.TRACKER_WEB,
3029 issuer=self.db.config.TRACKER_WEB)
3030
3031 result = jwt.decode(self.jwt['user'],
3032 secret, algorithms=['HS256'],
3033 audience=self.db.config.TRACKER_WEB,
3034 issuer=self.db.config.TRACKER_WEB)
3035 self.assertEqual(self.claim['user'],result)
3036
3037 result = jwt.decode(self.jwt['user:email'],
3038 secret, algorithms=['HS256'],
3039 audience=self.db.config.TRACKER_WEB,
3040 issuer=self.db.config.TRACKER_WEB)
3041 self.assertEqual(self.claim['user:email'],result)
3042
3043 # set environment for all jwt tests
3044 env = {
3045 'PATH_INFO': 'rest/data/user',
3046 'HTTP_HOST': 'localhost',
3047 'TRACKER_NAME': 'rounduptest',
3048 "REQUEST_METHOD": "GET"
3049 }
3050 self.dummy_client = client.Client(self.instance, MockNull(), env,
3051 [], None)
3052 self.dummy_client.db = self.db
3053 self.dummy_client.request.headers.get = self.get_header
3054 self.empty_form = cgi.FieldStorage()
3055 self.terse_form = cgi.FieldStorage()
3056 self.terse_form.list = [
3057 cgi.MiniFieldStorage('@verbose', '0'),
3058 ]
3059 self.dummy_client.form = cgi.FieldStorage()
3060 self.dummy_client.form.list = [
3061 cgi.MiniFieldStorage('@fields', 'username,address'),
3062 ]
3063 # accumulate json output for further analysis
3064 self.dummy_client.write = wh
3065
3066 # set up for limited user:email role token
3067 env['HTTP_AUTHORIZATION'] = 'bearer %s'%self.jwt['user:email']
3068 self.dummy_client.main()
3069 json_dict = json.loads(b2s(out[0]))
3070 print(json_dict)
3071 # user will be joe id 3 as auth works
3072 self.assertTrue('3', self.db.getuid())
3073 # there should be three items in the collection admin, anon, and joe
3074 self.assertEqual(3, len(json_dict['data']['collection']))
3075 # However this token has access to email addresses, so all three
3076 # should have email addresses. Order is by id by default.
3077 self.assertTrue('address' in json_dict['data']['collection'][0])
3078 self.assertTrue('address' in json_dict['data']['collection'][1])
3079 self.assertTrue('address' in json_dict['data']['collection'][2])
3080
3081
3082 @skip_jwt
3083 def test_disabled_jwt(self):
3084 # self.dummy_client.main() closes database, so
3085 # we need a new test with setup called for each test
3086 out = []
3087 def wh(s):
3088 out.append(s)
3089
3090 # set environment for all jwt tests
3091 env = {
3092 'PATH_INFO': 'rest/data/user',
3093 'HTTP_HOST': 'localhost',
3094 'TRACKER_NAME': 'rounduptest',
3095 "REQUEST_METHOD": "GET"
3096 }
3097 self.dummy_client = client.Client(self.instance, MockNull(), env,
3098 [], None)
3099 self.dummy_client.db = self.db
3100 self.dummy_client.request.headers.get = self.get_header
3101 self.empty_form = cgi.FieldStorage()
3102 self.terse_form = cgi.FieldStorage()
3103 self.terse_form.list = [
3104 cgi.MiniFieldStorage('@verbose', '0'),
3105 ]
3106 self.dummy_client.form = cgi.FieldStorage()
3107 self.dummy_client.form.list = [
3108 cgi.MiniFieldStorage('@fields', 'username,address'),
3109 ]
3110 # accumulate json output for further analysis
3111 self.dummy_client.write = wh
3112 # disable jwt validation by making secret too short
3113 # use the default value for this in configure.py.
3114 self.db.config['WEB_JWT_SECRET'] = "disabled"
3115 env['HTTP_AUTHORIZATION'] = 'bearer %s'%self.jwt['user']
3116 self.dummy_client.main()
3117 # user will be 1 as there is no auth
3118 self.assertTrue('1', self.db.getuid())
3119 self.assertEqual(out[0], b'Invalid Login - Support for jwt disabled by admin.')
3120
3121 @skip_jwt
3122 def test_bad_issue_jwt(self):
3123 # self.dummy_client.main() closes database, so
3124 # we need a new test with setup called for each test
3125 out = []
3126 def wh(s):
3127 out.append(s)
3128
3129 # set environment for all jwt tests
3130 env = {
3131 'PATH_INFO': 'rest/data/user',
3132 'HTTP_HOST': 'localhost',
3133 'TRACKER_NAME': 'rounduptest',
3134 "REQUEST_METHOD": "GET"
3135 }
3136 self.dummy_client = client.Client(self.instance, MockNull(), env,
3137 [], None)
3138 self.dummy_client.db = self.db
3139 self.dummy_client.request.headers.get = self.get_header
3140 self.empty_form = cgi.FieldStorage()
3141 self.terse_form = cgi.FieldStorage()
3142 self.terse_form.list = [
3143 cgi.MiniFieldStorage('@verbose', '0'),
3144 ]
3145 self.dummy_client.form = cgi.FieldStorage()
3146 self.dummy_client.form.list = [
3147 cgi.MiniFieldStorage('@fields', 'username,address'),
3148 ]
3149 # accumulate json output for further analysis
3150 self.dummy_client.write = wh
3151 env['HTTP_AUTHORIZATION'] = 'bearer %s'%self.jwt['badiss']
3152 self.dummy_client.main()
3153 # user will be 1 as there is no auth
3154 self.assertTrue('1', self.db.getuid())
3155 self.assertEqual(out[0], b'Invalid Login - Invalid issuer')
3156
3157 @skip_jwt
3158 def test_bad_audience_jwt(self):
3159 # self.dummy_client.main() closes database, so
3160 # we need a new test with setup called for each test
3161 out = []
3162 def wh(s):
3163 out.append(s)
3164
3165 # set environment for all jwt tests
3166 env = {
3167 'PATH_INFO': 'rest/data/user',
3168 'HTTP_HOST': 'localhost',
3169 'TRACKER_NAME': 'rounduptest',
3170 "REQUEST_METHOD": "GET"
3171 }
3172 self.dummy_client = client.Client(self.instance, MockNull(), env,
3173 [], None)
3174 self.dummy_client.db = self.db
3175 self.dummy_client.request.headers.get = self.get_header
3176 self.empty_form = cgi.FieldStorage()
3177 self.terse_form = cgi.FieldStorage()
3178 self.terse_form.list = [
3179 cgi.MiniFieldStorage('@verbose', '0'),
3180 ]
3181 self.dummy_client.form = cgi.FieldStorage()
3182 self.dummy_client.form.list = [
3183 cgi.MiniFieldStorage('@fields', 'username,address'),
3184 ]
3185 # accumulate json output for further analysis
3186 self.dummy_client.write = wh
3187 env['HTTP_AUTHORIZATION'] = 'bearer %s'%self.jwt['badaud']
3188 self.dummy_client.main()
3189 # user will be 1 as there is no auth
3190 self.assertTrue('1', self.db.getuid())
3191 self.assertEqual(out[0], b'Invalid Login - Invalid audience')
3192
3193 @skip_jwt
3194 def test_bad_roles_jwt(self):
3195 # self.dummy_client.main() closes database, so
3196 # we need a new test with setup called for each test
3197 out = []
3198 def wh(s):
3199 out.append(s)
3200
3201 # set environment for all jwt tests
3202 env = {
3203 'PATH_INFO': 'rest/data/user',
3204 'HTTP_HOST': 'localhost',
3205 'TRACKER_NAME': 'rounduptest',
3206 "REQUEST_METHOD": "GET"
3207 }
3208 self.dummy_client = client.Client(self.instance, MockNull(), env,
3209 [], None)
3210 self.dummy_client.db = self.db
3211 self.dummy_client.request.headers.get = self.get_header
3212 self.empty_form = cgi.FieldStorage()
3213 self.terse_form = cgi.FieldStorage()
3214 self.terse_form.list = [
3215 cgi.MiniFieldStorage('@verbose', '0'),
3216 ]
3217 self.dummy_client.form = cgi.FieldStorage()
3218 self.dummy_client.form.list = [
3219 cgi.MiniFieldStorage('@fields', 'username,address'),
3220 ]
3221 # accumulate json output for further analysis
3222 self.dummy_client.write = wh
3223 env['HTTP_AUTHORIZATION'] = 'bearer %s'%self.jwt['badroles']
3224 self.dummy_client.main()
3225 # user will be 1 as there is no auth
3226 self.assertTrue('1', self.db.getuid())
3227 self.assertEqual(out[0], b'Invalid Login - Token roles are invalid.')
3228
3229 @skip_jwt
3230 def test_bad_subject_jwt(self):
3231 # self.dummy_client.main() closes database, so
3232 # we need a new test with setup called for each test
3233 out = []
3234 def wh(s):
3235 out.append(s)
3236
3237 # set environment for all jwt tests
3238 env = {
3239 'PATH_INFO': 'rest/data/user',
3240 'HTTP_HOST': 'localhost',
3241 'TRACKER_NAME': 'rounduptest',
3242 "REQUEST_METHOD": "GET"
3243 }
3244 self.dummy_client = client.Client(self.instance, MockNull(), env,
3245 [], None)
3246 self.dummy_client.db = self.db
3247 self.dummy_client.request.headers.get = self.get_header
3248 self.empty_form = cgi.FieldStorage()
3249 self.terse_form = cgi.FieldStorage()
3250 self.terse_form.list = [
3251 cgi.MiniFieldStorage('@verbose', '0'),
3252 ]
3253 self.dummy_client.form = cgi.FieldStorage()
3254 self.dummy_client.form.list = [
3255 cgi.MiniFieldStorage('@fields', 'username,address'),
3256 ]
3257 # accumulate json output for further analysis
3258 self.dummy_client.write = wh
3259 env['HTTP_AUTHORIZATION'] = 'bearer %s'%self.jwt['badsub']
3260 self.dummy_client.main()
3261 # user will be 1 as there is no auth
3262 self.assertTrue('1', self.db.getuid())
3263 self.assertEqual(out[0], b'Invalid Login - Token subject is invalid.')
2761 3264
2762 def get_obj(path, id): 3265 def get_obj(path, id):
2763 return { 3266 return {
2764 'id': id, 3267 'id': id,
2765 'link': path + id 3268 'link': path + id

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