Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: use forgiving jwt authentication #197

Merged
merged 30 commits into from
Aug 14, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c3621ed
fix: use forgiving jwt authentication
robrap Aug 13, 2021
0dbad93
fix: Add a default value for ENABLE_FORGIVING_JWT_COOKIES.
feanil May 23, 2023
e0d9fae
test: Update jwt auth tests due to a code change.
feanil May 23, 2023
07ffa5e
style: Fix various pylint violations.
feanil May 23, 2023
4fce295
style: Update pylintrc and add an editorconfig from edx-lint.
feanil May 23, 2023
5b54d90
test: Update tests to also test forgiving JWT Auth.
feanil Jul 21, 2023
65919e4
test: Fix a test that didn't run correctly.
feanil Jul 21, 2023
cbe9c2d
fix: Don't always run the original middleware.
feanil Jul 22, 2023
ab7b16d
test: Add testing for forgiving JWT Auth.
feanil Jul 22, 2023
37deb3c
test: Add tests for the redirect middleware.
feanil Jul 24, 2023
f38cb1d
fixup! remove indents
robrap Jul 25, 2023
d0b2c49
fixup! fix typo
robrap Jul 25, 2023
50092b4
docs: Update docs/decisions/0002-remove-use-jwt-cookie-header.rst
Jul 26, 2023
f80ad42
docs: Apply suggestions from code review
Jul 26, 2023
58cd8ab
refactor: recombine original/forgiving process_view
robrap Jul 28, 2023
bd3af42
refactor: simplify conditional code
robrap Jul 28, 2023
74a45d1
fix: drop warning for missing cookies
robrap Jul 28, 2023
a35b32c
feat!: replace custom attribute request_jwt_cookie
robrap Jul 28, 2023
57ad929
refactor: simplify USE_JWT_COOKIE_HEADER case
robrap Jul 28, 2023
1e9f31a
docs: update annotations for request_auth_type_guess
robrap Aug 8, 2023
bfd163e
docs: add annotations for jwt_auth_failed
robrap Aug 8, 2023
b55d33a
fixup! forgiving jwt custom attribute updates
robrap Aug 8, 2023
b982fbd
fixup! refactor JwtAuthentication authenticate
robrap Aug 8, 2023
ef040c6
feat: enable JWT cookie testing
robrap Aug 10, 2023
a130e34
fixup! switch to jwt_auth_result custom attribute
robrap Aug 10, 2023
b84dd92
fixup! fix quality
robrap Aug 10, 2023
f85fbf1
fixup! fix quality
robrap Aug 10, 2023
1524623
fixup! switch to new DEPR ticket link
robrap Aug 14, 2023
8394d2b
fixup! update changelog and version
robrap Aug 14, 2023
81afc8f
fixup! update changelog formatting
robrap Aug 14, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions edx_rest_framework_extensions/auth/jwt/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,15 @@ def get_jwt_claim_mergeable_attributes(self):
return get_setting('JWT_PAYLOAD_MERGEABLE_USER_ATTRIBUTES')

def authenticate(self, request):
# Add monitoring to help resolve issues with JWT cookies.
# Note: In the very exceptional case that a JWT authorization header is also used
# and takes precedence over the JWT cookies, this custom attribute
# could be slightly misleading.
has_jwt_cookie = jwt_cookie_name() in request.COOKIES
set_custom_attribute('jwt_auth_has_jwt_cookie', has_jwt_cookie)

is_forgiving_jwt_cookies_enabled = get_setting(ENABLE_FORGIVING_JWT_COOKIES)
# .. custom_attribute_name: is_forgiving_jwt_cookies_enabled
# .. custom_attribute_description: This is temporary custom attribute to show
# whether ENABLE_FORGIVING_JWT_COOKIES is toggled on or off.
set_custom_attribute('is_forgiving_jwt_cookies_enabled', is_forgiving_jwt_cookies_enabled)

