Projet

Général

Profil

0003-provisionning-add-synchronous-provisionning-to-DRF-a.patch

Benjamin Dauvergne, 05 octobre 2022 16:39

Télécharger (15,5 ko)

Voir les différences:

Subject: [PATCH 3/3] provisionning: add synchronous provisionning to DRF
 authentication (#59135)

 hobo/provisionning/utils.py                 |  72 +++++++++++-
 hobo/rest_authentication.py                 |  22 ++--
 tests_authentic/test_rest_authentication.py |  15 ++-
 tests_multitenant/conftest.py               |   8 +-
 tests_multitenant/test_provisionning.py     | 120 +++++++++++++++++++-
 tests_multitenant/test_settings.py          |   1 +
 tests_multitenant/test_uwsgidecorators.py   |   5 +-
 7 files changed, 217 insertions(+), 26 deletions(-)
hobo/provisionning/utils.py
16 16

  
17 17
import hashlib
18 18
import logging
19
from urllib.parse import quote, urljoin
19 20

  
21
import requests
22
from django.conf import settings
20 23
from django.contrib.auth import get_user_model
21 24
from django.contrib.auth.models import Group
22 25
from django.db import IntegrityError
26
from django.db.models import Q
23 27
from django.db.transaction import atomic
24 28
from mellon.models import Issuer
25 29

  
30
from hobo import signature
26 31
from hobo.agent.common.models import Role
27 32
from hobo.multitenant.utils import provision_user_groups
28 33

  
......
51 56
    return s
52 57

  
53 58

  
59
def get_idp_service():
60
    idp_services = list((settings.KNOWN_SERVICES or {}).get('authentic', {}).values())
61
    return (idp_services or [None])[0]
62

  
63

  
54 64
def get_issuer(entity_id):
55 65
    issuer, created = Issuer.objects.get_or_create(entity_id=entity_id)
56 66
    return issuer
......
69 79
    }
70 80

  
71 81
    User = get_user_model()
72
    try:
73
        user = User.objects.get(
74
            saml_identifiers__name_id=o['uuid'], saml_identifiers__issuer__entity_id=entity_id
75
        )
76
    except User.DoesNotExist:
77
        user = User()
82
    user = get_user_from_name_id(name_id=o['uuid'], entity_id=entity_id) or User()
78 83

  
79 84
    for key in attributes:
80 85
        if getattr(user, key) != attributes[key]:
......
263 268
            except TryAgain:
264 269
                continue
265 270
            break
271

  
272

  
273
def get_user_from_name_id(name_id, entity_id=None, raise_on_missing=False):
274
    User = get_user_model()
275
    try:
276
        user = User.objects.get(
277
            saml_identifiers__name_id=name_id, saml_identifiers__issuer__entity_id=entity_id
278
        )
279
    except User.DoesNotExist:
280
        try:
281
            user = User.objects.get(Q(username=name_id[:30]) | Q(username=name_id[:150]))
282
        except User.DoesNotExist:
283
            user = None
284
    if not user:
285
        if raise_on_missing:
286
            raise User.DoesNotExist
287
    return user
288

  
289

  
290
class ProvisionningTemporaryError(RuntimeError):
291
    pass
292

  
293

  
294
def get_or_create_user_from_name_id(name_id, raise_on_missing=False):
295
    User = get_user_model()
296

  
297
    user = get_user_from_name_id(name_id=name_id)
298
    if user:
299
        return user
300

  
301
    idp_service = get_idp_service()
302
    if not idp_service:
303
        if raise_on_missing:
304
            raise User.DoesNotExist('no idp service defined')
305
        return None
306

  
307
    entity_id = idp_service['saml-idp-metadata-url']
308
    issuer = get_issuer(entity_id)
309
    users_api_url = urljoin(idp_service['url'], 'api/users/%s/' % quote(name_id, safe=''))
310
    try:
