diff --git a/config/settings.py b/config/settings.py index 115583d75..a581661d3 100644 --- a/config/settings.py +++ b/config/settings.py @@ -20,9 +20,9 @@ from dbt_copilot_python.utility import is_copilot from django.forms import Field from django_log_formatter_asim import ASIMFormatter +from govuk_onelogin_django import types as one_login_types from config.env import env -from web.one_login import types as one_login_types from web.utils.sentry import init_sentry # Build paths inside the project like this: BASE_DIR / "subdir". @@ -62,6 +62,8 @@ "django.contrib.sites", # STAFF-SSO client app "authbroker_client", + # GOV.OK One Login Client app + "govuk_onelogin_django", ] MIDDLEWARE = [ @@ -464,7 +466,7 @@ # Used to change one login auth level (remove 2FA in non-production) if env.gov_uk_one_login_authentication_level_override: - GOV_UK_ONE_LOGIN_AUTHENTICATION_LEVEL = env.gov_uk_one_login_authentication_level_override # type: ignore[assignment] + GOV_UK_ONE_LOGIN_AUTHENTICATION_LEVEL = env.gov_uk_one_login_authentication_level_override # Site URL management CASEWORKER_SITE_URL = env.caseworker_site_url diff --git a/pii-ner-exclude.txt b/pii-ner-exclude.txt index e18ccadef..1e6bbd8dd 100644 --- a/pii-ner-exclude.txt +++ b/pii-ner-exclude.txt @@ -5231,3 +5231,4 @@ v3.19.1 José João Caminhão Cachaçaria Pêssegó Jose Joao Caminhao Cachacaria Pessego +GOV.OK One Login Client diff --git a/pyproject.toml b/pyproject.toml index 4c0fe729a..53e5432ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,8 @@ module = [ "endesive.*", "PIL.*", "reportlab.*", + # TODO: Add types package. + "govuk_onelogin_django.*" ] ignore_missing_imports = true diff --git a/web/auth/backends.py b/web/auth/backends.py index e4d162b43..044490b38 100644 --- a/web/auth/backends.py +++ b/web/auth/backends.py @@ -3,6 +3,8 @@ from authbroker_client.backends import AuthbrokerBackend from django.contrib.auth.backends import ModelBackend from django.http import HttpRequest +from govuk_onelogin_django.backends import OneLoginBackend +from govuk_onelogin_django.types import UserInfo as OneLoginUserInfo from guardian.backends import check_support from guardian.conf import settings as guardian_settings from guardian.ctypes import get_content_type @@ -10,8 +12,6 @@ from web.mail.emails import send_new_user_welcome_email from web.models import User -from web.one_login.backends import OneLoginBackend -from web.one_login.types import UserInfo as OneLoginUserInfo from web.sites import is_caseworker_site from .types import STAFF_SSO_ID, StaffSSOProfile, StaffSSOUserCreateData diff --git a/web/auth/utils.py b/web/auth/utils.py index 9dca6fc57..c2b3fb3e8 100644 --- a/web/auth/utils.py +++ b/web/auth/utils.py @@ -5,10 +5,10 @@ from django.db import transaction from django.http import HttpRequest from django.utils import timezone +from govuk_onelogin_django.types import UserCreateData as OneLoginUserCreateData from web.models import Email as UserEmail from web.models import User -from web.one_login.types import UserCreateData as OneLoginUserCreateData from web.sites import is_exporter_site, is_importer_site from .types import StaffSSOUserCreateData diff --git a/web/domains/user/forms.py b/web/domains/user/forms.py index 63d0c35a4..e5664a074 100644 --- a/web/domains/user/forms.py +++ b/web/domains/user/forms.py @@ -4,11 +4,11 @@ from django.forms.widgets import EmailInput, Select, Textarea from django.utils.translation import gettext_lazy as _ from django_filters import CharFilter, ChoiceFilter, FilterSet +from govuk_onelogin_django.constants import ONE_LOGIN_UNSET_NAME from web.forms.fields import JqueryDateField, PhoneNumberField from web.forms.widgets import ICMSModelSelect2Widget, YesNoRadioSelectInline from web.models import Email, Exporter, Importer, PhoneNumber, User -from web.one_login.constants import ONE_LOGIN_UNSET_NAME class OneLoginNewUserUpdateForm(forms.ModelForm): diff --git a/web/domains/user/models.py b/web/domains/user/models.py index ea37ee8c7..d417eb782 100644 --- a/web/domains/user/models.py +++ b/web/domains/user/models.py @@ -4,11 +4,10 @@ from django.contrib.auth.models import AbstractUser from django.db import models from django.urls import reverse +from govuk_onelogin_django.constants import ONE_LOGIN_UNSET_NAME from guardian.core import ObjectPermissionChecker from guardian.mixins import GuardianUserMixin -from web.one_login.constants import ONE_LOGIN_UNSET_NAME - class User(GuardianUserMixin, AbstractUser): def __init__(self, *args, **kwargs): diff --git a/web/mail/types.py b/web/mail/types.py index 63d99ca4d..022034367 100644 --- a/web/mail/types.py +++ b/web/mail/types.py @@ -2,8 +2,7 @@ from typing import Any, TypedDict from django.conf import settings - -from web.one_login.constants import ONE_LOGIN_UNSET_NAME +from govuk_onelogin_django.constants import ONE_LOGIN_UNSET_NAME from .constants import DEFAULT_APPLICANT_GREETING diff --git a/web/middleware/one_login.py b/web/middleware/one_login.py index 7960e6b6b..291e12833 100644 --- a/web/middleware/one_login.py +++ b/web/middleware/one_login.py @@ -3,9 +3,9 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.shortcuts import redirect from django.urls import resolve, reverse +from govuk_onelogin_django.constants import ONE_LOGIN_UNSET_NAME from web.models import User -from web.one_login.constants import ONE_LOGIN_UNSET_NAME class UserFullyRegisteredMiddleware: diff --git a/web/one_login/__init__.py b/web/one_login/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/web/one_login/admin.py b/web/one_login/admin.py deleted file mode 100644 index 4185d360e..000000000 --- a/web/one_login/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -# from django.contrib import admin - -# Register your models here. diff --git a/web/one_login/apps.py b/web/one_login/apps.py deleted file mode 100644 index 0cb435714..000000000 --- a/web/one_login/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class OneLoginConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "one_login" diff --git a/web/one_login/backends.py b/web/one_login/backends.py deleted file mode 100644 index 3aaf0b147..000000000 --- a/web/one_login/backends.py +++ /dev/null @@ -1,73 +0,0 @@ -from typing import TYPE_CHECKING, Any, Literal - -from django.contrib.auth import get_user_model -from django.http import HttpRequest - -from . import types -from .constants import ONE_LOGIN_UNSET_NAME -from .utils import get_client, get_userinfo, has_valid_token - -if TYPE_CHECKING: - from django.contrib.auth.models import User - -UserModel = get_user_model() - - -class OneLoginBackend: - def authenticate(self, request: HttpRequest, **credentials: Any) -> "User | None": - user = None - client = get_client(request) - - if has_valid_token(client): - userinfo = get_userinfo(client) - - user = self.get_or_create_user(userinfo) - - if user and self.user_can_authenticate(user): - return user - - return None - - def get_or_create_user(self, profile: types.UserInfo) -> "User": - id_key = self.get_profile_id_name() - - user, created = UserModel.objects.get_or_create( - **{UserModel.USERNAME_FIELD: profile[id_key]}, - defaults=self.user_create_mapping(profile), - ) - - if created: - user.set_unusable_password() - user.save() - - return user - - def user_create_mapping(self, userinfo: types.UserInfo) -> types.UserCreateData: - return { - "email": userinfo["email"], - "first_name": ONE_LOGIN_UNSET_NAME, - "last_name": ONE_LOGIN_UNSET_NAME, - } - - @staticmethod - def get_profile_id_name() -> Literal["sub"]: - return "sub" - - def get_user(self, user_id: int) -> "User | None": - user_cls = get_user_model() - - try: - return user_cls.objects.get(pk=user_id) - - except user_cls.DoesNotExist: - return None - - def user_can_authenticate(self, user: "User") -> bool: - """Reject users with is_active=False. - - Custom user models that don't have that attribute are allowed. - """ - - is_active = getattr(user, "is_active", None) - - return is_active or is_active is None diff --git a/web/one_login/constants.py b/web/one_login/constants.py deleted file mode 100644 index e9e7f8382..000000000 --- a/web/one_login/constants.py +++ /dev/null @@ -1,3 +0,0 @@ -# Default name used when saving a user record for a new GOV.UK One Login user. -# We need this because GOV.UK One Login will not provide name data without identity verification. -ONE_LOGIN_UNSET_NAME = "one_login_unset" diff --git a/web/one_login/migrations/__init__.py b/web/one_login/migrations/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/web/one_login/models.py b/web/one_login/models.py deleted file mode 100644 index 24e16895d..000000000 --- a/web/one_login/models.py +++ /dev/null @@ -1 +0,0 @@ -# from django.db import models diff --git a/web/one_login/tests/__init__.py b/web/one_login/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/web/one_login/tests/test_backends.py b/web/one_login/tests/test_backends.py deleted file mode 100644 index 35af34a79..000000000 --- a/web/one_login/tests/test_backends.py +++ /dev/null @@ -1,115 +0,0 @@ -from unittest import mock - -from django.contrib.auth import get_user_model - -from web.one_login.backends import OneLoginBackend -from web.one_login.types import UserInfo - - -@mock.patch.multiple( - "web.one_login.backends", - get_client=mock.DEFAULT, - has_valid_token=mock.DEFAULT, - get_userinfo=mock.DEFAULT, - autospec=True, -) -def test_user_valid_user_create(db, rf, **mocks): - mocks["has_valid_token"].return_value = True - mocks["get_userinfo"].return_value = UserInfo( - sub="some-unique-key", email="user@test.com", email_verified=True # /PS-IGNORE - ) - - user = OneLoginBackend().authenticate(rf) - assert user is not None - assert user.email == "user@test.com" # /PS-IGNORE - assert user.username == "some-unique-key" - assert user.has_usable_password() is False - - -@mock.patch.multiple( - "web.one_login.backends", - get_client=mock.DEFAULT, - has_valid_token=mock.DEFAULT, - get_userinfo=mock.DEFAULT, - autospec=True, -) -def test_user_valid_user_not_create(db, rf, **mocks): - User = get_user_model() - user = User( - username="some-unique-key", - email="user@test.com", # /PS-IGNORE - first_name="Test", - last_name="User", - ) - user.set_password("password") - user.save() - - mocks["has_valid_token"].return_value = True - mocks["get_userinfo"].return_value = UserInfo( - sub="some-unique-key", email="user@test.com", email_verified=True # /PS-IGNORE - ) - - user = OneLoginBackend().authenticate(request=rf) - assert user is not None - - assert user.first_name == "Test" - assert user.last_name == "User" - assert user.email == "user@test.com" # /PS-IGNORE - assert user.has_usable_password() is True - - -@mock.patch.multiple( - "web.one_login.backends", - get_client=mock.DEFAULT, - has_valid_token=mock.DEFAULT, - get_userinfo=mock.DEFAULT, - autospec=True, -) -def test_user_inactive(db, rf, **mocks): - User = get_user_model() - user = User( - username="some-unique-key", - email="user@test.com", # /PS-IGNORE - first_name="Test", - last_name="User", - is_active=False, - ) - user.set_password("password") - user.save() - - mocks["has_valid_token"].return_value = True - mocks["get_userinfo"].return_value = UserInfo( - sub="some-unique-key", email="user@test.com", email_verified=True # /PS-IGNORE - ) - - user = OneLoginBackend().authenticate(request=rf) - assert user is None - - -@mock.patch.multiple( - "web.one_login.backends", - get_client=mock.DEFAULT, - has_valid_token=mock.DEFAULT, - get_userinfo=mock.DEFAULT, - autospec=True, -) -def test_invalid_user(db, rf, **mocks): - mocks["has_valid_token"].return_value = False - assert OneLoginBackend().authenticate(request=rf) is None - - -def test_get_user_user_exists(db): - User = get_user_model() - user = User( - username="some-unique-key", - email="user@test.com", # /PS-IGNORE - first_name="Test", - last_name="User", - ) - user.save() - - assert OneLoginBackend().get_user(user.pk) == user - - -def test_get_user_user_doesnt_exist(db): - assert OneLoginBackend().get_user(99999) is None diff --git a/web/one_login/tests/test_utils.py b/web/one_login/tests/test_utils.py deleted file mode 100644 index 5ac242bba..000000000 --- a/web/one_login/tests/test_utils.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import Any -from unittest import mock - -from django.test.client import Client, RequestFactory - -from web.one_login.utils import TOKEN_SESSION_KEY, get_one_login_logout_url - - -@mock.patch.multiple("web.one_login.utils", OneLoginConfig=mock.DEFAULT, autospec=True) -def test_get_one_login_logout_url(client: Client, rf: RequestFactory, **mocks: Any) -> None: - mocks["OneLoginConfig"].return_value.end_session_url = "https://fake-one.login.gov.uk/logout/" - - request = rf.request() - request.session = client.session - request.session[TOKEN_SESSION_KEY] = {"id_token": "FAKE-TOKEN"} - request.session.save() - - # Test without post logout callback url - assert get_one_login_logout_url(request) == "https://fake-one.login.gov.uk/logout/" - - # Test with post logout callback url - expected = "https://fake-one.login.gov.uk/logout/?id_token_hint=FAKE-TOKEN&post_logout_redirect_uri=https%3A%2F%2Fmy-site-post-logout-redirect%2F" - actual = get_one_login_logout_url(request, "https://my-site-post-logout-redirect/") - - assert expected == actual diff --git a/web/one_login/tests/test_views.py b/web/one_login/tests/test_views.py deleted file mode 100644 index 0d36e11f5..000000000 --- a/web/one_login/tests/test_views.py +++ /dev/null @@ -1,169 +0,0 @@ -from http import HTTPStatus -from unittest import mock - -import pytest -from authlib.jose.errors import InvalidClaimError -from django.conf import settings -from django.core.cache import cache -from django.test import override_settings -from django.urls import reverse - -from web.one_login.utils import TOKEN_SESSION_KEY, OneLoginConfig -from web.one_login.views import REDIRECT_SESSION_FIELD_NAME - -FAKE_OPENID_CONFIG_URL = "https://oidc.onelogin.gov.uk/.well-known/openid-configuration" -FAKE_AUTHORIZE_URL = "https://oidc.onelogin.gov.uk/authorize" - - -@pytest.fixture(autouse=True, scope="class") -def correct_settings(): - """Ensure One Login is enabled for all tests""" - - # Clear openid config before and after tests. - # Required for the following reasons: - # - The local application has already cached the real values (will break tests) - # - The tests have cached the fake value (will break local development) - cache.delete(OneLoginConfig.CACHE_KEY) - - with override_settings( - GOV_UK_ONE_LOGIN_ENABLED=True, - GOV_UK_ONE_LOGIN_OPENID_CONFIG_URL=FAKE_OPENID_CONFIG_URL, - # Required to fix tests (these tests don't really care about SITE_ID) - # 2 == The exporter site - SITE_ID=2, - ): - yield None - - cache.delete(OneLoginConfig.CACHE_KEY) - - -@pytest.fixture(autouse=True) -def openid_config(requests_mock): - requests_mock.get( - FAKE_OPENID_CONFIG_URL, - json={ - "authorization_endpoint": FAKE_AUTHORIZE_URL, - "token_endpoint": "", - "userinfo_endpoint": "", - "end_session_endpoint": "", - "issuer": "", - }, - ) - - -class TestAuthView: - @pytest.fixture(autouse=True) - def setup(self, db, client): - self.client = client - self.url = reverse("one_login:login") - - def test_auth_view(self): - response = self.client.get(self.url) - - assert response.status_code == HTTPStatus.FOUND - assert FAKE_AUTHORIZE_URL in response.url - - def test_auth_view_retains_next_url(self): - response = self.client.get(self.url + "?next=/workbasket/") - assert response.status_code == HTTPStatus.FOUND - assert self.client.session[REDIRECT_SESSION_FIELD_NAME] == "/workbasket/" - - def test_auth_view_retains_unsafe_next_url(self): - response = self.client.get(self.url + "?next=https://danger.com") - assert response.status_code == HTTPStatus.FOUND - assert not self.client.session[REDIRECT_SESSION_FIELD_NAME] - - -class TestAuthCallbackView: - @pytest.fixture(autouse=True) - def setup(self, db, client): - self.client = client - self.url = reverse("one_login:callback") - - @mock.patch.multiple( - "web.one_login.views", - get_oauth_state=mock.DEFAULT, - get_token=mock.DEFAULT, - authenticate=mock.DEFAULT, - login=mock.DEFAULT, - autospec=True, - ) - def test_auth_callback_view(self, **mocks): - auth_code = "fake-auth-code" - state = "fake-state" - - mocks["get_oauth_state"].return_value = state - mocks["get_token"].return_value = {"token": "fake"} - - response = self.client.get(f"{self.url}?code={auth_code}&state={state}") - - assert self.client.session[TOKEN_SESSION_KEY] == {"token": "fake"} - assert response.status_code == HTTPStatus.FOUND - assert response.url == reverse(settings.LOGIN_REDIRECT_URL) - - @mock.patch.multiple( - "web.one_login.views", - get_oauth_state=mock.DEFAULT, - get_token=mock.DEFAULT, - authenticate=mock.DEFAULT, - login=mock.DEFAULT, - autospec=True, - ) - def test_auth_callback_view_with_next_url(self, **mocks): - next_url = reverse("case:search", kwargs={"case_type": "import", "mode": "standard"}) - - # Magic session variable dance to persist session - # https://docs.djangoproject.com/en/4.2/topics/testing/tools/#django.test.Client.session - session = self.client.session - session[REDIRECT_SESSION_FIELD_NAME] = next_url - session.save() - - auth_code = "fake-auth-code" - state = "fake-state" - mocks["get_oauth_state"].return_value = state - mocks["get_token"].return_value = {"token": "fake"} - - response = self.client.get(f"{self.url}?code={auth_code}&state={state}") - - assert self.client.session[TOKEN_SESSION_KEY] == {"token": "fake"} - assert response.status_code == HTTPStatus.FOUND - assert response.url == next_url - - def test_auth_callback_view_no_code(self, caplog): - response = self.client.get(f"{self.url}") - - assert response.status_code == HTTPStatus.FOUND - assert response.url == reverse("login-start") - assert caplog.messages[0] == "No auth code returned from one_login" - - def test_auth_callback_view_no_session_state(self, caplog): - auth_code = "fake-auth-code" - state = "fake-state" - - response = self.client.get(f"{self.url}?code={auth_code}&state={state}") - assert response.status_code == HTTPStatus.BAD_REQUEST - assert caplog.messages[0] == "No state found in session" - - @mock.patch.multiple("web.one_login.views", get_oauth_state=mock.DEFAULT, autospec=True) - def test_auth_callback_view_invalid_state(self, caplog, **mocks): - auth_code = "fake-auth-code" - state = "invalid-fake-state" - mocks["get_oauth_state"].return_value = "fake-state" - - response = self.client.get(f"{self.url}?code={auth_code}&state={state}") - assert response.status_code == HTTPStatus.BAD_REQUEST - assert caplog.messages[0] == "Session state and passed back state differ" - - @mock.patch.multiple( - "web.one_login.views", get_oauth_state=mock.DEFAULT, get_token=mock.DEFAULT, autospec=True - ) - def test_auth_callback_view_invalid_token(self, caplog, **mocks): - auth_code = "fake-auth-code" - state = "fake-state" - - mocks["get_oauth_state"].return_value = state - mocks["get_token"].side_effect = InvalidClaimError("claim_value") - - response = self.client.get(f"{self.url}?code={auth_code}&state={state}") - assert response.status_code == HTTPStatus.BAD_REQUEST - assert caplog.messages[0] == "Unable to validate token" diff --git a/web/one_login/types.py b/web/one_login/types.py deleted file mode 100644 index a303f2ca2..000000000 --- a/web/one_login/types.py +++ /dev/null @@ -1,32 +0,0 @@ -import enum -from typing import TypedDict - - -class UserInfo(TypedDict): - # https://docs.sign-in.service.gov.uk/integrate-with-integration-environment/authenticate-your-user/#retrieve-user-information - # openid scope - sub: str - # email scope - email: str - email_verified: bool - - -class UserCreateData(TypedDict): - email: str - first_name: str - last_name: str - - -# https://docs.sign-in.service.gov.uk/before-integrating/choose-the-level-of-authentication/ -class AuthenticationLevel(enum.StrEnum): - # Username and Password - LOW_LEVEL = "Cl" - # LOW_LEVEL & two-factor authentication - MEDIUM_LEVEL = "Cl.Cm" - - -# https://docs.sign-in.service.gov.uk/before-integrating/choose-the-level-of-identity-confidence/ -class IdentityConfidenceLevel(enum.StrEnum): - NONE = "P0" - LOW = "P1" - MEDIUM = "P2" diff --git a/web/one_login/urls.py b/web/one_login/urls.py deleted file mode 100644 index d1eab12bf..000000000 --- a/web/one_login/urls.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.urls import path - -from .views import AuthCallbackView, AuthView - -app_name = "one_login" -urlpatterns = [ - path("login/", AuthView.as_view(), name="login"), - path("callback/", AuthCallbackView.as_view(), name="callback"), -] diff --git a/web/one_login/utils.py b/web/one_login/utils.py deleted file mode 100644 index 1a863a44f..000000000 --- a/web/one_login/utils.py +++ /dev/null @@ -1,257 +0,0 @@ -import base64 -import json -import logging -from typing import Any - -import requests -from authlib.integrations.requests_client import OAuth2Session -from authlib.jose import jwt -from authlib.oauth2.rfc7523 import PrivateKeyJWT -from authlib.oidc.core import IDToken -from django.conf import settings -from django.core.cache import cache -from django.http import HttpRequest, QueryDict -from django.urls import reverse -from django.utils.module_loading import import_string - -from . import types - -logger = logging.getLogger(__name__) -TOKEN_SESSION_KEY = "_one_login_token" - - -def get_client(request: HttpRequest) -> OAuth2Session: - callback_url = reverse("one_login:callback") - redirect_uri = request.build_absolute_uri(callback_url) - - session = OAuth2Session( - client_id=get_client_id(request), - client_secret=get_secret(request), - token_endpoint_auth_method="private_key_jwt", - redirect_uri=redirect_uri, - scope=get_scope(), - token=request.session.get(TOKEN_SESSION_KEY, None), - ) - - return session - - -class OneLoginConfig: - CACHE_KEY = "one_login_metadata_cache" - CACHE_EXPIRY = 60 * 60 # seconds - - def __init__(self) -> None: - self._conf: dict[str, Any] = {} - - def get_public_keys(self) -> list[dict[str, str]]: - # https://docs.sign-in.service.gov.uk/integrate-with-integration-environment/authenticate-your-user/#validate-your-id-token - resp = requests.get(self.openid_config["jwks_uri"]) - resp.raise_for_status() - data = resp.json() - - return data["keys"] - - @property - def openid_config(self) -> dict[str, Any]: - # Cached on instance - if self._conf: - logger.debug("one login conf: using instance attribute") - return self._conf - - # Cached in redis store - cache_config = cache.get(self.CACHE_KEY) - if cache_config: - logger.debug("one login conf: using cache value") - self._conf = json.loads(cache_config) - return self._conf - - # Retrieve and store value - config = self._get_configuration() - cache.set(self.CACHE_KEY, json.dumps(config), timeout=self.CACHE_EXPIRY) - self._conf = config - logger.debug("one login conf: using fresh value") - - return self._conf - - def _get_configuration(self) -> dict[str, Any]: - resp = requests.get(settings.GOV_UK_ONE_LOGIN_OPENID_CONFIG_URL) - resp.raise_for_status() - metadata = resp.json() - - return metadata - - @property - def authorise_url(self) -> str: - return self.openid_config["authorization_endpoint"] - - @property - def token_url(self) -> str: - return self.openid_config["token_endpoint"] - - @property - def userinfo_url(self) -> str: - return self.openid_config["userinfo_endpoint"] - - @property - def end_session_url(self) -> str: - return self.openid_config["end_session_endpoint"] - - @property - def issuer(self) -> str: - return self.openid_config["issuer"] - - -def get_token(request: HttpRequest, auth_code: str) -> dict: - client = get_client(request) - config = OneLoginConfig() - - client.register_client_auth_method(PrivateKeyJWT(token_endpoint=config.token_url)) - - # https://docs.sign-in.service.gov.uk/integrate-with-integration-environment/authenticate-your-user/#receive-response-for-make-a-token-request - token = client.fetch_token( - url=config.token_url, - code=auth_code, - # If you’re requesting a refresh token, you must set this parameter to refresh_token. - # Otherwise, you need to set the parameter to authorization_code. - grant_type="authorization_code", - # https://docs.sign-in.service.gov.uk/integrate-with-integration-environment/authenticate-your-user/#make-a-post-request-to-the-token-endpoint - # Required value when using private_key_jwt auth. - client_assertion_type="urn:ietf:params:oauth:client-assertion-type:jwt-bearer", - ) - - validate_token(request, token) - - return token - - -def validate_token(request: HttpRequest, token: dict[str, Any]) -> None: - config = OneLoginConfig() - stored_nonce = get_oauth_nonce(request) - - # id_token contents: - # https://docs.sign-in.service.gov.uk/integrate-with-integration-environment/authenticate-your-user/#understand-your-id-token - claims = jwt.decode( - token["id_token"], - config.get_public_keys(), - claims_cls=IDToken, - claims_options={ - "iss": {"essential": True, "value": config.issuer}, - "aud": {"essential": True, "value": get_client_id(request)}, - }, - claims_params={"nonce": stored_nonce}, - ) - claims.validate() - - -def get_userinfo(client: OAuth2Session) -> types.UserInfo: - config = OneLoginConfig() - resp = client.get(config.userinfo_url) - resp.raise_for_status() - - return resp.json() - - -def has_valid_token(client: OAuth2Session) -> bool: - # TODO: ICMSLST-2300 Revisit if supporting OIDC back-channel logout. - return client.token is not None - - -def store_oauth_state(request: HttpRequest, state: str) -> None: - request.session[f"{TOKEN_SESSION_KEY}_oauth_state"] = state - - -def get_oauth_state(request: HttpRequest) -> str | None: - return request.session.get(f"{TOKEN_SESSION_KEY}_oauth_state", None) - - -def delete_oauth_state(request: HttpRequest) -> None: - request.session.delete(f"{TOKEN_SESSION_KEY}_oauth_state") - - -def store_oauth_nonce(request: HttpRequest, nonce: str) -> None: - request.session[f"{TOKEN_SESSION_KEY}_oauth_nonce"] = nonce - - -def get_oauth_nonce(request: HttpRequest) -> str | None: - return request.session.get(f"{TOKEN_SESSION_KEY}_oauth_nonce", None) - - -def delete_oauth_nonce(request: HttpRequest) -> None: - request.session.delete(f"{TOKEN_SESSION_KEY}_oauth_nonce") - - -def get_secret(request: HttpRequest) -> bytes: - # key is stored like this: base64 -i private_key.pem so decode. - return base64.b64decode(get_client_secret(request)) # /PS-IGNORE - - -def get_scope(): - return getattr(settings, "GOV_UK_ONE_LOGIN_SCOPE", "openid email") - - -def get_client_id(request: HttpRequest) -> str: - """Fetch the client id in one of two ways. - - 1. Using a function called get_one_login_client_id defined in the module specified at - GOV_UK_ONE_LOGIN_GET_CLIENT_CONFIG_PATH setting. - 2. Returning the value found in GOV_UK_ONE_LOGIN_CLIENT_ID setting. - """ - - if path := getattr(settings, "GOV_UK_ONE_LOGIN_GET_CLIENT_CONFIG_PATH", None): - logger.debug(f"Using {path} to find get_one_login_client_id function.") - - get_one_login_client_id = import_string(f"{path}.get_one_login_client_id") - - return get_one_login_client_id(request) - - logger.debug("Using GOV_UK_ONE_LOGIN_CLIENT_ID to find client secret.") - - # Default if custom function not defined - return getattr(settings, "GOV_UK_ONE_LOGIN_CLIENT_ID", "") - - -def get_client_secret(request: HttpRequest) -> str: - """Fetch the client secret in one of two ways. - - 1. Using a function called get_one_login_client_secret defined in the module specified at - GOV_UK_ONE_LOGIN_GET_CLIENT_CONFIG_PATH setting. - 2. Returning the value found in GOV_UK_ONE_LOGIN_CLIENT_SECRET setting. - """ - - if path := getattr(settings, "GOV_UK_ONE_LOGIN_GET_CLIENT_CONFIG_PATH", None): - logger.debug(f"Using {path} to find get_one_login_client_secret function.") - - get_one_login_client_secret = import_string(f"{path}.get_one_login_client_secret") - - return get_one_login_client_secret(request) - - logger.debug("Using GOV_UK_ONE_LOGIN_CLIENT_SECRET to find client secret.") - - # Default if custom function not defined - return getattr(settings, "GOV_UK_ONE_LOGIN_CLIENT_SECRET", "") - - -def get_one_login_logout_url( - request: HttpRequest, post_logout_redirect_uri: str | None = None -) -> str: - """Get logout url for logging a user out of GOV.UK One Login. - - https://docs.sign-in.service.gov.uk/integrate-with-integration-environment/managing-your-users-sessions/#log-your-user-out-of-gov-uk-one-login - - :param request: Django HttpRequest instance - :param post_logout_redirect_uri: Optional redirect url - """ - - url = OneLoginConfig().end_session_url - - if post_logout_redirect_uri: - qd = QueryDict(mutable=True) - qd.update( - { - "id_token_hint": request.session[TOKEN_SESSION_KEY]["id_token"], - "post_logout_redirect_uri": post_logout_redirect_uri, - } - ) - url = f"{url}?{qd.urlencode()}" - - return url diff --git a/web/one_login/views.py b/web/one_login/views.py deleted file mode 100644 index 0e2765307..000000000 --- a/web/one_login/views.py +++ /dev/null @@ -1,116 +0,0 @@ -import logging -from typing import Any - -from authlib.common.security import generate_token -from authlib.jose.errors import InvalidClaimError -from django.conf import settings -from django.contrib.auth import REDIRECT_FIELD_NAME, authenticate, login -from django.core.exceptions import SuspiciousOperation -from django.http import HttpRequest -from django.shortcuts import redirect -from django.urls import reverse -from django.utils.http import url_has_allowed_host_and_scheme -from django.views.generic.base import RedirectView, View - -from .types import AuthenticationLevel, IdentityConfidenceLevel -from .utils import ( - TOKEN_SESSION_KEY, - OneLoginConfig, - delete_oauth_nonce, - delete_oauth_state, - get_client, - get_oauth_state, - get_token, - store_oauth_nonce, - store_oauth_state, -) - -logger = logging.getLogger(__name__) - - -def get_trust_vector( - auth_level: AuthenticationLevel, identity_level: IdentityConfidenceLevel -) -> dict[str, str]: - return {"vtr": f"['{auth_level}.{identity_level}']"} - - -REDIRECT_SESSION_FIELD_NAME = f"_oauth2_{REDIRECT_FIELD_NAME}" - - -def get_next_url(request): - """Copied straight from staff-sso-client. - - https://github.com/uktrade/django-staff-sso-client/blob/master/authbroker_client/views.py - """ - next_url = request.GET.get( - REDIRECT_FIELD_NAME, request.session.get(REDIRECT_SESSION_FIELD_NAME) - ) - if next_url and url_has_allowed_host_and_scheme( - next_url, allowed_hosts=settings.ALLOWED_HOSTS, require_https=request.is_secure() - ): - return next_url - - return None - - -class AuthView(RedirectView): - def get_redirect_url(self, *args, **kwargs): - client = get_client(self.request) - config = OneLoginConfig() - - nonce = generate_token() - trust_vector = get_trust_vector( - settings.GOV_UK_ONE_LOGIN_AUTHENTICATION_LEVEL, - settings.GOV_UK_ONE_LOGIN_CONFIDENCE_LEVEL, - ) - - url, state = client.create_authorization_url( - config.authorise_url, - nonce=nonce, - **trust_vector, - ) - - self.request.session[REDIRECT_SESSION_FIELD_NAME] = get_next_url(self.request) - store_oauth_state(self.request, state) - store_oauth_nonce(self.request, nonce) - - return url - - -class AuthCallbackView(View): - def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Any: - auth_code = self.request.GET.get("code", None) - - if not auth_code: - logger.error("No auth code returned from one_login") - return redirect(reverse("login-start")) - - state = get_oauth_state(self.request) - if not state: - logger.error("No state found in session") - raise SuspiciousOperation("No state found in session") - - auth_service_state = self.request.GET.get("state") - if state != auth_service_state: - logger.error("Session state and passed back state differ") - raise SuspiciousOperation("Session state and passed back state differ") - - try: - token = get_token(self.request, auth_code) - except InvalidClaimError: - logger.error("Unable to validate token") - raise SuspiciousOperation("Unable to validate token") - - self.request.session[TOKEN_SESSION_KEY] = dict(token) - delete_oauth_state(self.request) - delete_oauth_nonce(self.request) - - # Get or create the user - user = authenticate(request) - - if user is not None: - login(request, user) - - next_url = get_next_url(request) or getattr(settings, "LOGIN_REDIRECT_URL", "/") - - return redirect(next_url) diff --git a/web/tests/auth/test_backends.py b/web/tests/auth/test_backends.py index 3baae7fb6..f9e68b0c2 100644 --- a/web/tests/auth/test_backends.py +++ b/web/tests/auth/test_backends.py @@ -6,9 +6,9 @@ from django.contrib.sites.models import Site from django.utils.timezone import make_aware from freezegun import freeze_time +from govuk_onelogin_django.types import UserInfo from web.auth.backends import ICMSGovUKOneLoginBackend, ICMSStaffSSOBackend -from web.one_login.types import UserInfo from web.sites import SiteName # NOTE: Copied tests from here: @@ -218,7 +218,7 @@ def test_get_user_user_doesnt_exist(): class TestICMSGovUKOneLoginBackend: @freeze_time("2024-01-01 12:00:00") @mock.patch.multiple( - "web.one_login.backends", + "govuk_onelogin_django.backends", get_client=mock.DEFAULT, has_valid_token=mock.DEFAULT, get_userinfo=mock.DEFAULT, @@ -245,7 +245,7 @@ def test_user_valid_user_create( @freeze_time("2024-01-01 12:00:00") @mock.patch.multiple( - "web.one_login.backends", + "govuk_onelogin_django.backends", get_client=mock.DEFAULT, has_valid_token=mock.DEFAULT, get_userinfo=mock.DEFAULT, @@ -288,7 +288,7 @@ def test_user_valid_legacy_user_not_create( @freeze_time("2024-01-01 12:00:00") @mock.patch.multiple( - "web.one_login.backends", + "govuk_onelogin_django.backends", get_client=mock.DEFAULT, has_valid_token=mock.DEFAULT, get_userinfo=mock.DEFAULT, @@ -335,7 +335,7 @@ def test_user_valid_legacy_user_not_create_case_insensitive( @freeze_time("2024-01-01 12:00:00") @mock.patch.multiple( - "web.one_login.backends", + "govuk_onelogin_django.backends", get_client=mock.DEFAULT, has_valid_token=mock.DEFAULT, get_userinfo=mock.DEFAULT, diff --git a/web/tests/domains/user/test_forms.py b/web/tests/domains/user/test_forms.py index 1ef060450..2606bc777 100644 --- a/web/tests/domains/user/test_forms.py +++ b/web/tests/domains/user/test_forms.py @@ -1,6 +1,7 @@ import random from django.test import TestCase +from govuk_onelogin_django.constants import ONE_LOGIN_UNSET_NAME from web.domains.user.forms import ( OneLoginNewUserUpdateForm, @@ -9,7 +10,6 @@ UserPhoneNumberForm, ) from web.models import PhoneNumber -from web.one_login.constants import ONE_LOGIN_UNSET_NAME TOTAL_TEST_USERS = 20 diff --git a/web/tests/domains/user/test_views.py b/web/tests/domains/user/test_views.py index 1d188cbba..b42d041fa 100644 --- a/web/tests/domains/user/test_views.py +++ b/web/tests/domains/user/test_views.py @@ -7,13 +7,13 @@ from django.urls import reverse from django.utils import timezone from freezegun import freeze_time +from govuk_onelogin_django.constants import ONE_LOGIN_UNSET_NAME from pytest_django.asserts import assertContains, assertInHTML, assertRedirects from web.forms.fields import JQUERY_DATE_FORMAT from web.mail.constants import EmailTypes from web.mail.url_helpers import get_email_verification_url from web.models import Email, EmailVerification, PhoneNumber, User -from web.one_login.constants import ONE_LOGIN_UNSET_NAME from web.sites import SiteName, get_exporter_site_domain, get_importer_site_domain from web.tests.auth import AuthTestCase from web.tests.conftest import LOGIN_URL diff --git a/web/tests/middleware/test_one_login.py b/web/tests/middleware/test_one_login.py index e797cb8d7..cf985ad59 100644 --- a/web/tests/middleware/test_one_login.py +++ b/web/tests/middleware/test_one_login.py @@ -2,9 +2,9 @@ from django.http import HttpResponseRedirect from django.urls import reverse +from govuk_onelogin_django.constants import ONE_LOGIN_UNSET_NAME from web.middleware.one_login import UserFullyRegisteredMiddleware -from web.one_login.constants import ONE_LOGIN_UNSET_NAME class TestUserFullyRegisteredMiddleware: diff --git a/web/tests/views/test_views.py b/web/tests/views/test_views.py index 699b4241c..be0591e7f 100644 --- a/web/tests/views/test_views.py +++ b/web/tests/views/test_views.py @@ -5,6 +5,7 @@ from django.http import QueryDict from django.test import override_settings from django.urls import reverse, reverse_lazy +from govuk_onelogin_django.utils import get_one_login_logout_url from pytest_django.asserts import assertRedirects from web.domains.case.export.forms import CreateExportApplicationForm @@ -13,7 +14,6 @@ from web.domains.commodity.widgets import UsageCountryWidget from web.domains.contacts.widgets import ContactWidget from web.models import CommodityGroup, ImportApplicationType -from web.one_login.utils import get_one_login_logout_url from web.tests.auth import AuthTestCase from web.views import views diff --git a/web/urls.py b/web/urls.py index 6995b5b06..aa61665da 100644 --- a/web/urls.py +++ b/web/urls.py @@ -44,7 +44,7 @@ def register_converter_if_required(converter, type_name): path("auth/", include("authbroker_client.urls")), # # gov-uk-one-login urls - path("one-login/", include("web.one_login.urls")), + path("one-login/", include("govuk_onelogin_django.urls")), # # ICMS V1 Account recovery view path("account-recovery/", LegacyAccountRecoveryView.as_view(), name="account-recovery"), diff --git a/web/views/views.py b/web/views/views.py index b022bd648..6f7de2c87 100644 --- a/web/views/views.py +++ b/web/views/views.py @@ -23,11 +23,11 @@ from django.views.generic.list import ListView from django_filters import FilterSet from django_select2.views import AutoResponseView +from govuk_onelogin_django.utils import get_one_login_logout_url from web.domains.case.shared import ImpExpStatus from web.flow.errors import ProcessError, ProcessStatusError, TaskError from web.models import Task -from web.one_login.utils import get_one_login_logout_url from web.sites import ( get_exporter_site_domain, get_importer_site_domain,