Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Authentification obligatoire sur l'API #1628

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
1 change: 1 addition & 0 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,7 @@
"DEFAULT_AUTHENTICATION_CLASSES": [
"lemarche.api.authentication.CustomBearerAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
}


Expand Down
42 changes: 0 additions & 42 deletions lemarche/api/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,15 @@ class CustomBearerAuthentication(BaseAuthentication):
"""
Authentication via:
1. Authorization header: Bearer <token> (recommended).
2. URL parameter ?token=<token> (deprecated, temporary support).
"""

def authenticate(self, request):
token = None
warning_issued = False

# Priority to the Authorization header
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.split("Bearer ")[1]
elif request.GET.get("token"): # Otherwise, try the URL parameter
token = request.GET.get("token")
warning_issued = True
logger.info("Authentication via URL token detected. This method is deprecated and less secure.")

# If no token is provided
if not token:
Expand All @@ -46,10 +40,6 @@ def authenticate(self, request):
except User.DoesNotExist:
raise AuthenticationFailed("Invalid or expired token")

# Add a warning in the response for URL tokens
if warning_issued:
request._deprecated_auth_warning = True # Marker for middleware or view

# Return the user and the token
return (user, token)

Expand All @@ -58,35 +48,3 @@ def authenticate_header(self, request):
Returns the expected header for 401 responses.
"""
return 'Bearer realm="api"'


class DeprecationWarningMiddleware:
"""
Middleware to inform users that authentication via URL `?token=` is deprecated.

This middleware checks if the request contains a deprecated authentication token
and adds a warning header to the response if it does.

Attributes:
get_response (callable): The next middleware or view in the chain.

Methods:
__call__(request):
Processes the request and adds a deprecation warning header to the response
if the request contains a deprecated authentication token.
"""

def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
response = self.get_response(request)

# Add a warning if the marker is set in the request
if hasattr(request, "_deprecated_auth_warning") and request._deprecated_auth_warning:
response.headers["Deprecation-Warning"] = (
"URL token authentication is deprecated and will be removed on 2025/01. "
"Please use Authorization header with Bearer tokens."
)

return response
2 changes: 1 addition & 1 deletion lemarche/api/emails/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def has_permission(self, request, view) -> bool:


class InboundParsingEmailView(APIView):
permission_classes = [BrevoWhitelistPermission] + APIView.permission_classes
permission_classes = [BrevoWhitelistPermission] # override the default class that requires Authentication

@extend_schema(exclude=True)
def post(self, request):
Expand Down
7 changes: 4 additions & 3 deletions lemarche/api/networks/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ class NetworkApiTest(TestCase):
def setUpTestData(cls):
NetworkFactory(name="Reseau 1")
NetworkFactory(name="Reseau 2")
UserFactory(api_key="admin")
cls.token = "a" * 64
UserFactory(api_key=cls.token)

def test_should_return_network_list(self):
url = reverse("api:networks-list") # anonymous user
response = self.client.get(url)
url = reverse("api:networks-list")
response = self.client.get(url, headers={"authorization": f"Bearer {self.token}"})
self.assertEqual(response.data["count"], 2)
self.assertEqual(len(response.data["results"]), 2)
self.assertTrue("slug" in response.data["results"][0])
24 changes: 13 additions & 11 deletions lemarche/api/perimeters/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,25 +24,25 @@ def setUpTestData(cls):
cls.perimeter_region = PerimeterFactory(
name="Auvergne-Rhône-Alpes", kind=Perimeter.KIND_REGION, insee_code="R84"
)
UserFactory(api_key="admin")
cls.token = "a" * 64
UserFactory(api_key=cls.token)
cls.authenticated_client = cls.client_class(headers={"authorization": f"Bearer {cls.token}"})

def test_should_return_perimeter_list(self):
url = reverse("api:perimeters-list") # anonymous user
response = self.client.get(url)
url = reverse("api:perimeters-list")
response = self.authenticated_client.get(url)
self.assertEqual(response.data["count"], 3)
self.assertEqual(len(response.data["results"]), 3)

def test_should_filter_perimeter_list_by_kind(self):
# single
url = reverse("api:perimeters-list") + f"?kind={Perimeter.KIND_CITY}" # anonymous user
response = self.client.get(url)
url = reverse("api:perimeters-list") + f"?kind={Perimeter.KIND_CITY}"
response = self.authenticated_client.get(url)
self.assertEqual(response.data["count"], 1)
self.assertEqual(len(response.data["results"]), 1)
# multiple
url = (
reverse("api:perimeters-list") + f"?kind={Perimeter.KIND_CITY}&kind={Perimeter.KIND_DEPARTMENT}"
) # anonymous user # noqa
response = self.client.get(url)
url = reverse("api:perimeters-list") + f"?kind={Perimeter.KIND_CITY}&kind={Perimeter.KIND_DEPARTMENT}"
response = self.authenticated_client.get(url)
self.assertEqual(response.data["count"], 1 + 1)
self.assertEqual(len(response.data["results"]), 1 + 1)

Expand Down Expand Up @@ -127,8 +127,10 @@ def test_should_filter_perimeters_autocomplete_by_result_count(self):

class PerimeterChoicesApiTest(TestCase):
def test_should_return_perimeter_kinds_list(self):
url = reverse("api:perimeter-kinds-list") # anonymous user
response = self.client.get(url)
token = "a" * 64
UserFactory(api_key=token)
url = reverse("api:perimeter-kinds-list")
response = self.client.get(url, headers={"authorization": f"Bearer {token}"})
self.assertEqual(response.data["count"], 3)
self.assertEqual(len(response.data["results"]), 3)
self.assertTrue("id" in response.data["results"][0])
Expand Down
1 change: 1 addition & 0 deletions lemarche/api/perimeters/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class PerimeterAutocompleteViewSet(mixins.ListModelMixin, viewsets.GenericViewSe
queryset = Perimeter.objects.all()
serializer_class = PerimeterSimpleSerializer
filterset_class = PerimeterAutocompleteFilter
permission_classes = [] # override default permission, this endpoint is open
pagination_class = None
http_method_names = ["get"]

Expand Down
7 changes: 4 additions & 3 deletions lemarche/api/sectors/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ class SectorApiTest(TestCase):
def setUpTestData(cls):
SectorFactory()
SectorFactory()
UserFactory(api_key="admin")
cls.token = "a" * 64
UserFactory(api_key=cls.token)

def test_should_return_sector_list(self):
url = reverse("api:sectors-list") # anonymous user
response = self.client.get(url)
url = reverse("api:sectors-list")
response = self.client.get(url, headers={"authorization": f"Bearer {self.token}"})
self.assertEqual(response.data["count"], 2)
self.assertEqual(len(response.data["results"]), 2)
self.assertTrue("slug" in response.data["results"][0])
Expand Down
28 changes: 0 additions & 28 deletions lemarche/api/siaes/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,31 +74,3 @@ class Meta:
"created_at",
"updated_at",
]


class SiaeListSerializer(SiaeDetailSerializer):
class Meta:
model = Siae
fields = [
"id",
"name",
"brand",
"slug",
"siret",
"nature",
"kind",
"kind_parent",
"contact_website",
"logo_url",
# additional contact_ fields available in detail
"address",
"city",
"post_code",
"department",
"region",
"is_active",
# is_ boolean fields available in detail
# M2M fields available in detail
"created_at",
"updated_at",
]
Loading