311
        response = requests.get(signature.sign_url(users_api_url, idp_service['secret']), timeout=5)
312
        response.raise_for_status()
313
    except requests.HTTPError as e:
314
        if e.response.status_code == 404:
315
            if raise_on_missing:
316
                raise User.DoesNotExist
317
            return None
318
        if raise_on_missing:
319
            raise ProvisionningTemporaryError(str(e) or repr(e))
320
        return None
321
    except requests.RequestException as e:
322
        if raise_on_missing:
323
            raise ProvisionningTemporaryError(str(e) or repr(e))
324
        return None
325
    return provision_user(issuer.entity_id, o=response.json())
hobo/rest_authentication.py
9 9
from rest_framework import authentication, exceptions, status
10 10

  
11 11
from hobo import signature
12
from hobo.provisionning.utils import ProvisionningTemporaryError, get_or_create_user_from_name_id
12 13
from hobo.requests_wrapper import Requests
13 14

  
14 15
try:
......
81 82

  
82 83
class PublikAuthenticationFailed(exceptions.APIException):
83 84
    status_code = status.HTTP_401_UNAUTHORIZED
84
    default_code = 'invalid-signature'
85 85

  
86
    def __init__(self, code):
87
        self.detail = {'err': 1, 'err_desc': code}
86
    def __init__(self, code, description=None):
87
        self.detail = {'err': 1, 'err_class': code, 'err_desc': code}
88
        if description:
89
            self.detail['err_desc'] = description
90

  
91

  
92
class PublikAuthenticationTemporaryFailure(PublikAuthenticationFailed):
93
    status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
88 94

  
89 95

  
90 96
class PublikAuthentication(authentication.BaseAuthentication):
......
110 116

  
111 117
            elif UserSAMLIdentifier:
112 118
                try:
113
                    return UserSAMLIdentifier.objects.get(name_id=name_id).user
114
                except UserSAMLIdentifier.DoesNotExist:
119
                    return get_or_create_user_from_name_id(name_id, raise_on_missing=True)
120
                except ProvisionningTemporaryError as e:
121
                    raise PublikAuthenticationTemporaryFailure('idp-not-reachable', str(e))
122
                except User.DoesNotExist:
115 123
                    raise PublikAuthenticationFailed('user-not-found')
116 124
            else:
117 125
                raise PublikAuthenticationFailed('no-usable-model')
......
123 131
                pass
124 132
            if hasattr(settings, 'HOBO_ANONYMOUS_SERVICE_USER_CLASS'):
125 133
                klass = import_string(settings.HOBO_ANONYMOUS_SERVICE_USER_CLASS)
126
                self.logger.info('anonymous signature validated')
127 134
                return klass()
128 135
            raise PublikAuthenticationFailed('no-user-for-orig')
129 136

  
......
149 156
            ), 'signature.check_url should never return False with raise_on_error'
150 157
        except signature.SignatureError as e:
151 158
            self.logger.warning('publik rest-framework-authentication failed: %s', e)
152
            raise PublikAuthenticationFailed(str(e))
159
            raise PublikAuthenticationFailed('invalid-signature', str(e))
153 160
        user = self.resolve_user(request)
154
        self.logger.info('user authenticated with signature %s', user)
155 161
        return (user, None)
156 162

  
157 163

  
tests_authentic/test_rest_authentication.py
145 145

  
146 146
        response = view(request)
147 147
        assert response.status_code == 401
148
        assert response.data == {'err': 1, 'err_desc': 'user-not-found'}
148
        assert response.data['err'] == 1
149
        assert response.data['err_desc'] == 'user-not-found'
149 150

  
150 151
        # Service authentication, wrong timestamp
