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

feat: handle jwt cookie vs session user mismatch #388

Merged
merged 4 commits into from
Oct 10, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
10 changes: 10 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ Change Log
Unreleased
----------

[8.11.0] - 2023-10-04
---------------------

Added
~~~~~
* Added toggle EDX_DRF_EXTENSIONS[ENABLE_JWT_VS_SESSION_USER_CHECK] to enable the following:

* New custom attributes is_jwt_vs_session_user_check_enabled, jwt_auth_session_user_id, jwt_auth_and_session_user_mismatch, and invalid_jwt_cookie_user_id for monitoring and debugging.
* When forgiving JWT cookies are also enabled, user mismatches will now result in a failure, rather than a forgiving JWT.

[8.10.0] - 2023-09-19
---------------------

Expand Down
2 changes: 1 addition & 1 deletion edx_rest_framework_extensions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
""" edx Django REST Framework extensions. """

__version__ = '8.10.0' # pragma: no cover
__version__ = '8.11.0' # pragma: no cover
101 changes: 93 additions & 8 deletions edx_rest_framework_extensions/auth/jwt/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@
from rest_framework import exceptions
from rest_framework_jwt.authentication import JSONWebTokenAuthentication

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.auth.jwt.decoder import (
configured_jwt_decode_handler,
unsafe_jwt_decode_handler,
)
from edx_rest_framework_extensions.config import (
ENABLE_FORGIVING_JWT_COOKIES,
ENABLE_JWT_VS_SESSION_USER_CHECK,
)
from edx_rest_framework_extensions.settings import get_setting


Expand Down Expand Up @@ -100,6 +106,8 @@ def authenticate(self, request):

# CSRF passed validation with authenticated user
set_custom_attribute('jwt_auth_result', 'success-cookie')
# adds additional monitoring for mismatches
self._is_jwt_cookie_and_session_user_mismatched(request, jwt_user=user_and_auth[0])
return user_and_auth

except Exception as exception:
Expand All @@ -112,14 +120,19 @@ def authenticate(self, request):
exception_to_report = _deepest_jwt_exception(exception)
set_custom_attribute('jwt_auth_failed', 'Exception:{}'.format(repr(exception_to_report)))

is_jwt_failure_forgiven = is_forgiving_jwt_cookies_enabled and is_authenticating_with_jwt_cookie
if is_jwt_failure_forgiven:
set_custom_attribute('jwt_auth_result', 'forgiven-failure')
return None
if is_authenticating_with_jwt_cookie:
# This check also adds monitoring details for all failed JWT cookies
is_user_mismatch = self._is_jwt_cookie_and_session_user_mismatched(request)
if is_forgiving_jwt_cookies_enabled:
if is_user_mismatch:
set_custom_attribute('jwt_auth_result', 'user-mismatch-failure')
raise
set_custom_attribute('jwt_auth_result', 'forgiven-failure')
return None
set_custom_attribute('jwt_auth_result', 'failed-cookie')
else:
set_custom_attribute('jwt_auth_result', 'failed-auth-header')
raise

set_custom_attribute('jwt_auth_result', 'failed-auth-header')
raise

def authenticate_credentials(self, payload):
Expand Down Expand Up @@ -216,6 +229,78 @@ def is_authenticating_with_jwt_cookie(cls, request):
except Exception: # pylint: disable=broad-exception-caught
return False

def _is_jwt_cookie_and_session_user_mismatched(self, request, jwt_user=None):
"""
Returns True if JWT cookie and session user do not match, False otherwise.

Arguments:
request: The request.
jwt_user (User): The valid JWT user. If not user is supplied, it is assumed that
robrap marked this conversation as resolved.
Show resolved Hide resolved
the cookie was invalid, and we attempt to get the user_id from the invalid
token.

Other notes:
- If ENABLE_JWT_VS_SESSION_USER_CHECK is toggled off, always return False.
- Also adds monitoring details for mismatches.
- Should only be called for JWT cookies.
"""
is_jwt_vs_session_user_check_enabled = get_setting(ENABLE_JWT_VS_SESSION_USER_CHECK)
# .. custom_attribute_name: is_jwt_vs_session_user_check_enabled
# .. custom_attribute_description: This is temporary custom attribute to show
# whether ENABLE_JWT_VS_SESSION_USER_CHECK is toggled on or off.
set_custom_attribute('is_jwt_vs_session_user_check_enabled', is_jwt_vs_session_user_check_enabled)
if not is_jwt_vs_session_user_check_enabled:
return False

