From d7b76db594c2ea321b4e42a4c287eae362fc381b Mon Sep 17 00:00:00 2001
From: lilly
Date: Thu, 26 Dec 2024 21:36:35 +0100
Subject: [PATCH] prevent csrf attack against django integration by saving
state
Previously, the integration was vulnerable against the kind of CSRF
attack described in RFC 6749 section 10.12. (See [1]). In this, a
user-agent could be forcibly logged into a new session by redirecting
it to a login-callback url that could be supplied by an attacker.
This has now been fixed by storing an authentication state in the
django session associated with a user-agent.
[1]: https://www.rfc-editor.org/rfc/rfc6749#section-10.12
---
.../integrations/django/apps.py | 2 +-
.../integrations/django/views.py | 23 +++++++++++++++++++
.../django_test_project/tests/test_login.py | 20 ++++++++++++++++
3 files changed, 44 insertions(+), 1 deletion(-)
diff --git a/src/simple_openid_connect/integrations/django/apps.py b/src/simple_openid_connect/integrations/django/apps.py
index 92c1b3d..840ea51 100644
--- a/src/simple_openid_connect/integrations/django/apps.py
+++ b/src/simple_openid_connect/integrations/django/apps.py
@@ -52,7 +52,7 @@ class SettingsModel(BaseModel):
OPENID_USER_MAPPER: str = (
"simple_openid_connect.integrations.django.user_mapping.UserMapper"
)
- "An string specifying a class that inherits from :class:`simple_openid_connect.integrations.django.user_mapping.UserMapper`."
+ "A string specifying a class that inherits from :class:`simple_openid_connect.integrations.django.user_mapping.UserMapper`."
class OpenidAppConfig(AppConfig):
diff --git a/src/simple_openid_connect/integrations/django/views.py b/src/simple_openid_connect/integrations/django/views.py
index 6f4403a..5a9f01a 100644
--- a/src/simple_openid_connect/integrations/django/views.py
+++ b/src/simple_openid_connect/integrations/django/views.py
@@ -31,6 +31,18 @@
logger = logging.getLogger(__name__)
+class InvalidAuthStateError(Exception):
+ """
+ Exception that is thrown when the LoginCallbackView is served and the user-agent has no authentication procedure currently in progress
+ """
+
+ def __init__(self) -> None:
+ super().__init__(
+ self,
+ "User-Agent has no authentication procedures in progress so the login-callback will not be processed",
+ )
+
+
class InitLoginView(View):
"""
The view which handles initiating a login.
@@ -42,6 +54,11 @@ def get(self, request: HttpRequest) -> HttpResponse:
logout(request)
if "next" in request.GET.keys():
request.session["login_redirect_url"] = request.GET["next"]
+
+ # save the login state into the session to prevent CSRF attacks (openid state parameter could be used instead)
+ # See https://www.rfc-editor.org/rfc/rfc6749#section-10.12
+ request.session["openid_auth_in_progress"] = True
+
client = OpenidAppConfig.get_instance().get_client(request)
redirect = client.authorization_code_flow.start_authentication()
return HttpResponseRedirect(redirect)
@@ -61,6 +78,12 @@ class LoginCallbackView(View):
def get(self, request: HttpRequest) -> HttpResponse:
client = OpenidAppConfig.get_instance().get_client(request)
+ # prevent CSRF attacks by verifying that the user agent is curently in the process of authenticating
+ if request.session.get("openid_auth_in_progress", False) is not True:
+ raise InvalidAuthStateError()
+ else:
+ del request.session["openid_auth_in_progress"]
+
# exchange the passed code for tokens
token_response = client.authorization_code_flow.handle_authentication_result(
current_url=request.get_full_path(),
diff --git a/tests/django_test_project/django_test_project/tests/test_login.py b/tests/django_test_project/django_test_project/tests/test_login.py
index 7e07d84..3eff6e1 100644
--- a/tests/django_test_project/django_test_project/tests/test_login.py
+++ b/tests/django_test_project/django_test_project/tests/test_login.py
@@ -8,6 +8,7 @@
from responses import matchers
from simple_openid_connect.integrations.django.apps import OpenidAppConfig
+from simple_openid_connect.integrations.django.views import InvalidAuthStateError
@pytest.mark.django_db
@@ -159,3 +160,22 @@ def test_directly_accessing_protected_resource(
assert response.status_code == 200
assert response.wsgi_request.path == resolve_url("test-protected-view")
assert response.content == b"hello user user1"
+
+
+@pytest.mark.django_db
+def test_unsolicited_callback_csrf(
+ dyn_client, dummy_provider_config, dummy_provider_settings, response_mock, jwks
+):
+ # arrange
+ settings = OpenidAppConfig.get_instance().safe_settings
+
+ # act
+ # throws ConnectionError when the implementation tries to redeem the code
+ with pytest.raises(InvalidAuthStateError):
+ response = dyn_client.get(
+ "https://app.example.com"
+ + resolve_url(settings.OPENID_REDIRECT_URI)
+ + "?code=code.foobar123"
+ )
+
+ # assert