From 745b96309bd7ce13e645e66cb854dc4279010616 Mon Sep 17 00:00:00 2001
From: Emmanuel Cazenave <ecazenave@entrouvert.com>
Date: Mon, 22 Aug 2022 14:23:04 +0200
Subject: [PATCH 2/2] rest_authentication: add api client authentication
 (#67085)

---
 debian/debian_config_common.py    |  5 +-
 hobo/rest_authentication.py       | 51 +++++++++++++++++++
 hobo/test_urls.py                 | 12 +++++
 tests/settings.py                 |  8 +++
 tests/test_rest_authentication.py | 85 +++++++++++++++++++++++++++++++
 tox.ini                           |  1 +
 6 files changed, 161 insertions(+), 1 deletion(-)
 create mode 100644 tests/test_rest_authentication.py

diff --git a/debian/debian_config_common.py b/debian/debian_config_common.py
index 7b6eb5e..b5dbcd3 100644
--- a/debian/debian_config_common.py
+++ b/debian/debian_config_common.py
@@ -397,7 +397,10 @@ STATICS_HASH_COUNTER = '/var/lib/publik/statics-counter'
 if 'rest_framework' in INSTALLED_APPS:
     if 'REST_FRAMEWORK' not in globals():
         REST_FRAMEWORK = {}
-    REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] = ('hobo.rest_authentication.PublikAuthentication',)
+    REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] = (
+        'hobo.rest_authentication.PublikAuthentication',
+        'hobo.rest_authentication.APIClientAuthentication',
+    )
     REST_FRAMEWORK['DEFAULT_PERMISSION_CLASSES'] = ('rest_framework.permissions.IsAuthenticated',)
     REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = ('rest_framework.renderers.JSONRenderer',)
 
diff --git a/hobo/rest_authentication.py b/hobo/rest_authentication.py
index 3a383c0..cf80e71 100644
--- a/hobo/rest_authentication.py
+++ b/hobo/rest_authentication.py
@@ -1,5 +1,6 @@
 import logging
 
+import requests
 from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.contrib.auth.models import AnonymousUser
@@ -8,6 +9,7 @@ from django.utils.module_loading import import_string
 from rest_framework import authentication, exceptions, status
 
 from hobo import signature
+from hobo.requests_wrapper import Requests
 
 try:
     from mellon.models import UserSAMLIdentifier
@@ -51,6 +53,32 @@ class AnonymousAdminServiceUser(AnonymousUser):
         return 'Publik Service Admin'
 
 
+class APIClientUser:
+
+    is_active = True
+    is_anonymous = False
+    is_authenticated = True
+    is_superuser = False
+    roles = []
+
+    def __init__(self, is_active, is_anonymous, is_authenticated, is_superuser, roles):
+        self.is_active = is_active
+        self.is_anonymous = is_anonymous
+        self.is_authenticated = is_authenticated
+        self.is_superuser = is_superuser
+        self.roles = roles
+
+    @classmethod
+    def from_dict(cls, data):
+        return cls(
+            is_active=data['is_active'],
+            is_anonymous=data['is_anonymous'],
+            is_authenticated=data['is_authenticated'],
+            is_superuser=data['is_superuser'],
+            roles=data['roles'],
+        )
+
+
 class PublikAuthenticationFailed(exceptions.APIException):
     status_code = status.HTTP_401_UNAUTHORIZED
     default_code = 'invalid-signature'
@@ -125,3 +153,26 @@ class PublikAuthentication(authentication.BaseAuthentication):
         user = self.resolve_user(request)
         self.logger.info('user authenticated with signature %s', user)
         return (user, None)
+
+
+class APIClientAuthentication(authentication.BasicAuthentication):
+    def authenticate_credentials(self, identifier, password, request=None):
+        idp_services = list(getattr(settings, 'KNOWN_SERVICES', {}).get('authentic', {}).values())
+        if not idp_services:
+            return None
+        authentic = idp_services[0]
+        url = authentic['url'] + 'api/check-api-client/'
+        response = Requests().post(url, json={'identifier': identifier, 'password': password})
+        try:
+            response.raise_for_status()
+        except requests.exceptions.RequestException:
+            return None
+
+        result = response.json()
+        if 'err' not in result or 'data' not in result or result['err'] == 1:
+            return None
+        try:
+            api_client = APIClientUser.from_dict(result['data'])
+        except Exception:
+            return None
+        return api_client, None
diff --git a/hobo/test_urls.py b/hobo/test_urls.py
index 0a4939e..ec50eb6 100644
--- a/hobo/test_urls.py
+++ b/hobo/test_urls.py
@@ -3,6 +3,9 @@ import logging
 from django.conf.urls import url
 from django.core.exceptions import PermissionDenied
 from django.http import HttpResponse
