2018-11-16 08:10:35 +00:00
|
|
|
"""Basic SAML Processor"""
|
|
|
|
|
|
|
|
import time
|
|
|
|
import uuid
|
|
|
|
|
2019-10-04 07:50:25 +00:00
|
|
|
from defusedxml import ElementTree
|
2019-10-01 08:24:10 +00:00
|
|
|
from structlog import get_logger
|
2018-11-16 08:10:35 +00:00
|
|
|
|
2019-10-07 14:33:48 +00:00
|
|
|
from passbook.providers.saml import exceptions, utils, xml_render
|
2018-11-16 08:10:35 +00:00
|
|
|
|
|
|
|
MINUTES = 60
|
|
|
|
HOURS = 60 * MINUTES
|
|
|
|
|
|
|
|
|
|
|
|
def get_random_id():
|
|
|
|
"""Random hex id"""
|
|
|
|
# It is very important that these random IDs NOT start with a number.
|
2019-12-31 11:51:16 +00:00
|
|
|
random_id = "_" + uuid.uuid4().hex
|
2018-11-16 08:10:35 +00:00
|
|
|
return random_id
|
|
|
|
|
|
|
|
|
|
|
|
def get_time_string(delta=0):
|
|
|
|
"""Get Data formatted in SAML format"""
|
|
|
|
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.
|
|
|
|
# pylint: disable=too-many-instance-attributes
|
|
|
|
class Processor:
|
|
|
|
"""Base SAML 2.0 AuthnRequest to Response Processor.
|
|
|
|
Sub-classes should provide Service Provider-specific functionality."""
|
|
|
|
|
2019-04-29 19:39:41 +00:00
|
|
|
is_idp_initiated = False
|
|
|
|
|
2019-12-31 11:51:16 +00:00
|
|
|
_audience = ""
|
2018-11-16 08:10:35 +00:00
|
|
|
_assertion_params = None
|
|
|
|
_assertion_xml = None
|
|
|
|
_assertion_id = None
|
|
|
|
_django_request = None
|
|
|
|
_relay_state = None
|
|
|
|
_request = None
|
|
|
|
_request_id = None
|
|
|
|
_request_xml = None
|
|
|
|
_request_params = None
|
|
|
|
_response_id = None
|
|
|
|
_response_xml = None
|
|
|
|
_response_params = None
|
|
|
|
_saml_request = None
|
|
|
|
_saml_response = None
|
|
|
|
_session_index = None
|
|
|
|
_subject = None
|
2019-12-31 11:51:16 +00:00
|
|
|
_subject_format = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"
|
2019-03-08 11:47:50 +00:00
|
|
|
_system_params = {}
|
2018-11-16 08:10:35 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def dotted_path(self):
|
|
|
|
"""Return a dotted path to this class"""
|
2019-12-31 11:51:16 +00:00
|
|
|
return "{module}.{class_name}".format(
|
|
|
|
module=self.__module__, class_name=self.__class__.__name__
|
|
|
|
)
|
2018-11-16 08:10:35 +00:00
|
|
|
|
|
|
|
def __init__(self, remote):
|
|
|
|
self.name = remote.name
|
|
|
|
self._remote = remote
|
2019-10-04 08:08:53 +00:00
|
|
|
self._logger = get_logger()
|
2019-12-31 11:51:16 +00:00
|
|
|
self._system_params["ISSUER"] = self._remote.issuer
|
|
|
|
self._logger.debug("processor configured")
|
2018-11-16 08:10:35 +00:00
|
|
|
|
|
|
|
def _build_assertion(self):
|
|
|
|
"""Builds _assertion_params."""
|
|
|
|
self._determine_assertion_id()
|
|
|
|
self._determine_audience()
|
|
|
|
self._determine_subject()
|
|
|
|
self._determine_session_index()
|
|
|
|
|
|
|
|
self._assertion_params = {
|
2019-12-31 11:51:16 +00:00
|
|
|
"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(86400 * 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,
|
2018-11-16 08:10:35 +00:00
|
|
|
}
|
|
|
|
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 = {
|
2019-12-31 11:51:16 +00:00
|
|
|
"ASSERTION": self._assertion_xml,
|
|
|
|
"ISSUE_INSTANT": get_time_string(),
|
|
|
|
"RESPONSE_ID": self._response_id,
|
|
|
|
"RESPONSE_SIGNATURE": "", # initially unsigned
|
2018-11-16 08:10:35 +00:00
|
|
|
}
|
|
|
|
self._response_params.update(self._system_params)
|
|
|
|
self._response_params.update(self._request_params)
|
|
|
|
|
|
|
|
def _decode_request(self):
|
|
|
|
"""Decodes _request_xml from _saml_request."""
|
|
|
|
|
2019-12-31 11:51:16 +00:00
|
|
|
self._request_xml = utils.decode_base64_and_inflate(self._saml_request).decode(
|
|
|
|
"utf-8"
|
|
|
|
)
|
2018-11-16 08:10:35 +00:00
|
|
|
|
2019-12-31 11:51:16 +00:00
|
|
|
self._logger.debug("SAML request decoded")
|
2018-11-16 08:10:35 +00:00
|
|
|
|
|
|
|
def _determine_assertion_id(self):
|
|
|
|
"""Determines the _assertion_id."""
|
|
|
|
self._assertion_id = get_random_id()
|
|
|
|
|
|
|
|
def _determine_audience(self):
|
|
|
|
"""Determines the _audience."""
|
2019-04-18 09:30:21 +00:00
|
|
|
self._audience = self._remote.audience
|
2019-12-31 11:51:16 +00:00
|
|
|
self._logger.info("determined audience")
|
2018-11-16 08:10:35 +00:00
|
|
|
|
|
|
|
def _determine_response_id(self):
|
|
|
|
"""Determines _response_id."""
|
|
|
|
self._response_id = get_random_id()
|
|
|
|
|
|
|
|
def _determine_session_index(self):
|
|
|
|
self._session_index = self._django_request.session.session_key
|
|
|
|
|
|
|
|
def _determine_subject(self):
|
|
|
|
"""Determines _subject and _subject_type for Assertion Subject."""
|
|
|
|
self._subject = self._django_request.user.email
|
|
|
|
|
|
|
|
def _encode_response(self):
|
|
|
|
"""Encodes _response_xml to _encoded_xml."""
|
2018-12-26 16:21:20 +00:00
|
|
|
self._saml_response = utils.nice64(str.encode(self._response_xml))
|
2018-11-16 08:10:35 +00:00
|
|
|
|
|
|
|
def _extract_saml_request(self):
|
|
|
|
"""Retrieves the _saml_request AuthnRequest from the _django_request."""
|
2019-12-31 11:51:16 +00:00
|
|
|
self._saml_request = self._django_request.session["SAMLRequest"]
|
|
|
|
self._relay_state = self._django_request.session["RelayState"]
|
2018-11-16 08:10:35 +00:00
|
|
|
|
|
|
|
def _format_assertion(self):
|
|
|
|
"""Formats _assertion_params as _assertion_xml."""
|
2019-12-13 12:45:10 +00:00
|
|
|
# https://commons.lbl.gov/display/IDMgmt/Attribute+Definitions
|
2019-12-31 11:51:16 +00:00
|
|
|
self._assertion_params["ATTRIBUTES"] = [
|
2018-11-16 08:10:35 +00:00
|
|
|
{
|
2019-12-31 11:51:16 +00:00
|
|
|
"FriendlyName": "eduPersonPrincipalName",
|
|
|
|
"Name": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6",
|
|
|
|
"Value": self._django_request.user.email,
|
2018-11-16 08:10:35 +00:00
|
|
|
},
|
|
|
|
{
|
2019-12-31 11:51:16 +00:00
|
|
|
"FriendlyName": "cn",
|
|
|
|
"Name": "urn:oid:2.5.4.3",
|
|
|
|
"Value": self._django_request.user.name,
|
2018-11-16 08:10:35 +00:00
|
|
|
},
|
|
|
|
{
|
2019-12-31 11:51:16 +00:00
|
|
|
"FriendlyName": "mail",
|
|
|
|
"Name": "urn:oid:0.9.2342.19200300.100.1.3",
|
|
|
|
"Value": self._django_request.user.email,
|
2018-11-16 08:10:35 +00:00
|
|
|
},
|
|
|
|
{
|
2019-12-31 11:51:16 +00:00
|
|
|
"FriendlyName": "displayName",
|
|
|
|
"Name": "urn:oid:2.16.840.1.113730.3.1.241",
|
|
|
|
"Value": self._django_request.user.username,
|
2018-11-16 08:10:35 +00:00
|
|
|
},
|
2019-12-13 12:45:10 +00:00
|
|
|
{
|
2019-12-31 11:51:16 +00:00
|
|
|
"FriendlyName": "uid",
|
|
|
|
"Name": "urn:oid:0.9.2342.19200300.100.1.1",
|
|
|
|
"Value": self._django_request.user.pk,
|
2019-12-13 12:45:10 +00:00
|
|
|
},
|
2018-11-16 08:10:35 +00:00
|
|
|
]
|
2019-10-07 14:33:48 +00:00
|
|
|
from passbook.providers.saml.models import SAMLPropertyMapping
|
2019-12-31 11:51:16 +00:00
|
|
|
|
2019-03-08 11:47:50 +00:00
|
|
|
for mapping in self._remote.property_mappings.all().select_subclasses():
|
|
|
|
if isinstance(mapping, SAMLPropertyMapping):
|
|
|
|
mapping_payload = {
|
2019-12-31 11:51:16 +00:00
|
|
|
"Name": mapping.saml_name,
|
|
|
|
"ValueArray": [],
|
|
|
|
"FriendlyName": mapping.friendly_name,
|
2019-03-08 11:47:50 +00:00
|
|
|
}
|
2019-03-08 14:16:25 +00:00
|
|
|
for value in mapping.values:
|
2019-12-31 11:51:16 +00:00
|
|
|
mapping_payload["ValueArray"].append(
|
|
|
|
value.format(
|
|
|
|
user=self._django_request.user, request=self._django_request
|
|
|
|
)
|
|
|
|
)
|
|
|
|
self._assertion_params["ATTRIBUTES"].append(mapping_payload)
|
2018-11-16 08:10:35 +00:00
|
|
|
self._assertion_xml = xml_render.get_assertion_xml(
|
2019-12-31 11:51:16 +00:00
|
|
|
"saml/xml/assertions/generic.xml", self._assertion_params, signed=True
|
|
|
|
)
|
2018-11-16 08:10:35 +00:00
|
|
|
|
|
|
|
def _format_response(self):
|
|
|
|
"""Formats _response_params as _response_xml."""
|
2019-12-31 11:51:16 +00:00
|
|
|
assertion_id = self._assertion_params["ASSERTION_ID"]
|
|
|
|
self._response_xml = xml_render.get_response_xml(
|
|
|
|
self._response_params, saml_provider=self._remote, assertion_id=assertion_id
|
|
|
|
)
|
2018-11-16 08:10:35 +00:00
|
|
|
|
|
|
|
def _get_django_response_params(self):
|
|
|
|
"""Returns a dictionary of parameters for the response template."""
|
|
|
|
return {
|
2019-12-31 11:51:16 +00:00
|
|
|
"acs_url": self._request_params["ACS_URL"],
|
|
|
|
"saml_response": self._saml_response,
|
|
|
|
"relay_state": self._relay_state,
|
|
|
|
"autosubmit": self._remote.application.skip_authorization,
|
2018-11-16 08:10:35 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
def _parse_request(self):
|
|
|
|
"""Parses various parameters from _request_xml into _request_params."""
|
|
|
|
# Minimal test to verify that it's not binarily encoded still:
|
2019-12-31 11:51:16 +00:00
|
|
|
if not str(self._request_xml.strip()).startswith("<"):
|
|
|
|
raise Exception(
|
|
|
|
"RequestXML is not valid XML; "
|
|
|
|
"it may need to be decoded or decompressed."
|
|
|
|
)
|
2019-10-04 07:50:25 +00:00
|
|
|
|
|
|
|
root = ElementTree.fromstring(self._request_xml)
|
2018-11-16 08:10:35 +00:00
|
|
|
params = {}
|
2019-12-31 11:51:16 +00:00
|
|
|
params["ACS_URL"] = root.attrib["AssertionConsumerServiceURL"]
|
|
|
|
params["REQUEST_ID"] = root.attrib["ID"]
|
|
|
|
params["DESTINATION"] = root.attrib.get("Destination", "")
|
|
|
|
params["PROVIDER_NAME"] = root.attrib.get("ProviderName", "")
|
2018-11-16 08:10:35 +00:00
|
|
|
self._request_params = params
|
|
|
|
|
|
|
|
def _reset(self, django_request, 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 = sp_config
|
|
|
|
self._assertion_xml = sp_config
|
|
|
|
self._assertion_id = sp_config
|
|
|
|
self._django_request = django_request
|
|
|
|
self._relay_state = sp_config
|
|
|
|
self._request = sp_config
|
|
|
|
self._request_id = sp_config
|
|
|
|
self._request_xml = sp_config
|
|
|
|
self._request_params = sp_config
|
|
|
|
self._response_id = sp_config
|
|
|
|
self._response_xml = sp_config
|
|
|
|
self._response_params = sp_config
|
|
|
|
self._saml_request = sp_config
|
|
|
|
self._saml_response = sp_config
|
|
|
|
self._session_index = sp_config
|
|
|
|
self._subject = sp_config
|
2019-12-31 11:51:16 +00:00
|
|
|
self._subject_format = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"
|
|
|
|
self._system_params = {"ISSUER": self._remote.issuer}
|
2018-11-16 08:10:35 +00:00
|
|
|
|
|
|
|
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.
|
|
|
|
"""
|
2019-12-31 11:51:16 +00:00
|
|
|
request_acs_url = self._request_params["ACS_URL"]
|
2018-11-16 08:10:35 +00:00
|
|
|
|
|
|
|
if self._remote.acs_url != request_acs_url:
|
2019-12-31 11:51:16 +00:00
|
|
|
msg = "couldn't find ACS url '{}' in SAML2IDP_REMOTES " "setting.".format(
|
|
|
|
request_acs_url
|
|
|
|
)
|
2018-11-16 08:10:35 +00:00
|
|
|
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."""
|
|
|
|
|
|
|
|
def can_handle(self, request):
|
|
|
|
"""Returns true if this processor can handle this request."""
|
|
|
|
self._reset(request)
|
|
|
|
# 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.
|
2019-04-29 19:39:41 +00:00
|
|
|
# Only call can_handle if SP initiated Request, otherwise we have no Request
|
|
|
|
if not self.is_idp_initiated:
|
|
|
|
self.can_handle(self._django_request)
|
|
|
|
|
2018-11-16 08:10:35 +00:00
|
|
|
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_django_response_params()
|
|
|
|
|
2018-12-26 16:21:20 +00:00
|
|
|
def init_deep_link(self, request, url):
|
2018-11-16 08:10:35 +00:00
|
|
|
"""Initialize this Processor to make an IdP-initiated call to the SP's
|
|
|
|
deep-linked URL."""
|
2018-12-26 16:21:20 +00:00
|
|
|
self._reset(request)
|
|
|
|
acs_url = self._remote.acs_url
|
2018-11-16 08:10:35 +00:00
|
|
|
# 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 = {
|
2019-12-31 11:51:16 +00:00
|
|
|
"ACS_URL": acs_url,
|
|
|
|
"DESTINATION": "",
|
|
|
|
"PROVIDER_NAME": "",
|
2018-11-16 08:10:35 +00:00
|
|
|
}
|
|
|
|
self._relay_state = url
|