Skip to content

Commit 4e2099e

Browse files
committed
add ECD BPN auth
1 parent ff5163e commit 4e2099e

10 files changed

Lines changed: 570 additions & 235 deletions

File tree

fl_server/settings/development.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,8 @@
5757

5858
CSRF_COOKIE_SECURE = False
5959
SESSION_COOKIE_SECURE = True
60+
61+
62+
# Eclipse Dataspace Components (EDC) Settings
63+
64+
EDC_API_KEY = "hanspetermayer"

fl_server/settings/production.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,8 @@ def get_secret(env_key: str, *, ensure: bool = False) -> str | None:
9999

100100
# Error reporting
101101
# https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/#error-reporting
102+
103+
104+
# Eclipse Dataspace Components (EDC) Settings
105+
106+
EDC_API_KEY = get_secret("FL_EDC_API_KEY")

fl_server_api/openapi.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from docstring_parser import Docstring, parse, RenderingStyle
55
from docstring_parser.google import compose
66
from drf_spectacular.authentication import BasicScheme
7+
from drf_spectacular.extensions import OpenApiAuthenticationExtension
78
from drf_spectacular.openapi import AutoSchema
89
from drf_spectacular.utils import OpenApiExample, OpenApiResponse
910
from inspect import cleandoc
@@ -19,9 +20,23 @@ class BasicAuthAllowingTokenAuthInUrlScheme(BasicScheme):
1920
"""
2021

2122
target_class = "fl_server_api.views.base.BasicAuthAllowingTokenAuthInUrl"
23+
name = 'basicTokenAuth'
2224
priority = 0
2325

2426

27+
class EDCAuthenticationScheme(OpenApiAuthenticationExtension):
28+
"""
29+
A class that enables EDC BPN authentication.
30+
"""
31+
32+
target_class = "fl_server_api.views.base.EDCAuthentication"
33+
name = 'edcAuth'
34+
priority = 0
35+
36+
def get_security_definition(self, auto_schema):
37+
return {}
38+
39+
2540
def create_error_response(
2641
response_description: Optional[str],
2742
example_name: str,

fl_server_api/serializers/user.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
# SPDX-FileCopyrightText: 2026 German Aerospace Center (DLR)
22
# SPDX-License-Identifier: Apache-2.0
33

4+
from django.http import HttpRequest
45
from rest_framework import serializers
56
from rest_framework.authtoken.models import Token
67

78
from fl_server_core.models import User
9+
from fl_server_core.models.user import create_edc_bpn
810

911

1012
class UserSerializer(serializers.ModelSerializer):
@@ -68,19 +70,22 @@ def to_representation(self, instance):
6870
del data["token"]
6971
return data
7072

71-
def create(self, validated_data):
73+
def create(self, validated_data, edc_bpn: str | HttpRequest | None) -> User:
7274
"""
7375
Create a new User instance.
7476
7577
The user's password is set using the `set_password` method.
7678
7779
Args:
7880
validated_data (dict): The validated data for the new User instance.
81+
edc_bpn (str | None): The users EDC BPN.
7982
8083
Returns:
8184
User: The created User instance.
8285
"""
8386
user = User.objects.create(**validated_data)
8487
user.set_password(validated_data["password"])
8588
user.save()
89+
if edc_bpn:
90+
create_edc_bpn(user, edc_bpn)
8691
return user

fl_server_api/tests/test_user.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
# SPDX-License-Identifier: Apache-2.0
33

44
import base64
5+
from django.conf import settings
56
from django.test import TestCase
67
from rest_framework.authtoken.models import Token
78
from typing import Any, Dict, Optional, Union
89
from uuid import uuid4
910

11+
from fl_server_core.models.user import create_edc_bpn
1012
from fl_server_core.tests import BASE_URL, Dummy
1113
from fl_server_core.models import User
1214

@@ -146,3 +148,54 @@ def test_get_user_trainings(self):
146148
sorted([str(training.id) for training in trainings]),
147149
sorted([training["id"] for training in response_json])
148150
)
151+
152+
def test_get_user_via_edc_bpn(self):
153+
user = Dummy.create_user()
154+
edc = create_edc_bpn(user, "BPNLC03955YVFP6B")
155+
self.client.defaults["X-Api-Key"] = settings.EDC_API_KEY
156+
self.client.defaults["Edc-Bpn"] = edc.bpn
157+
self.client.defaults["Edc-Contract-Agreement-Id"] = uuid4()
158+
response = self.client.get(f"{BASE_URL}/users/")
159+
self.assertEqual(200, response.status_code)
160+
self.assertEqual("application/json", response["content-type"])
161+
response_json = response.json()
162+
self.assertEqual(1, len(response_json))
163+
self.assertUserEqual(user, response_json[0])
164+
165+
def test_get_user_via_edc_bpn_without_contract_agreement_id(self):
166+
user = Dummy.create_user()
167+
edc = create_edc_bpn(user, "BPNLC03955YVFP6B")
168+
self.client.defaults["X-Api-Key"] = settings.EDC_API_KEY
169+
self.client.defaults["Edc-Bpn"] = edc.bpn
170+
with self.assertLogs("django.request", level="WARNING") as cm:
171+
response = self.client.get(f"{BASE_URL}/users/")
172+
self.assertEqual(cm.output, [
173+
"WARNING:django.request:Unauthorized: /api/users/",
174+
])
175+
self.assertEqual(401, response.status_code)
176+
177+
def test_get_user_via_edc_bpn_with_incorrect_x_api_key(self):
178+
user = Dummy.create_user()
179+
edc = create_edc_bpn(user, "BPNLC03955YVFP6B")
180+
self.client.defaults["X-Api-Key"] = "wrong-api-key"
181+
self.client.defaults["Edc-Bpn"] = edc.bpn
182+
self.client.defaults["Edc-Contract-Agreement-Id"] = uuid4()
183+
with self.assertLogs("django.request", level="WARNING") as cm:
184+
response = self.client.get(f"{BASE_URL}/users/")
185+
self.assertEqual(cm.output, [
186+
"WARNING:django.request:Unauthorized: /api/users/",
187+
])
188+
self.assertEqual(401, response.status_code)
189+
190+
def test_get_user_via_edc_bpn_with_none_exiting_bpn(self):
191+
user = Dummy.create_user()
192+
create_edc_bpn(user, "BPNLC03955YVFP6B")
193+
self.client.defaults["X-Api-Key"] = settings.EDC_API_KEY
194+
self.client.defaults["Edc-Bpn"] = "none existing BPN"
195+
self.client.defaults["Edc-Contract-Agreement-Id"] = uuid4()
196+
with self.assertLogs("django.request", level="WARNING") as cm:
197+
response = self.client.get(f"{BASE_URL}/users/")
198+
self.assertEqual(cm.output, [
199+
"WARNING:django.request:Unauthorized: /api/users/",
200+
])
201+
self.assertEqual(401, response.status_code)

fl_server_api/views/base.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
# SPDX-FileCopyrightText: 2026 German Aerospace Center (DLR)
22
# SPDX-License-Identifier: Apache-2.0
33

4+
from django.conf import settings
5+
from django.core.exceptions import ObjectDoesNotExist
46
from logging import getLogger
5-
from rest_framework.authentication import BasicAuthentication, SessionAuthentication, TokenAuthentication
7+
from rest_framework.authentication import (
8+
BaseAuthentication, BasicAuthentication, SessionAuthentication, TokenAuthentication
9+
)
610
from rest_framework.permissions import IsAuthenticated
711
from rest_framework.viewsets import ViewSet as DjangoViewSet
812

13+
from fl_server_core.models.user import Edc, get_edc_bpn_from_request
14+
915

1016
class BasicAuthAllowingTokenAuthInUrl(BasicAuthentication):
1117
"""
@@ -34,6 +40,30 @@ def authenticate_credentials(self, userid_or_token, password, request=None):
3440
return super().authenticate_credentials(userid_or_token, password, request)
3541

