0002-update-and-cache-metadata-from-URL-and-path-10196.patch
README | ||
---|---|---|
76 | 76 |
MELLON_IDENTITY_PROVIDERS |
77 | 77 |
------------------------- |
78 | 78 | |
79 |
A list of dictionaries, only one key is mandatory in those |
|
80 |
dictionaries `METADATA` it should contain the UTF-8 content of the |
|
81 |
metadata file of the identity provider or if it starts with a slash |
|
82 |
the absolute path toward a metadata file. All other keys are override |
|
83 |
of generic settings. |
|
79 |
A list of dictionaries, they must contain at least one of the keys `METADATA` |
|
80 |
(inline copy of the identity provider metadata), `METADATA_URL` URL of the IdP |
|
81 |
metadata file, or `METADATA_PATH` an absolute path to the IdP metadata file.. |
|
82 |
All other keys are override of generic settings. |
|
83 | ||
84 |
When using an URL, the URL is automatically cached in the `MEDIA_ROOT` |
|
85 |
directory of your application in the directory named `mellon_metadata_cache`. |
|
86 |
If you restart the application and the URL is unavailable, the file cache will |
|
87 |
be used. The cache will be refreshed every `MELLON_METADATA_CACHE_TIME` seconds. |
|
88 |
If the HTTP retrieval of the metadata URL takes longer thant |
|
89 |
`METTON_METADATA_HTTP_TIMEOUT` seconds, retrieval will be skipped. |
|
90 | ||
91 |
When the cache is already loaded, retrievals are done in the background by a |
|
92 |
thread. |
|
93 | ||
94 |
When using a local absolute path, the metadata is reloaded each time the |
|
95 |
modification time of the file is superior to the last time it was loaded. |
|
84 | 96 | |
85 | 97 |
MELLON_PUBLIC_KEYS |
86 | 98 |
------------------ |
... | ... | |
261 | 273 |
Should be post or artifact. Default is post. You can refer to the SAML 2.0 |
262 | 274 |
specification to learn the difference. |
263 | 275 | |
276 |
MELLON_METADATA_CACHE_TIME |
|
277 |
-------------------------- |
|
278 | ||
279 |
When using METADATA_URL to reference a metadata file, it's the duration in |
|
280 |
secondes between refresh of the metadata file. Default is 3600 seconds, 1 hour. |
|
281 | ||
282 |
METTON_METADATA_HTTP_TIMEOUT |
|
283 |
--------------------------- |
|
284 | ||
285 |
Timeout in seconds for HTTP call made to retrieve metadata files. Default is 10 |
|
286 |
seconds. |
|
287 | ||
264 | 288 |
Tests |
265 | 289 |
===== |
266 | 290 |
debian/control | ||
---|---|---|
15 | 15 |
python (>= 2.7), |
16 | 16 |
python-django (>= 1.5), |
17 | 17 |
python-isodate, |
18 |
python-lasso |
|
18 |
python-lasso, |
|
19 |
python-atomicwrites |
|
19 | 20 |
Breaks: python-hobo (<< 0.34.5) |
20 | 21 |
Description: SAML authentication for Django |
21 | 22 | |
... | ... | |
24 | 25 |
Depends: ${misc:Depends}, ${python:Depends}, |
25 | 26 |
python3-django (>= 1.5), |
26 | 27 |
python3-isodate, |
27 |
python3-lasso |
|
28 |
python3-lasso, |
|
29 |
python3-atomicwrites |
|
28 | 30 |
Description: SAML authentication for Django |
mellon/adapters.py | ||
---|---|---|
13 | 13 |
# You should have received a copy of the GNU Affero General Public License |
14 | 14 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
15 | 15 | |
16 |
from xml.etree import ElementTree as ET |
|
17 |
import hashlib |
|
16 | 18 |
import logging |
19 |
import os |
|
20 |
import threading |
|
21 |
import time |
|
17 | 22 |
import uuid |
18 |
from xml.etree import ElementTree as ET |
|
19 | 23 | |
20 | 24 |
import lasso |
21 | 25 |
import requests |
22 | 26 |
import requests.exceptions |
27 |
from atomicwrites import atomic_write |
|
23 | 28 | |
24 | 29 |
from django.core.exceptions import PermissionDenied |
30 |
from django.core.files.storage import default_storage |
|
25 | 31 |
from django.contrib import auth |
26 | 32 |
from django.contrib.auth.models import Group |
27 | 33 |
from django.utils import six |
28 | 34 |
from django.utils.encoding import force_text |
35 |
from django.utils.six.moves.urllib.parse import urlparse |
|
29 | 36 | |
30 | 37 |
from . import utils, app_settings, models |
31 | 38 | |
39 |
logger = logging.getLogger(__name__) |
|
40 | ||
32 | 41 | |
33 | 42 |
class UserCreationError(Exception): |
34 | 43 |
pass |
35 | 44 | |
36 | 45 | |
37 | 46 |
class DefaultAdapter(object): |
38 |
def __init__(self, *args, **kwargs): |
|
39 |
self.logger = logging.getLogger(__name__) |
|
40 | ||
41 | 47 |
def get_idp(self, entity_id): |
42 | 48 |
'''Find the first IdP definition matching entity_id''' |
43 | 49 |
for idp in self.get_idps(): |
... | ... | |
49 | 55 | |
50 | 56 |
def get_idps(self): |
51 | 57 |
for i, idp in enumerate(self.get_identity_providers_setting()): |
52 |
if 'METADATA_URL' in idp and 'METADATA' not in idp: |
|
58 |
if self.load_idp(idp, i): |
|
59 |
yield idp |
|
60 | ||
61 |
def load_metadata_path(self, idp, i): |
|
62 |
path = idp['METADATA_PATH'] |
|
63 |
if not os.path.exists(path): |
|
64 |
logger.warning('metadata path %s does not exist', path) |
|
65 |
return |
|
66 |
last_update = idp.get('METADATA_PATH_LAST_UPDATE', 0) |
|
67 |
try: |
|
68 |
mtime = os.stat(path).st_mtime |
|
69 |
except OSError as e: |
|
70 |
logger.warning('metadata path %s : stat() call failed, %s', path, e) |
|
71 |
return |
|
72 |
if last_update == 0 or mtime >= last_update: |
|
73 |
idp['METADATA_PATH_LAST_UPDATE'] = time.time() |
|
74 |
try: |
|
75 |
with open(path) as fd: |
|
76 |
metadata = fd.read() |
|
77 |
except OSError as e: |
|
78 |
logger.warning('metadata path %s : open()/read() call failed, %s', path, e) |
|
79 |
return |
|
80 |
entity_id = self.load_entity_id(metadata, i) |
|
81 |
if not entity_id: |
|
82 |
logger.error('invalid metadata file retrieved from %s', path) |
|
83 |
return |
|
84 |
if 'ENTITY_ID' in idp and idp['ENTITY_ID'] != entity_id: |
|
85 |
logger.error('metadata path %s : entityID changed %r != %r', path, entity_id, idp['ENTITY_ID']) |
|
86 |
del idp['ENTITY_ID'] |
|
87 |
idp['METADATA'] = metadata |
|
88 | ||
89 |
def load_metadata_url(self, idp, i): |
|
90 |
url = idp['METADATA_URL'] |
|
91 |
metadata_cache_time = utils.get_setting(idp, 'METADATA_CACHE_TIME') |
|
92 |
timeout = utils.get_setting(idp, 'METADATA_HTTP_TIMEOUT') |
|
93 | ||
94 |
warning = logger.warning |
|
95 |
if 'METADATA' not in idp: |
|
96 |
# if we have no metadata in cache, we must emit errors |
|
97 |
warning = logger.error |
|
98 | ||
99 |
try: |
|
100 |
hostname = urlparse(url).hostname |
|
101 |
except (ValueError, TypeError) as e: |
|
102 |
warning('invalid METADATA_URL %r: %s', url, e) |
|
103 |
return |
|
104 |
if not hostname: |
|
105 |
warning('no hostname in METADATA_URL %r: %s', url) |
|
106 |
return |
|
107 | ||
108 |
last_update = idp.get('METADATA_URL_LAST_UPDATE', 0) |
|
109 |
now = time.time() |
|
110 | ||
111 |
try: |
|
112 |
url_fingerprint = hashlib.md5(url.encode('ascii')).hexdigest() |
|
113 |
file_cache_key = '%s_%s.xml' % (hostname, url_fingerprint) |
|
114 |
except (UnicodeError, TypeError, ValueError): |
|
115 |
warning('unable to compute file_cache_key') |
|
116 |
return |
|
117 | ||
118 |
cache_directory = default_storage.path('mellon_metadata_cache') |
|
119 |
file_cache_path = os.path.join(cache_directory, file_cache_key) |
|
120 | ||
121 |
if metadata_cache_time: |
|
122 |
# METADATA_CACHE_TIME == 0 disable the file cache |
|
123 |
if not os.path.exists(cache_directory): |
|
124 |
os.makedirs(cache_directory) |
|
125 | ||
126 |
if os.path.exists(file_cache_path) and 'METADATA' not in idp: |
|
127 |
try: |
|
128 |
with open(file_cache_path) as fd: |
|
129 |
idp['METADATA'] = fd.read() |
|
130 |
# use file cache mtime as last_update time, prevent too many loading from different workers |
|
131 |
last_update = max(last_update, os.stat(file_cache_path).st_mtime) |
|
132 |
except OSError: |
|
133 |
warning('metadata url %s : error when loading the file cache %s', url, file_cache_path) |
|
134 | ||
135 |
# fresh cache, skip loading |
|
136 |
if last_update and 'METADATA' in idp and (now - last_update) < metadata_cache_time: |
|
137 |
return |
|
138 | ||
139 |
def __http_get(): |
|
140 |
try: |
|
53 | 141 |
verify_ssl_certificate = utils.get_setting( |
54 | 142 |
idp, 'VERIFY_SSL_CERTIFICATE') |
55 | 143 |
try: |
56 |
response = requests.get(idp['METADATA_URL'], verify=verify_ssl_certificate)
|
|
144 |
response = requests.get(url, verify=verify_ssl_certificate, timeout=timeout)
|
|
57 | 145 |
response.raise_for_status() |
58 | 146 |
except requests.exceptions.RequestException as e: |
59 |
self.logger.error( |
|
60 |
u'retrieval of metadata URL %r failed with error %s for %d-th idp', |
|
61 |
idp['METADATA_URL'], e, i) |
|
62 |
continue |
|
147 |
warning('metadata url %s : HTTP request failed %s', url, e) |
|
148 |
return |
|
149 | ||
150 |
entity_id = self.load_entity_id(response.text, i) |
|
151 |
if not entity_id: |
|
152 |
warning('invalid metadata file retrieved from %s', url) |
|
153 |
return |
|
154 | ||
155 |
if 'ENTITY_ID' in idp and idp['ENTITY_ID'] != entity_id: |
|
156 |
# entityID change is always en error |
|
157 |
logger.error('metadata url %s : entityID changed %r != %r', url, entity_id, idp['ENTITY_ID']) |
|
158 |
del idp['ENTITY_ID'] |
|
159 | ||
63 | 160 |
idp['METADATA'] = response.text |
64 |
elif 'METADATA' in idp: |
|
65 |
if idp['METADATA'].startswith('/'): |
|
66 |
idp['METADATA'] = open(idp['METADATA']).read() |
|
67 |
else: |
|
68 |
self.logger.error(u'missing METADATA or METADATA_URL in %d-th idp', i) |
|
69 |
continue |
|
161 |
idp['METADATA_URL_LAST_UPDATE'] = now |
|
162 |
if metadata_cache_time: |
|
163 |
try: |
|
164 |
with atomic_write(file_cache_path, mode='wb', overwrite=True) as fd: |
|
165 |
fd.write(response.text.encode('utf-8')) |
|
166 |
except OSError as e: |
|
167 |
logger.error('metadata url %s : could not write file cache %s, %s', url, file_cache_path, e) |
|
168 |
idp['METADATA_PATH'] = file_cache_path |
|
169 |
# prevent reloading of the file cache immediately |
|
170 |
idp['METADATA_PATH_LAST_UPDATE'] = time.time() + 1 |
|
171 |
logger.debug('metadata url %s : update throught HTTP', url) |
|
172 |
finally: |
|
173 |
# release thread object |
|
174 |
idp.pop('METADATA_URL_UPDATE_THREAD', None) |
|
175 |
# emit an error if cache is too old |
|
176 |
stale_timeout = 24 * metadata_cache_time |
|
177 |
if last_update and (now - idp['METADATA_URL_LAST_UPDATE']) > stale_timeout: |
|
178 |
logger.error('metadata url %s : not updated since %.1f hours', |
|
179 |
stale_timeout / 3600.0) |
|
180 | ||
181 |
# we have cache, update in background |
|
182 |
if last_update and 'METADATA' in idp: |
|
183 |
t = threading.Thread(target=__http_get) |
|
184 |
t.start() |
|
185 |
# store thread in idp for tests |
|
186 |
idp['METADATA_URL_UPDATE_THREAD'] = t |
|
187 |
# suspend updates for HTTP timeout + 5 seconds |
|
188 |
idp['METADATA_URL_LAST_UPDATE'] = last_update + timeout + 5 |
|
189 |
else: |
|
190 |
# synchronous update |
|
191 |
__http_get() |
|
192 | ||
193 |
def load_metadata(self, idp, i): |
|
194 |
# legacy support |
|
195 |
if 'METADATA' in idp and idp['METADATA'].startswith('/'): |
|
196 |
idp['METADATA_PATH'] = idp['METADATA'] |
|
197 |
del idp['METADATA'] |
|
198 | ||
199 |
if 'METADATA_PATH' in idp: |
|
200 |
self.load_metadata_path(idp, i) |
|
201 | ||
202 |
if 'METADATA_URL' in idp: |
|
203 |
self.load_metadata_url(idp, i) |
|
204 | ||
205 |
if 'METADATA' in idp: |
|
70 | 206 |
if 'ENTITY_ID' not in idp: |
71 |
try: |
|
72 |
doc = ET.fromstring(idp['METADATA']) |
|
73 |
except (TypeError, ET.ParseError): |
|
74 |
self.logger.error(u'METADATA of %d-th idp is invalid', i) |
|
75 |
continue |
|
76 |
if doc.tag != '{%s}EntityDescriptor' % lasso.SAML2_METADATA_HREF: |
|
77 |
self.logger.error(u'METADATA of %d-th idp has no EntityDescriptor root tag', i) |
|
78 |
continue |
|
79 | ||
80 |
if 'entityID' not in doc.attrib: |
|
81 |
self.logger.error( |
|
82 |
u'METADATA of %d-th idp has no entityID attribute on its root tag', i) |
|
83 |
continue |
|
84 |
idp['ENTITY_ID'] = doc.attrib['entityID'] |
|
85 |
yield idp |
|
207 |
entity_id = self.load_entity_id(idp['METADATA'], i) |
|
208 |
if entity_id: |
|
209 |
idp['ENTITY_ID'] = entity_id |
|
210 | ||
211 |
if 'ENTITY_ID' in idp: |
|
212 |
return idp['METADATA'] |
|
213 | ||
214 |
def load_entity_id(self, metadata, i): |
|
215 |
try: |
|
216 |
doc = ET.fromstring(metadata) |
|
217 |
except (TypeError, ET.ParseError): |
|
218 |
logger.error(u'METADATA of %d-th idp is invalid', i) |
|
219 |
return None |
|
220 |
if doc.tag != '{%s}EntityDescriptor' % lasso.SAML2_METADATA_HREF: |
|
221 |
logger.error(u'METADATA of %d-th idp has no EntityDescriptor root tag', i) |
|
222 |
return None |
|
223 | ||
224 |
if 'entityID' not in doc.attrib: |
|
225 |
logger.error( |
|
226 |
u'METADATA of %d-th idp has no entityID attribute on its root tag', i) |
|
227 |
return None |
|
228 |
return doc.attrib['entityID'] |
|
229 | ||
230 |
def load_idp(self, idp, i): |
|
231 |
self.load_metadata(idp, i) |
|
232 |
return 'ENTITY_ID' in idp |
|
86 | 233 | |
87 | 234 |
def authorize(self, idp, saml_attributes): |
88 | 235 |
if not idp: |
... | ... | |
102 | 249 |
username = force_text(username_template).format( |
103 | 250 |
realm=realm, attributes=saml_attributes, idp=idp)[:30] |
104 | 251 |
except ValueError: |
105 |
self.logger.error(u'invalid username template %r', username_template)
|
|
252 |
logger.error(u'invalid username template %r', username_template) |
|
106 | 253 |
except (AttributeError, KeyError, IndexError) as e: |
107 |
self.logger.error(
|
|
254 |
logger.error( |
|
108 | 255 |
u'invalid reference in username template %r: %s', username_template, e) |
109 | 256 |
except Exception: |
110 |
self.logger.exception(u'unknown error when formatting username')
|
|
257 |
logger.exception(u'unknown error when formatting username') |
|
111 | 258 |
else: |
112 | 259 |
return username |
113 | 260 | |
... | ... | |
117 | 264 |
def finish_create_user(self, idp, saml_attributes, user): |
118 | 265 |
username = self.format_username(idp, saml_attributes) |
119 | 266 |
if not username: |
120 |
self.logger.warning('could not build a username, login refused')
|
|
267 |
logger.warning('could not build a username, login refused') |
|
121 | 268 |
raise UserCreationError |
122 | 269 |
user.username = username |
123 | 270 |
user.save() |
... | ... | |
133 | 280 |
if len(name_id) == 1: |
134 | 281 |
name_id = name_id[0] |
135 | 282 |
else: |
136 |
self.logger.warning('more than one value for attribute %r, cannot federate',
|
|
137 |
transient_federation_attribute)
|
|
283 |
logger.warning('more than one value for attribute %r, cannot federate', |
|
284 |
transient_federation_attribute) |
|
138 | 285 |
return None |
139 | 286 |
else: |
140 | 287 |
return None |
... | ... | |
146 | 293 |
saml_identifiers__issuer=issuer) |
147 | 294 |
except User.DoesNotExist: |
148 | 295 |
if not utils.get_setting(idp, 'PROVISION'): |
149 |
self.logger.warning('provisionning disabled, login refused')
|
|
296 |
logger.warning('provisionning disabled, login refused') |
|
150 | 297 |
return None |
151 | 298 |
user = self.create_user(User) |
152 | 299 |
saml_id, created = models.UserSAMLIdentifier.objects.get_or_create( |
... | ... | |
157 | 304 |
except UserCreationError: |
158 | 305 |
user.delete() |
159 | 306 |
return None |
160 |
self.logger.info('created new user %s with name_id %s from issuer %s', |
|
161 |
user, name_id, issuer) |
|
307 |
logger.info('created new user %s with name_id %s from issuer %s', user, name_id, issuer) |
|
162 | 308 |
else: |
163 | 309 |
user.delete() |
164 | 310 |
user = saml_id.user |
165 |
self.logger.info('looked up user %s with name_id %s from issuer %s', |
|
166 |
user, name_id, issuer) |
|
311 |
logger.info('looked up user %s with name_id %s from issuer %s', user, name_id, issuer) |
|
167 | 312 |
return user |
168 | 313 | |
169 | 314 |
def provision(self, user, idp, saml_attributes): |
... | ... | |
179 | 324 |
try: |
180 | 325 |
value = force_text(tpl).format(realm=realm, attributes=saml_attributes, idp=idp) |
181 | 326 |
except ValueError: |
182 |
self.logger.warning(u'invalid attribute mapping template %r', tpl)
|
|
327 |
logger.warning(u'invalid attribute mapping template %r', tpl) |
|
183 | 328 |
except (AttributeError, KeyError, IndexError, ValueError) as e: |
184 |
self.logger.warning(
|
|
329 |
logger.warning( |
|
185 | 330 |
u'invalid reference in attribute mapping template %r: %s', tpl, e) |
186 | 331 |
else: |
187 | 332 |
model_field = user._meta.get_field(field) |
... | ... | |
191 | 336 |
old_value = getattr(user, field) |
192 | 337 |
setattr(user, field, value) |
193 | 338 |
attribute_set = True |
194 |
self.logger.info(u'set field %s of user %s to value %r (old value %r)', field, |
|
195 |
user, value, old_value) |
|
339 |
logger.info(u'set field %s of user %s to value %r (old value %r)', field, user, value, old_value) |
|
196 | 340 |
if attribute_set: |
197 | 341 |
user.save() |
198 | 342 | |
... | ... | |
215 | 359 |
user.is_staff = True |
216 | 360 |
user.is_superuser = True |
217 | 361 |
attribute_set = True |
218 |
self.logger.info('flag is_staff and is_superuser added to user %s', user)
|
|
362 |
logger.info('flag is_staff and is_superuser added to user %s', user) |
|
219 | 363 |
break |
220 | 364 |
else: |
221 | 365 |
if user.is_superuser or user.is_staff: |
222 | 366 |
user.is_staff = False |
223 | 367 |
user.is_superuser = False |
224 |
self.logger.info('flag is_staff and is_superuser removed from user %s', user)
|
|
368 |
logger.info('flag is_staff and is_superuser removed from user %s', user) |
|
225 | 369 |
attribute_set = True |
226 | 370 |
if attribute_set: |
227 | 371 |
user.save() |
... | ... | |
245 | 389 |
continue |
246 | 390 |
groups.append(group) |
247 | 391 |
for group in Group.objects.filter(pk__in=[g.pk for g in groups]).exclude(user=user): |
248 |
self.logger.info(
|
|
392 |
logger.info( |
|
249 | 393 |
u'adding group %s (%s) to user %s (%s)', group, group.pk, user, user.pk) |
250 | 394 |
User.groups.through.objects.get_or_create(group=group, user=user) |
251 | 395 |
qs = User.groups.through.objects.exclude( |
252 | 396 |
group__pk__in=[g.pk for g in groups]).filter(user=user) |
253 | 397 |
for rel in qs: |
254 |
self.logger.info(u'removing group %s (%s) from user %s (%s)', rel.group, |
|
255 |
rel.group.pk, rel.user, rel.user.pk) |
|
398 |
logger.info(u'removing group %s (%s) from user %s (%s)', rel.group, rel.group.pk, rel.user, rel.user.pk) |
|
256 | 399 |
qs.delete() |
mellon/app_settings.py | ||
---|---|---|
40 | 40 |
'ARTIFACT_RESOLVE_TIMEOUT': 10.0, |
41 | 41 |
'LOGIN_HINTS': [], |
42 | 42 |
'SIGNATURE_METHOD': 'RSA-SHA256', |
43 |
'METADATA_CACHE_TIME': 3600, |
|
44 |
'METADATA_HTTP_TIMEOUT': 10, |
|
43 | 45 |
} |
44 | 46 | |
45 | 47 |
@property |
mellon/utils.py | ||
---|---|---|
95 | 95 |
key = key[0] |
96 | 96 |
server.setEncryptionPrivateKeyWithPassword(key, password) |
97 | 97 |
for idp in get_idps(): |
98 |
try: |
|
99 |
server.addProviderFromBuffer(lasso.PROVIDER_ROLE_IDP, idp['METADATA']) |
|
100 |
except lasso.Error as e: |
|
101 |
logger.error(u'bad metadata in idp %r', idp['ENTITY_ID']) |
|
102 |
logger.debug(u'lasso error: %s', e) |
|
103 |
continue |
|
98 |
if idp and idp.get('METADATA'): |
|
99 |
try: |
|
100 |
server.addProviderFromBuffer(lasso.PROVIDER_ROLE_IDP, idp['METADATA']) |
|
101 |
except lasso.Error as e: |
|
102 |
logger.error(u'bad metadata in idp %s, %s', idp['ENTITY_ID'], e) |
|
104 | 103 |
cache[root] = server |
105 | 104 |
settings._MELLON_SERVER_CACHE = cache |
106 | 105 |
return settings._MELLON_SERVER_CACHE.get(root) |
... | ... | |
276 | 275 |
xml_encoding[0] = encoding |
277 | 276 |
parser = expat.ParserCreate() |
278 | 277 |
parser.XmlDeclHandler = xmlDeclHandler |
279 |
parser.Parse(content, True) |
|
278 |
try: |
|
279 |
parser.Parse(content, True) |
|
280 |
except expat.ExpatError as e: |
|
281 |
raise ValueError('invalid XML %s' % e) |
|
280 | 282 |
return xml_encoding[0] |
281 | 283 | |
282 | 284 |
mellon/views.py | ||
---|---|---|
169 | 169 |
'''show error message to user after a login failure''' |
170 | 170 |
login = self.profile |
171 | 171 |
idp = utils.get_idp(login.remoteProviderId) |
172 |
if not idp: |
|
173 |
self.log.warning('entity id %r is unknown', login.remoteProviderId) |
|
174 |
return HttpResponseBadRequest( |
|
175 |
'entity id %r is unknown' % login.remoteProviderId) |
|
172 | 176 |
error_url = utils.get_setting(idp, 'ERROR_URL') |
173 | 177 |
error_redirect_after_timeout = utils.get_setting(idp, 'ERROR_REDIRECT_AFTER_TIMEOUT') |
174 | 178 |
if error_url: |
... | ... | |
391 | 395 | |
392 | 396 |
next_url = check_next_url(self.request, request.GET.get(REDIRECT_FIELD_NAME)) |
393 | 397 |
idp = self.get_idp(request) |
394 |
if idp is None:
|
|
398 |
if not idp:
|
|
395 | 399 |
return HttpResponseBadRequest('no idp found') |
396 | 400 |
self.profile = login = utils.create_login(request) |
397 | 401 |
self.log.debug('authenticating to %r', idp['ENTITY_ID']) |
setup.py | ||
---|---|---|
94 | 94 |
'django>=1.5,<2.0', |
95 | 95 |
'requests', |
96 | 96 |
'isodate', |
97 |
'atomicwrites', |
|
97 | 98 |
], |
98 | 99 |
setup_requires=[ |
99 | 100 |
'django>=1.5,<2.0', |
tests/conftest.py | ||
---|---|---|
13 | 13 |
# You should have received a copy of the GNU Affero General Public License |
14 | 14 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
15 | 15 | |
16 |
import os |
|
16 | 17 |
import logging |
18 | ||
17 | 19 |
import pytest |
18 | 20 |
import django_webtest |
19 | 21 | |
20 | 22 | |
23 |
@pytest.fixture(autouse=True) |
|
24 |
def settings(settings, tmpdir): |
|
25 |
settings.MEDIA_ROOT = str(tmpdir.mkdir('media')) |
|
26 |
return settings |
|
27 | ||
28 | ||
21 | 29 |
@pytest.fixture |
22 |
def app(request): |
|
30 |
def app(request, settings):
|
|
23 | 31 |
wtm = django_webtest.WebTestMixin() |
24 | 32 |
wtm._patch_settings() |
25 | 33 |
request.addfinalizer(wtm._unpatch_settings) |
... | ... | |
38 | 46 | |
39 | 47 | |
40 | 48 |
@pytest.fixture |
41 |
def private_settings(request): |
|
49 |
def private_settings(request, tmpdir):
|
|
42 | 50 |
import django.conf |
43 | 51 |
from django.conf import UserSettingsHolder |
44 | 52 |
old = django.conf.settings._wrapped |
... | ... | |
57 | 65 |
caplog.handler.stream = py.io.TextIO() |
58 | 66 |
caplog.handler.records = [] |
59 | 67 |
return caplog |
68 | ||
69 | ||
70 |
@pytest.fixture(scope='session') |
|
71 |
def metadata(): |
|
72 |
with open(os.path.join(os.path.dirname(__file__), 'metadata.xml')) as fd: |
|
73 |
yield fd.read() |
|
74 | ||
75 | ||
76 |
@pytest.fixture |
|
77 |
def metadata_path(tmpdir, metadata): |
|
78 |
metadata_path = tmpdir / 'metadata.xml' |
|
79 |
with metadata_path.open('w') as fd: |
|
80 |
fd.write(metadata) |
|
81 |
yield str(metadata_path) |
tests/test_default_adapter.py | ||
---|---|---|
13 | 13 |
# You should have received a copy of the GNU Affero General Public License |
14 | 14 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
15 | 15 | |
16 |
import pytest
|
|
16 |
import datetime
|
|
17 | 17 |
import re |
18 | 18 |
import lasso |
19 |
import time |
|
19 | 20 |
from multiprocessing.pool import ThreadPool |
20 | 21 | |
22 |
import pytest |
|
23 | ||
21 | 24 |
from django.contrib import auth |
22 | 25 |
from django.db import connection |
23 | 26 | |
... | ... | |
149 | 152 |
user = SAMLBackend().authenticate(saml_attributes=saml_attributes) |
150 | 153 |
assert user.is_superuser is True |
151 | 154 |
assert user.is_staff is True |
152 |
assert not 'flag is_staff and is_superuser removed' in caplog.text
|
|
155 |
assert 'flag is_staff and is_superuser removed' not in caplog.text
|
|
153 | 156 | |
154 | 157 | |
155 | 158 |
def test_provision_absent_attribute(settings, django_user_model, caplog): |
... | ... | |
211 | 214 |
user = adapter.lookup_user(idp, saml_attributes) |
212 | 215 |
assert user is None |
213 | 216 |
assert User.objects.count() == 0 |
217 | ||
218 | ||
219 |
@pytest.fixture |
|
220 |
def adapter(): |
|
221 |
return DefaultAdapter() |
|
222 | ||
223 | ||
224 |
def test_load_metadata_simple(adapter, metadata): |
|
225 |
idp = {'METADATA': metadata} |
|
226 |
assert adapter.load_metadata(idp, 0) == metadata |
|
227 | ||
228 | ||
229 |
def test_load_metadata_legacy(adapter, metadata_path, metadata): |
|
230 |
idp = {'METADATA': metadata_path} |
|
231 |
assert adapter.load_metadata(idp, 0) == metadata |
|
232 |
assert idp['METADATA'] == metadata |
|
233 | ||
234 | ||
235 |
def test_load_metadata_path(adapter, metadata_path, metadata, freezer): |
|
236 |
now = time.time() |
|
237 |
idp = {'METADATA_PATH': str(metadata_path)} |
|
238 |
assert adapter.load_metadata(idp, 0) == metadata |
|
239 |
assert idp['METADATA'] == metadata |
|
240 |
assert idp['METADATA_PATH_LAST_UPDATE'] == now |
|
241 | ||
242 | ||
243 |
def test_load_metadata_url(settings, adapter, metadata, httpserver, freezer, caplog): |
|
244 |
now = time.time() |
|
245 |
httpserver.serve_content(content=metadata, headers={'Content-Type': 'application/xml'}) |
|
246 |
idp = {'METADATA_URL': httpserver.url} |
|
247 |
assert adapter.load_metadata(idp, 0) == metadata |
|
248 |
assert idp['METADATA'] == metadata |
|
249 |
assert idp['METADATA_URL_LAST_UPDATE'] == now |
|
250 |
assert 'METADATA_PATH' in idp |
|
251 |
assert idp['METADATA_PATH'].startswith(settings.MEDIA_ROOT) |
|
252 |
with open(idp['METADATA_PATH']) as fd: |
|
253 |
assert fd.read() == metadata |
|
254 |
assert idp['METADATA_PATH_LAST_UPDATE'] == now + 1 |
|
255 |
httpserver.serve_content(content=metadata.replace('idp5', 'idp6'), |
|
256 |
headers={'Content-Type': 'application/xml'}) |
|
257 |
assert adapter.load_metadata(idp, 0) == metadata |
|
258 | ||
259 |
freezer.move_to(datetime.timedelta(seconds=3601)) |
|
260 |
caplog.clear() |
|
261 |
assert adapter.load_metadata(idp, 0) == metadata |
|
262 |
# wait for update thread to finish |
|
263 |
try: |
|
264 |
idp['METADATA_URL_UPDATE_THREAD'].join() |
|
265 |
except KeyError: |
|
266 |
pass |
|
267 |
new_meta = adapter.load_metadata(idp, 0) |
|
268 |
assert new_meta != metadata |
|
269 |
assert new_meta == metadata.replace('idp5', 'idp6') |
|
270 |
assert 'entityID changed' in caplog.records[-1].message |
|
271 |
assert caplog.records[-1].levelname == 'ERROR' |
|
272 |
# test load from file cache |
|
273 |
del idp['METADATA'] |
|
274 |
del idp['METADATA_PATH'] |
|
275 |
del idp['METADATA_PATH_LAST_UPDATE'] |
|
276 |
httpserver.serve_content(content='', headers={'Content-Type': 'application/xml'}) |
|
277 |
assert adapter.load_metadata(idp, 0) == metadata.replace('idp5', 'idp6') |
|
278 | ||
279 | ||
280 |
def test_load_metadata_url_stale_timeout(settings, adapter, metadata, httpserver, freezer, caplog): |
|
281 |
httpserver.serve_content(content=metadata, headers={'Content-Type': 'application/xml'}) |
|
282 |
idp = {'METADATA_URL': httpserver.url} |
|
283 |
assert adapter.load_metadata(idp, 0) == metadata |
|
284 |
httpserver.serve_content(content='', headers={'Content-Type': 'application/xml'}) |
|
285 |
assert adapter.load_metadata(idp, 0) == metadata |
|
286 | ||
287 |
freezer.move_to(datetime.timedelta(seconds=24 * 3600 - 1)) |
|
288 |
assert adapter.load_metadata(idp, 0) == metadata |
|
289 | ||
290 |
# wait for update thread to finish |
|
291 |
try: |
|
292 |
idp['METADATA_URL_UPDATE_THREAD'].join() |
|
293 |
except KeyError: |
|
294 |
pass |
|
295 |
assert caplog.records[-1].levelname == 'WARNING' |
|
296 | ||
297 |
freezer.move_to(datetime.timedelta(seconds=3601)) |
|
298 |
assert adapter.load_metadata(idp, 0) == metadata |
|
299 | ||
300 |
# wait for update thread to finish |
|
301 |
try: |
|
302 |
idp['METADATA_URL_UPDATE_THREAD'].join() |
|
303 |
except KeyError: |
|
304 |
pass |
|
305 |
assert caplog.records[-1].levelname == 'ERROR' |
tests/test_utils.py | ||
---|---|---|
13 | 13 |
# You should have received a copy of the GNU Affero General Public License |
14 | 14 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
15 | 15 | |
16 |
import re |
|
17 | 16 |
import datetime |
18 | 17 | |
19 | 18 |
import mock |
20 | 19 |
import lasso |
21 |
import requests.exceptions |
|
22 |
from httmock import HTTMock |
|
23 | 20 | |
24 |
from mellon.utils import create_server, create_metadata, iso8601_to_datetime, flatten_datetime |
|
25 |
import mellon.utils |
|
21 |
from mellon.utils import create_metadata, iso8601_to_datetime, flatten_datetime |
|
26 | 22 |
from xml_utils import assert_xml_constraints |
27 | 23 | |
28 |
from utils import error_500, metadata_response |
|
29 | ||
30 | ||
31 |
def test_create_server_connection_error(mocker, rf, private_settings, caplog): |
|
32 |
mocker.patch('requests.get', |
|
33 |
side_effect=requests.exceptions.ConnectionError('connection error')) |
|
34 |
private_settings.MELLON_IDENTITY_PROVIDERS = [ |
|
35 |
{ |
|
36 |
'METADATA_URL': 'http://example.com/metadata', |
|
37 |
} |
|
38 |
] |
|
39 |
request = rf.get('/') |
|
40 |
create_server(request) |
|
41 |
assert 'connection error' in caplog.text |
|
42 | ||
43 | ||
44 |
def test_create_server_internal_server_error(mocker, rf, private_settings, caplog): |
|
45 |
private_settings.MELLON_IDENTITY_PROVIDERS = [ |
|
46 |
{ |
|
47 |
'METADATA_URL': 'http://example.com/metadata', |
|
48 |
} |
|
49 |
] |
|
50 |
request = rf.get('/') |
|
51 |
assert not 'failed with error' in caplog.text |
|
52 |
with HTTMock(error_500): |
|
53 |
create_server(request) |
|
54 |
assert 'failed with error' in caplog.text |
|
55 | ||
56 | ||
57 |
def test_create_server_invalid_metadata(mocker, rf, private_settings, caplog): |
|
58 |
private_settings.MELLON_IDENTITY_PROVIDERS = [ |
|
59 |
{ |
|
60 |
'METADATA': 'xxx', |
|
61 |
} |
|
62 |
] |
|
63 |
request = rf.get('/') |
|
64 |
assert not 'failed with error' in caplog.text |
|
65 |
with HTTMock(error_500): |
|
66 |
create_server(request) |
|
67 |
assert len(caplog.records) == 1 |
|
68 |
assert re.search('METADATA.*is invalid', caplog.text) |
|
69 | ||
70 | ||
71 |
def test_create_server_invalid_metadata_file(mocker, rf, private_settings, caplog): |
|
72 |
private_settings.MELLON_IDENTITY_PROVIDERS = [ |
|
73 |
{ |
|
74 |
'METADATA': '/xxx', |
|
75 |
} |
|
76 |
] |
|
77 |
request = rf.get('/') |
|
78 |
assert not 'failed with error' in caplog.text |
|
79 |
with mock.patch('mellon.adapters.open', mock.mock_open(read_data='yyy'), create=True): |
|
80 |
with HTTMock(error_500): |
|
81 |
server = create_server(request) |
|
82 |
assert len(server.providers) == 0 |
|
83 | ||
84 | ||
85 |
def test_create_server_good_metadata_file(mocker, rf, private_settings, caplog): |
|
86 |
private_settings.MELLON_IDENTITY_PROVIDERS = [ |
|
87 |
{ |
|
88 |
'METADATA': '/xxx', |
|
89 |
} |
|
90 |
] |
|
91 |
request = rf.get('/') |
|
92 |
with mock.patch( |
|
93 |
'mellon.adapters.open', mock.mock_open(read_data=open('tests/metadata.xml').read()), |
|
94 |
create=True): |
|
95 |
server = create_server(request) |
|
96 |
assert 'ERROR' not in caplog.text |
|
97 |
assert len(server.providers) == 1 |
|
98 | ||
99 | ||
100 |
def test_create_server_good_metadata(mocker, rf, private_settings, caplog): |
|
101 |
private_settings.MELLON_IDENTITY_PROVIDERS = [ |
|
102 |
{ |
|
103 |
'METADATA': open('tests/metadata.xml').read(), |
|
104 |
} |
|
105 |
] |
|
106 |
request = rf.get('/') |
|
107 |
assert not 'failed with error' in caplog.text |
|
108 |
server = create_server(request) |
|
109 |
assert 'ERROR' not in caplog.text |
|
110 |
assert len(server.providers) == 1 |
|
111 | ||
112 | ||
113 |
def test_create_server_invalid_idp_dict(mocker, rf, private_settings, caplog): |
|
114 |
private_settings.MELLON_IDENTITY_PROVIDERS = [ |
|
115 |
{ |
|
116 |
} |
|
117 |
] |
|
118 |
request = rf.get('/') |
|
119 |
assert not 'failed with error' in caplog.text |
|
120 |
create_server(request) |
|
121 |
assert 'missing METADATA' in caplog.text |
|
122 | ||
123 | ||
124 |
def test_create_server_good_metadata_url(mocker, rf, private_settings, caplog): |
|
125 |
private_settings.MELLON_IDENTITY_PROVIDERS = [ |
|
126 |
{ |
|
127 |
'METADATA_URL': 'http://example.com/metadata', |
|
128 |
} |
|
129 |
] |
|
130 | ||
131 |
request = rf.get('/') |
|
132 |
assert not 'failed with error' in caplog.text |
|
133 |
with HTTMock(metadata_response): |
|
134 |
server = create_server(request) |
|
135 |
assert 'ERROR' not in caplog.text |
|
136 |
assert len(server.providers) == 1 |
|
137 | ||
138 | 24 | |
139 | 25 |
def test_create_metadata(rf, private_settings, caplog): |
140 | 26 |
ns = { |
tox.ini | ||
---|---|---|
1 | 1 |
[tox] |
2 |
envlist = {coverage-,}py2-{dj18,dj111}-{pg,sqlite},py3-dj111-{pg,sqlite}
|
|
2 |
envlist = coverage-py2-{dj18,dj111}-{pg,sqlite},coverage-py3-dj111-{pg,sqlite}
|
|
3 | 3 |
toxworkdir = {env:TMPDIR:/tmp}/tox-{env:USER}/django-mellon/ |
4 | 4 | |
5 | 5 |
[testenv] |
... | ... | |
24 | 24 |
pytest-random |
25 | 25 |
pytest-mock |
26 | 26 |
pytest-django |
27 |
pytest-freezegun |
|
28 |
pytest-localserver |
|
27 | 29 |
pytz |
28 | 30 |
lxml |
29 | 31 |
cssselect |
30 |
- |