Skip to content

Commit ee0f562

Browse files
authored
Merge pull request #40 from alexykot/feature/asymmetric-support
Asymmetric crypto support
2 parents fb3e932 + 2216875 commit ee0f562

File tree

9 files changed

+177
-12
lines changed

9 files changed

+177
-12
lines changed

docs/options.rst

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ General Options:
2222
``JWT_REFRESH_TOKEN_EXPIRES`` How long a refresh token should live before it expires. This
2323
takes a ``datetime.timedelta``, and defaults to 30 days
2424
``JWT_ALGORITHM`` Which algorithm to sign the JWT with. `See here <https://pyjwt.readthedocs.io/en/latest/algorithms.html>`_
25-
for the options. Defaults to ``'HS256'``. Note that Asymmetric
26-
(Public-key) algorithms are not currently supported.
25+
for the options. Defaults to ``'HS256'``.
26+
``JWT_PUBLIC_KEY`` The public key needed for RSA and ECDSA based signing algorithms.
27+
Has to be provided if any of ``RS*`` or ``ES*`` algorithms is used.
28+
PEM format expected.
2729
================================= =========================================
2830

2931

flask_jwt_extended/config.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import simplekv
55
from flask import current_app
6+
from jwt.algorithms import requires_cryptography
67

78

89
class _Config(object):
@@ -15,6 +16,18 @@ class _Config(object):
1516
object. All of these values are read only.
1617
"""
1718

19+
@property
20+
def is_asymmetric(self):
21+
return self.algorithm in requires_cryptography
22+
23+
@property
24+
def encode_key(self):
25+
return self.secret_key
26+
27+
@property
28+
def decode_key(self):
29+
return self.public_key if self.is_asymmetric else self.secret_key
30+
1831
@property
1932
def token_location(self):
2033
locations = current_app.config['JWT_TOKEN_LOCATION']
@@ -172,6 +185,17 @@ def secret_key(self):
172185
raise RuntimeError('flask SECRET_KEY must be set')
173186
return key
174187

188+
@property
189+
def public_key(self):
190+
key = None
191+
if self.algorithm in requires_cryptography:
192+
key = current_app.config.get('JWT_PUBLIC_KEY', None)
193+
if not key:
194+
raise RuntimeError('JWT_PUBLIC_KEY must be set to use '
195+
'asymmetric cryptography algorith '
196+
'"{crypto_algorithm}"'.format(crypto_algorithm=self.algorithm))
197+
return key
198+
175199
@property
176200
def cookie_max_age(self):
177201
# Returns the appropiate value for max_age for flask set_cookies. If
@@ -180,3 +204,5 @@ def cookie_max_age(self):
180204
return None if self.session_cookie else 2147483647 # 2^31
181205

182206
config = _Config()
207+
208+

flask_jwt_extended/jwt_manager.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,12 @@ def _set_default_configuration_options(app):
138138
app.config.setdefault('JWT_REFRESH_TOKEN_EXPIRES', datetime.timedelta(days=30))
139139

140140
# What algorithm to use to sign the token. See here for a list of options:
141-
# https://github.com/jpadilla/pyjwt/blob/master/jwt/api_jwt.py (note
142-
# that public private key is not yet supported in this extension)
141+
# https://github.com/jpadilla/pyjwt/blob/master/jwt/api_jwt.py
143142
app.config.setdefault('JWT_ALGORITHM', 'HS256')
144143

144+
# must be set if using asymmetric cryptography algorithm (RS* or EC*)
145+
app.config.setdefault('JWT_PUBLIC_KEY', None)
146+
145147
# Options for blacklisting/revoking tokens
146148
app.config.setdefault('JWT_BLACKLIST_ENABLED', False)
147149
app.config.setdefault('JWT_BLACKLIST_STORE', None)
@@ -251,15 +253,15 @@ def create_refresh_token(self, identity):
251253
"""
252254
refresh_token = encode_refresh_token(
253255
identity=self._user_identity_callback(identity),
254-
secret=config.secret_key,
256+
secret=config.encode_key,
255257
algorithm=config.algorithm,
256258
expires_delta=config.refresh_expires,
257259
csrf=config.csrf_protect
258260
)
259261

