Commit 67fc4602 authored by ale's avatar ale

add some code ported from django-saml2-idp

parent 278c74d8
# -*- coding: utf-8 -*-
from __future__ import absolute_import
import base64
import time
import uuid
from flask import request, session, current_app
from BeautifulSoup import BeautifulStoneSoup
from . import codex
from . import exceptions
from . import xml_render
from .logging import get_saml_logger
MINUTES = 60
HOURS = 60 * MINUTES
def get_random_id():
# It is very important that these random IDs NOT start with a number.
random_id = '_' + uuid.uuid4().hex
return random_id
def get_time_string(delta=0):
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() + delta))
# Design note: I've tried to make this easy to sub-class and override
# just the bits you need to override. I've made use of object properties,
# so that your sub-classes have access to all information: use wisely.
# Formatting note: These methods are alphabetized.
class Processor(object):
"""
Base SAML 2.0 AuthnRequest to Response Processor.
Sub-classes should provide Service Provider-specific functionality.
"""
@property
def dotted_path(self):
return '{module}.{class_name}'.format(
module=self.__module__,
class_name=self.__class__.__name__)
def __init__(self, config, name=None):
self.name = name
self._config = config.copy()
self._logger = get_saml_logger()
processor_path = self._config.get('processor', 'invalid')
self._logger.info('initializing processor',
configured_processor=processor_path,
processor=self.dotted_path)
if processor_path != self.dotted_path:
raise exceptions.ImproperlyConfigured(
"config is invalid for this processor: {}".format(self._config))
if 'acs_url' not in self._config:
raise exceptions.ImproperlyConfigured(
"no ACS URL specified in SP configuration: {}".format(
self._config))
self._logger.info('processor configured', config=self._config)
def _build_assertion(self):
"""
Builds _assertion_params.
"""
self._determine_assertion_id()
self._determine_audience()
self._determine_subject()
self._determine_session_index()
self._assertion_params = {
'ASSERTION_ID': self._assertion_id,
'ASSERTION_SIGNATURE': '', # it's unsigned
'AUDIENCE': self._audience,
'AUTH_INSTANT': get_time_string(),
'ISSUE_INSTANT': get_time_string(),
'NOT_BEFORE': get_time_string(-1 * HOURS), #TODO: Make these settings.
'NOT_ON_OR_AFTER': get_time_string(15 * MINUTES),
'SESSION_INDEX': self._session_index,
'SESSION_NOT_ON_OR_AFTER': get_time_string(8 * HOURS),
'SP_NAME_QUALIFIER': self._audience,
'SUBJECT': self._subject,
'SUBJECT_FORMAT': self._subject_format,
}
self._assertion_params.update(self._system_params)
self._assertion_params.update(self._request_params)
def _build_response(self):
"""
Builds _response_params.
"""
self._determine_response_id()
self._response_params = {
'ASSERTION': self._assertion_xml,
'ISSUE_INSTANT': get_time_string(),
'RESPONSE_ID': self._response_id,
'RESPONSE_SIGNATURE': '', # initially unsigned
}
self._response_params.update(self._system_params)
self._response_params.update(self._request_params)
def _decode_request(self):
"""
Decodes _request_xml from _saml_request.
"""
self._request_xml = base64.b64decode(self._saml_request)
self._logger.debug('SAML request decoded',
decoded_request=self._request_xml)
def _determine_assertion_id(self):
"""
Determines the _assertion_id.
"""
self._assertion_id = get_random_id()
def _determine_audience(self):
"""
Determines the _audience.
"""
self._audience = self._request_params.get('DESTINATION', None)
if not self._audience:
self._audience = self._request_params.get('PROVIDER_NAME', None)
self._logger.info('determined audience', audience=self._audience)
def _determine_response_id(self):
"""
Determines _response_id.
"""
self._response_id = get_random_id()
def _determine_session_index(self):
# TODO: find a replacement for Flask!
self._session_index = self._django_request.session.session_key
def _determine_subject(self):
"""
Determines _subject and _subject_type for Assertion Subject.
"""
# TODO: Fetch user from request!
self._subject = self._django_request.user.email
def _encode_response(self):
"""
Encodes _response_xml to _encoded_xml.
"""
self._saml_response = codex.nice64(self._response_xml)
def _extract_saml_request(self):
"""
Retrieves the _saml_request AuthnRequest from the _django_request.
"""
self._saml_request = session['SAMLRequest']
self._relay_state = session['RelayState']
def _format_assertion(self):
"""
Formats _assertion_params as _assertion_xml.
"""
raise NotImplemented()
def _format_response(self):
"""
Formats _response_params as _response_xml.
"""
sign_it = current_app.config['SAML2IDP_CONFIG']['signing']
self._response_xml = xml_render.get_response_xml(self._response_params, signed=sign_it)
def _get_response_params(self):
"""
Returns a dictionary of parameters for the response template.
"""
return {
'acs_url': self._request_params['ACS_URL'],
'saml_response': self._saml_response,
'relay_state': self._relay_state,
'autosubmit': current_app.config['SAML2IDP_CONFIG']['autosubmit'],
}
def _parse_request(self):
"""
Parses various parameters from _request_xml into _request_params.
"""
#Minimal test to verify that it's not binarily encoded still:
if not self._request_xml.strip().startswith('<'):
raise Exception('RequestXML is not valid XML; '
'it may need to be decoded or decompressed.')
soup = BeautifulStoneSoup(self._request_xml)
request = soup.findAll()[0]
params = {}
params['ACS_URL'] = request['assertionconsumerserviceurl']
params['REQUEST_ID'] = request['id']
params['DESTINATION'] = request.get('destination', '')
params['PROVIDER_NAME'] = request.get('providername', '')
self._request_params = params
def _reset(self, sp_config=None):
"""
Initialize (and reset) object properties, so we don't risk carrying
over anything from the last authentication.
If provided, use sp_config throughout; otherwise, it will be set in
_validate_request().
"""
self._assertion_params = None
self._assertion_xml = None
self._relay_state = None
self._request = None
self._request_id = None
self._request_xml = None
self._request_params = None
self._response_id = None
self._saml_request = None
self._saml_response = None
self._subject = None
self._subject_format = 'urn:oasis:names:tc:SAML:2.0:nameid-format:email'
self._system_params = {
'ISSUER': current_app.config['SAML2IDP_CONFIG']['issuer'],
}
def _validate_request(self):
"""
Validates the SAML request against the SP configuration of this
processor. Sub-classes should override this and raise a
`CannotHandleAssertion` exception if the validation fails.
Raises:
CannotHandleAssertion: if the ACS URL specified in the SAML request
doesn't match the one specified in the processor config.
"""
request_acs_url = self._request_params['ACS_URL']
if self._config['acs_url'] != request_acs_url:
msg = ("couldn't find ACS url '{}' in SAML2IDP_REMOTES "
"setting.".format(request_acs_url))
self._logger.info(msg)
raise exceptions.CannotHandleAssertion(msg)
def _validate_user(self):
"""
Validates the User. Sub-classes should override this and
throw an CannotHandleAssertion Exception if the validation does not succeed.
"""
pass
def can_handle(self):
"""
Returns true if this processor can handle the current request.
"""
self._reset()
# Read the request.
try:
self._extract_saml_request()
except Exception as exc:
msg = "can't find SAML request in user session: %s" % exc
self._logger.info(msg)
raise exceptions.CannotHandleAssertion(msg)
try:
self._decode_request()
except Exception as exc:
msg = "can't decode SAML request: %s" % exc
self._logger.info(msg)
raise exceptions.CannotHandleAssertion(msg)
try:
self._parse_request()
except Exception as exc:
msg = "can't parse SAML request: %s" % exc
self._logger.info(msg)
raise exceptions.CannotHandleAssertion(msg)
self._validate_request()
return True
def generate_response(self):
"""
Processes request and returns template variables suitable for a response.
"""
# Build the assertion and response.
self._validate_user()
self._build_assertion()
self._format_assertion()
self._build_response()
self._format_response()
self._encode_response()
# Return proper template params.
return self._get_response_params()
def init_deep_link(self, sp_config, url):
"""
Initialize this Processor to make an IdP-initiated call to the SP's
deep-linked URL.
"""
self._reset(sp_config)
acs_url = self._config['acs_url']
# NOTE: The following request params are made up. Some are blank,
# because they comes over in the AuthnRequest, but we don't have an
# AuthnRequest in this case:
# - Destination: Should be this IdP's SSO endpoint URL. Not used in the response?
# - ProviderName: According to the spec, this is optional.
self._request_params = {
'ACS_URL': acs_url,
'DESTINATION': '',
'PROVIDER_NAME': '',
}
self._relay_state = url
# Portions borrowed from:
# http://stackoverflow.com/questions/1089662/python-inflate-and-deflate-implementations
import zlib
import base64
def decode_base64_and_inflate( b64string ):
decoded_data = base64.b64decode( b64string )
return zlib.decompress( decoded_data , -15)
def deflate_and_base64_encode( string_val ):
zlibbed_str = zlib.compress( string_val )
compressed_string = zlibbed_str[2:-4]
return base64.b64encode( compressed_string )
def nice64(src):
""" Returns src base64-encoded and formatted nicely for our XML. """
return src.encode('base64').replace('\n', '')
import base
import exceptions
import xml_render
class Processor(base.Processor):
"""
Demo Response Handler Processor for testing against django-saml2-sp.
"""
def _format_assertion(self):
# NOTE: This uses the SalesForce assertion for the demo.
self._assertion_xml = xml_render.get_assertion_salesforce_xml(self._assertion_params, signed=True)
class AttributeProcessor(base.Processor):
"""
Demo Response Handler Processor for testing against django-saml2-sp;
Adds SAML attributes to the assertion.
"""
def _format_assertion(self):
# NOTE: This uses the SalesForce assertion for the demo.
self._assertion_params['ATTRIBUTES'] = {
'foo': 'bar',
}
self._assertion_xml = xml_render.get_assertion_salesforce_xml(self._assertion_params, signed=True)
class CannotHandleAssertion(Exception):
"""
This processor does not handle this assertion.
"""
def __init__(self, msg):
self.msg = msg
def __str__(self):
return repr(self.msg)
class UserNotAuthorized(Exception):
"""
User not authorized for SAML 2.0 authentication.
"""
def __init__(self, msg):
self.msg = msg
def __str__(self):
return repr(self.msg)
class ImproperlyConfigured(Exception):
pass
from __future__ import absolute_import
import os
from flask import Blueprint, current_app, request, session, abort, redirect, make_response, url_for
from . import exceptions
from . import xml_signing
from .logging import get_saml_logger
saml_app = Blueprint('saml', __name__,
template_folder='templates')
logger = get_saml_logger()
sso_cookie_name = 'SSO_SAML'
def _verifier():
if not hasattr(current_app, 'saml_sso_verifier'):
current_app.saml_sso_verifier = sso.Verifier(
current_app.config['PUBLIC_KEY'],
current_app.config['SAML_SSO_SERVICE'],
current_app.config['SSO_DOMAIN'],
[])
return current_app.saml_sso_verifier
def login_required(fn):
def _wrapper(*args, **kwargs):
# Try to fetch the cookie.
try:
tkt = _verifier().verify(request.cookies.get(sso_cookie_name))
return fn(*args, **kwargs)
except (TypeError, sso.Error) as e:
redir_url = 'https://%s?%s' % (
login_server, urllib.urlencode({
's': service,
'd': _make_absolute_url()}))
return redirect(redir_url)
return functools.wraps(_wrapper)
@saml_app.route('/sso_login')
def sso_login():
tkt_str = request.args['t']
next_url = request.args['d']
resp = redirect(next_url)
resp.set_cookie(sso_cookie_name, tkt_str)
return resp
@saml_app.route('/sso_logout')
def sso_logout():
resp = make_response('OK')
resp.set_cookie(sso_cookie_name, '', expires=0)
return resp
@saml_app.route('/login', methods=('GET', 'POST'))
def login_begin():
if request.method == 'POST':
source = request.form
else:
source = request.args
try:
session['SAMLRequest'] = source['SAMLRequest']
except KeyError:
abort(400)
session['RelayState'] = source.get('RelayState', '')
return redirect(url_for('saml.login_process'))
@saml_app.route('/init/{resource}/{target}/')
@login_required
def login_init(resource, target):
name, sp_config = metadata.get_config_for_resource(resource)
proc = registry.get_processor(name, sp_config)
try:
linkdict = dict(metadata.get_links(sp_config))
pattern = linkdict[resource]
except KeyError:
abort(400)
url = pattern % target
proc.init_deep_link(sp_config, url)
return _generate_response(proc)
@saml_app.route('/login/process')
@login_required
def login_process():
proc = registry.find_processor(request)
return _generate_response(proc)
@saml_app.route('/logout')
def logout():
pass
@saml_app.route('/metadata/xml/')
def descriptor():
idp_config = current_app.config['SAML2IDP_CONFIG']
tv = {
'entity_id': idp_config['issuer'],
'slo_url': current_app.config['SAML_SLO_URL'],
'sso_url': current_app.config['SAML_LOGIN_URL'],
'pubkey': xml_signing.load_certificate(idp_config),
}
resp = make_response(render_template('saml/idpssodescriptor.xml', tv))
resp.headers['Content-Type'] = 'application/xml'
return resp
def _generate_response(processor):
try:
tv = processor.generate_response()
except exceptions.UserNotAuthorized:
return render_template('saml/invalid_user.html')
return render_template('saml/login.html', tv)
import base
import codex
import exceptions
import xml_render
class Processor(base.Processor):
"""
SalesForce.com-specific SAML 2.0 AuthnRequest to Response Handler Processor.
"""
def _decode_request(self):
"""
Decodes request using both Base64 and Zipping.
"""
self._request_xml = codex.decode_base64_and_inflate(self._saml_request)
def _validate_request(self):
"""
Validates the _saml_request. Sub-classes should override this and
throw an Exception if the validation does not succeed.
"""
super(Processor, self)._validate_request()
if not '.google.com/a/' in self._request_params['ACS_URL']:
raise exceptions.CannotHandleAssertion('AssertionConsumerService is not a Google Apps URL.')
def _format_assertion(self):
self._assertion_xml = xml_render.get_assertion_googleapps_xml(self._assertion_params, signed=True)
# -*- coding: utf-8 -*-
import structlog
def get_saml_logger():
"""
Get a logger named `saml2idp` after the main package.
"""
return structlog.get_logger('saml2idp')
"""
Query metadata from settings.
"""
from __future__ import absolute_import
from .exceptions import ImproperlyConfigured
from flask import current_app
def get_config_for_acs(acs_url):
"""
Return SP configuration instance that handles acs_url.
"""
for friendlyname, config in current_app.config['SAML2IDP_REMOTES'].items():
if config['acs_url'] == acs_url:
return config
msg = 'SAML2IDP_REMOTES is not configured to handle the AssertionConsumerService at "%s"'
raise ImproperlyConfigured(msg % resource_name)
def get_config_for_resource(resource_name):
"""
Return the SP configuration that handles a deep-link resource_name.
"""
for friendlyname, config in current_app.config['SAML2IDP_REMOTES'].items():
links = get_links(config)
for name, pattern in links:
if name == resource_name:
return friendlyname, config
msg = 'SAML2IDP_REMOTES is not configured to handle a link resource "%s"'
raise ImproperlyConfigured(msg % resource_name)
def get_deeplink_resources():
"""
Returns a list of resources that can be used for deep-linking.
"""
resources = []
for key, sp_config in current_app.config['SAML2IDP_REMOTES'].items():
links = get_links(sp_config)
for resource, patterns in links:
if '/' not in resource:
# It's a simple deeplink, which is handled by 'login_init' URL.
continue
resources.append(resource)
return resources
def get_links(sp_config):
"""
Returns a list of (resource, pattern) tuples for the 'links' for an sp.
"""
links = sp_config.get('links', [])
if type(links) is dict:
links = links.items()
return links
# -*- coding: utf-8 -*-
from __future__ import absolute_import
"""
Registers and loads Processor classes from settings.
"""
import warnings
from importlib import import_module
from . import base
from . import exceptions
from . import xml_render
from .logging import get_saml_logger
logger = get_saml_logger()
def SSOProcessor(base.Processor):
def _validate_request(self):
super(SSOProcessor, self)._validate_request()
url = self._request_params['ACS_URL']
if '.autistici.org' not in url: