diff --git a/activate_account_project/settings/base.py b/activate_account_project/settings/base.py index c1e4258d71d4ab03667f8a5f305609bde5fddb4b..83fe3c2fcd50b24a9c9f06957c4f67da6b0675d3 100644 --- a/activate_account_project/settings/base.py +++ b/activate_account_project/settings/base.py @@ -1,8 +1,10 @@ import os import sys +from datetime import timedelta import externalsettings import structlog +from rest_framework.settings import api_settings from api.versions import AVAILABLE_VERSIONS @@ -49,20 +51,18 @@ ALLOWED_HOSTS = ["*"] #: Installed applications INSTALLED_APPS = [ - "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", "whitenoise.runserver_nostatic", # use whitenoise even in development "django.contrib.staticfiles", "crispy_forms", "django_filters", "drf_spectacular", "rest_framework", - "rest_framework.authtoken", + "knox", "activate_account", "api", + "authentication", ] #: Installed middleware @@ -70,12 +70,9 @@ MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", "corsheaders.middleware.CorsMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "django_structlog.middlewares.RequestMiddleware", ] @@ -143,15 +140,17 @@ STATIC_URL = "/static/" #: Authentication backends AUTHENTICATION_BACKENDS = [ - "django.contrib.auth.backends.ModelBackend", + "authentication.backend.ActivateAccountBackend", ] # Configure DRF to use Django's session authentication to determine the current user REST_FRAMEWORK = { "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_AUTHENTICATION_CLASSES": [ - "rest_framework.authentication.SessionAuthentication", - "api.authentication.BearerTokenAuthentication", + "knox.auth.TokenAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", ], "DEFAULT_RENDERER_CLASSES": [ "djangorestframework_camel_case.render.CamelCaseJSONRenderer", @@ -165,6 +164,22 @@ REST_FRAMEWORK = { "ALLOWED_VERSIONS": AVAILABLE_VERSIONS, } +KNOX_TOKEN_MODEL = "knox.AuthToken" + +REST_KNOX = { + "SECURE_HASH_ALGORITHM": "hashlib.sha512", + "AUTH_TOKEN_CHARACTER_LENGTH": 64, + "TOKEN_TTL": timedelta(hours=1), + "USER_SERIALIZER": "knox.serializers.UserSerializer", + "TOKEN_LIMIT_PER_USER": None, + "AUTO_REFRESH": False, + "AUTO_REFRESH_MAX_TTL": None, + "MIN_REFRESH_INTERVAL": 60, + "AUTH_HEADER_PREFIX": "Token", + "EXPIRY_DATETIME_FORMAT": api_settings.DATETIME_FORMAT, + "TOKEN_MODEL": "knox.AuthToken", +} + # settings for the OpenAPI schema generator # https://drf-spectacular.readthedocs.io/en/latest/settings.html?highlight=settings SPECTACULAR_SETTINGS = { diff --git a/activate_account_project/urls.py b/activate_account_project/urls.py index c941a64a4a21f315dc49d66adc9d318602ad6cd7..cbee72620b5e8f01c3d3b358d861ffc05f46e9d8 100644 --- a/activate_account_project/urls.py +++ b/activate_account_project/urls.py @@ -13,8 +13,8 @@ Including another URLconf 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ + from django.conf import settings -from django.contrib import admin from django.http import HttpResponse from django.urls import include, path, re_path @@ -27,14 +27,13 @@ except ImportError: HAVE_DDT = False urlpatterns = [ - path("admin/", admin.site.urls), - path("accounts/", include("django.contrib.auth.urls")), # use `healthy` rather than `healthz` as Cloud Run reserves the use of the `/healthz` endpoint path( "healthy", lambda request: HttpResponse("ok", content_type="text/plain"), name="healthy", ), + path("authentication/", include("authentication.urls")), # You'll also need to update api/tests/test_versions_view.py # Include the base API urls - which gives a view of the available API versions path("", include("api.urls")), diff --git a/api/authentication.py b/api/authentication.py deleted file mode 100644 index 6ff36d8707567ada6a4fed8ea9d2e6397fde2484..0000000000000000000000000000000000000000 --- a/api/authentication.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -Custom authentication providers. - -""" -from rest_framework.authentication import TokenAuthentication - - -class BearerTokenAuthentication(TokenAuthentication): - """ - Custom version of DRF's standard TokenAuthentication which uses the keyword "Bearer" so that - the required header becomes "Authorization: Bearer [...]". This is more in line with modern - practice. - - """ - - keyword = "Bearer" diff --git a/authentication/__init__.py b/authentication/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/authentication/authentication.py b/authentication/authentication.py new file mode 100644 index 0000000000000000000000000000000000000000..2ab61863625cb285cb97f075e8135bdeecdec6f0 --- /dev/null +++ b/authentication/authentication.py @@ -0,0 +1,25 @@ +from django.contrib.auth import authenticate as django_authenticate +from django.utils.translation import gettext_lazy as _ +from rest_framework import exceptions +from rest_framework.authentication import BaseAuthentication, get_authorization_header + + +class ActivateAccountAuthentication(BaseAuthentication): + def authenticate(self, request): + auth = get_authorization_header(request).split() + + if not auth or auth[0].lower() != b"activate": + return None + + if len(auth) == 1: + msg = _("Invalid activate account header. No credentials provided.") + raise exceptions.AuthenticationFailed(msg) + elif len(auth) > 2: + msg = _( + "Invalid activate account header. Credentials string should not contain spaces." + ) + raise exceptions.AuthenticationFailed(msg) + + user = django_authenticate(request=request, username=auth[1].decode("utf-8")) + + return (user, None) diff --git a/authentication/backend.py b/authentication/backend.py new file mode 100644 index 0000000000000000000000000000000000000000..7e2c50ac4737cad93d8da26dcb076976266117a7 --- /dev/null +++ b/authentication/backend.py @@ -0,0 +1,22 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import BaseBackend + +UserModel = get_user_model() + + +class ActivateAccountBackend(BaseBackend): + def authenticate(self, request, username=None, **kwargs): + if username == "test": + # _default_manager is a documented property: + # https://docs.djangoproject.com/en/5.1/topics/db/managers/#django.db.models.Model._default_manager + try: + return UserModel._default_manager.get_by_natural_key(username) + except UserModel.DoesNotExist: + pass + return None + + def get_user(self, user_id): + try: + return UserModel._default_manager.get(pk=user_id) + except UserModel.DoesNotExist: + return None diff --git a/authentication/urls.py b/authentication/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..7b63428be8e6a9a2d696276b31d14817e5a60b13 --- /dev/null +++ b/authentication/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from knox import views as knox_views + +from .views import LoginView + +urlpatterns = [ + path("login/", LoginView.as_view(), name="knox_login"), + path("logout/", knox_views.LogoutView.as_view(), name="knox_logout"), + path("logoutall/", knox_views.LogoutAllView.as_view(), name="knox_logoutall"), +] diff --git a/authentication/views.py b/authentication/views.py new file mode 100644 index 0000000000000000000000000000000000000000..ada3cf01ad5dc092c80a493c48bb2969d7021858 --- /dev/null +++ b/authentication/views.py @@ -0,0 +1,8 @@ +from knox.views import LoginView as KnoxLoginView + +from .authentication import ActivateAccountAuthentication + + +class LoginView(KnoxLoginView): + authentication_classes = [ActivateAccountAuthentication] + versioning_class = None diff --git a/docker-compose.yml b/docker-compose.yml index 2cc3b8ac6b3da41c0946ae45f2569124184781ad..911751b2ad61d867ab4996cbf9ab30e09ee965f1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,29 +32,13 @@ services: profiles: - development ports: - - 9000:8000 + - 8000:8000 - 5678:5678 depends_on: <<: *webapp-depends-on - gateway-emulator: - condition: service_healthy volumes: - .:/usr/src/app:ro - gateway-emulator: &gateway-emulator - image: >- - registry.gitlab.developers.cam.ac.uk/uis/devops/api/api-gateway-emulator/main:latest - profiles: - - development - environment: &gateway-emulator-environment - GATEWAY_SCOPES: "activate_account" - GATEWAY_PROXY_DESTINATION_URL: "http://webapp:8000/" - healthcheck: - test: ["CMD", "wget", "--spider", "http://0.0.0.0:9001/proxy/status"] - interval: 10s - ports: - - 8000:9000 - - 8001:9001 # App running in production mode. This can be run via: # docker compose --profile production up --build @@ -64,22 +48,12 @@ services: context: . depends_on: <<: *webapp-depends-on - gateway-emulator-production: - condition: service_healthy profiles: - production environment: <<: *django-application-environment volumes: [] - gateway-emulator-production: - <<: *gateway-emulator - profiles: - - production - environment: - <<: *gateway-emulator-environment - GATEWAY_PROXY_DESTINATION_URL: "http://webapp-production:8000/" - # Service to allow management commands to be run via: # docker compose run --build --rm manage ... manage: diff --git a/poetry.lock b/poetry.lock index 890dc0631d5ab8f067c1aaf025cd50b216172937..e2d4668c4bcbdbd658217faba3d3f307cae6cf55 100644 --- a/poetry.lock +++ b/poetry.lock @@ -397,6 +397,21 @@ files = [ [package.dependencies] python-ipware = ">=2.0.3" +[[package]] +name = "django-rest-knox" +version = "5.0.2" +description = "Authentication for django rest framework" +optional = false +python-versions = ">=3.8" +files = [ + {file = "django_rest_knox-5.0.2-py3-none-any.whl", hash = "sha256:694da5d0ad6eb3edbfd7cdc8d69c089fc074e6b0e548e00ff2750bf2fdfadb6f"}, + {file = "django_rest_knox-5.0.2.tar.gz", hash = "sha256:f283622bcf5d28a6a0203845c065d06c7432efa54399ae32070c61ac03af2d6f"}, +] + +[package.dependencies] +django = ">=4.2" +djangorestframework = "*" + [[package]] name = "django-structlog" version = "5.3.0" @@ -1959,4 +1974,4 @@ brotli = ["brotli"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "03996e77d677705e0cb4ac0e39b85a09776c1c4c9f52929d264ded31fc7ace4f" +content-hash = "6952e0fd9c9029bf44a8075ded472e59f60b464ed99f062b3cb7988840862b11" diff --git a/pyproject.toml b/pyproject.toml index b7b0bd189ed0002847630b14972ddcc9b9069f4e..b4655e313907940c6075cf752c48de36811a013a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -105,6 +105,7 @@ proxyprefix = "^0.1.3" whitenoise = "^6.5.0" setuptools = "^73.0.1" # Added to resolve ImportError of 'pkg_resources' after Gunicorn update due to CVE-2024-1135. factory-boy = "^3.3.1" +django-rest-knox = "^5.0.2" [tool.poetry.group.dev.dependencies] # Dependencies for running the web application in local development.