0003-provisionning-add-synchronous-provisionning-to-DRF-a.patch
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 |
- |