django-mellon/mellon/federation_utils.py

222 lines
7.2 KiB
Python

import fcntl
import json
import lasso
import logging
import tempfile
from datetime import timedelta
from django.utils.text import slugify
from datetime import datetime
import requests
from xml.etree import ElementTree as ET
import os
import hashlib
import os.path
from django.core.files.storage import default_storage
def truncate_unique(s, length=250):
if len(s) < length:
return s
md5 = hashlib.md5(s.encode('ascii')).hexdigest()
# we should be the first and last characters from the URL
l = (length - len(md5)) / 2 - 2 # four additional characters
assert l > 20
return s[:l] + '...' + s[-l:] + '_' + md5
def url2filename(url):
return truncate_unique(slugify(url), 230)
def load_federation_cache(url):
logger = logging.getLogger(__name__)
try:
filename = url2filename(url)
path = os.path.join('metadata-cache', filename)
unix_path = default_storage.path(path)
if not os.path.exists('metadata-cache'):
os.makedirs('metadata-cache')
f = open(unix_path, 'w')
try:
fcntl.lockf(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
except IOError:
return
else:
with tempfile.NamedTemporaryFile(dir=os.path.dirname(unix_path), delete=False) as temp:
try:
# increase modified time by one hour to prevent too many updates
st = os.stat(unix_path)
os.utime(unix_path, (st.st_atime, st.st_mtime + 3600))
response = requests.get(url)
response.raise_for_status()
temp.write(response.content)
temp.flush()
os.rename(temp.name, unix_path)
except:
logger.error('Could\'nt fetch %r', url)
os.unlink(temp.name)
finally:
fcntl.lockf(f, fcntl.LOCK_UN)
finally:
f.close()
except OSError:
logger.exception(u"could create the intermediary 'metadata-cache' "
"folder")
return
except:
logger.exception(u'failed to load federation from %s', url)
def get_federation_from_url(url, update_cache=False):
logger = logging.getLogger(__name__)
filename = url2filename(url)
path = os.path.join('metadata-cache', filename)
if not default_storage.exists(path) or update_cache or \
default_storage.created_time(path) < datetime.now() - timedelta(days=1):
load_federation_cache(url)
else:
logger.warning('federation %s has not been loaded', url)
return path
def idp_metadata_filepath(entity_id):
filename = url2filename(entity_id)
return os.path.join('./metadata-cache', filename)
def idp_settings_filepath(entity_id):
filename = url2filename(entity_id) + "_settings.json"
return os.path.join('./metadata-cache', filename)
def idp_metadata_is_cached(entity_id):
filepath = idp_metadata_filepath(entity_id)
if not default_storage.exists(filepath):
return False
return True
def idp_metadata_is_file(metadata):
# XXX too restrictive (e.g. 'metadata/http-somemetadataserver-com-md00.xml'
# could be a file too...)
# On the opposite, `if "http://" in metadata or "https://" in metadata:" is
# equally restrictive.
# Using a URLValidator doesn't seem adequate either.
if metadata.startswith('/') or metadata.startswith('./'):
return True
def idp_metadata_needs_refresh(entity_id, update_cache=False):
filepath = idp_metadata_filepath(entity_id)
if not default_storage.exists(filepath) or update_cache or \
default_storage.created_time(filepath) < datetime.now() - timedelta(days=1):
return True
return False
def idp_settings_needs_refresh(entity_id, update_cache=False):
filepath = idp_settings_filepath(entity_id)
if not default_storage.exists(filepath) or update_cache or \
default_storage.created_time(filepath) < datetime.now() - timedelta(days=1):
return True
return False
def idp_metadata_store(metadata_content):
entity_id = idp_metadata_extract_entity_id(metadata_content)
if not entity_id:
return
logger = logging.getLogger(__name__)
filepath = idp_metadata_filepath(entity_id)
if idp_metadata_needs_refresh(entity_id):
with open(filepath, 'w') as f:
try:
fcntl.lockf(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
f.write(metadata_content)
fcntl.lockf(f, fcntl.LOCK_UN)
except:
logger.error('Couldn\'t store metadata for EntityID %r',
entity_id)
return
return filepath
def idp_metadata_load(entity_id):
logger = logging.getLogger(__name__)
filepath = idp_metadata_filepath(entity_id)
if default_storage.exists(filepath):
logger.info('Loading metadata for EntityID %r', entity_id)
with open(filepath, 'r') as f:
return f.read()
else:
logger.warning('No metadata file for EntityID %r', entity_id)
def idp_settings_store(idp):
"""
Stores an IDP settings when loaded from a federation.
"""
logger = logging.getLogger(__name__)
entity_id = idp.get('ENTITY_ID')
filepath = idp_settings_filepath(entity_id)
idp_settings = {}
if not entity_id:
return
for key, value in idp.items():
if key not in ('METADATA', 'ENTITY_ID'):
idp_settings.update({key: value})
if idp_settings_needs_refresh(entity_id) and idp_settings:
with open(filepath, 'w') as f:
try:
fcntl.lockf(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
f.write(json.dumps(idp_settings))
fcntl.lockf(f, fcntl.LOCK_UN)
except:
logger.error('Couldn\'t store settings for EntityID %r',
entity_id)
return
return filepath
def idp_settings_load(entity_id):
logger = logging.getLogger(__name__)
filepath = idp_settings_filepath(entity_id)
if default_storage.exists(filepath):
logger.info('Loading JSON settings for EntityID %r', entity_id)
with open(filepath, 'r') as f:
try:
idp_settings = json.loads(f.read())
except:
logger.warning('Couldn\'t load JSON settings for EntityID %r',
entity_id)
else:
return idp_settings
else:
logger.warning('No JSON settings file for EntityID %r', entity_id)
def idp_metadata_extract_entity_id(metadata_content):
logger = logging.getLogger(__name__)
try:
doc = ET.fromstring(metadata_content)
except (TypeError, ET.ParseError):
logger.error(u'METADATA of idp %r is invalid', metadata_content)
return
if doc.tag != '{%s}EntityDescriptor' % lasso.SAML2_METADATA_HREF:
logger.error(u'METADATA of idp %r has no EntityDescriptor root tag',
metadata_content)
return
if not 'entityID' in doc.attrib:
logger.error(
u'METADATA of idp %r has no entityID attribute on its root tag',
metadata_content)
return
return doc.attrib['entityID']