151 152
        request = rf.get(
......
154 155

  
155 156
        response = view(request)
156 157
        assert response.status_code == 401
157
        assert response.data == {
158
            'err': 1,
159
            'err_desc': "invalid timestamp, time data 'xxx' does not match format '%Y-%m-%dT%H:%M:%SZ'",
160
        }
158
        assert response.data['err'] == 1
159
        assert (
160
            response.data['err_desc']
161
            == "invalid timestamp, time data 'xxx' does not match format '%Y-%m-%dT%H:%M:%SZ'"
162
        )
161 163

  
162 164
        # Service authentication
163 165
        request = rf.get(signature.sign_url('/?orig=zzz', secret_key))
......
169 171
        del settings.HOBO_ANONYMOUS_SERVICE_USER_CLASS
170 172
        response = view(request)
171 173
        assert response.status_code == 401
172
        assert response.data == {'err': 1, 'err_desc': 'no-user-for-orig'}
174
        assert response.data['err'] == 1
175
        assert response.data['err_desc'] == 'no-user-for-orig'
tests_multitenant/conftest.py
1 1
import pytest
2
from tenant_schemas.utils import tenant_context
2 3

  
3 4

  
4 5
@pytest.fixture
......
68 69
                            'service-id': 'authentic',
69 70
                            'base_url': 'http://other.example.net',
70 71
                            'legacy_urls': [{'base_url': 'http://olda2.example.net'}],
72
                            'saml-idp-metadata-url': 'https://other.example.net/idp/saml2/metadata',
71 73
                        },
72 74
                        {
73 75
                            'slug': 'another',
......
82 84
                            'service-id': 'combo',
83 85
                            'template_name': '...portal-user...',
84 86
                            'base_url': 'http://portal-user.example.net',
87
                            'secret_key': 'abcdefg',
85 88
                        },
86 89
                    ],
87 90
                },
......
116 119

  
117 120
@pytest.fixture
118 121
def tenant(make_tenant):
119
    return make_tenant('tenant.example.net')
122
    tenant = make_tenant('tenant.example.net')
123

  
124
    with tenant_context(tenant):
125
        yield tenant
120 126

  
121 127

  
122 128
@pytest.fixture
tests_multitenant/test_provisionning.py
1
from hobo.provisionning.utils import NotificationProcessing
1
import json
2

  
3
import pytest
4
import requests
5
from httmock import HTTMock, urlmatch
6
from mellon.models import Issuer
7
from rest_framework import permissions
8
from rest_framework.response import Response
9
from rest_framework.views import APIView
10

  
11
from hobo import rest_authentication, signature
12
from hobo.provisionning.utils import (
13
    NotificationProcessing,
14
    ProvisionningTemporaryError,
15
    get_or_create_user_from_name_id,
16
    get_user_from_name_id,
17
)
2 18

  
3 19

  
4 20
def test_truncate_role_name():
......
20 36
        assert len(truncated) == max_length
21 37
        assert truncated not in seen
22 38
        seen.add(truncated)
39

  
40

  
41
@urlmatch()
42
def request_exception(url, request):
43
    raise requests.ConnectionError
44

  
45

  
46
NAME_ID = '1234' * 8
47

  
48

  
49
@urlmatch(path='/api/users/')
50
def user_payload(url, request):
51
    return {
52
        'status_code': 200,
53
        'content': json.dumps(
54
            {
55
                'uuid': NAME_ID,
56
                'first_name': 'John',
57
                'last_name': 'Doe',
58
                'email': 'john.doe@example.net',
59
                'is_superuser': False,
60
                'is_active': True,
61
            }
62
        ),
63
    }
64

  
65

  
66
@urlmatch(path='/api/users/')
67
def error_404(url, request):
68
    return {
69
        'status_code': 404,
70
    }
71

  
72

  
73
def test_get_or_create_user_from_name_id(tenant, django_user_model):
74
    Issuer.objects.create(entity_id='https://idp.examle.net/idp/saml2/metadata')
75

  
76
    assert get_user_from_name_id(NAME_ID) is None
77
    with pytest.raises(django_user_model.DoesNotExist):
78
        get_user_from_name_id(NAME_ID, raise_on_missing=True)
