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 13 commits
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
100 changes: 100 additions & 0 deletions .editorconfig
robrap marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# ***************************
# ** DO NOT EDIT THIS FILE **
# ***************************
#
# This file was generated by edx-lint: https://github.com/openedx/edx-lint
#
# If you want to change this file, you have two choices, depending on whether
# you want to make a local change that applies only to this repo, or whether
# you want to make a central change that applies to all repos using edx-lint.
#
# Note: If your .editorconfig file is simply out-of-date relative to the latest
# .editorconfig in edx-lint, ensure you have the latest edx-lint installed
# and then follow the steps for a "LOCAL CHANGE".
#
# LOCAL CHANGE:
#
# 1. Edit the local .editorconfig_tweaks file to add changes just to this
# repo's file.
#
# 2. Run:
#
# $ edx_lint write .editorconfig
#
# 3. This will modify the local file. Submit a pull request to get it
# checked in so that others will benefit.
#
#
# CENTRAL CHANGE:
#
# 1. Edit the .editorconfig file in the edx-lint repo at
# https://github.com/openedx/edx-lint/blob/master/edx_lint/files/.editorconfig
#
# 2. install the updated version of edx-lint (in edx-lint):
#
# $ pip install .
#
# 3. Run (in edx-lint):
#
# $ edx_lint write .editorconfig
#
# 4. Make a new version of edx_lint, submit and review a pull request with the
# .editorconfig update, and after merging, update the edx-lint version and
# publish the new version.
#
# 5. In your local repo, install the newer version of edx-lint.
#
# 6. Run:
#
# $ edx_lint write .editorconfig
#
# 7. This will modify the local file. Submit a pull request to get it
# checked in so that others will benefit.
#
#
#
#
#
# STAY AWAY FROM THIS FILE!
#
#
#
#
#
# SERIOUSLY.
#
# ------------------------------
# Generated by edx-lint version: 5.3.4
# ------------------------------
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
indent_style = space
indent_size = 4
max_line_length = 120
trim_trailing_whitespace = true

[{Makefile, *.mk}]
indent_style = tab
indent_size = 8

[*.{yml,yaml,json}]
indent_size = 2

[*.js]
indent_size = 2

[*.diff]
trim_trailing_whitespace = false