3642

43+
class EDCAuthentication(BaseAuthentication):
44+
def authenticate(self, request):
45+
# 1. check EDC connector API key from settings
46+
api_key = request.META.get("X-Api-Key", "")
47+
if api_key != settings.EDC_API_KEY:
48+
return None
49+
# 2. get EDC user BPN and Contract-Agreement-Id from request header
50+
bpn = get_edc_bpn_from_request(request)
51+
if not bpn:
52+
return None
53+
# 3. get user from EDC settings via BPN
54+
try:
55+
edc = Edc.objects.get(pk=bpn)
56+
except ObjectDoesNotExist:
57+
return None
58+
user = edc.user
59+
if not user:
60+
return None
61+
return (user, None)
62+
63+
def authenticate_header(self, request):
64+
return "EDC"
65+
66+
3767
class ViewSet(DjangoViewSet):
3868
"""
3969
A base ViewSet that includes default authentication and permission classes.
@@ -46,11 +76,12 @@ class ViewSet(DjangoViewSet):
4676

4777
_logger = getLogger("fl.server")
4878

49-
# Note: BasicAuthentication is sensles here since it will and can't never be called due to
79+
# Note: BasicAuthentication is senseless here since it will and can't never be called due to
5080
# BasicAuthAllowingTokenAuthInUrl but is required for OpenAPI to work.
5181
# Also note that the order of BasicAuthAllowingTokenAuthInUrl and BasicAuthentication is important
5282
# since if BasicAuthentication is first, Django won't ever call BasicAuthAllowingTokenAuthInUrl!
5383
authentication_classes = [
84+
EDCAuthentication,
5485
TokenAuthentication,
5586
BasicAuthAllowingTokenAuthInUrl,
5687
BasicAuthentication,

fl_server_api/views/user.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ def create_user(self, request: HttpRequest) -> HttpResponse:
115115
Returns:
116116
HttpResponse: new created user as json response
117117
"""
118-
user = UserSerializer().create(request.data)
118+
user = UserSerializer().create(request.data, request)
119119
serializer = UserSerializer(user, context={"request_user_id": user.id})
120120
return Response(serializer.data, status=status.HTTP_201_CREATED)
121121

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Generated by Django 6.0.6 on 2026-06-17 13:18
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('fl_server_core', '0008_globalmodel_preprocessing'),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name='Edc',
17+
fields=[
18+
('bpn', models.CharField(max_length=20, primary_key=True, serialize=False)),
19+
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
20+
],
21+
),
22+
]

