This repository was archived by the owner on Jan 26, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 93
IDP dynamic configuration #126
Open
challet
wants to merge
13
commits into
lighthouse-intelligence:master
Choose a base branch
from
challet:conf-loader
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
e2ba17b
add a configuration callable setting
challet 2133655
codestyle
challet 546c3d4
fixes and tests
challet e3241b5
simplify conf loading
challet 5447080
alows to specify a custom queryset for SPs
challet 64b139c
add documentation
challet a057d03
Check if an SP is available with the current IdP
challet 6edf72a
remove IPD as Server inherited
challet 444789e
Merge branch 'master' into conf-loader
challet 0947e9c
use a none queryset
challet 55b60c8
change doc
challet fcadbee
use only one view mixin
challet a2525eb
linter
challet File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import copy | ||
| from typing import Callable, Optional, Union | ||
|
|
||
| from django.conf import settings | ||
| from django.core.exceptions import ImproperlyConfigured | ||
| from django.http import HttpRequest | ||
| from django.utils.module_loading import import_string | ||
|
|
||
|
|
||
| def get_callable(path: Union[Callable, str]) -> Callable: | ||
| """ Import the function at a given path and return it | ||
| """ | ||
| if callable(path): | ||
| return path | ||
|
|
||
| try: | ||
| config_loader = import_string(path) | ||
| except ImportError as e: | ||
| raise ImproperlyConfigured(f'Error importing SAML config loader {path}: "{e}"') | ||
|
|
||
| if not callable(config_loader): | ||
| raise ImproperlyConfigured("SAML config loader must be a callable object.") | ||
|
|
||
| return config_loader | ||
|
|
||
|
|
||
| def get_config(config_loader_path: Optional[Union[Callable, str]] = None, request: Optional[HttpRequest] = None) -> dict: | ||
| """ Load a config_loader function if necessary, and call that function with the request as argument. | ||
| If the config_loader_path is a callable instead of a string, no importing is necessary and it will be used directly. | ||
| Return the resulting SPConfig. | ||
| """ | ||
| static_config = copy.deepcopy(settings.SAML_IDP_CONFIG) | ||
|
|
||
| if config_loader_path is None: | ||
| return static_config or {} | ||
| else: | ||
| return get_callable(config_loader_path)(static_config, request) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,53 +1,68 @@ | ||
| import copy | ||
|
|
||
| from django.conf import settings | ||
| from django.core.exceptions import ImproperlyConfigured | ||
| from django.http import HttpRequest | ||
| from django.utils.translation import gettext as _ | ||
| from saml2.config import IdPConfig | ||
| from saml2.metadata import entity_descriptor | ||
| from saml2.server import Server | ||
| from typing import Callable, Dict, Optional, Union | ||
|
|
||
| from .conf import get_callable, get_config | ||
|
|
||
|
|
||
| class IDP: | ||
| """ Access point for the IDP Server instance | ||
| """ | ||
| _server_instance: Server = None | ||
| _server_instances: Dict[str, Server] = {} | ||
|
|
||
| @classmethod | ||
| def construct_metadata(cls, with_local_sp: bool = True) -> dict: | ||
| def construct_metadata(cls, idp_conf: dict, request: Optional[HttpRequest] = None, with_local_sp: bool = True) -> IdPConfig: | ||
| """ Get the config including the metadata for all the configured service providers. """ | ||
| conf = IdPConfig() | ||
|
|
||
| from .models import ServiceProvider | ||
| idp_config = copy.deepcopy(settings.SAML_IDP_CONFIG) | ||
| if idp_config: | ||
| idp_config['metadata'] = { # type: ignore | ||
| 'local': ( | ||
| [sp.metadata_path() for sp in ServiceProvider.objects.filter(active=True)] | ||
| if with_local_sp else []), | ||
| } | ||
| return idp_config | ||
| sp_queryset = ServiceProvider.objects.none() | ||
| if with_local_sp: | ||
| sp_queryset = ServiceProvider.objects.filter(active=True) | ||
| if getattr(settings, "SAML_IDP_FILTER_SP_QUERYSET", None) is not None: | ||
| sp_queryset = get_callable(settings.SAML_IDP_FILTER_SP_QUERYSET)(sp_queryset, request) | ||
|
|
||
| idp_conf['metadata'] = { # type: ignore | ||
| 'local': ( | ||
| [sp.metadata_path() for sp in sp_queryset] | ||
| if with_local_sp else [] | ||
| ), | ||
| } | ||
| try: | ||
| conf.load(idp_conf) | ||
| except Exception as e: | ||
| raise ImproperlyConfigured(_('Could not instantiate an IDP based on the SAML_IDP_CONFIG settings and configured ServiceProviders: {}').format(str(e))) | ||
| return conf | ||
|
|
||
| @classmethod | ||
| def load(cls, request: Optional[HttpRequest] = None, config_loader_path: Optional[Union[Callable, str]] = None) -> Server: | ||
| idp_conf = get_config(config_loader_path, request) | ||
| if "entityid" not in idp_conf: | ||
| raise ImproperlyConfigured('The configuration must contain an entityid') | ||
| entity_id = idp_conf["entityid"] | ||
|
|
||
| if entity_id not in cls._server_instances: | ||
| # actually initialize the IdP server and cache it | ||
| conf = cls.construct_metadata(idp_conf, request) | ||
| cls._server_instances[entity_id] = Server(config=conf) | ||
|
|
||
| return cls._server_instances[entity_id] | ||
|
|
||
| @classmethod | ||
| def load(cls, force_refresh: bool = False) -> Server: | ||
| """ Instantiate a IDP Server instance based on the config defined in the SAML_IDP_CONFIG settings. | ||
| Throws an ImproperlyConfigured exception if it could not do so for any reason. | ||
| """ | ||
| if cls._server_instance is None or force_refresh: | ||
| conf = IdPConfig() | ||
| md = cls.construct_metadata() | ||
| try: | ||
| conf.load(md) | ||
| cls._server_instance = Server(config=conf) | ||
| except Exception as e: | ||
| raise ImproperlyConfigured(_('Could not instantiate an IDP based on the SAML_IDP_CONFIG settings and configured ServiceProviders: {}').format(str(e))) | ||
| return cls._server_instance | ||
| def flush(cls): | ||
| cls._server_instances = {} | ||
|
|
||
| @classmethod | ||
| def metadata(cls) -> str: | ||
| def metadata(cls, request: Optional[HttpRequest] = None, config_loader_path: Optional[Union[Callable, str]] = None) -> str: | ||
| """ Get the IDP metadata as a string. """ | ||
| conf = IdPConfig() | ||
| try: | ||
| conf.load(cls.construct_metadata(with_local_sp=False)) | ||
| conf = cls.construct_metadata(get_config(config_loader_path, request), request, with_local_sp=False) | ||
| metadata = entity_descriptor(conf) | ||
| except Exception as e: | ||
| raise ImproperlyConfigured(_('Could not instantiate IDP metadata based on the SAML_IDP_CONFIG settings and configured ServiceProviders: {}').format(str(e))) | ||
| raise ImproperlyConfigured(_('Could not instantiate IDP metadata: {}').format(str(e))) | ||
| return str(metadata) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -173,7 +173,7 @@ def save(self, *args, **kwargs): | |
| if not self.metadata_expiration_dt: | ||
| self.metadata_expiration_dt = extract_validuntil_from_metadata(self.local_metadata) | ||
| super().save(*args, **kwargs) | ||
| IDP.load(force_refresh=True) | ||
| IDP.flush() | ||
|
|
||
| @property | ||
| def attribute_mapping(self) -> Dict[str, str]: | ||
|
|
@@ -228,14 +228,10 @@ def metadata_path(self) -> str: | |
|
|
||
| @property | ||
| def sign_response(self) -> bool: | ||
| if self._sign_response is None: | ||
| return getattr(IDP.load().config, "sign_response", False) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No more IDP load in models since their config depends on the http request |
||
| return self._sign_response | ||
|
|
||
| @property | ||
| def sign_assertion(self) -> bool: | ||
| if self._sign_assertion is None: | ||
| return getattr(IDP.load().config, "sign_assertion", False) | ||
| return self._sign_assertion | ||
|
|
||
| @property | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,7 @@ | |
| from saml2.authn_context import PASSWORD, AuthnBroker, authn_context_class_ref | ||
| from saml2.ident import NameID | ||
| from saml2.saml import NAMEID_FORMAT_UNSPECIFIED | ||
| from saml2.server import Server | ||
|
|
||
| from .error_views import error_cbv | ||
| from .idp import IDP | ||
|
|
@@ -83,15 +84,22 @@ def check_access(processor: BaseProcessor, request: HttpRequest) -> None: | |
| raise PermissionDenied(_("You do not have access to this resource")) | ||
|
|
||
|
|
||
| def get_sp_config(sp_entity_id: str) -> ServiceProvider: | ||
| """ Get a dict with the configuration for a SP according to the SAML_IDP_SPCONFIG settings. | ||
| def get_sp_config(sp_entity_id: str, idp_server: Server) -> ServiceProvider: | ||
| """ Get a dict with the configuration for a SP according to the SAML_IDP_SPCONFIG settings and the SP model. | ||
| Raises an exception if no SP matching the given entity id can be found. | ||
| """ | ||
| try: | ||
| if sp_entity_id not in idp_server.metadata.keys(): | ||
| raise ObjectDoesNotExist() | ||
| sp = ServiceProvider.objects.get(entity_id=sp_entity_id, active=True) | ||
| except ObjectDoesNotExist: | ||
| raise ImproperlyConfigured(_("No active Service Provider object matching the entity_id '{}' found").format(sp_entity_id)) | ||
| return sp | ||
| raise ObjectDoesNotExist( | ||
| _("No active Service Provider object matching the entity_id '{}' found for the Identity Provider '{}").format( | ||
| sp_entity_id, idp_server.ident.name_qualifier | ||
| ) | ||
| ) | ||
| else: | ||
| return sp | ||
|
|
||
|
|
||
| def get_authn(req_info=None): | ||
|
|
@@ -101,7 +109,7 @@ def get_authn(req_info=None): | |
| return broker.get_authn_by_accr(req_authn_context) | ||
|
|
||
|
|
||
| def build_authn_response(user: User, authn, resp_args, service_provider: ServiceProvider) -> list: # type: ignore | ||
| def build_authn_response(user: User, authn, resp_args, service_provider: ServiceProvider, idp_server: Server) -> list: # type: ignore | ||
| """ pysaml2 server.Server.create_authn_response wrapper | ||
| """ | ||
| policy = resp_args.get('name_id_policy', None) | ||
|
|
@@ -110,7 +118,6 @@ def build_authn_response(user: User, authn, resp_args, service_provider: Service | |
| else: | ||
| name_id_format = policy.format | ||
|
|
||
| idp_server = IDP.load() | ||
| idp_name_id_format_list = idp_server.config.getattr("name_id_format", "idp") or [NAMEID_FORMAT_UNSPECIFIED] | ||
|
|
||
| if name_id_format not in idp_name_id_format_list: | ||
|
|
@@ -127,8 +134,8 @@ def build_authn_response(user: User, authn, resp_args, service_provider: Service | |
| userid=user_id, | ||
| sp_entity_id=service_provider.entity_id, | ||
| # Signing | ||
| sign_response=service_provider.sign_response, | ||
| sign_assertion=service_provider.sign_assertion, | ||
| sign_response=service_provider.sign_response if service_provider.sign_response is not None else getattr(idp_server, 'sign_response', False), | ||
| sign_assertion=service_provider.sign_assertion if service_provider.sign_assertion is not None else getattr(idp_server, 'sign_assertion', False), | ||
| sign_alg=service_provider.signing_algorithm, | ||
| digest_alg=service_provider.digest_algorithm, | ||
| # Encryption | ||
|
|
@@ -139,8 +146,18 @@ def build_authn_response(user: User, authn, resp_args, service_provider: Service | |
|
|
||
|
|
||
| class IdPHandlerViewMixin: | ||
| """ Contains some methods used by multiple views """ | ||
| config_loader_path = getattr(settings, 'SAML_IDP_CONFIG_LOADER', None) | ||
|
|
||
| def get_config_loader_path(self, request: HttpRequest): | ||
| return self.config_loader_path | ||
|
|
||
| def get_idp_server(self, request: HttpRequest) -> Server: | ||
| return IDP.load(request, self.get_config_loader_path(request)) | ||
|
|
||
| def get_idp_metadata(self, request: HttpRequest) -> str: | ||
| return IDP.metadata(request, self.get_config_loader_path(request)) | ||
|
|
||
| """ Contains some methods used by multiple views """ | ||
| def render_login_html_to_string(self, context=None, request=None, using=None): | ||
| """ Render the html response for the login action. Can be using a custom html template if set on the view. """ | ||
| default_login_template_name = 'djangosaml2idp/login.html' | ||
|
|
@@ -179,7 +196,7 @@ def create_html_response(self, request: HttpRequest, binding, authn_resp, destin | |
| "type": "POST", | ||
| } | ||
| else: | ||
| idp_server = IDP.load() | ||
| idp_server = self.get_idp_server(request) | ||
| http_args = idp_server.apply_binding( | ||
| binding=binding, | ||
| msg_str=authn_resp, | ||
|
|
@@ -230,7 +247,7 @@ def get(self, request, *args, **kwargs): | |
| # TODO: would it be better to store SAML info in request objects? | ||
| # AuthBackend takes request obj as argument... | ||
| try: | ||
| idp_server = IDP.load() | ||
| idp_server = self.get_idp_server(request) | ||
|
|
||
| # Parse incoming request | ||
| req_info = idp_server.parse_authn_request(request.session['SAMLRequest'], binding) | ||
|
|
@@ -245,15 +262,17 @@ def get(self, request, *args, **kwargs): | |
| resp_args = idp_server.response_args(req_info.message) | ||
| # Set SP and Processor | ||
| sp_entity_id = resp_args.pop('sp_entity_id') | ||
| service_provider = get_sp_config(sp_entity_id) | ||
| service_provider = get_sp_config(sp_entity_id, idp_server) | ||
| # Check if user has access | ||
| try: | ||
| # Check if user has access to SP | ||
| check_access(service_provider.processor, request) | ||
| except (ObjectDoesNotExist) as excp: | ||
| return error_cbv.handle_error(request, exception=excp, status_code=404) | ||
| except PermissionDenied as excp: | ||
| return error_cbv.handle_error(request, exception=excp, status_code=403) | ||
| # Construct SamlResponse message | ||
| authn_resp = build_authn_response(request.user, get_authn(), resp_args, service_provider) | ||
| authn_resp = build_authn_response(request.user, get_authn(), resp_args, service_provider, idp_server) | ||
| except Exception as e: | ||
| return error_cbv.handle_error(request, exception=e, status_code=500) | ||
|
|
||
|
|
@@ -280,11 +299,15 @@ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | |
| request_data = request.POST or request.GET | ||
| passed_data: Dict[str, Union[str, List[str]]] = request_data.copy().dict() | ||
|
|
||
| idp_server = self.get_idp_server(request) | ||
|
|
||
| try: | ||
| # get sp information from the parameters | ||
| sp_entity_id = str(passed_data['sp']) | ||
| service_provider = get_sp_config(sp_entity_id) | ||
| service_provider = get_sp_config(sp_entity_id, idp_server) | ||
| processor: BaseProcessor = service_provider.processor # type: ignore | ||
| except (ObjectDoesNotExist) as excp: | ||
| return error_cbv.handle_error(request, exception=excp, status_code=404) | ||
| except (KeyError, ImproperlyConfigured) as excp: | ||
| return error_cbv.handle_error(request, exception=excp, status_code=400) | ||
|
|
||
|
|
@@ -294,8 +317,6 @@ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | |
| except PermissionDenied as excp: | ||
| return error_cbv.handle_error(request, exception=excp, status_code=403) | ||
|
|
||
| idp_server = IDP.load() | ||
|
|
||
| binding_out, destination = idp_server.pick_binding( | ||
| service="assertion_consumer_service", | ||
| entity_id=sp_entity_id) | ||
|
|
@@ -305,7 +326,7 @@ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | |
| passed_data['in_response_to'] = "IdP_Initiated_Login" | ||
|
|
||
| # Construct SamlResponse messages | ||
| authn_resp = build_authn_response(request.user, get_authn(), passed_data, service_provider) | ||
| authn_resp = build_authn_response(request.user, get_authn(), passed_data, service_provider, idp_server) | ||
|
|
||
| html_response = self.create_html_response(request, binding_out, authn_resp, destination, passed_data.get('RelayState', "")) | ||
| return self.render_response(request, html_response, processor) | ||
|
|
@@ -354,7 +375,7 @@ def get(self, request: HttpRequest, *args, **kwargs): | |
| relay_state = request.session['RelayState'] | ||
| logger.debug("--- {} requested [\n{}] to IDP ---".format(self.__service_name, binding)) | ||
|
|
||
| idp_server = IDP.load() | ||
| idp_server = self.get_idp_server(request) | ||
|
|
||
| # adapted from pysaml2 examples/idp2/idp_uwsgi.py | ||
| try: | ||
|
|
@@ -414,18 +435,20 @@ def get(self, request: HttpRequest, *args, **kwargs): | |
| return self.render_response(request, html_response, None) | ||
|
|
||
|
|
||
| @method_decorator(never_cache, name="dispatch") | ||
| class MetadataView(IdPHandlerViewMixin, View): | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved as a class based view for inheriting the dynamic configuration loading method |
||
| def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||
| """ Returns an XML with the SAML 2.0 metadata for this Idp. | ||
| The metadata is constructed on-the-fly based on the config dict in the django settings. | ||
| """ | ||
| metadata = self.get_idp_metadata(request) | ||
| return HttpResponse(content=metadata.encode("utf-8"), content_type="text/xml; charset=utf8",) | ||
|
|
||
|
|
||
| @never_cache | ||
| def get_multifactor(request: HttpRequest) -> HttpResponse: | ||
| if hasattr(settings, "SAML_IDP_MULTIFACTOR_VIEW"): | ||
| multifactor_class = import_string(getattr(settings, "SAML_IDP_MULTIFACTOR_VIEW")) | ||
| else: | ||
| multifactor_class = ProcessMultiFactorView | ||
| return multifactor_class.as_view()(request) | ||
|
|
||
|
|
||
| @never_cache | ||
| def metadata(request: HttpRequest) -> HttpResponse: | ||
| """ Returns an XML with the SAML 2.0 metadata for this Idp. | ||
| The metadata is constructed on-the-fly based on the config dict in the django settings. | ||
| """ | ||
| return HttpResponse(content=IDP.metadata().encode('utf-8'), content_type="text/xml; charset=utf8") | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are now several instances cached : the keys of this dict are the
entityids of the metadata. So it is expected the IDP conf is persistent for oneentityid(see the load method)