[.git/*]
trim_trailing_whitespace = false

[COMMIT_EDITMSG]
max_line_length = 72

[*.rst]
max_line_length = 79

# bbcbced841ed335dd8abb7456a6b13485d701b40
4 changes: 2 additions & 2 deletions docs/authentication.rst
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Authentication
==============
Authentication classes are used to associate a request with a user. Unless otherwise noted, all of the classes below
adhere to the Django `REST Framework's API for authentication classes <http://www.django-rest-framework.org/api-guide/authentication/>`_.

Authentication classes are used to associate a request with a user. Unless otherwise noted, all of the classes below adhere to the Django `REST Framework's API for authentication classes <http://www.django-rest-framework.org/api-guide/authentication/>`_.

.. automodule:: edx_rest_framework_extensions.authentication
:members:
50 changes: 50 additions & 0 deletions docs/decisions/0002-remove-use-jwt-cookie-header.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
2. Remove HTTP_USE_JWT_COOKIE Header
====================================

Status
------

Accepted

Context
-------

This ADR explains `why the request header HTTP_USE_JWT_COOKIE`_ was added.

Use of this header has several problems:

* The purpose and need for the ``HTTP_USE_JWT_COOKIE`` header is confusing. It has led to developer confusion when trying to test API calls using JWT cookies, but having the auth fail because they didn't realize this special header was also required.
* In some cases, the JWT cookies are sent to services, but go unused because of this header. Additional oauth redirects then become required in circumstances where they otherwise wouldn't be needed.
* Some features have been added, like `JwtRedirectToLoginIfUnauthenticatedMiddleware`_, that can be greatly simplified or possibly removed altogether if the ``HTTP_USE_JWT_COOKIE`` header were retired.


Decision
--------

Replace the `HTTP_USE_JWT_COOKIE` header with forgiving authentication when using JWT cookies. By "forgiving", we mean that JWT authentication would no longer raise exceptions for failed authentication when using JWT cookies, but instead would simply return None.

By returning None from JwtAuthentication, rather than raising an authentication failure, we enable services to move on to other classes, like SessionAuthentication, rather than aborting the authentication process. Failure messages could still be surfaced using `set_custom_metric` for debugging purposes.

Rather than checking for the `HTTP_USE_JWT_COOKIE`, the `JwtAuthCookieMiddleware`_ would always reconstitute the JWT cookie if the parts were available.

The proposal includes protecting all changes with a temporary rollout feature toggle ``ENABLE_FORGIVING_JWT_COOKIES``. This can be used to ensure no harm is done for each service before cleaning up the old header.

.. _JwtAuthCookieMiddleware: https://github.com/edx/edx-drf-extensions/blob/270cf521a72b506d7df595c4c479c7ca232b4bec/edx_rest_framework_extensions/auth/jwt/middleware.py#L164

Consequences
------------

* Makes authentication simpler, more clear, and more predictable.

* For example, local testing of endpoints outside of MFEs will use JWT cookies rather than failing, which has been misleading for engineers.

* Greatly simplifies features like `JwtRedirectToLoginIfUnauthenticatedMiddleware`_.
* Service authentication can take advantage of JWT cookies more often.
* Services can more consistently take advantage of the JWT payload of the JWT cookie.
* Additional clean-up when retiring the ``HTTP_USE_JWT_COOKIE`` header will be needed:

* ``HTTP_USE_JWT_COOKIE`` should be removed from frontend-platform auth code when ready.
robrap marked this conversation as resolved.
Show resolved Hide resolved
* ADR that explains `why the request header HTTP_USE_JWT_COOKIE`_ was created should be updated to point to this ADR.

.. _why the request header HTTP_USE_JWT_COOKIE: https://github.com/edx/edx-platform/blob/master/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0009-jwt-in-session-cookie.rst#login---cookie---api
.. _JwtRedirectToLoginIfUnauthenticatedMiddleware: https://github.com/edx/edx-drf-extensions/blob/270cf521a72b506d7df595c4c479c7ca232b4bec/edx_rest_framework_extensions/auth/jwt/middleware.py#L87
64 changes: 62 additions & 2 deletions edx_rest_framework_extensions/auth/jwt/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
from rest_framework_jwt.authentication import JSONWebTokenAuthentication

from edx_rest_framework_extensions.auth.jwt.constants import USE_JWT_COOKIE_HEADER
from edx_rest_framework_extensions.auth.jwt.cookies import jwt_cookie_name
from edx_rest_framework_extensions.auth.jwt.decoder import configured_jwt_decode_handler
from edx_rest_framework_extensions.config import ENABLE_FORGIVING_JWT_COOKIES
from edx_rest_framework_extensions.settings import get_setting


Expand Down Expand Up @@ -59,6 +61,21 @@ 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)
robrap marked this conversation as resolved.
Show resolved Hide resolved

is_forgiving_jwt_cookies_enabled = get_setting(ENABLE_FORGIVING_JWT_COOKIES)
if is_forgiving_jwt_cookies_enabled:
set_custom_attribute('jwt_auth_forgiving_jwt_cookies', True)
robrap marked this conversation as resolved.
Show resolved Hide resolved
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):
try:
user_and_auth = super().authenticate(request)

Expand All @@ -80,11 +97,54 @@ def authenticate(self, request):
# Errors in production do not need to be logged (as they may be noisy),
# but debug logging can help quickly resolve issues during development.
logger.debug('Failed JWT Authentication,', exc_info=exception)
# Note: I think this case should only include AuthenticationFailed and PermissionDenied,
# but will monitor the custom attribute to verify.
# Useful monitoring for debugging various types of failures.
set_custom_attribute('jwt_auth_failed', 'Exception:{}'.format(repr(exception)))
robrap marked this conversation as resolved.
Show resolved Hide resolved
raise

def _authenticate_forgiving_jwt_cookies(self, request):
"""
Authenticate using a JWT token.

If the JWT is in the authorization header, and authentication fails, we will raise
an exception which will halt additional authentication methods, and is the default
behavior of DRF authentication classes.

If the JWT token is found in the JWT cookie, we've had issues with the cookie sometimes
containing an expired token. For failed authentication, instead of raising an exception,
we will return None which will enable other authentication classes to be attempted where
defined (e.g. SessionAuthentication).

See docs/decisions/0002-remove-use-jwt-cookie-header.rst

"""
has_jwt_cookie = jwt_cookie_name() in request.COOKIES
try:
user_and_auth = super().authenticate(request)

# Unauthenticated, CSRF validation not required
if not user_and_auth:
return user_and_auth

# Not using JWT cookie, CSRF validation not required
if not has_jwt_cookie:
return user_and_auth

self.enforce_csrf(request)

# CSRF passed validation with authenticated user
return user_and_auth

except Exception as exception:
# Errors in production do not need to be logged (as they may be noisy),
# but debug logging can help quickly resolve issues during development.
logger.debug('Failed JWT Authentication,', exc_info=exception)
# Useful monitoring for debugging various types of failures.
set_custom_attribute('jwt_auth_failed', 'Exception:{}'.format(repr(exception)))
if has_jwt_cookie:
# See docs/decisions/0002-remove-use-jwt-cookie-header.rst for details.
feanil marked this conversation as resolved.
Show resolved Hide resolved
return None
raise

def authenticate_credentials(self, payload):
"""Get or create an active user with the username contained in the payload."""
# TODO it would be good to refactor this heavily-nested function.
Expand Down
63 changes: 59 additions & 4 deletions edx_rest_framework_extensions/auth/jwt/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@
jwt_cookie_name,
jwt_cookie_signature_name,
)
from edx_rest_framework_extensions.config import ENABLE_SET_REQUEST_USER_FOR_JWT_COOKIE
from edx_rest_framework_extensions.config import (
ENABLE_FORGIVING_JWT_COOKIES,
ENABLE_SET_REQUEST_USER_FOR_JWT_COOKIE,
)
from edx_rest_framework_extensions.permissions import (
LoginRedirectIfUnauthenticated,
NotJwtRestrictedApplication,
Expand Down Expand Up @@ -133,8 +136,16 @@ def process_view(self, request, view_func, view_args, view_kwargs): # pylint: d
Enables Jwt Authentication for endpoints using the LoginRedirectIfUnauthenticated permission class.
"""
self._check_and_cache_login_required_found(view_func)
if self.is_jwt_auth_enabled_with_login_required(request, view_func):
request.META[USE_JWT_COOKIE_HEADER] = 'true'

# Forgiving JWT cookies is an alternative to the older USE_JWT_COOKIE_HEADER.
# If the rollout for forgiving JWT cookies succeeds, we would need to see if
# this middleware could be simplified or replaced by a simpler solution, because
# at least one part of the original solution required this middleware to insert
# the USE_JWT_COOKIE_HEADER.
is_forgiving_jwt_cookies_enabled = get_setting(ENABLE_FORGIVING_JWT_COOKIES)
if not is_forgiving_jwt_cookies_enabled:
if self.is_jwt_auth_enabled_with_login_required(request, view_func):
request.META[USE_JWT_COOKIE_HEADER] = 'true'

def process_response(self, request, response):
"""
Expand Down Expand Up @@ -213,9 +224,18 @@ def _get_missing_cookie_message_and_attribute(self, cookie_name):
def process_view(self, request, view_func, view_args, view_kwargs): # pylint: disable=unused-argument
"""
Reconstitute the full JWT and add a new cookie on the request object.

Additionally, may add the user to the request to make it available in process_view. (See below.)
"""
assert hasattr(request, 'session'), "The Django authentication middleware requires session middleware to be installed. Edit your MIDDLEWARE setting to insert 'django.contrib.sessions.middleware.SessionMiddleware'." # noqa E501 line too long

is_forgiving_jwt_cookies_enabled = get_setting(ENABLE_FORGIVING_JWT_COOKIES)
if is_forgiving_jwt_cookies_enabled:
self._process_view_forgiving_jwt_cookies(request, view_func)
else:
self._process_view_original(request, view_func)
robrap marked this conversation as resolved.
Show resolved Hide resolved

def _process_view_original(self, request, view_func):
use_jwt_cookie_requested = request.META.get(USE_JWT_COOKIE_HEADER)
header_payload_cookie = request.COOKIES.get(jwt_cookie_header_payload_name())
signature_cookie = request.COOKIES.get(jwt_cookie_signature_name())
Expand Down Expand Up @@ -251,9 +271,44 @@ def process_view(self, request, view_func, view_args, view_kwargs): # pylint: d
else:
attribute_value = 'missing-both'
log.warning('Both JWT auth cookies missing. JWT auth cookies will not be reconstituted.')
robrap marked this conversation as resolved.
Show resolved Hide resolved
monitoring.set_custom_attribute('request_jwt_cookie', attribute_value)

def _process_view_forgiving_jwt_cookies(self, request, view_func):
header_payload_cookie = request.COOKIES.get(jwt_cookie_header_payload_name())
signature_cookie = request.COOKIES.get(jwt_cookie_signature_name())

if header_payload_cookie and signature_cookie:
# Reconstitute JWT auth cookie if split cookies are available.
request.COOKIES[jwt_cookie_name()] = '{}{}{}'.format(
header_payload_cookie,
JWT_DELIMITER,
signature_cookie,
)
attribute_value = 'success'
elif header_payload_cookie or signature_cookie:
# Log unexpected case of only finding one cookie.
if not header_payload_cookie:
log_message, attribute_value = self._get_missing_cookie_message_and_attribute(
jwt_cookie_header_payload_name()
)
if not signature_cookie:
log_message, attribute_value = self._get_missing_cookie_message_and_attribute(
jwt_cookie_signature_name()
)
log.warning(log_message)
else:
attribute_value = 'missing-both'
log.warning('Both JWT auth cookies missing. JWT auth cookies will not be reconstituted.')

monitoring.set_custom_attribute('request_jwt_cookie', attribute_value)

has_jwt_cookie = jwt_cookie_name() in request.COOKIES
is_set_request_user_for_jwt_cookie_enabled = get_setting(ENABLE_SET_REQUEST_USER_FOR_JWT_COOKIE)
if has_jwt_cookie and is_set_request_user_for_jwt_cookie_enabled:
robrap marked this conversation as resolved.
Show resolved Hide resolved
# 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
request.user = SimpleLazyObject(lambda: _get_user_from_jwt(request, view_func))


def _get_user_from_jwt(request, view_func):
user = get_user(request)
Expand All @@ -276,7 +331,7 @@ def _get_user_from_jwt(request, view_func):
'Jwt Authentication expected, but view %s is not using a JwtAuthentication class.', view_func
)
except Exception: # pylint: disable=broad-except
log.exception('Unknown error attempting to complete Jwt Authentication.') # pragma: no cover
log.exception('Unknown Jwt Authentication error attempting to retrieve the user.') # pragma: no cover

return user

Expand Down
Loading