# TODO: Robert: Refactor back into this single method in a separate commit
if is_forgiving_jwt_cookies_enabled:
set_custom_attribute('jwt_auth_forgiving_jwt_cookies', True)
return self._authenticate_forgiving_jwt_cookies(request)
set_custom_attribute('jwt_auth_forgiving_jwt_cookies', False)
return self._authenticate_original(request)

def _authenticate_original(self, request):
Expand Down Expand Up @@ -142,9 +139,15 @@ def _authenticate_forgiving_jwt_cookies(self, request):
# .. custom_attribute_description: Includes a summary of the JWT failure exception
# for debugging.
set_custom_attribute('jwt_auth_failed', 'Exception:{}'.format(repr(exception)))
# .. custom_attribute_name: jwt_auth_failure_forgiven
# .. custom_attribute_description: This attribute will be True if the JWT failure
# is forgiven. Only JWT cookie failures will be forgiven. In the case of a
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's Only JWT cookie failures will be forgiven. meant to communicate? It suggests that maybe JWT auth where you use a JWT Authorization header is not forgiven? Is that right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@feanil: You are correct. Also, this comment went away when this attribute was removed in favor of jwt_auth_result, so there is no doc updates needed.

# forgiven failure, authenticate will return None rather than raise an
# exception, allowing other authentication classes to process. This attribute
# will be False for failures that are not forgiven.
# See docs/decisions/0002-remove-use-jwt-cookie-header.rst for details.
set_custom_attribute('jwt_auth_failure_forgiven', has_jwt_cookie)
if has_jwt_cookie:
# Returning None is how a JWT cookie failure becomes forgiving.
# See docs/decisions/0002-remove-use-jwt-cookie-header.rst for details.
return None
raise

Expand Down
5 changes: 5 additions & 0 deletions edx_rest_framework_extensions/auth/jwt/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,11 @@ def process_view(self, request, view_func, view_args, view_kwargs): # pylint: d
log.warning(log_message)

has_reconstituted_jwt_cookie = jwt_cookie_name() in request.COOKIES
# .. custom_attribute_name: has_jwt_cookie
# .. custom_attribute_description: Enables us to see requests which have the full reconstituted
# JWT cookie. If this attribute is missing, it is assumed to be False.
monitoring.set_custom_attribute('has_jwt_cookie', has_reconstituted_jwt_cookie)

if has_reconstituted_jwt_cookie and get_setting(ENABLE_SET_REQUEST_USER_FOR_JWT_COOKIE):
# DRF does not set request.user until process_response. This makes it available in process_view.
# For more info, see https://github.com/jpadilla/django-rest-framework-jwt/issues/45#issuecomment-74996698
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,12 @@ def test_authenticate_credentials_no_usernames(self):

@mock.patch('edx_rest_framework_extensions.auth.jwt.authentication.set_custom_attribute')
def test_authenticate_csrf_protected(self, mock_set_custom_attribute):
""" Verify authenticate exception for CSRF protected cases. """
"""
Ensure authenticate for JWTs properly handles CSRF errors.

Note: When using forgiving JWTs, all JWT cookie exceptions, including CSRF, will
result in a None so that other authentication classes will also be checked.
"""
request = RequestFactory().post('/')

request.META[USE_JWT_COOKIE_HEADER] = 'true'
Expand All @@ -191,13 +196,13 @@ def test_authenticate_csrf_protected(self, mock_set_custom_attribute):
with mock.patch.object(JSONWebTokenAuthentication, 'authenticate', return_value=('mock-user', "mock-auth")):
if get_setting(ENABLE_FORGIVING_JWT_COOKIES):
assert JwtAuthentication().authenticate(request) is None
robrap marked this conversation as resolved.
Show resolved Hide resolved
mock_set_custom_attribute.assert_any_call('jwt_auth_failure_forgiven', True)
else:
with self.assertRaises(PermissionDenied) as context_manager:
JwtAuthentication().authenticate(request)

assert context_manager.exception.detail.startswith('CSRF Failed')

