diff --git a/.env_template b/.env_template index f2279ea8aa82a294cb1ae9fd459cc3c9b904792c..14663ce9aa1904fce922566a5d295de494250c6c 100644 --- a/.env_template +++ b/.env_template @@ -1,3 +1,10 @@ SECRET_KEY= DEBUG=True DALIA_TRIPLESTORE_BASE_URL=http://fuseki:3030/ + +IAM4NFDI_CLIENT_ID= +IAM4NFDI_CLIENT_SECRET= +# Redirect URI should point to the frontend's /auth/callback/iam4nfdi page (absolute URL). +IAM4NFDI_REDIRECT_URI= +# Frontend page to redirect to when OIDC login flow fails (absolute URL). +IAM4NFDI_LOGIN_ERROR_PAGE= diff --git a/Dockerfile b/Dockerfile index 5164b39bf3070cdc2d36e0e0b4036392eef4404a..e35f97d6be7196eb3f57d7dfb350ef218fcf935b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,12 +2,19 @@ FROM python:3.11 WORKDIR /django -COPY manage.py requirements.txt /django/ +COPY manage.py requirements.txt django-allauth.patch /django/ COPY project/ /django/project RUN \ set -ex && \ - pip install --no-cache-dir -r requirements.txt + pip install --no-cache-dir -r requirements.txt && \ + pip uninstall -y django-allauth[socialaccount] && \ + git clone https://codeberg.org/allauth/django-allauth.git --depth 1 --branch 65.6.0 && \ + cd django-allauth && \ + git apply ../django-allauth.patch && \ + cd .. && \ + pip install -e django-allauth[socialaccount] + # Don't care about the relational database at the moment. RUN \ diff --git a/django-allauth.patch b/django-allauth.patch new file mode 100644 index 0000000000000000000000000000000000000000..d5743c605575286f568d5e212b459b11ff3f32bd --- /dev/null +++ b/django-allauth.patch @@ -0,0 +1,29 @@ +diff --git a/allauth/socialaccount/providers/oauth2/client.py b/allauth/socialaccount/providers/oauth2/client.py +index c0098df..9730260 100644 +--- a/allauth/socialaccount/providers/oauth2/client.py ++++ b/allauth/socialaccount/providers/oauth2/client.py +@@ -1,3 +1,6 @@ ++import os ++import urllib ++ + import requests + from urllib.parse import parse_qsl + +@@ -28,7 +31,7 @@ class OAuth2Client: + self.request = request + self.access_token_method = access_token_method + self.access_token_url = access_token_url +- self.callback_url = callback_url ++ self.callback_url = os.environ.get("IAM4NFDI_REDIRECT_URI") + self.consumer_key = consumer_key + self.consumer_secret = consumer_secret + self.scope_delimiter = scope_delimiter +@@ -56,7 +59,7 @@ class OAuth2Client: + "code": code, + } + if self.basic_auth: +- auth = requests.auth.HTTPBasicAuth(self.consumer_key, self.consumer_secret) ++ auth = requests.auth.HTTPBasicAuth(urllib.parse.quote_plus(self.consumer_key), self.consumer_secret) + else: + auth = None + data.update( diff --git a/project/dalia/apps.py b/project/dalia/apps.py index a1402107489a8bd42c3b064972b944bb6a9cee65..87623eb88729bfbae492e5b5dc3c72306e40c986 100644 --- a/project/dalia/apps.py +++ b/project/dalia/apps.py @@ -4,3 +4,7 @@ from django.apps import AppConfig class DaliaConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'project.dalia' + + def ready(self): + # Implicitly connect signal handlers decorated with @receiver. + import project.dalia.signals # noqa diff --git a/project/dalia/migrations/0001_initial.py b/project/dalia/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..3eecf5478f68257c56daee8c8fb56001ad63af7f --- /dev/null +++ b/project/dalia/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# Generated by Django 5.1.7 on 2025-03-27 19:31 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='UserProfile', + fields=[ + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), + ('dalia_id', models.UUIDField(default=uuid.uuid4, unique=True)), + ('orcid', models.CharField(max_length=37)), + ], + ), + ] diff --git a/project/dalia/models.py b/project/dalia/models.py index 71a836239075aa6e6e4ecb700e9c42c95c022d91..7615d3f6ec30a54fb9635612520fffc0aca89dcc 100644 --- a/project/dalia/models.py +++ b/project/dalia/models.py @@ -1,3 +1,10 @@ +import uuid + +from django.conf import settings from django.db import models -# Create your models here. + +class UserProfile(models.Model): + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, primary_key=True) + dalia_id = models.UUIDField(default=uuid.uuid4, unique=True) + orcid = models.CharField(max_length=37) # "https://orcid.org/XXXX-XXXX-XXXX-XXXX" diff --git a/project/dalia/signals.py b/project/dalia/signals.py new file mode 100644 index 0000000000000000000000000000000000000000..c01ef14c62bd28ebdf1f7c37f4e28695a095da58 --- /dev/null +++ b/project/dalia/signals.py @@ -0,0 +1,12 @@ +from django.conf import settings +from django.db.models.signals import post_save +from django.dispatch import receiver + +from project.dalia.models import UserProfile + + +@receiver(post_save, sender=settings.AUTH_USER_MODEL) +def initialize_user_profile_signal(sender, instance, created, **kwargs): + if created: + UserProfile(user=instance) + instance.userprofile.save() diff --git a/project/dalia/urls.py b/project/dalia/urls.py index 466998bd1de2851f85855e8ddc65edee69c1d260..1504260ae5c1fabd0682be1c202b82dddf59e933 100644 --- a/project/dalia/urls.py +++ b/project/dalia/urls.py @@ -21,4 +21,5 @@ urlpatterns = [ path('v1/curation/suggest/target-groups', views.CurationSuggestTargetGroupsView.as_view(), name="curation_suggest_target_groups"), path('v1/curation/suggest/media-types', views.CurationSuggestMediaTypesView.as_view(), name="curation_suggest_media_types"), path('v1/curation/suggest/relation-types', views.CurationSuggestRelationTypesView.as_view(), name="curation_suggest_relation_types"), + path("v1/hello/", views.HelloAPIView.as_view()), ] diff --git a/project/dalia/views.py b/project/dalia/views.py index c44f3e957ddda4b7d25c88f1c900b2647b3b419b..1c34045ff089d5a455a4122b45906e4cbad0904c 100644 --- a/project/dalia/views.py +++ b/project/dalia/views.py @@ -1,6 +1,8 @@ from uuid import UUID +from allauth.headless.contrib.rest_framework.authentication import XSessionTokenAuthentication from django.http import HttpResponse, HttpResponseNotFound +from rest_framework import authentication, permissions from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView @@ -182,3 +184,15 @@ class CurationSuggestRelationTypesView(APIView): get_relation_types_suggestions(), many=True ) return Response(serializer.data) + + +# Example view to demonstrate how to implement authentication checks +class HelloAPIView(APIView): + authentication_classes = [ + authentication.SessionAuthentication, + XSessionTokenAuthentication, + ] + permission_classes = [permissions.IsAuthenticated] + + def post(self, request: Request): + return Response({"message": f"Hello {request.user.username}!"}) diff --git a/project/settings.py b/project/settings.py index 5ce711c139fe27d54543fbb442e3cb9cba7cb41c..7d5cdd09871ab9607fba78127e26dc718f96d17c 100644 --- a/project/settings.py +++ b/project/settings.py @@ -43,6 +43,11 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'allauth', + 'allauth.account', + 'allauth.socialaccount', + 'allauth.socialaccount.providers.openid_connect', + 'allauth.headless', 'rest_framework', 'project.dalia', ] @@ -55,6 +60,7 @@ MIDDLEWARE = [ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'allauth.account.middleware.AccountMiddleware', ] ROOT_URLCONF = 'project.urls' @@ -75,6 +81,10 @@ TEMPLATES = [ }, ] +AUTHENTICATION_BACKENDS = [ + 'allauth.account.auth_backends.AuthenticationBackend', +] + WSGI_APPLICATION = 'project.wsgi.application' @@ -130,6 +140,40 @@ STATIC_URL = 'static/' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +# django-allauth settings +HEADLESS_ONLY = True +SOCIALACCOUNT_ONLY = True +ACCOUNT_EMAIL_VERIFICATION = "none" # required by SOCIALACCOUNT_ONLY = True +SOCIALACCOUNT_PROVIDERS = {} + +IAM4NFDI_CLIENT_ID = os.environ.get("IAM4NFDI_CLIENT_ID", None) +IAM4NFDI_CLIENT_SECRET = os.environ.get("IAM4NFDI_CLIENT_SECRET", None) +IAM4NFDI_REDIRECT_URI = os.environ.get("IAM4NFDI_REDIRECT_URI", None) +IAM4NFDI_LOGIN_ERROR_PAGE = os.environ.get("IAM4NFDI_LOGIN_ERROR_PAGE", None) + +if IAM4NFDI_CLIENT_ID and IAM4NFDI_CLIENT_SECRET and IAM4NFDI_REDIRECT_URI and IAM4NFDI_LOGIN_ERROR_PAGE: + HEADLESS_FRONTEND_URLS = { + "socialaccount_login_error": IAM4NFDI_LOGIN_ERROR_PAGE, + } + + SOCIALACCOUNT_PROVIDERS["openid_connect"] = { + "APPS": [ + { + "provider_id": "iam4nfdi", + "name": "IAM4NFDI Infrastructure Proxy", + "client_id": IAM4NFDI_CLIENT_ID, + "secret": IAM4NFDI_CLIENT_SECRET, + "settings": { + "server_url": "https://infraproxy.nfdi-aai.dfn.de/.well-known/openid-configuration", + "auth_params": { + # response_type can be "code", "id_token" or "token" depending on OIDC provider config. + # "response_type": "...", + "redirect_uri": IAM4NFDI_REDIRECT_URI, + }, + }, + }, + ], + } # dalia app settings DALIA_TRIPLESTORE_BASE_URL = os.environ.get("DALIA_TRIPLESTORE_BASE_URL", "http://fuseki:3030/") diff --git a/project/urls.py b/project/urls.py index 41455dcb840bb1871638672366c930c9bc31520a..52f284412a83a2212bd72aef47029223353c44a5 100644 --- a/project/urls.py +++ b/project/urls.py @@ -20,4 +20,6 @@ from project.dalia import urls as dalia_urls urlpatterns = [ path('api/dalia/', include(dalia_urls)), + path("api/accounts/", include("allauth.urls")), + path("api/_allauth/", include("allauth.headless.urls")), ] diff --git a/requirements.txt b/requirements.txt index 7643ff7050267397b0fb5bc0560c603264fd5f58..f20cb8ca3b613b4f30be51987e851ef514a0b1dd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ djangorestframework-dataclasses==1.3.1 gunicorn==23.0.0 rdflib==7.1.3 python-dotenv==1.0.1 +django-allauth[socialaccount]==65.6.0 diff --git a/tests/project/dalia/test_signals.py b/tests/project/dalia/test_signals.py new file mode 100644 index 0000000000000000000000000000000000000000..51916a0ae38adcc22319455830a11dc1c2766e57 --- /dev/null +++ b/tests/project/dalia/test_signals.py @@ -0,0 +1,34 @@ +from django.contrib.auth import get_user_model + +from project.dalia.models import UserProfile + + +def test_initialize_user_profile_signal_initializes_user_profile_after_user_is_created(db): + user_model = get_user_model() + assert user_model.objects.count() == 0 + + user = user_model(username="test") + user.save() + + assert user_model.objects.count() == 1 + assert UserProfile.objects.count() == 1 + assert user.userprofile.orcid == "" + assert user.userprofile.dalia_id + + +def test_initialize_user_profile_signal_does_not_change_user_profile_after_user_is_changed(db): + user_model = get_user_model() + user = user_model(username="test") + user.save() + assert user_model.objects.count() == 1 + assert UserProfile.objects.count() == 1 + orcid = user.userprofile.orcid + dalia_id = user.userprofile.dalia_id + + user.first_name = "abc" + user.save() + + assert user_model.objects.count() == 1 + assert UserProfile.objects.count() == 1 + assert user.userprofile.orcid == orcid + assert user.userprofile.dalia_id == dalia_id