""" Backend for SAML 2.0 support Terminology: "Service Provider" (SP): Your web app "Identity Provider" (IdP): The third-party site that is authenticating users via SAML """ import json from onelogin.saml2.auth import OneLogin_Saml2_Auth from onelogin.saml2.settings import OneLogin_Saml2_Settings from ..exceptions import AuthFailed, AuthMissingParameter from .base import BaseAuth # Helpful constants: OID_COMMON_NAME = "urn:oid:2.5.4.3" OID_EDU_PERSON_PRINCIPAL_NAME = "urn:oid:1.3.6.1.4.1.5923.1.1.1.6" OID_EDU_PERSON_ENTITLEMENT = "urn:oid:1.3.6.1.4.1.5923.1.1.1.7" OID_GIVEN_NAME = "urn:oid:2.5.4.42" OID_MAIL = "urn:oid:0.9.2342.19200300.100.1.3" OID_SURNAME = "urn:oid:2.5.4.4" OID_USERID = "urn:oid:0.9.2342.19200300.100.1.1" class SAMLIdentityProvider: """Wrapper around configuration for a SAML Identity provider""" def __init__(self, name, **kwargs): """Load and parse configuration""" self.name = name # name should be a slug and must not contain a colon, which # could conflict with uid prefixing: assert ( ":" not in self.name and " " not in self.name ), 'IdP "name" should be a slug (short, no spaces)' self.conf = kwargs def get_user_permanent_id(self, attributes): """ The most important method: Get a permanent, unique identifier for this user from the attributes supplied by the IdP. If you want to use the NameID, it's available via attributes['name_id'] """ uid = attributes[self.conf.get("attr_user_permanent_id", OID_USERID)] if isinstance(uid, list): uid = uid[0] return uid # Attributes processing: def get_user_details(self, attributes): """ Given the SAML attributes extracted from the SSO response, get the user data like name. """ return { "fullname": self.get_attr(attributes, "attr_full_name", OID_COMMON_NAME), "first_name": self.get_attr(attributes, "attr_first_name", OID_GIVEN_NAME), "last_name": self.get_attr(attributes, "attr_last_name", OID_SURNAME), "username": self.get_attr(attributes, "attr_username", OID_USERID), "email": self.get_attr(attributes, "attr_email", OID_MAIL), } def get_attr(self, attributes, conf_key, default_attribute): """ Internal helper method. Get the attribute 'default_attribute' out of the attributes, unless self.conf[conf_key] overrides the default by specifying another attribute to use. """ key = self.conf.get(conf_key, default_attribute) value = attributes[key] if key in attributes else None if isinstance(value, list): value = value[0] if value else None return value @property def entity_id(self): """Get the entity ID for this IdP""" # Required. e.g. "https://idp.testshib.org/idp/shibboleth" return self.conf["entity_id"] @property def sso_url(self): """Get the SSO URL for this IdP""" # Required. e.g. # "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO" return self.conf["url"] @property def slo_url(self): """Get the SLO URL for this IdP""" return self.conf.get("slo_url") @property def saml_config_dict(self): """Get the IdP configuration dict in the format required by python-saml""" result = { "entityId": self.entity_id, "singleSignOnService": { "url": self.sso_url, # python-saml only supports Redirect "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", }, } if self.slo_url: result["singleLogoutService"] = { "url": self.slo_url, "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", } cert = self.conf.get("x509cert", None) if cert: result["x509cert"] = cert return result cert = self.conf.get("x509certMulti", None) if cert: result["x509certMulti"] = cert return result raise KeyError("IDP must contain x509cert or x509certMulti") class DummySAMLIdentityProvider(SAMLIdentityProvider): """ A placeholder IdP used when we must specify something, e.g. when generating SP metadata. If OneLogin_Saml2_Auth is modified to not always require IdP config, this can be removed. """ def __init__(self): super().__init__( "dummy", entity_id="https://dummy.none/saml2", url="https://dummy.none/SSO", x509cert="", ) class SAMLAuth(BaseAuth): """ PSA Backend that implements SAML 2.0 Service Provider (SP) functionality. Unlike all of the other backends, this one can be configured to work with many identity providers (IdPs). For example, a University that belongs to a Shibboleth federation may support authentication via ~100 partner universities. Also, the IdP configuration can be changed at runtime if you require that functionality - just subclass this and override `get_idp()`. Several settings are required. Here's an example: SOCIAL_AUTH_SAML_SP_ENTITY_ID = "https://saml.example.com/" SOCIAL_AUTH_SAML_SP_PUBLIC_CERT = "... X.509 certificate string ..." SOCIAL_AUTH_SAML_SP_PRIVATE_KEY = "... private key ..." SOCIAL_AUTH_SAML_ORG_INFO = { "en-US": { "name": "example", "displayname": "Example Inc.", "url": "http://example.com" } } SOCIAL_AUTH_SAML_TECHNICAL_CONTACT = { "givenName": "Tech Gal", "emailAddress": "technical@example.com" } SOCIAL_AUTH_SAML_SUPPORT_CONTACT = { "givenName": "Support Guy", "emailAddress": "support@example.com" } SOCIAL_AUTH_SAML_ENABLED_IDPS = { "testshib": { "entity_id": "https://idp.testshib.org/idp/shibboleth", "url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO", "x509cert": "MIIEDjCCAvagAwIBAgIBADANBgkqhkiG9w0B... ...8Bbnl+ev0peYzxFyF5sQA==", } } Optional settings: SOCIAL_AUTH_SAML_SP_EXTRA = {} SOCIAL_AUTH_SAML_SECURITY_CONFIG = {} """ name = "saml" EXTRA_DATA = [] def get_idp(self, idp_name): """Given the name of an IdP, get a SAMLIdentityProvider instance""" idp_config = self.setting("ENABLED_IDPS")[idp_name] return SAMLIdentityProvider(idp_name, **idp_config) def generate_saml_config(self, idp=None): """ Generate the configuration required to instantiate OneLogin_Saml2_Auth """ # The shared absolute URL that all IdPs redirect back to - # this is specified in our metadata.xml: abs_completion_url = self.redirect_uri config = { "contactPerson": { "technical": self.setting("TECHNICAL_CONTACT"), "support": self.setting("SUPPORT_CONTACT"), }, "debug": True, "idp": idp.saml_config_dict if idp else {}, "organization": self.setting("ORG_INFO"), "security": { "metadataValidUntil": "", "metadataCacheDuration": "P10D", # metadata valid for ten days }, "sp": { "assertionConsumerService": { "url": abs_completion_url, # python-saml only supports HTTP-POST "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", }, "entityId": self.setting("SP_ENTITY_ID"), "x509cert": self.setting("SP_PUBLIC_CERT"), "privateKey": self.setting("SP_PRIVATE_KEY"), }, "strict": True, # We must force strict mode - for security } config["security"].update(self.setting("SECURITY_CONFIG", {})) config["sp"].update(self.setting("SP_EXTRA", {})) return config def generate_metadata_xml(self): """ Helper method that can be used from your web app to generate the XML metadata required to link your web app as a Service Provider. Returns (metadata XML string, list of errors) Example usage (Django): from ..apps.django_app.utils import load_strategy, \ load_backend def saml_metadata_view(request): complete_url = reverse('social:complete', args=("saml", )) saml_backend = load_backend(load_strategy(request), "saml", complete_url) metadata, errors = saml_backend.generate_metadata_xml() if not errors: return HttpResponse(content=metadata, content_type='text/xml') return HttpResponseServerError(content=', '.join(errors)) """ config = self.generate_saml_config() saml_settings = OneLogin_Saml2_Settings(config, sp_validation_only=True) metadata = saml_settings.get_sp_metadata() errors = saml_settings.validate_metadata(metadata) return metadata, errors def _create_saml_auth(self, idp): """Get an instance of OneLogin_Saml2_Auth""" config = self.generate_saml_config(idp) request_info = { "https": "on" if self.strategy.request_is_secure() else "off", "http_host": self.strategy.request_host(), "script_name": self.strategy.request_path(), "get_data": self.strategy.request_get(), "post_data": self.strategy.request_post(), } return OneLogin_Saml2_Auth(request_info, config) def auth_url(self): """Get the URL to which we must redirect in order to authenticate the user""" try: idp_name = self.strategy.request_data()["idp"] except KeyError: raise AuthMissingParameter(self, "idp") auth = self._create_saml_auth(idp=self.get_idp(idp_name)) # Below, return_to sets the RelayState, which can contain # arbitrary data. We use it to store the specific SAML IdP # name, since we multiple IdPs share the same auth_complete # URL, and the URL to redirect to after auth completes. relay_state = { "idp": idp_name, "next": self.data.get("next"), } return auth.login(return_to=json.dumps(relay_state)) def get_user_details(self, response): """Get user details like full name, email, etc. from the response - see auth_complete""" idp = self.get_idp(response["idp_name"]) return idp.get_user_details(response["attributes"]) def get_user_id(self, details, response): """ Get the permanent ID for this user from the response. We prefix each ID with the name of the IdP so that we can connect multiple IdPs to this user. """ idp = self.get_idp(response["idp_name"]) uid = idp.get_user_permanent_id(response["attributes"]) return f"{idp.name}:{uid}" def auth_complete(self, *args, **kwargs): """ The user has been redirected back from the IdP and we should now log them in, if everything checks out. """ try: relay_state_str = self.strategy.request_data()["RelayState"] except KeyError: raise AuthMissingParameter(self, "RelayState") try: relay_state = json.loads(relay_state_str) if not isinstance(relay_state, dict) or "idp" not in relay_state: raise ValueError( "RelayState is expected to contain a JSON object with an 'idp' key" ) except ValueError: # Assume RelayState is just the idp_name, as it used to be in previous versions of this code. # This ensures compatibility with previous versions. idp_name = relay_state_str else: idp_name = relay_state["idp"] if next_url := relay_state.get("next"): # The do_complete action expects the "next" URL to be in session state or the request params. self.strategy.session_set(kwargs.get("redirect_name", "next"), next_url) idp = self.get_idp(idp_name) auth = self._create_saml_auth(idp) auth.process_response() errors = auth.get_errors() if errors or not auth.is_authenticated(): reason = auth.get_last_error_reason() raise AuthFailed(self, f"SAML login failed: {errors} ({reason})") attributes = auth.get_attributes() attributes["name_id"] = auth.get_nameid() self._check_entitlements(idp, attributes) response = { "idp_name": idp_name, "attributes": attributes, "session_index": auth.get_session_index(), } kwargs.update({"response": response, "backend": self}) return self.strategy.authenticate(*args, **kwargs) def extra_data(self, user, uid, response, details=None, *args, **kwargs): extra_data = super().extra_data( user, uid, response["attributes"], details=details, *args, **kwargs ) extra_data["session_index"] = response["session_index"] extra_data["name_id"] = response["attributes"]["name_id"] return extra_data def request_logout(self, idp_name, social_auth, return_to=None): idp = self.get_idp(idp_name) auth = self._create_saml_auth(idp) name_id = social_auth.extra_data["name_id"] session_index = social_auth.extra_data["session_index"] return auth.logout( name_id=name_id, session_index=session_index, return_to=return_to ) def process_logout(self, idp_name, delete_session_cb): idp = self.get_idp(idp_name) auth = self._create_saml_auth(idp) url = auth.process_slo(delete_session_cb=delete_session_cb) errors = auth.get_errors() return url, errors def _check_entitlements(self, idp, attributes): """ Additional verification of a SAML response before authenticating the user. Subclasses can override this method if they need custom validation code, such as requiring the presence of an eduPersonEntitlement. raise social_core.exceptions.AuthForbidden if the user should not be authenticated, or do nothing to allow the login pipeline to continue. """ pass