+from rest_framework import permissions
+from rest_framework.response import Response
+from rest_framework.views import APIView
 
 
 def helloworld(request):
@@ -16,6 +19,15 @@ def helloworld(request):
     return HttpResponse('Hello world %s' % request.META['REMOTE_ADDR'])
 
 
+class AuthenticatedTestView(APIView):
+
+    permission_classes = [permissions.IsAuthenticated]
+
+    def get(self, request):
+        return Response({'some': 'data'})
+
+
 urlpatterns = [
     url(r'^$', helloworld),
+    url(r'^authenticated-testview/', AuthenticatedTestView.as_view()),
 ]
diff --git a/tests/settings.py b/tests/settings.py
index f2a0f05..52a9de8 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -26,3 +26,11 @@ DATABASES = {
 }
 
 TEMPLATES[0]['OPTIONS'].setdefault('builtins', []).append('hobo.templatetags.hobo')
+
+REST_FRAMEWORK = {
+    'DEFAULT_AUTHENTICATION_CLASSES': (
+        'hobo.rest_authentication.PublikAuthentication',
+        'hobo.rest_authentication.APIClientAuthentication',
+    ),
+    'DEFAULT_RENDERER_CLASSES': ('rest_framework.renderers.JSONRenderer',),
+}
diff --git a/tests/test_rest_authentication.py b/tests/test_rest_authentication.py
new file mode 100644
index 0000000..eabf6df
--- /dev/null
+++ b/tests/test_rest_authentication.py
@@ -0,0 +1,85 @@
+import base64
+
+import responses
+from django.test import RequestFactory
+from rest_framework.views import APIView
+
+
+class TestView(APIView):
+    def get(self, request):
+        return Response({'some': 'data'})
+
+
+@responses.activate
+def test_apiclient_authentication(app, db, settings):
+    settings.ROOT_URLCONF = 'hobo.test_urls'
+    app.authorization = ('Basic', ('foo', 'bar'))
+    # No KNOWN_SERVICES : 403
+    resp = app.get('/authenticated-testview/', status=403)
+    # No authentic in  KNOWN_SERVICES : 403
+    settings.KNOWN_SERVICES = {}
+    resp = app.get('/authenticated-testview/', status=403)
+
+    settings.KNOWN_SERVICES = {
+        'authentic': {
+            'idp': {
+                'title': 'Foobar',
+                'url': 'https://idp.example.invalid/',
+                'orig': 'example.org',
+                'secret': 'xxx',
+            }
+        }
+    }
+    # check-api-client returns an error : 403
+    responses.add(
+        responses.POST,
+        'https://idp.example.invalid/api/check-api-client/',
+        status=403,
+    )
+    resp = app.get('/authenticated-testview/', status=403)
+
+    # no 'err' key in check-api-client response : 403
+    responses.add(
+        responses.POST,
+        'https://idp.example.invalid/api/check-api-client/',
+        json={'foo': 'bar'},
+        status=200,
+    )
+    resp = app.get('/authenticated-testview/', status=403)
+
+    # authentication refused by a2 : 403
+    responses.add(
+        responses.POST,
+        'https://idp.example.invalid/api/check-api-client/',
+        json={'err': 1},
+        status=200,
+    )
+    resp = app.get('/authenticated-testview/', status=403)
+
+    # error when deserializing the api client : 403
+    responses.add(
+        responses.POST,
+        'https://idp.example.invalid/api/check-api-client/',
+        json={'err': 0, 'data': {'foo': 'bar'}},
+        status=200,
+    )
+    resp = app.get('/authenticated-testview/', status=403)
+
+    # accces Granted
+    responses.add(
+        responses.POST,
+        'https://idp.example.invalid/api/check-api-client/',
+        json={
+            'err': 0,
+            'data': {
+                'is_active': True,
+                'is_anonymous': False,
+                'is_authenticated': True,
+                'is_superuser': False,
+                'restrict_to_anonymised_data': False,
+                'roles': [],
+            },
+        },
+        status=200,
+    )
+    resp = app.get('/authenticated-testview/')
diff --git a/tox.ini b/tox.ini
index 923d3ba..468ed5f 100644
--- a/tox.ini
+++ b/tox.ini
@@ -57,6 +57,7 @@ deps:
 	mock<4
 	httmock
 	requests
+        responses
 	pytest-freezegun
 	xmlschema<1.1
 	enum34<=1.1.6
-- 
2.30.2