fl_server_core/models/user.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33

44
from django.conf import settings
55
from django.contrib.auth.models import AbstractUser
6+
from django.db import models
67
from django.db.models import BooleanField, URLField, UUIDField
78
from django.db.models.signals import post_save
89
from django.dispatch import receiver
10+
from django.http import HttpRequest
911
from rest_framework.authtoken.models import Token
1012
from uuid import UUID, uuid4
1113

@@ -38,6 +40,17 @@ class User(AbstractUser, NotificationReceiver):
3840
"""Endpoint to send the message to."""
3941

4042

43+
class Edc(models.Model):
44+
"""
45+
EDC related configuration.
46+
"""
47+
48+
bpn = models.CharField(max_length=20, primary_key=True)
49+
"""Business Partner Number (BPN)"""
50+
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
51+
"""User related to the EDC configuration."""
52+
53+
4154
@receiver(post_save, sender=settings.AUTH_USER_MODEL)
4255
def create_auth_token(sender, instance=None, created=False, *args, **kwargs):
4356
"""
@@ -52,3 +65,38 @@ def create_auth_token(sender, instance=None, created=False, *args, **kwargs):
5265
"""
5366
if created:
5467
Token.objects.create(user=instance)
68+
69+
70+
def create_edc_bpn(user: User, bpn: str | HttpRequest) -> Edc | None:
71+
"""
72+
Save EDC BPN for user.
73+
74+
Args:
75+
user (User): User.
76+
bpn (str | HttpRequest): EDC BPN or http request where the EDC BPN is included inside the header.
77+
78+
Returns:
79+
Edc | None: EDC object or None if not successful.
80+
"""
81+
if not isinstance(bpn, str):
82+
bpn = get_edc_bpn_from_request(bpn)
83+
if bpn is None:
84+
return None
85+
return Edc.objects.create(user=user, bpn=bpn)
86+
87+
88+
def get_edc_bpn_from_request(request: HttpRequest) -> str | None:
89+
"""
90+
Get EDC BPN from http request object (headers).
91+
92+
Args:
93+
request (HttpRequest): http request object.
94+
95+
Returns:
96+
str | None: EDC BPN or None if not found.
97+
"""
98+
bpn = request.META.get("Edc-Bpn", "")
99+
contract_agreement_id = request.META.get("Edc-Contract-Agreement-Id", "")
100+
if not bpn or not contract_agreement_id:
101+
return None
102+
return bpn

0 commit comments

Comments
 (0)