mock_set_custom_attribute.assert_called_with(
mock_set_custom_attribute.assert_any_call(
'jwt_auth_failed',
"Exception:PermissionDenied('CSRF Failed: CSRF cookie not set.')",
)
Expand All @@ -217,7 +222,8 @@ def test_get_decoded_jwt_from_auth(self, is_jwt_authentication):
decoded_jwt = authentication.get_decoded_jwt_from_auth(mock_request_with_cookie)
self.assertEqual(expected_decoded_jwt, decoded_jwt)

def test_authenticate_with_correct_jwt_authorization(self):
@mock.patch('edx_rest_framework_extensions.auth.jwt.authentication.set_custom_attribute')
def test_authenticate_with_correct_jwt_authorization(self, mock_set_custom_attribute):
"""
With JWT header it continues and validates the credentials and throws error.

Expand All @@ -226,6 +232,10 @@ def test_authenticate_with_correct_jwt_authorization(self):
jwt_token = self._get_test_jwt_token()
request = RequestFactory().get('/', HTTP_AUTHORIZATION=f"JWT {jwt_token}")
assert JwtAuthentication().authenticate(request)
mock_set_custom_attribute.assert_any_call(
'is_forgiving_jwt_cookies_enabled',
get_setting(ENABLE_FORGIVING_JWT_COOKIES)
)

def test_authenticate_with_incorrect_jwt_authorization(self):
""" With JWT header it continues and validates the credentials and throws error. """
Expand Down
13 changes: 9 additions & 4 deletions edx_rest_framework_extensions/auth/jwt/tests/test_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ def setUp(self):
def test_do_not_use_jwt_cookies(self, mock_set_custom_attribute):
self.middleware.process_view(self.request, None, None, None)
self.assertIsNone(self.request.COOKIES.get(jwt_cookie_name()))
mock_set_custom_attribute.assert_called_once_with('use_jwt_cookie_requested', False)
mock_set_custom_attribute.assert_any_call('use_jwt_cookie_requested', False)

@ddt.data(
(jwt_cookie_header_payload_name(), jwt_cookie_signature_name()),
Expand All @@ -421,14 +421,18 @@ def test_missing_cookies(
'%s cookie is missing. JWT auth cookies will not be reconstituted.' %
missing_cookie_name
)
mock_set_custom_attribute.assert_called_once_with('use_jwt_cookie_requested', True)
mock_set_custom_attribute.assert_any_call('use_jwt_cookie_requested', True)
mock_set_custom_attribute.assert_any_call('has_jwt_cookie', False)


@patch('edx_django_utils.monitoring.set_custom_attribute')
def test_no_cookies(self, mock_set_custom_attribute):
self.request.META[USE_JWT_COOKIE_HEADER] = 'true'
self.middleware.process_view(self.request, None, None, None)
self.assertIsNone(self.request.COOKIES.get(jwt_cookie_name()))
mock_set_custom_attribute.assert_called_once_with('use_jwt_cookie_requested', True)
mock_set_custom_attribute.assert_any_call('use_jwt_cookie_requested', True)
mock_set_custom_attribute.assert_any_call('has_jwt_cookie', False)


@patch('edx_django_utils.monitoring.set_custom_attribute')
def test_success(self, mock_set_custom_attribute):
Expand All @@ -437,7 +441,8 @@ def test_success(self, mock_set_custom_attribute):
self.request.COOKIES[jwt_cookie_signature_name()] = 'signature'
self.middleware.process_view(self.request, None, None, None)
self.assertEqual(self.request.COOKIES[jwt_cookie_name()], 'header.payload.signature')
mock_set_custom_attribute.assert_called_once_with('use_jwt_cookie_requested', True)
mock_set_custom_attribute.assert_any_call('use_jwt_cookie_requested', True)
mock_set_custom_attribute.assert_any_call('has_jwt_cookie', True)

_LOG_WARN_AUTHENTICATION_FAILED = 0
_LOG_WARN_MISSING_JWT_AUTHENTICATION_CLASS = 1
Expand Down