Compare commits

...
4 changed files with 74 additions and 6 deletions
@@ -172,3 +172,39 @@ class TestApiUiSettings(DirectoriesMixin, APITestCase):
self.assertIsNotNone( self.assertIsNotNone(
response.data["settings"]["outlook_oauth_url"], response.data["settings"]["outlook_oauth_url"],
) )
@override_settings(
OAUTH_CALLBACK_BASE_URL="http://localhost:8000",
GMAIL_OAUTH_CLIENT_ID="abc123",
GMAIL_OAUTH_CLIENT_SECRET="def456",
GMAIL_OAUTH_ENABLED=True,
OUTLOOK_OAUTH_CLIENT_ID="ghi789",
OUTLOOK_OAUTH_CLIENT_SECRET="jkl012",
OUTLOOK_OAUTH_ENABLED=True,
)
def test_settings_oauth_state_reused_across_calls(self) -> None:
"""
GIVEN:
- A mail account oauth flow has been started, storing a state in the session
WHEN:
- The ui_settings endpoint is called again before the flow completes
THEN:
- The same oauth state is reused, not replaced with a new one
"""
response = self.client.get(self.ENDPOINT, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
first_state = self.client.session["oauth_state"]
first_gmail_url = response.data["settings"]["gmail_oauth_url"]
first_outlook_url = response.data["settings"]["outlook_oauth_url"]
response = self.client.get(self.ENDPOINT, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(self.client.session["oauth_state"], first_state)
self.assertEqual(
response.data["settings"]["gmail_oauth_url"],
first_gmail_url,
)
self.assertEqual(
response.data["settings"]["outlook_oauth_url"],
first_outlook_url,
)
+7 -3
View File
@@ -4002,15 +4002,19 @@ class UiSettingsView(GenericAPIView[Any]):
ui_settings["auditlog_enabled"] = settings.AUDIT_LOG_ENABLED ui_settings["auditlog_enabled"] = settings.AUDIT_LOG_ENABLED
if settings.GMAIL_OAUTH_ENABLED or settings.OUTLOOK_OAUTH_ENABLED: if settings.GMAIL_OAUTH_ENABLED or settings.OUTLOOK_OAUTH_ENABLED:
manager = PaperlessMailOAuth2Manager() # Reuse an in-flight state instead of minting a new one on every
# settings fetch, or a concurrent request can invalidate a login
# that's still in progress.
manager = PaperlessMailOAuth2Manager(
state=request.session.get("oauth_state"),
)
request.session["oauth_state"] = manager.state
if settings.GMAIL_OAUTH_ENABLED: if settings.GMAIL_OAUTH_ENABLED:
ui_settings["gmail_oauth_url"] = manager.get_gmail_authorization_url() ui_settings["gmail_oauth_url"] = manager.get_gmail_authorization_url()
request.session["oauth_state"] = manager.state
if settings.OUTLOOK_OAUTH_ENABLED: if settings.OUTLOOK_OAUTH_ENABLED:
ui_settings["outlook_oauth_url"] = ( ui_settings["outlook_oauth_url"] = (
manager.get_outlook_authorization_url() manager.get_outlook_authorization_url()
) )
request.session["oauth_state"] = manager.state
ui_settings["email_enabled"] = settings.EMAIL_ENABLED ui_settings["email_enabled"] = settings.EMAIL_ENABLED
@@ -138,6 +138,16 @@ class TestMailOAuth(
MailAccount.objects.filter(imap_server="imap.gmail.com").exists(), MailAccount.objects.filter(imap_server="imap.gmail.com").exists(),
) )
# State is single-use and was cleared by the callback above, so a new
# flow needs a new state stored in the session.
session = self.client.session
session.update(
{
"oauth_state": "test_state",
},
)
session.save()
# Test Outlook OAuth callback # Test Outlook OAuth callback
response = self.client.get( response = self.client.get(
"/api/oauth/callback/?code=test_code&state=test_state", "/api/oauth/callback/?code=test_code&state=test_state",
@@ -180,6 +190,16 @@ class TestMailOAuth(
MailAccount.objects.filter(imap_server="imap.gmail.com").exists(), MailAccount.objects.filter(imap_server="imap.gmail.com").exists(),
) )
# State is single-use and was cleared by the callback above, so a
# new flow needs a new state stored in the session.
session = self.client.session
session.update(
{
"oauth_state": "test_state",
},
)
session.save()
# Test Outlook OAuth callback # Test Outlook OAuth callback
response = self.client.get( response = self.client.get(
"/api/oauth/callback/?code=test_code&state=test_state", "/api/oauth/callback/?code=test_code&state=test_state",
+11 -3
View File
@@ -4,6 +4,7 @@ from datetime import timedelta
from http import HTTPStatus from http import HTTPStatus
from typing import Any from typing import Any
from django.conf import settings
from django.http import HttpResponseBadRequest from django.http import HttpResponseBadRequest
from django.http import HttpResponseForbidden from django.http import HttpResponseForbidden
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
@@ -256,9 +257,14 @@ class OauthCallbackView(GenericAPIView[Any]):
) )
return HttpResponseBadRequest("Invalid request, see logs for more detail") return HttpResponseBadRequest("Invalid request, see logs for more detail")
oauth_manager = PaperlessMailOAuth2Manager( session_state = request.session.get("oauth_state")
state=request.session.get("oauth_state"), if not session_state and not settings.DEBUG:
) logger.error(
"Invalid oauth callback request: no state in session",
)
return HttpResponseBadRequest("Invalid request, see logs for more detail")
oauth_manager = PaperlessMailOAuth2Manager(state=session_state)
state = request.query_params.get("state", "") state = request.query_params.get("state", "")
if not oauth_manager.validate_state(state): if not oauth_manager.validate_state(state):
@@ -315,3 +321,5 @@ class OauthCallbackView(GenericAPIView[Any]):
return HttpResponseRedirect( return HttpResponseRedirect(
f"{oauth_manager.oauth_redirect_url}?oauth_success=0", f"{oauth_manager.oauth_redirect_url}?oauth_success=0",
) )
finally:
request.session.pop("oauth_state", None)