mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-05-30 01:10:04 +02:00
Merge pull request #1916 from cisagov/rjm/1814-401
Issue #1814: Enhance state mismatch error handling and messaging
This commit is contained in:
commit
59c8625102
4 changed files with 55 additions and 22 deletions
|
@ -33,8 +33,8 @@ class AuthenticationFailed(OIDCException):
|
||||||
friendly_message = "This login attempt didn't work."
|
friendly_message = "This login attempt didn't work."
|
||||||
|
|
||||||
|
|
||||||
class NoStateDefined(OIDCException):
|
class StateMismatch(AuthenticationFailed):
|
||||||
friendly_message = "The session state is None."
|
friendly_message = "State mismatch. This login attempt didn't work."
|
||||||
|
|
||||||
|
|
||||||
class InternalError(OIDCException):
|
class InternalError(OIDCException):
|
||||||
|
|
|
@ -182,10 +182,20 @@ class Client(oic.Client):
|
||||||
|
|
||||||
if authn_response["state"] != session.get("state", None):
|
if authn_response["state"] != session.get("state", None):
|
||||||
# this most likely means the user's Django session vanished
|
# this most likely means the user's Django session vanished
|
||||||
logger.error("Received state not the same as expected for %s" % state)
|
|
||||||
if session.get("state", None) is None:
|
if session.get("state", None) is None:
|
||||||
raise o_e.NoStateDefined()
|
logger.error(
|
||||||
raise o_e.AuthenticationFailed(locator=state)
|
f"The OP state {state} does not match the session state. "
|
||||||
|
f"The session state is None. "
|
||||||
|
f"authn_response['state'] = {authn_response['state']} "
|
||||||
|
f"session.get('state', None) = {session.get('state', None)}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
f"The OP state {state} does not match the session state. "
|
||||||
|
f"authn_response['state'] = {authn_response['state']} "
|
||||||
|
f"session.get('state', None) = {session.get('state', None)}"
|
||||||
|
)
|
||||||
|
raise o_e.StateMismatch()
|
||||||
|
|
||||||
if self.behaviour.get("response_type") == "code":
|
if self.behaviour.get("response_type") == "code":
|
||||||
# need an access token to get user info (and to log the user out later)
|
# need an access token to get user info (and to log the user out later)
|
||||||
|
|
|
@ -4,7 +4,7 @@ from django.http import HttpResponse
|
||||||
from django.test import Client, TestCase, RequestFactory
|
from django.test import Client, TestCase, RequestFactory
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from djangooidc.exceptions import NoStateDefined, InternalError
|
from djangooidc.exceptions import StateMismatch, InternalError
|
||||||
from ..views import login_callback
|
from ..views import login_callback
|
||||||
|
|
||||||
from .common import less_console_noise
|
from .common import less_console_noise
|
||||||
|
@ -129,21 +129,35 @@ class ViewsTest(TestCase):
|
||||||
self.assertContains(response, "Hi")
|
self.assertContains(response, "Hi")
|
||||||
|
|
||||||
def test_login_callback_with_no_session_state(self, mock_client):
|
def test_login_callback_with_no_session_state(self, mock_client):
|
||||||
"""If the local session is None (ie the server restarted while user was logged out),
|
"""If the local session does not match the OP session,
|
||||||
we do not throw an exception. Rather, we attempt to login again."""
|
we do not throw an exception. Rather, we attempt to login again."""
|
||||||
with less_console_noise():
|
with less_console_noise():
|
||||||
# MOCK
|
# MOCK get_default_acr_value and the callback to raise StateMismatch
|
||||||
# mock the acr_value to some string
|
# error when called
|
||||||
# mock the callback function to raise the NoStateDefined Exception
|
|
||||||
mock_client.get_default_acr_value.side_effect = self.create_acr
|
mock_client.get_default_acr_value.side_effect = self.create_acr
|
||||||
mock_client.callback.side_effect = NoStateDefined()
|
mock_client.callback.side_effect = StateMismatch()
|
||||||
# TEST
|
# TEST receiving a response from login.gov
|
||||||
# test the login callback
|
|
||||||
response = self.client.get(reverse("openid_login_callback"))
|
response = self.client.get(reverse("openid_login_callback"))
|
||||||
# ASSERTIONS
|
# ASSERT
|
||||||
# assert that the user is redirected to the start of the login process
|
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.url, "/")
|
self.assertEqual(response.url, "/")
|
||||||
|
# Check that the redirect_attempted flag is set in the session
|
||||||
|
self.assertTrue(self.client.session.get("redirect_attempted", False))
|
||||||
|
|
||||||
|
def test_login_callback_with_no_session_state_attempt_again_only_once(self, mock_client):
|
||||||
|
"""We only attempt to relogin once. After that, it's the error page for you."""
|
||||||
|
with less_console_noise():
|
||||||
|
# MOCK get_default_acr_value, redirect_attempted to True and the callback
|
||||||
|
# to raise StateMismatch error when called
|
||||||
|
mock_client.get_default_acr_value.side_effect = self.create_acr
|
||||||
|
mock_client.callback.side_effect = StateMismatch()
|
||||||
|
session = self.client.session
|
||||||
|
session["redirect_attempted"] = True
|
||||||
|
session.save()
|
||||||
|
# TEST receiving a response from login.gov
|
||||||
|
response = self.client.get(reverse("openid_login_callback"))
|
||||||
|
# ASSERT
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
def test_login_callback_reads_next(self, mock_client):
|
def test_login_callback_reads_next(self, mock_client):
|
||||||
"""If the next value is set in the session, test that login_callback returns
|
"""If the next value is set in the session, test that login_callback returns
|
||||||
|
|
|
@ -101,16 +101,25 @@ def login_callback(request):
|
||||||
if user:
|
if user:
|
||||||
login(request, user)
|
login(request, user)
|
||||||
logger.info("Successfully logged in user %s" % user)
|
logger.info("Successfully logged in user %s" % user)
|
||||||
# Double login bug (1507)?
|
# Clear the flag if the exception is not caught
|
||||||
|
request.session.pop("redirect_attempted", None)
|
||||||
return redirect(request.session.get("next", "/"))
|
return redirect(request.session.get("next", "/"))
|
||||||
else:
|
else:
|
||||||
raise o_e.BannedUser()
|
raise o_e.BannedUser()
|
||||||
except o_e.NoStateDefined as nsd_err:
|
except o_e.StateMismatch as nsd_err:
|
||||||
# In the event that a user is in the middle of a login when the app is restarted,
|
# Check if the redirect has already been attempted
|
||||||
# their session state will no longer be available, so redirect the user to the
|
if not request.session.get("redirect_attempted", False):
|
||||||
# beginning of login process without raising an error to the user.
|
# Set the flag to indicate that the redirect has been attempted
|
||||||
|
request.session["redirect_attempted"] = True
|
||||||
|
|
||||||
|
# In the event of a state mismatch between OP and session, redirect the user to the
|
||||||
|
# beginning of login process without raising an error to the user. Attempt once.
|
||||||
logger.warning(f"No State Defined: {nsd_err}")
|
logger.warning(f"No State Defined: {nsd_err}")
|
||||||
return redirect(request.session.get("next", "/"))
|
return redirect(request.session.get("next", "/"))
|
||||||
|
else:
|
||||||
|
# Clear the flag if the exception is not caught
|
||||||
|
request.session.pop("redirect_attempted", None)
|
||||||
|
return error_page(request, nsd_err)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
return error_page(request, err)
|
return error_page(request, err)
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue