Projet

Général

Profil

0002-rest_authentication-add-api-client-authentication-67.patch

Emmanuel Cazenave, 05 septembre 2022 14:32

Télécharger (10,4 ko)

Voir les différences:

Subject: [PATCH 2/2] rest_authentication: add api client authentication
 (#67085)

 debian/debian_config_common.py    |   5 +-
 hobo/rest_authentication.py       |  66 +++++++++++++++++
 hobo/test_urls.py                 |  12 ++++
 tests/settings.py                 |   8 +++
 tests/test_rest_authentication.py | 115 ++++++++++++++++++++++++++++++
 tox.ini                           |   1 +
 6 files changed, 206 insertions(+), 1 deletion(-)
 create mode 100644 tests/test_rest_authentication.py
debian/debian_config_common.py
397 397
if 'rest_framework' in INSTALLED_APPS:
398 398
    if 'REST_FRAMEWORK' not in globals():
399 399
        REST_FRAMEWORK = {}
400
    REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] = ('hobo.rest_authentication.PublikAuthentication',)
400
    REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] = (
401
        'hobo.rest_authentication.PublikAuthentication',
402
        'hobo.rest_authentication.APIClientAuthentication',
403
    )
401 404
    REST_FRAMEWORK['DEFAULT_PERMISSION_CLASSES'] = ('rest_framework.permissions.IsAuthenticated',)
402 405
    REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = ('rest_framework.renderers.JSONRenderer',)
403 406

  
hobo/rest_authentication.py
1 1
import logging
2 2

  
3
import requests
3 4
from django.conf import settings
4 5
from django.contrib.auth import get_user_model
5 6
from django.contrib.auth.models import AnonymousUser
......
8 9
from rest_framework import authentication, exceptions, status
9 10

  
10 11
from hobo import signature
12
from hobo.requests_wrapper import Requests
11 13

  
12 14
try:
13 15
    from mellon.models import UserSAMLIdentifier
......
51 53
        return 'Publik Service Admin'
52 54

  
53 55

  
56
class APIClientUser:
57

  
58
    is_active = True
59
    is_anonymous = False
60
    is_authenticated = True
61
    is_superuser = False
62
    roles = []
63

  
64
    def __init__(self, is_active, is_anonymous, is_authenticated, is_superuser, roles):
65
        self.is_active = is_active
66
        self.is_anonymous = is_anonymous
67
        self.is_authenticated = is_authenticated
68
        self.is_superuser = is_superuser
69
        self.roles = roles
70

  
71
    @classmethod
72
    def from_dict(cls, data):
73
        return cls(
74
            is_active=data['is_active'],
75
            is_anonymous=data['is_anonymous'],
76
            is_authenticated=data['is_authenticated'],
77
            is_superuser=data['is_superuser'],
78
            roles=data['roles'],
79
        )
80

  
81

  
54 82
class PublikAuthenticationFailed(exceptions.APIException):
55 83
    status_code = status.HTTP_401_UNAUTHORIZED
56 84
    default_code = 'invalid-signature'
......
125 153
        user = self.resolve_user(request)
126 154
        self.logger.info('user authenticated with signature %s', user)
127 155
        return (user, None)
156

  
157

  
158
class APIClientAuthenticationUnavailable(exceptions.APIException):
159
    status_code = status.HTTP_503_SERVICE_UNAVAILABLE
160
    default_code = 'IDP temporarily unavailable, try again later.'
161

  
162
    def __init__(self):
163
        self.detail = {'err': 1, 'err_desc': self.default_code}
164

  
165

  
166
class APIClientAuthentication(authentication.BasicAuthentication):
167
    def authenticate_credentials(self, identifier, password, request=None):
168
        idp_services = list(getattr(settings, 'KNOWN_SERVICES', {}).get('authentic', {}).values())
169
        if not idp_services:
170
            return None
171
        authentic = idp_services[0]
172
        url = authentic['url'] + 'api/check-api-client/'
173

  
174
        try:
175
            response = Requests().post(url, json={'identifier': identifier, 'password': password})
176
        except requests.Timeout:
177
            raise APIClientAuthenticationUnavailable()
178
        except requests.RequestException as err:
179
            raise APIClientAuthenticationUnavailable()
180

  
181
        try:
182
            response.raise_for_status()
183
        except requests.exceptions.RequestException:
184
            return None
185

  
186
        result = response.json()
187
        if 'err' not in result or 'data' not in result or result['err'] == 1:
188
            return None
189
        try:
190
            api_client = APIClientUser.from_dict(result['data'])
191
        except Exception:
192
            return None
193
        return api_client, None
hobo/test_urls.py
3 3
from django.conf.urls import url
4 4
from django.core.exceptions import PermissionDenied
5 5
from django.http import HttpResponse
6
from rest_framework import permissions
7
from rest_framework.response import Response
8
from rest_framework.views import APIView
6 9

  
7 10

  
8 11
def helloworld(request):
......
16 19
    return HttpResponse('Hello world %s' % request.META['REMOTE_ADDR'])
17 20

  
18 21

  
22
class AuthenticatedTestView(APIView):
23

  
24
    permission_classes = [permissions.IsAuthenticated]
25

  
26
    def get(self, request):
27
        return Response({'some': 'data'})
