Mercurial > p > roundup > code
comparison 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 |
comparison
equal
deleted
inserted
replaced
| 7808:6c5f8da9fca7 | 7809:be6cb2e0d471 |
|---|---|
| 138 | 138 |
| 139 if jwt: | 139 if jwt: |
| 140 # must be 32 chars in length minimum (I think this is at least | 140 # must be 32 chars in length minimum (I think this is at least |
| 141 # 256 bits of data) | 141 # 256 bits of data) |
| 142 | 142 |
| 143 secret = "TestingTheJwtSecretTestingTheJwtSecret" | 143 self.old_secret = "TestingTheJwtSecretTestingTheJwtSecret" |
| 144 self.db.config['WEB_JWT_SECRET'] = secret | 144 self.new_secret = "TestingTheNEW JwtSecretTestingTheNEWJwtSecret" |
| 145 self.db.config['WEB_JWT_SECRET'] = self.old_secret | |
| 145 | 146 |
| 146 # generate all timestamps in UTC. | 147 # generate all timestamps in UTC. |
| 147 base_datetime = datetime(1970, 1, 1, tzinfo=myutc) | 148 base_datetime = datetime(1970, 1, 1, tzinfo=myutc) |
| 148 | 149 |
| 149 # A UTC timestamp for now. | 150 # A UTC timestamp for now. |
| 179 self.claim = {} | 180 self.claim = {} |
| 180 # generate invalid claim with expired timestamp | 181 # generate invalid claim with expired timestamp |
| 181 self.claim['expired'] = copy(claim) | 182 self.claim['expired'] = copy(claim) |
| 182 self.claim['expired']['exp'] = expired_ts | 183 self.claim['expired']['exp'] = expired_ts |
| 183 self.jwt['expired'] = tostr(jwt.encode( | 184 self.jwt['expired'] = tostr(jwt.encode( |
| 184 self.claim['expired'], secret, | 185 self.claim['expired'], self.old_secret, |
| 185 algorithm='HS256')) | 186 algorithm='HS256')) |
| 186 | 187 |
| 187 # generate valid claim with user role | 188 # generate valid claim with user role |
| 188 self.claim['user'] = copy(claim) | 189 self.claim['user'] = copy(claim) |
| 189 self.claim['user']['exp'] = plus1min_ts | 190 self.claim['user']['exp'] = plus1min_ts |
| 190 self.jwt['user'] = tostr(jwt.encode( | 191 self.jwt['user'] = tostr(jwt.encode( |
| 191 self.claim['user'], secret, | 192 self.claim['user'], self.old_secret, |
| 192 algorithm='HS256')) | 193 algorithm='HS256')) |
| 194 | |
| 195 # generate valid claim with user role and new secret | |
| 196 self.claim['user_new_secret'] = copy(claim) | |
| 197 self.claim['user_new_secret']['exp'] = plus1min_ts | |
| 198 self.jwt['user_new_secret'] = tostr(jwt.encode( | |
| 199 self.claim['user'], self.new_secret, | |
| 200 algorithm='HS256')) | |
| 201 | |
| 193 # generate invalid claim bad issuer | 202 # generate invalid claim bad issuer |
| 194 self.claim['badiss'] = copy(claim) | 203 self.claim['badiss'] = copy(claim) |
| 195 self.claim['badiss']['iss'] = "http://someissuer/bugs" | 204 self.claim['badiss']['iss'] = "http://someissuer/bugs" |
| 196 self.jwt['badiss'] = tostr(jwt.encode( | 205 self.jwt['badiss'] = tostr(jwt.encode( |
| 197 self.claim['badiss'], secret, | 206 self.claim['badiss'], self.old_secret, |
| 198 algorithm='HS256')) | 207 algorithm='HS256')) |
| 199 # generate invalid claim bad aud(ience) | 208 # generate invalid claim bad aud(ience) |
| 200 self.claim['badaud'] = copy(claim) | 209 self.claim['badaud'] = copy(claim) |
| 201 self.claim['badaud']['aud'] = "http://someaudience/bugs" | 210 self.claim['badaud']['aud'] = "http://someaudience/bugs" |
| 202 self.jwt['badaud'] = tostr(jwt.encode( | 211 self.jwt['badaud'] = tostr(jwt.encode( |
| 203 self.claim['badaud'], secret, | 212 self.claim['badaud'], self.old_secret, |
| 204 algorithm='HS256')) | 213 algorithm='HS256')) |
| 205 # generate invalid claim bad sub(ject) | 214 # generate invalid claim bad sub(ject) |
| 206 self.claim['badsub'] = copy(claim) | 215 self.claim['badsub'] = copy(claim) |
| 207 self.claim['badsub']['sub'] = str("99") | 216 self.claim['badsub']['sub'] = str("99") |
| 208 self.jwt['badsub'] = tostr( | 217 self.jwt['badsub'] = tostr( |
| 209 jwt.encode(self.claim['badsub'], secret, | 218 jwt.encode(self.claim['badsub'], self.old_secret, |
| 210 algorithm='HS256')) | 219 algorithm='HS256')) |
| 211 # generate invalid claim bad roles | 220 # generate invalid claim bad roles |
| 212 self.claim['badroles'] = copy(claim) | 221 self.claim['badroles'] = copy(claim) |
| 213 self.claim['badroles']['roles'] = ["badrole1", "badrole2"] | 222 self.claim['badroles']['roles'] = ["badrole1", "badrole2"] |
| 214 self.jwt['badroles'] = tostr(jwt.encode( | 223 self.jwt['badroles'] = tostr(jwt.encode( |
| 215 self.claim['badroles'], secret, | 224 self.claim['badroles'], self.old_secret, |
| 216 algorithm='HS256')) | 225 algorithm='HS256')) |
| 217 # generate valid claim with limited user:email role | 226 # generate valid claim with limited user:email role |
| 218 self.claim['user:email'] = copy(claim) | 227 self.claim['user:email'] = copy(claim) |
| 219 self.claim['user:email']['roles'] = ["user:email"] | 228 self.claim['user:email']['roles'] = ["user:email"] |
| 220 self.jwt['user:email'] = tostr(jwt.encode( | 229 self.jwt['user:email'] = tostr(jwt.encode( |
| 221 self.claim['user:email'], secret, | 230 self.claim['user:email'], self.old_secret, |
| 222 algorithm='HS256')) | 231 algorithm='HS256')) |
| 223 | 232 |
| 224 # generate valid claim with limited user:emailnorest role | 233 # generate valid claim with limited user:emailnorest role |
| 225 self.claim['user:emailnorest'] = copy(claim) | 234 self.claim['user:emailnorest'] = copy(claim) |
| 226 self.claim['user:emailnorest']['roles'] = ["user:emailnorest"] | 235 self.claim['user:emailnorest']['roles'] = ["user:emailnorest"] |
| 227 self.jwt['user:emailnorest'] = tostr(jwt.encode( | 236 self.jwt['user:emailnorest'] = tostr(jwt.encode( |
| 228 self.claim['user:emailnorest'], secret, | 237 self.claim['user:emailnorest'], self.old_secret, |
| 229 algorithm='HS256')) | 238 algorithm='HS256')) |
| 230 | 239 |
| 231 self.db.tx_Source = 'web' | 240 self.db.tx_Source = 'web' |
| 232 | 241 |
| 233 self.db.issue.addprop(tx_Source=hyperdb.String()) | 242 self.db.issue.addprop(tx_Source=hyperdb.String()) |
| 3627 # we need a new test with setup called for each test | 3636 # we need a new test with setup called for each test |
| 3628 out = [] | 3637 out = [] |
| 3629 def wh(s): | 3638 def wh(s): |
| 3630 out.append(s) | 3639 out.append(s) |
| 3631 | 3640 |
| 3632 secret = self.db.config.WEB_JWT_SECRET | 3641 secret = self.db.config.WEB_JWT_SECRET[0] |
| 3633 | 3642 |
| 3634 # verify library and tokens are correct | 3643 # verify library and tokens are correct |
| 3635 self.assertRaises(jwt.exceptions.InvalidTokenError, | 3644 self.assertRaises(jwt.exceptions.InvalidTokenError, |
| 3636 jwt.decode, self.jwt['expired'], | 3645 jwt.decode, self.jwt['expired'], |
| 3637 secret, algorithms=['HS256'], | 3646 secret, algorithms=['HS256'], |
| 3680 # this will be the admin still as auth failed | 3689 # this will be the admin still as auth failed |
| 3681 self.assertEqual('1', self.db.getuid()) | 3690 self.assertEqual('1', self.db.getuid()) |
| 3682 self.assertEqual(out[0], b'Invalid Login - Signature has expired') | 3691 self.assertEqual(out[0], b'Invalid Login - Signature has expired') |
| 3683 del(out[0]) | 3692 del(out[0]) |
| 3684 | 3693 |
| 3694 @skip_jwt | |
| 3695 def test_user_jwt_key_rotation_mutlisig(self): | |
| 3696 # self.dummy_client.main() closes database, so | |
| 3697 # we need a new test with setup called for each test | |
| 3698 out = [] | |
| 3699 def wh(s): | |
| 3700 out.append(s) | |
| 3701 | |
| 3702 # verify library and tokens are correct | |
| 3703 self.assertRaises(jwt.exceptions.InvalidTokenError, | |
| 3704 jwt.decode, self.jwt['expired'], | |
| 3705 self.old_secret, algorithms=['HS256'], | |
| 3706 audience=self.db.config.TRACKER_WEB, | |
| 3707 issuer=self.db.config.TRACKER_WEB) | |
| 3708 | |
| 3709 result = jwt.decode(self.jwt['user_new_secret'], | |
| 3710 self.new_secret, algorithms=['HS256'], | |
| 3711 audience=self.db.config.TRACKER_WEB, | |
| 3712 issuer=self.db.config.TRACKER_WEB) | |
| 3713 self.assertEqual(self.claim['user'],result) | |
| 3714 | |
| 3715 result = jwt.decode(self.jwt['user:email'], | |
| 3716 self.old_secret, algorithms=['HS256'], | |
| 3717 audience=self.db.config.TRACKER_WEB, | |
| 3718 issuer=self.db.config.TRACKER_WEB) | |
| 3719 self.assertEqual(self.claim['user:email'],result) | |
| 3720 | |
| 3721 # set environment for all jwt tests | |
| 3722 env = { | |
| 3723 'PATH_INFO': 'rest/data/user', | |
| 3724 'HTTP_HOST': 'localhost', | |
| 3725 'TRACKER_NAME': 'rounduptest', | |
| 3726 "REQUEST_METHOD": "GET" | |
| 3727 } | |
| 3728 | |
| 3729 # test case where rotation key is used, | |
| 3730 # add spaces after ',' to test config system | |
| 3731 self.db.config['WEB_JWT_SECRET'] = "%s, %s, " % ( | |
| 3732 self.new_secret, self.old_secret | |
| 3733 ) | |
| 3734 | |
| 3735 self.dummy_client = client.Client(self.instance, MockNull(), env, | |
| 3736 [], None) | |
| 3737 self.dummy_client.db = self.db | |
| 3738 self.dummy_client.request.headers.get = self.get_header | |
| 3739 self.empty_form = cgi.FieldStorage() | |
| 3740 self.terse_form = cgi.FieldStorage() | |
| 3741 self.terse_form.list = [ | |
| 3742 cgi.MiniFieldStorage('@verbose', '0'), | |
| 3743 ] | |
| 3744 self.dummy_client.form = cgi.FieldStorage() | |
| 3745 self.dummy_client.form.list = [ | |
| 3746 cgi.MiniFieldStorage('@fields', 'username,address'), | |
| 3747 ] | |
| 3748 # accumulate json output for further analysis | |
| 3749 self.dummy_client.write = wh | |
| 3750 | |
| 3751 # set up for standard user role token | |
| 3752 env['HTTP_AUTHORIZATION'] = 'bearer %s'%self.jwt['user'] | |
| 3753 | |
| 3754 self.dummy_client.main() | |
| 3755 print(out[0]) | |
| 3756 json_dict = json.loads(b2s(out[0])) | |
| 3757 print(json_dict) | |
| 3758 # user will be joe id 3 as auth works | |
| 3759 self.assertTrue('3', self.db.getuid()) | |
| 3760 # there should be three items in the collection admin, anon, and joe | |
| 3761 self.assertEqual(3, len(json_dict['data']['collection'])) | |
| 3762 # since this token has no access to email addresses, only joe | |
| 3763 # should have email addresses. Order is by id by default. | |
| 3764 self.assertFalse('address' in json_dict['data']['collection'][0]) | |
| 3765 self.assertFalse('address' in json_dict['data']['collection'][1]) | |
| 3766 self.assertTrue('address' in json_dict['data']['collection'][2]) | |
| 3767 del(out[0]) | |
| 3768 self.db.setCurrentUser('admin') | |
| 3769 | |
| 3770 @skip_jwt | |
| 3771 def test_user_jwt_key_rotation_sig_failure(self): | |
| 3772 # self.dummy_client.main() closes database, so | |
| 3773 # we need a new test with setup called for each test | |
| 3774 out = [] | |
| 3775 def wh(s): | |
| 3776 out.append(s) | |
| 3777 | |
| 3778 # verify library and tokens are correct | |
| 3779 self.assertRaises(jwt.exceptions.InvalidTokenError, | |
| 3780 jwt.decode, self.jwt['expired'], | |
| 3781 self.old_secret, algorithms=['HS256'], | |
| 3782 audience=self.db.config.TRACKER_WEB, | |
| 3783 issuer=self.db.config.TRACKER_WEB) | |
| 3784 | |
| 3785 result = jwt.decode(self.jwt['user_new_secret'], | |
| 3786 self.new_secret, algorithms=['HS256'], | |
| 3787 audience=self.db.config.TRACKER_WEB, | |
| 3788 issuer=self.db.config.TRACKER_WEB) | |
| 3789 self.assertEqual(self.claim['user'],result) | |
| 3790 | |
| 3791 result = jwt.decode(self.jwt['user:email'], | |
| 3792 self.old_secret, algorithms=['HS256'], | |
| 3793 audience=self.db.config.TRACKER_WEB, | |
| 3794 issuer=self.db.config.TRACKER_WEB) | |
| 3795 self.assertEqual(self.claim['user:email'],result) | |
| 3796 | |
| 3797 # set environment for all jwt tests | |
| 3798 env = { | |
| 3799 'PATH_INFO': 'rest/data/user', | |
| 3800 'HTTP_HOST': 'localhost', | |
| 3801 'TRACKER_NAME': 'rounduptest', | |
| 3802 "REQUEST_METHOD": "GET" | |
| 3803 } | |
| 3804 | |
| 3805 self.dummy_client = client.Client(self.instance, MockNull(), env, | |
| 3806 [], None) | |
| 3807 self.dummy_client.db = self.db | |
| 3808 self.dummy_client.request.headers.get = self.get_header | |
| 3809 self.empty_form = cgi.FieldStorage() | |
| 3810 self.terse_form = cgi.FieldStorage() | |
| 3811 self.terse_form.list = [ | |
| 3812 cgi.MiniFieldStorage('@verbose', '0'), | |
| 3813 ] | |
| 3814 self.dummy_client.form = cgi.FieldStorage() | |
| 3815 self.dummy_client.form.list = [ | |
| 3816 cgi.MiniFieldStorage('@fields', 'username,address'), | |
| 3817 ] | |
| 3818 # accumulate json output for further analysis | |
| 3819 self.dummy_client.write = wh | |
| 3820 | |
| 3821 # test case where new json secret is in place | |
| 3822 self.db.config['WEB_JWT_SECRET'] = self.new_secret | |
| 3823 | |
| 3824 # set up for standard user role token | |
| 3825 env['HTTP_AUTHORIZATION'] = 'bearer %s'%self.jwt['user'] | |
| 3826 self.dummy_client.main() | |
| 3827 print(out[0]) | |
| 3828 | |
| 3829 self.assertEqual(out[0], | |
| 3830 b'Invalid Login - Signature verification failed') | |
| 3831 del(out[0]) | |
| 3832 self.db.setCurrentUser('admin') | |
| 3685 | 3833 |
| 3686 @skip_jwt | 3834 @skip_jwt |
| 3687 def test_user_jwt(self): | 3835 def test_user_jwt(self): |
| 3688 # self.dummy_client.main() closes database, so | 3836 # self.dummy_client.main() closes database, so |
| 3689 # we need a new test with setup called for each test | 3837 # we need a new test with setup called for each test |
| 3690 out = [] | 3838 out = [] |
| 3691 def wh(s): | 3839 def wh(s): |
| 3692 out.append(s) | 3840 out.append(s) |
| 3693 | 3841 |
| 3694 secret = self.db.config.WEB_JWT_SECRET | 3842 secret = self.db.config.WEB_JWT_SECRET[0] |
| 3695 | 3843 |
| 3696 # verify library and tokens are correct | 3844 # verify library and tokens are correct |
| 3697 self.assertRaises(jwt.exceptions.InvalidTokenError, | 3845 self.assertRaises(jwt.exceptions.InvalidTokenError, |
| 3698 jwt.decode, self.jwt['expired'], | 3846 jwt.decode, self.jwt['expired'], |
| 3699 secret, algorithms=['HS256'], | 3847 secret, algorithms=['HS256'], |
| 3760 # we need a new test with setup called for each test | 3908 # we need a new test with setup called for each test |
| 3761 out = [] | 3909 out = [] |
| 3762 def wh(s): | 3910 def wh(s): |
| 3763 out.append(s) | 3911 out.append(s) |
| 3764 | 3912 |
| 3765 secret = self.db.config.WEB_JWT_SECRET | 3913 secret = self.db.config.WEB_JWT_SECRET[0] |
| 3766 | 3914 |
| 3767 # verify library and tokens are correct | 3915 # verify library and tokens are correct |
| 3768 self.assertRaises(jwt.exceptions.InvalidTokenError, | 3916 self.assertRaises(jwt.exceptions.InvalidTokenError, |
| 3769 jwt.decode, self.jwt['expired'], | 3917 jwt.decode, self.jwt['expired'], |
| 3770 secret, algorithms=['HS256'], | 3918 secret, algorithms=['HS256'], |
| 3828 # we need a new test with setup called for each test | 3976 # we need a new test with setup called for each test |
| 3829 out = [] | 3977 out = [] |
| 3830 def wh(s): | 3978 def wh(s): |
| 3831 out.append(s) | 3979 out.append(s) |
| 3832 | 3980 |
| 3833 secret = self.db.config.WEB_JWT_SECRET | 3981 secret = self.db.config.WEB_JWT_SECRET[0] |
| 3834 | 3982 |
| 3835 # verify library and tokens are correct | 3983 # verify library and tokens are correct |
| 3836 self.assertRaises(jwt.exceptions.InvalidTokenError, | 3984 self.assertRaises(jwt.exceptions.InvalidTokenError, |
| 3837 jwt.decode, self.jwt['expired'], | 3985 jwt.decode, self.jwt['expired'], |
| 3838 secret, algorithms=['HS256'], | 3986 secret, algorithms=['HS256'], |
| 3884 self.assertTrue('error' in json_dict) | 4032 self.assertTrue('error' in json_dict) |
| 3885 self.assertTrue(json_dict['error']['status'], 403) | 4033 self.assertTrue(json_dict['error']['status'], 403) |
| 3886 self.assertTrue(json_dict['error']['msg'], "Forbidden.") | 4034 self.assertTrue(json_dict['error']['msg'], "Forbidden.") |
| 3887 | 4035 |
| 3888 @skip_jwt | 4036 @skip_jwt |
| 3889 def test_disabled_jwt(self): | 4037 def test_admin_disabled_jwt(self): |
| 3890 # self.dummy_client.main() closes database, so | 4038 # self.dummy_client.main() closes database, so |
| 3891 # we need a new test with setup called for each test | 4039 # we need a new test with setup called for each test |
| 3892 out = [] | 4040 out = [] |
| 3893 def wh(s): | 4041 def wh(s): |
| 3894 out.append(s) | 4042 out.append(s) |
