Mercurial > p > roundup > code
diff test/rest_common.py @ 7809:be6cb2e0d471
feat: add support for rotating jwt keys
This allows jwt_secret to have multiple ',' separated secrets. The
first/leftmost should be used to sign new JWTs. All of them are used
(starting from left/newest) to try to verify a JWT.
If the first secret is < 32 chars in length JWTs are disabled. If any
of the other secrets are < 32 chars, the configuration code causes the
software to exit. This prevents insecure (too short) secrets from
being used.
Updated doc examples and tests.
| author | John Rouillard <rouilj@ieee.org> |
|---|---|
| date | Thu, 14 Mar 2024 19:04:19 -0400 |
| parents | 8f29e4ea05ce |
| children | 03c1b7ae3a68 |
line wrap: on
line diff
--- a/test/rest_common.py Wed Mar 13 18:25:59 2024 -0400 +++ b/test/rest_common.py Thu Mar 14 19:04:19 2024 -0400 @@ -140,8 +140,9 @@ # 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 + self.old_secret = "TestingTheJwtSecretTestingTheJwtSecret" + self.new_secret = "TestingTheNEW JwtSecretTestingTheNEWJwtSecret" + self.db.config['WEB_JWT_SECRET'] = self.old_secret # generate all timestamps in UTC. base_datetime = datetime(1970, 1, 1, tzinfo=myutc) @@ -181,51 +182,59 @@ self.claim['expired'] = copy(claim) self.claim['expired']['exp'] = expired_ts self.jwt['expired'] = tostr(jwt.encode( - self.claim['expired'], secret, + self.claim['expired'], self.old_secret, algorithm='HS256')) # generate valid claim with user role self.claim['user'] = copy(claim) self.claim['user']['exp'] = plus1min_ts self.jwt['user'] = tostr(jwt.encode( - self.claim['user'], secret, + self.claim['user'], self.old_secret, algorithm='HS256')) + + # generate valid claim with user role and new secret + self.claim['user_new_secret'] = copy(claim) + self.claim['user_new_secret']['exp'] = plus1min_ts + self.jwt['user_new_secret'] = tostr(jwt.encode( + self.claim['user'], self.new_secret, + algorithm='HS256')) + # generate invalid claim bad issuer self.claim['badiss'] = copy(claim) self.claim['badiss']['iss'] = "http://someissuer/bugs" self.jwt['badiss'] = tostr(jwt.encode( - self.claim['badiss'], secret, + self.claim['badiss'], self.old_secret, algorithm='HS256')) # generate invalid claim bad aud(ience) self.claim['badaud'] = copy(claim) self.claim['badaud']['aud'] = "http://someaudience/bugs" self.jwt['badaud'] = tostr(jwt.encode( - self.claim['badaud'], secret, + self.claim['badaud'], self.old_secret, algorithm='HS256')) # generate invalid claim bad sub(ject) self.claim['badsub'] = copy(claim) self.claim['badsub']['sub'] = str("99") self.jwt['badsub'] = tostr( - jwt.encode(self.claim['badsub'], secret, + jwt.encode(self.claim['badsub'], self.old_secret, algorithm='HS256')) # generate invalid claim bad roles self.claim['badroles'] = copy(claim) self.claim['badroles']['roles'] = ["badrole1", "badrole2"] self.jwt['badroles'] = tostr(jwt.encode( - self.claim['badroles'], secret, + self.claim['badroles'], self.old_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'] = tostr(jwt.encode( - self.claim['user:email'], secret, + self.claim['user:email'], self.old_secret, algorithm='HS256')) # generate valid claim with limited user:emailnorest role self.claim['user:emailnorest'] = copy(claim) self.claim['user:emailnorest']['roles'] = ["user:emailnorest"] self.jwt['user:emailnorest'] = tostr(jwt.encode( - self.claim['user:emailnorest'], secret, + self.claim['user:emailnorest'], self.old_secret, algorithm='HS256')) self.db.tx_Source = 'web' @@ -3629,7 +3638,7 @@ def wh(s): out.append(s) - secret = self.db.config.WEB_JWT_SECRET + secret = self.db.config.WEB_JWT_SECRET[0] # verify library and tokens are correct self.assertRaises(jwt.exceptions.InvalidTokenError, @@ -3682,6 +3691,145 @@ self.assertEqual(out[0], b'Invalid Login - Signature has expired') del(out[0]) + @skip_jwt + def test_user_jwt_key_rotation_mutlisig(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) + + # verify library and tokens are correct + self.assertRaises(jwt.exceptions.InvalidTokenError, + jwt.decode, self.jwt['expired'], + self.old_secret, algorithms=['HS256'], + audience=self.db.config.TRACKER_WEB, + issuer=self.db.config.TRACKER_WEB) + + result = jwt.decode(self.jwt['user_new_secret'], + self.new_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'], + self.old_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" + } + + # test case where rotation key is used, + # add spaces after ',' to test config system + self.db.config['WEB_JWT_SECRET'] = "%s, %s, " % ( + self.new_secret, self.old_secret + ) + + 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_jwt_key_rotation_sig_failure(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) + + # verify library and tokens are correct + self.assertRaises(jwt.exceptions.InvalidTokenError, + jwt.decode, self.jwt['expired'], + self.old_secret, algorithms=['HS256'], + audience=self.db.config.TRACKER_WEB, + issuer=self.db.config.TRACKER_WEB) + + result = jwt.decode(self.jwt['user_new_secret'], + self.new_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'], + self.old_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 + + # test case where new json secret is in place + self.db.config['WEB_JWT_SECRET'] = self.new_secret + + # set up for standard user role token + env['HTTP_AUTHORIZATION'] = 'bearer %s'%self.jwt['user'] + self.dummy_client.main() + print(out[0]) + + self.assertEqual(out[0], + b'Invalid Login - Signature verification failed') + del(out[0]) + self.db.setCurrentUser('admin') @skip_jwt def test_user_jwt(self): @@ -3691,7 +3839,7 @@ def wh(s): out.append(s) - secret = self.db.config.WEB_JWT_SECRET + secret = self.db.config.WEB_JWT_SECRET[0] # verify library and tokens are correct self.assertRaises(jwt.exceptions.InvalidTokenError, @@ -3762,7 +3910,7 @@ def wh(s): out.append(s) - secret = self.db.config.WEB_JWT_SECRET + secret = self.db.config.WEB_JWT_SECRET[0] # verify library and tokens are correct self.assertRaises(jwt.exceptions.InvalidTokenError, @@ -3830,7 +3978,7 @@ def wh(s): out.append(s) - secret = self.db.config.WEB_JWT_SECRET + secret = self.db.config.WEB_JWT_SECRET[0] # verify library and tokens are correct self.assertRaises(jwt.exceptions.InvalidTokenError, @@ -3886,7 +4034,7 @@ self.assertTrue(json_dict['error']['msg'], "Forbidden.") @skip_jwt - def test_disabled_jwt(self): + def test_admin_disabled_jwt(self): # self.dummy_client.main() closes database, so # we need a new test with setup called for each test out = []