260262
# If blacklisting is enabled, store this token in our key-value store
261263
if config.blacklist_enabled:
262-
decoded_token = decode_jwt(refresh_token, config.secret_key,
264+
decoded_token = decode_jwt(refresh_token, config.decode_key,
263265
config.algorithm, csrf=config.csrf_protect)
264266
store_token(decoded_token, revoked=False)
265267
return refresh_token
@@ -282,15 +284,15 @@ def create_access_token(self, identity, fresh=False):
282284
"""
283285
access_token = encode_access_token(
284286
identity=self._user_identity_callback(identity),
285-
secret=config.secret_key,
287+
secret=config.encode_key,
286288
algorithm=config.algorithm,
287289
expires_delta=config.access_expires,
288290
fresh=fresh,
289291
user_claims=self._user_claims_callback(identity),
290292
csrf=config.csrf_protect
291293
)
292294
if config.blacklist_enabled and config.blacklist_access_tokens:
293-
decoded_token = decode_jwt(access_token, config.secret_key,
295+
decoded_token = decode_jwt(access_token, config.decode_key,
294296
config.algorithm, csrf=config.csrf_protect)
295297
store_token(decoded_token, revoked=False)
296298
return access_token

flask_jwt_extended/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def create_refresh_token(*args, **kwargs):
5151

5252

5353
def get_csrf_token(encoded_token):
54-
token = decode_jwt(encoded_token, config.secret_key, config.algorithm, csrf=True)
54+
token = decode_jwt(encoded_token, config.decode_key, config.algorithm, csrf=True)
5555
return token['csrf']
5656

5757

flask_jwt_extended/view_decorators.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ def _decode_jwt_from_headers():
9898
raise InvalidHeaderError(msg)
9999
token = parts[1]
100100

101-
return decode_jwt(token, config.secret_key, config.algorithm, csrf=False)
101+
return decode_jwt(token, config.decode_key, config.algorithm, csrf=False)
102102

103103

104104
def _decode_jwt_from_cookies(request_type):
@@ -115,7 +115,7 @@ def _decode_jwt_from_cookies(request_type):
115115

116116
decoded_token = decode_jwt(
117117
encoded_token=encoded_token,
118-
secret=config.secret_key,
118+
secret=config.decode_key,
119119
algorithm=config.algorithm,
120120
csrf=config.csrf_protect
121121
)

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ alabaster==0.7.9
22
Babel==2.3.4
33
click==6.6
44
coverage==4.2
5+
cryptography==1.8.1
56
docutils==0.12
67
Flask==0.11.1
78
imagesize==0.7.1

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
packages=['flask_jwt_extended'],
1818
zip_safe=False,
1919
platforms='any',
20-
install_requires=['Flask', 'PyJWT', 'simplekv'],
20+
install_requires=['Flask', 'PyJWT', 'simplekv', 'cryptography'],
2121
classifiers=[
2222
'Development Status :: 4 - Beta',
2323
'Environment :: Web Environment',

tests/test_config.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,15 @@ def test_default_configs(self):
4545
self.assertEqual(config.access_expires, timedelta(minutes=15))
4646
self.assertEqual(config.refresh_expires, timedelta(days=30))
4747
self.assertEqual(config.algorithm, 'HS256')
48+
self.assertEqual(config.is_asymmetric, False)
4849
self.assertEqual(config.blacklist_enabled, False)
4950
self.assertEqual(config.blacklist_checks, 'refresh')
5051
self.assertEqual(config.blacklist_access_tokens, False)
5152

5253
self.assertEqual(config.secret_key, self.app.secret_key)
54+
self.assertEqual(config.public_key, None)
55+
self.assertEqual(config.encode_key, self.app.secret_key)
56+
self.assertEqual(config.decode_key, self.app.secret_key)
5357
self.assertEqual(config.cookie_max_age, None)
5458

5559
with self.assertRaises(RuntimeError):
@@ -166,6 +170,15 @@ def test_invalid_config_options(self):
166170
with self.assertRaises(RuntimeError):
167171
config.secret_key
168172

173+
self.app.secret_key = None
174+
with self.assertRaises(RuntimeError):
175+
config.encode_key
176+
177+
self.app.config['JWT_ALGORITHM'] = 'RS256'
178+
self.app.config['JWT_PUBLIC_KEY'] = None
179+
with self.assertRaises(RuntimeError):
180+
config.decode_key
181+
169182
def test_depreciated_options(self):
170183
self.app.config['JWT_CSRF_HEADER_NAME'] = 'Auth'
171184

@@ -205,3 +218,14 @@ def test_special_config_options(self):
205218
self.app.config['JWT_TOKEN_LOCATION'] = ['cookies']
206219
self.app.config['JWT_COOKIE_CSRF_PROTECT'] = False
207220
self.assertEqual(config.csrf_protect, False)
221+
222+
def test_asymmetric_encryption_key_handling(self):
223+
self.app.secret_key = 'MOCK_RSA_PRIVATE_KEY'
224+
self.app.config['JWT_PUBLIC_KEY'] = 'MOCK_RSA_PUBLIC_KEY'
225+
self.app.config['JWT_ALGORITHM'] = 'RS256'
226+
227+
with self.app.test_request_context():
228+
self.assertEqual(config.is_asymmetric, True)
229+
self.assertEqual(config.secret_key, 'MOCK_RSA_PRIVATE_KEY')
230+
self.assertEqual(config.encode_key, 'MOCK_RSA_PRIVATE_KEY')
231+
self.assertEqual(config.decode_key, 'MOCK_RSA_PUBLIC_KEY')

tests/test_protected_endpoints.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -814,3 +814,113 @@ def test_accessing_endpoint_without_jwt(self):
814814
data = json.loads(response.get_data(as_text=True))
815815
self.assertEqual(status_code, 401)
816816
self.assertIn('msg', data)
817+
818+
819+
# random 1024bit RSA keypair
820+
RSA_PRIVATE = """
821+
-----BEGIN RSA PRIVATE KEY-----
822+
MIICXgIBAAKBgQDN+p9a9oMyqRzkae8yLdJcEK0O0WesH6JiMz+KDrpUwAoAM/KP
823+
DnxFnROJDSBHyHEmPVn5x8GqV5lQ9+6l97jdEEcPo6wkshycM82fgcxOmvtAy4Uo
824+
xq/AeplYqplhcUTGVuo4ZldOLmN8ksGmzhWpsOdT0bkYipHCn5sWZxd21QIDAQAB
825+
AoGBAMJ0++KVXXEDZMpjFDWsOq898xNNMHG3/8ZzmWXN161RC1/7qt/RjhLuYtX9
826+
NV9vZRrzyrDcHAKj5pMhLgUzpColKzvdG2vKCldUs2b0c8HEGmjsmpmgoI1Tdf9D
827+
G1QK+q9pKHlbj/MLr4vZPX6xEwAFeqRKlzL30JPD+O6mOXs1AkEA8UDzfadH1Y+H
828+
bcNN2COvCqzqJMwLNRMXHDmUsjHfR2gtzk6D5dDyEaL+O4FLiQCaNXGWWoDTy/HJ
829+
Clh1Z0+KYwJBANqRtJ+RvdgHMq0Yd45MMyy0ODGr1B3PoRbUK8EdXpyUNMi1g3iJ
830+
tXMbLywNkTfcEXZTlbbkVYwrEl6P2N1r42cCQQDb9UQLBEFSTRJE2RRYQ/CL4yt3
831+
cTGmqkkfyr/v19ii2jEpMBzBo8eQnPL+fdvIhWwT3gQfb+WqxD9v10bzcmnRAkEA
832+
mzTgeHd7wg3KdJRtQYTmyhXn2Y3VAJ5SG+3qbCW466NqoCQVCeFwEh75rmSr/Giv
833+
lcDhDZCzFuf3EWNAcmuMfQJARsWfM6q7v2p6vkYLLJ7+VvIwookkr6wymF5Zgb9d
834+
E6oTM2EeUPSyyrj5IdsU2JCNBH1m3JnUflz8p8/NYCoOZg==
835+
-----END RSA PRIVATE KEY-----
836+
"""
837+
RSA_PUBLIC = """
838+
-----BEGIN RSA PUBLIC KEY-----
839+
MIGJAoGBAM36n1r2gzKpHORp7zIt0lwQrQ7RZ6wfomIzP4oOulTACgAz8o8OfEWd
840+
E4kNIEfIcSY9WfnHwapXmVD37qX3uN0QRw+jrCSyHJwzzZ+BzE6a+0DLhSjGr8B6
841+
mViqmWFxRMZW6jhmV04uY3ySwabOFamw51PRuRiKkcKfmxZnF3bVAgMBAAE=
842+
-----END RSA PUBLIC KEY-----
843+
"""
844+
845+
class TestEndpointsWithAssymmetricCrypto(unittest.TestCase):
846+
847+
def setUp(self):
848+
self.app = Flask(__name__)
849+
self.app.secret_key = RSA_PRIVATE
850+
self.app.config['JWT_PUBLIC_KEY'] = RSA_PUBLIC
851+
self.app.config['JWT_ALGORITHM'] = 'RS256'
852+
self.app.config['JWT_ACCESS_TOKEN_EXPIRES'] = timedelta(seconds=1)
853+
self.app.config['JWT_REFRESH_TOKEN_EXPIRES'] = timedelta(seconds=1)
854+
self.jwt_manager = JWTManager(self.app)
855+
self.client = self.app.test_client()
856+
857+
@self.app.route('/auth/login', methods=['POST'])
858+
def login():
859+
ret = {
860+
'access_token': create_access_token('test', fresh=True),
861+
'refresh_token': create_refresh_token('test')
862+
}
863+
return jsonify(ret), 200
864+
865+
@self.app.route('/auth/refresh', methods=['POST'])
866+
@jwt_refresh_token_required
867+
def refresh():
868+
username = get_jwt_identity()
869+
ret = {'access_token': create_access_token(username, fresh=False)}
870+
return jsonify(ret), 200
871+
872+
@self.app.route('/auth/fresh-login', methods=['POST'])
873+
def fresh_login():
874+
ret = {'access_token': create_access_token('test', fresh=True)}
875+
return jsonify(ret), 200
876+
877+
@self.app.route('/protected')
878+
@jwt_required
879+
def protected():
880+
return jsonify({'msg': "hello world"})
881+
882+
@self.app.route('/fresh-protected')
883+
@fresh_jwt_required
884+
def fresh_protected():
885+
return jsonify({'msg': "fresh hello world"})
886+
887+
def _jwt_post(self, url, jwt):
888+
response = self.client.post(url, content_type='application/json',
889+
headers={'Authorization': 'Bearer {}'.format(jwt)})
890+
status_code = response.status_code
891+
data = json.loads(response.get_data(as_text=True))
892+
return status_code, data
893+
894+
def _jwt_get(self, url, jwt, header_name='Authorization', header_type='Bearer'):
895+
header_type = '{} {}'.format(header_type, jwt).strip()
896+
response = self.client.get(url, headers={header_name: header_type})
897+
status_code = response.status_code
898+
data = json.loads(response.get_data(as_text=True))
899+
return status_code, data
900+
901+
def test_login(self):
902+
response = self.client.post('/auth/login')
903+
status_code = response.status_code
904+
data = json.loads(response.get_data(as_text=True))
905+
self.assertEqual(status_code, 200)
906+
self.assertIn('access_token', data)
907+
self.assertIn('refresh_token', data)
908+
909+
def test_fresh_login(self):
910+
response = self.client.post('/auth/fresh-login')
911+
status_code = response.status_code
912+
data = json.loads(response.get_data(as_text=True))
913+
self.assertEqual(status_code, 200)
914+
self.assertIn('access_token', data)
915+
self.assertNotIn('refresh_token', data)
916+
917+
def test_refresh(self):
918+
response = self.client.post('/auth/login')
919+
data = json.loads(response.get_data(as_text=True))
920+
access_token = data['access_token']
921+
refresh_token = data['refresh_token']
922+
923+
status_code, data = self._jwt_post('/auth/refresh', refresh_token)
924+
self.assertEqual(status_code, 200)
925+
self.assertIn('access_token', data)
926+
self.assertNotIn('refresh_token', data)

0 commit comments

Comments
 (0)