79

  
80
    with HTTMock(request_exception):
81
        assert get_or_create_user_from_name_id(NAME_ID) is None
82
        with pytest.raises(ProvisionningTemporaryError):
83
            get_or_create_user_from_name_id(NAME_ID, raise_on_missing=True)
84

  
85
    with HTTMock(error_404):
86
        assert get_or_create_user_from_name_id(NAME_ID) is None
87
        with pytest.raises(django_user_model.DoesNotExist):
88
            get_or_create_user_from_name_id(NAME_ID, raise_on_missing=True)
89

  
90
    with HTTMock(user_payload):
91
        user = get_or_create_user_from_name_id(NAME_ID)
92
        assert user is not None
93
        assert user.first_name == 'John'
94
        assert user.username == NAME_ID
95
        assert user.saml_identifiers.get().name_id == NAME_ID
96
        assert django_user_model.objects.count() == 1
97

  
98

  
99
class DummyAPIView(APIView):
100
    authentication_classes = (rest_authentication.PublikAuthentication,)
101
    permission_classes = (permissions.IsAuthenticated,)
102

  
103
    def get(self, request, format=None):
104
        return Response({'err': 0})
105

  
106

  
107
def test_rest_authentication_provisionning_ok(tenant, settings, rf, django_user_model):
108
    key = settings.KNOWN_SERVICES['combo']['another2']['secret']
109
    request = rf.get(signature.sign_url('/api/misc/?param1=2&NameID=%s&orig=portal-user.example.net', key))
110

  
111
    view = DummyAPIView.as_view()
112

  
113
    with HTTMock(user_payload):
114
        response = view(request)
115
    assert response.status_code == 200
116
    assert response.data == {'err': 0}
117
    user = django_user_model.objects.get()
118
    assert user.first_name == 'John'
119
    assert user.username == NAME_ID
120
    assert user.saml_identifiers.get().name_id == NAME_ID
121

  
122

  
123
def test_rest_authentication_provisionning_nok(tenant, settings, rf):
124
    key = settings.KNOWN_SERVICES['combo']['another2']['secret']
125
    request = rf.get(signature.sign_url('/api/misc/?param1=2&NameID=abcd&orig=portal-user.example.net', key))
126

  
127
    view = DummyAPIView.as_view()
128

  
129
    with HTTMock(request_exception):
130
        response = view(request)
131
        assert response.status_code == 500
132
        assert response.data['err'] == 1
133
        assert response.data['err_class'] == 'idp-not-reachable'
134
        assert 'ConnectionError' in response.data['err_desc']
135

  
136
    with HTTMock(error_404):
137
        response = view(request)
138
        assert response.status_code == 401
139
        assert response.data['err'] == 1
140
        assert response.data['err_class'] == 'user-not-found'
tests_multitenant/test_settings.py
205 205
                'saml-sp-metadata-url',
206 206
                'provisionning-url',
207 207
                'secondary',
208
                'saml-idp-metadata-url',
208 209
            } == authentic_other_keys
209 210
            assert (
210 211
                settings.KNOWN_SERVICES['authentic']['other']['url'] == hobo_json['services'][2]['base_url']
tests_multitenant/test_uwsgidecorators.py
44 44

  
45 45

  
46 46
def test_mocked_uwsgi_tenant(uwsgi, tenant):
47
    from tenant_schemas.utils import tenant_context
48

  
49 47
    @hobo.multitenant.uwsgidecorators.spool
50 48
    def function(a, b):
51 49
        pass
52 50

  
53
    with tenant_context(tenant):
54
        function.spool(1, 2)
51
    function.spool(1, 2)
55 52

  
56 53
    assert set(uwsgi.spool.call_args[1].keys()) == {'body', 'tenant', 'name'}
57 54
    assert pickle.loads(uwsgi.spool.call_args[1]['body']) == {'args': (1, 2), 'kwargs': {}}
58
-