28

  
29

  
19 30
urlpatterns = [
20 31
    url(r'^$', helloworld),
32
    url(r'^authenticated-testview/', AuthenticatedTestView.as_view()),
21 33
]
tests/settings.py
26 26
}
27 27

  
28 28
TEMPLATES[0]['OPTIONS'].setdefault('builtins', []).append('hobo.templatetags.hobo')
29

  
30
REST_FRAMEWORK = {
31
    'DEFAULT_AUTHENTICATION_CLASSES': (
32
        'hobo.rest_authentication.PublikAuthentication',
33
        'hobo.rest_authentication.APIClientAuthentication',
34
    ),
35
    'DEFAULT_RENDERER_CLASSES': ('rest_framework.renderers.JSONRenderer',),
36
}
tests/test_rest_authentication.py
1
import base64
2

  
3
import pytest
4
import requests
5
import responses
6
from django.test import RequestFactory
7

  
8

  
9
@pytest.fixture
10
def settings_with_idp(settings):
11
    settings.ROOT_URLCONF = 'hobo.test_urls'
12
    settings.KNOWN_SERVICES = {
13
        'authentic': {
14
            'idp': {
15
                'title': 'Foobar',
16
                'url': 'https://idp.example.invalid/',
17
                'orig': 'example.org',
18
                'secret': 'xxx',
19
            }
20
        }
21
    }
22
    return settings
23

  
24

  
25
@pytest.fixture
26
def app_with_auth(app):
27
    app.authorization = ('Basic', ('foo', 'bar'))
28
    return app
29

  
30

  
31
def test_no_known_services(app_with_auth, db, settings):
32
    settings.ROOT_URLCONF = 'hobo.test_urls'
33
    resp = app_with_auth.get('/authenticated-testview/', status=403)
34

  
35

  
36
def test_no_idp_in_known_services(app_with_auth, db, settings):
37
    settings.ROOT_URLCONF = 'hobo.test_urls'
38
    settings.KNOWN_SERVICES = {}
39
    resp = app_with_auth.get('/authenticated-testview/', status=403)
40

  
41

  
42
def test_idp_connection_error(app_with_auth, db, settings_with_idp):
43
    with responses.RequestsMock() as rsps:
44
        rsps.post('https://idp.example.invalid/api/check-api-client/', status=403)
45
        resp = app_with_auth.get('/authenticated-testview/', status=403)
46

  
47

  
48
def test_idp_timeout(app_with_auth, db, settings_with_idp):
49
    with responses.RequestsMock() as rsps:
50
        rsps.post('https://idp.example.invalid/api/check-api-client/', body=requests.Timeout('...'))
51
        resp = app_with_auth.get('/authenticated-testview/', status=503)
52
        assert resp.json == {'err': 1, 'err_desc': 'IDP temporarily unavailable, try again later.'}
53

  
54

  
55
def test_idp_connection_error(app_with_auth, db, settings_with_idp):
56
    with responses.RequestsMock() as rsps:
57
        rsps.post('https://idp.example.invalid/api/check-api-client/', body=requests.RequestException('...'))
58
        resp = app_with_auth.get('/authenticated-testview/', status=503)
59
        assert resp.json == {'err': 1, 'err_desc': 'IDP temporarily unavailable, try again later.'}
60

  
61

  
62
def test_idp_no_err_key(app_with_auth, db, settings_with_idp):
63
    with responses.RequestsMock() as rsps:
64
        rsps.post(
65
            'https://idp.example.invalid/api/check-api-client/',
66
            json={'foo': 'bar'},
67
            status=200,
68
        )
69
        resp = app_with_auth.get('/authenticated-testview/', status=403)
70

  
71

  
72
def test_idp_app_error(app_with_auth, db, settings_with_idp):
73
    with responses.RequestsMock() as rsps:
74
        rsps.post(
75
            'https://idp.example.invalid/api/check-api-client/',
76
            json={'err': 1},
77
            status=200,
78
        )
79
        resp = app_with_auth.get('/authenticated-testview/', status=403)
80

  
81

  
82
def test_idp_wrong_serialization(app_with_auth, db, settings_with_idp):
83
    with responses.RequestsMock() as rsps:
84
        rsps.post(
85
            'https://idp.example.invalid/api/check-api-client/',
86
            json={'err': 0, 'data': {'foo': 'bar'}},
87
            status=200,
88
        )
89
        resp = app_with_auth.get('/authenticated-testview/', status=403)
90

  
91

  
92
def test_no_credentials(app, db, settings_with_idp):
93
    # test that the '/authenticated-testview/' really requires authentication,
94
    # otherwise all the others tests are meaningless.
95
    resp = app.get('/authenticated-testview/', status=403)
96

  
97

  
98
def test_access_granted(app_with_auth, db, settings_with_idp):
99
    with responses.RequestsMock() as rsps:
100
        rsps.post(
101
            'https://idp.example.invalid/api/check-api-client/',
102
            json={
103
                'err': 0,
104
                'data': {
105
                    'is_active': True,
106
                    'is_anonymous': False,
107
                    'is_authenticated': True,
108
                    'is_superuser': False,
109
                    'restrict_to_anonymised_data': False,
110
                    'roles': [],
111
                },
112
            },
113
            status=200,
114
        )
115
        resp = app_with_auth.get('/authenticated-testview/')
tox.ini
57 57
	mock<4
58 58
	httmock
59 59
	requests
60
        responses
60 61
	pytest-freezegun
61 62
	xmlschema<1.1
62 63
	enum34<=1.1.6
63
-