has_request_user = (
hasattr(request, '_request') and hasattr(request._request, 'user') # pylint: disable=protected-access
)
if not has_request_user: # pragma: no cover
# .. custom_attribute_name: jwt_auth_request_user_not_found
# .. custom_attribute_description: This custom attribute will show that we
# were unable to find the session user. This should not occur outside
# of tests, because there should still be an unauthenticated user, but
# this attribute could be used to check for the unexpected.
set_custom_attribute('jwt_auth_request_user_not_found', True)
return False

wsgi_request_user = request._request.user # pylint: disable=protected-access
if wsgi_request_user and wsgi_request_user.is_authenticated:
session_user_id = wsgi_request_user.id
else:
session_user_id = None

if jwt_user:
jwt_user_id = jwt_user.id
else:
cookie_token = JSONWebTokenAuthentication.get_token_from_cookies(request.COOKIES)
invalid_decoded_jwt = unsafe_jwt_decode_handler(cookie_token)
robrap marked this conversation as resolved.
Show resolved Hide resolved
jwt_user_id = invalid_decoded_jwt['user_id']
robrap marked this conversation as resolved.
Show resolved Hide resolved
# .. custom_attribute_name: invalid_jwt_cookie_user_id
# .. custom_attribute_description: The user_id pulled from the invalid/failed
# JWT cookie.
set_custom_attribute('invalid_jwt_cookie_user_id', jwt_user_id)
robrap marked this conversation as resolved.
Show resolved Hide resolved

if not session_user_id or session_user_id == jwt_user_id:
return False

# .. custom_attribute_name: jwt_auth_session_user_id
# .. custom_attribute_description: Session authentication may have completed
# in middleware before even getting to DRF. Although this authentication
# won't stick, because it will be replaced by DRF authentication, we
# record it, because it sometimes does not match the JWT cookie user.
# The name of this attribute is simply to clarify that this was found
# during JWT authentication.
set_custom_attribute('jwt_auth_session_user_id', session_user_id)

# .. custom_attribute_name: jwt_auth_and_session_user_mismatch
# .. custom_attribute_description: True if session authentication user id and
# the JWT cookie user id may not match. When they match, this attribute
# won't be included. See jwt_auth_session_user_id for additional details.
set_custom_attribute('jwt_auth_and_session_user_mismatch', True)

return True


def is_jwt_authenticated(request):
successful_authenticator = getattr(request, 'successful_authenticator', None)
Expand Down
31 changes: 31 additions & 0 deletions edx_rest_framework_extensions/auth/jwt/decoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,23 @@ def jwt_decode_handler(token, decode_symmetric_token=True):
return _set_token_defaults(decoded_token)


def unsafe_jwt_decode_handler(token):
"""
Decodes a JSON Web Token (JWT) with NO verification.

Args:
token (str): JWT to be decoded.

Returns:
dict: Decoded JWT payload

Raises:
InvalidTokenError: Decoding fails.
"""
decoded_token = _unsafe_decode_token_with_no_verification(token)
return _set_token_defaults(decoded_token)


def configured_jwt_decode_handler(token):
"""
Calls the ``jwt_decode_handler`` configured in the ``JWT_DECODE_HANDLER`` setting.
Expand Down Expand Up @@ -361,6 +378,20 @@ def _decode_and_verify_token(token, jwt_issuer):
return decoded_token


def _unsafe_decode_token_with_no_verification(token):
"""
Returns a decoded JWT token with no verification.
"""
options = {
'verify_exp': False,
'verify_aud': False,
'verify_iss': False,
'verify_signature': False,
}
decoded_token = jwt.decode(token, options=options)
return decoded_token


def get_verification_jwk_key_set(asymmetric_keys=None, secret_key=None):
"""
Creates a JWK Keyset containing the provided keys.
Expand Down
Loading