feat: RBAC + SAML support

This commit is contained in:
Markos Gogoulos
2025-04-05 12:44:21 +03:00
committed by GitHub
parent 8fecccce1c
commit 05414f66c7
158 changed files with 6423 additions and 106 deletions

View File

View File

@@ -0,0 +1,61 @@
from allauth.socialaccount.providers.base import ProviderAccount
from allauth.socialaccount.providers.saml.provider import SAMLProvider
from django.http import HttpResponseRedirect
from saml_auth.custom.utils import build_auth
class SAMLAccount(ProviderAccount):
pass
class CustomSAMLProvider(SAMLProvider):
def _extract(self, data):
custom_configuration = self.app.saml_configurations.first()
if custom_configuration:
provider_config = custom_configuration.saml_provider_settings
else:
provider_config = self.app.settings
raw_attributes = data.get_attributes()
attributes = {}
attribute_mapping = provider_config.get("attribute_mapping", self.default_attribute_mapping)
# map configured provider attributes
for key, provider_keys in attribute_mapping.items():
if isinstance(provider_keys, str):
provider_keys = [provider_keys]
for provider_key in provider_keys:
attribute_list = raw_attributes.get(provider_key, None)
# if more than one keys, get them all comma separated
if attribute_list is not None and len(attribute_list) > 1:
attributes[key] = ",".join(attribute_list)
break
elif attribute_list is not None and len(attribute_list) > 0:
attributes[key] = attribute_list[0]
break
attributes["email_verified"] = False
email_verified = provider_config.get("email_verified", False)
if email_verified:
if isinstance(email_verified, str):
email_verified = email_verified.lower() in ["true", "1", "t", "y", "yes"]
attributes["email_verified"] = email_verified
# return username as the uid value
if "uid" in attributes:
attributes["username"] = attributes["uid"]
# If we did not find an email, check if the NameID contains the email.
if not attributes.get("email") and (
data.get_nameid_format() == "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
# Alternatively, if `use_id_for_email` is true, then we always interpret the nameID as email
or provider_config.get("use_nameid_for_email", False) # noqa
):
attributes["email"] = data.get_nameid()
return attributes
def redirect(self, request, process, next_url=None, data=None, **kwargs):
auth = build_auth(request, self)
# If we pass `return_to=None` `auth.login` will use the URL of the
# current view.
redirect = auth.login(return_to="")
self.stash_redirect_state(request, process, next_url, data, state_id=auth.get_last_request_id(), **kwargs)
return HttpResponseRedirect(redirect)

38
saml_auth/custom/urls.py Normal file
View File

@@ -0,0 +1,38 @@
from django.urls import include, path, re_path
from . import views
urlpatterns = [
re_path(
r"^saml/(?P<organization_slug>[^/]+)/",
include(
[
path(
"acs/",
views.acs,
name="saml_acs",
),
path(
"acs/finish/",
views.finish_acs,
name="saml_finish_acs",
),
path(
"sls/",
views.sls,
name="saml_sls",
),
path(
"metadata/",
views.metadata,
name="saml_metadata",
),
path(
"login/",
views.login,
name="saml_login",
),
]
),
)
]

173
saml_auth/custom/utils.py Normal file
View File

@@ -0,0 +1,173 @@
from urllib.parse import urlparse
from allauth.socialaccount.adapter import get_adapter
from allauth.socialaccount.models import SocialApp
from allauth.socialaccount.providers.saml.provider import SAMLProvider
from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured
from django.http import Http404
from django.urls import reverse
from django.utils.http import urlencode
from onelogin.saml2.auth import OneLogin_Saml2_Auth
from onelogin.saml2.constants import OneLogin_Saml2_Constants
from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser
def get_app_or_404(request, organization_slug):
adapter = get_adapter()
try:
return adapter.get_app(request, provider=SAMLProvider.id, client_id=organization_slug)
except SocialApp.DoesNotExist:
raise Http404(f"no SocialApp found with client_id={organization_slug}")
def prepare_django_request(request):
result = {
"https": "on" if request.is_secure() else "off",
"http_host": request.META["HTTP_HOST"],
"script_name": request.META["PATH_INFO"],
"get_data": request.GET.copy(),
# 'lowercase_urlencoding': True,
"post_data": request.POST.copy(),
}
return result
def build_sp_config(request, provider_config, org):
acs_url = request.build_absolute_uri(reverse("saml_acs", args=[org]))
sls_url = request.build_absolute_uri(reverse("saml_sls", args=[org]))
metadata_url = request.build_absolute_uri(reverse("saml_metadata", args=[org]))
# SP entity ID generated with the following precedence:
# 1. Explicitly configured SP via the SocialApp.settings
# 2. Fallback to the SAML metadata urlpattern
_sp_config = provider_config.get("sp", {})
sp_entity_id = _sp_config.get("entity_id")
sp_config = {
"entityId": sp_entity_id or metadata_url,
"assertionConsumerService": {
"url": acs_url,
"binding": OneLogin_Saml2_Constants.BINDING_HTTP_POST,
},
"singleLogoutService": {
"url": sls_url,
"binding": OneLogin_Saml2_Constants.BINDING_HTTP_REDIRECT,
},
}
avd = provider_config.get("advanced", {})
if avd.get("x509cert") is not None:
sp_config["x509cert"] = avd["x509cert"]
if avd.get("x509cert_new"):
sp_config["x509certNew"] = avd["x509cert_new"]
if avd.get("private_key") is not None:
sp_config["privateKey"] = avd["private_key"]
if avd.get("name_id_format") is not None:
sp_config["NameIDFormat"] = avd["name_id_format"]
return sp_config
def fetch_metadata_url_config(idp_config):
metadata_url = idp_config["metadata_url"]
entity_id = idp_config["entity_id"]
cache_key = f"saml.metadata.{metadata_url}.{entity_id}"
saml_config = cache.get(cache_key)
if saml_config is None:
saml_config = OneLogin_Saml2_IdPMetadataParser.parse_remote(
metadata_url,
entity_id=entity_id,
timeout=idp_config.get("metadata_request_timeout", 10),
)
cache.set(
cache_key,
saml_config,
idp_config.get("metadata_cache_timeout", 60 * 60 * 4),
)
return saml_config
def build_saml_config(request, provider_config, org):
avd = provider_config.get("advanced", {})
security_config = {
"authnRequestsSigned": avd.get("authn_request_signed", False),
"digestAlgorithm": avd.get("digest_algorithm", OneLogin_Saml2_Constants.SHA256),
"logoutRequestSigned": avd.get("logout_request_signed", False),
"logoutResponseSigned": avd.get("logout_response_signed", False),
"requestedAuthnContext": False,
"signatureAlgorithm": avd.get("signature_algorithm", OneLogin_Saml2_Constants.RSA_SHA256),
"signMetadata": avd.get("metadata_signed", False),
"wantAssertionsEncrypted": avd.get("want_assertion_encrypted", False),
"wantAssertionsSigned": avd.get("want_assertion_signed", False),
"wantMessagesSigned": avd.get("want_message_signed", False),
"nameIdEncrypted": avd.get("name_id_encrypted", False),
"wantNameIdEncrypted": avd.get("want_name_id_encrypted", False),
"allowSingleLabelDomains": avd.get("allow_single_label_domains", False),
"rejectDeprecatedAlgorithm": avd.get("reject_deprecated_algorithm", True),
"wantNameId": avd.get("want_name_id", False),
"wantAttributeStatement": avd.get("want_attribute_statement", True),
"allowRepeatAttributeName": avd.get("allow_repeat_attribute_name", True),
}
saml_config = {
"strict": avd.get("strict", True),
"security": security_config,
}
contact_person = provider_config.get("contact_person")
if contact_person:
saml_config["contactPerson"] = contact_person
organization = provider_config.get("organization")
if organization:
saml_config["organization"] = organization
idp = provider_config.get("idp")
if idp is None:
raise ImproperlyConfigured("`idp` missing")
metadata_url = idp.get("metadata_url")
if metadata_url:
meta_config = fetch_metadata_url_config(idp)
saml_config["idp"] = meta_config["idp"]
else:
saml_config["idp"] = {
"entityId": idp["entity_id"],
"x509cert": idp["x509cert"],
"singleSignOnService": {"url": idp["sso_url"]},
}
slo_url = idp.get("slo_url")
if slo_url:
saml_config["idp"]["singleLogoutService"] = {"url": slo_url}
saml_config["sp"] = build_sp_config(request, provider_config, org)
return saml_config
def encode_relay_state(state):
params = {"state": state}
return urlencode(params)
def decode_relay_state(relay_state):
"""According to the spec, RelayState need not be a URL, yet,
``onelogin.saml2` exposes it as ``return_to -- The target URL the user
should be redirected to after login``. Also, for an IdP initiated login
sometimes a URL is used.
"""
next_url = None
if relay_state:
parts = urlparse(relay_state)
if parts.scheme or parts.netloc or (parts.path and parts.path.startswith("/")):
next_url = relay_state
return next_url
def build_auth(request, provider):
req = prepare_django_request(request)
custom_configuration = provider.app.saml_configurations.first()
if custom_configuration:
custom_settings = custom_configuration.saml_provider_settings
config = build_saml_config(request, custom_settings, provider.app.client_id)
else:
config = build_saml_config(request, provider.app.settings, provider.app.client_id)
auth = OneLogin_Saml2_Auth(req, config)
return auth

180
saml_auth/custom/views.py Normal file
View File

@@ -0,0 +1,180 @@
import binascii
import logging
from allauth.account.adapter import get_adapter as get_account_adapter
from allauth.account.internal.decorators import login_not_required
from allauth.core.internal import httpkit
from allauth.socialaccount.helpers import (
complete_social_login,
render_authentication_error,
)
from allauth.socialaccount.providers.base.constants import AuthError, AuthProcess
from allauth.socialaccount.providers.base.views import BaseLoginView
from allauth.socialaccount.sessions import LoginSession
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from onelogin.saml2.auth import OneLogin_Saml2_Settings
from onelogin.saml2.errors import OneLogin_Saml2_Error
from .utils import build_auth, build_saml_config, decode_relay_state, get_app_or_404
logger = logging.getLogger(__name__)
class SAMLViewMixin:
def get_app(self, organization_slug):
app = get_app_or_404(self.request, organization_slug)
return app
def get_provider(self, organization_slug):
app = self.get_app(organization_slug)
return app.get_provider(self.request)
@method_decorator(csrf_exempt, name="dispatch")
@method_decorator(login_not_required, name="dispatch")
class ACSView(SAMLViewMixin, View):
def dispatch(self, request, organization_slug):
url = reverse(
"saml_finish_acs",
kwargs={"organization_slug": organization_slug},
)
response = HttpResponseRedirect(url)
acs_session = LoginSession(request, "saml_acs_session", "saml-acs-session")
acs_session.store.update({"request": httpkit.serialize_request(request)})
acs_session.save(response)
return response
acs = ACSView.as_view()
@method_decorator(login_not_required, name="dispatch")
class FinishACSView(SAMLViewMixin, View):
def dispatch(self, request, organization_slug):
provider = self.get_provider(organization_slug)
acs_session = LoginSession(request, "saml_acs_session", "saml-acs-session")
acs_request = None
acs_request_data = acs_session.store.get("request")
if acs_request_data:
acs_request = httpkit.deserialize_request(acs_request_data, HttpRequest())
acs_session.delete()
if not acs_request:
logger.error("Unable to finish login, SAML ACS session missing")
return render_authentication_error(request, provider)
auth = build_auth(acs_request, provider)
error_reason = None
errors = []
try:
# We're doing the check for a valid `InResponeTo` ourselves later on
# (*) by checking if there is a matching state stashed.
auth.process_response(request_id=None)
except binascii.Error:
errors = ["invalid_response"]
error_reason = "Invalid response"
except OneLogin_Saml2_Error as e:
errors = ["error"]
error_reason = str(e)
if not errors:
errors = auth.get_errors()
if errors:
# e.g. ['invalid_response']
error_reason = auth.get_last_error_reason() or error_reason
logger.error("Error processing SAML ACS response: %s: %s" % (", ".join(errors), error_reason))
return render_authentication_error(
request,
provider,
extra_context={
"saml_errors": errors,
"saml_last_error_reason": error_reason,
},
)
if not auth.is_authenticated():
return render_authentication_error(request, provider, error=AuthError.CANCELLED)
login = provider.sociallogin_from_response(request, auth)
# (*) If we (the SP) initiated the login, there should be a matching
# state.
state_id = auth.get_last_response_in_response_to()
if state_id:
login.state = provider.unstash_redirect_state(request, state_id)
else:
# IdP initiated SSO
reject = provider.app.settings.get("advanced", {}).get("reject_idp_initiated_sso", True)
if reject:
logger.error("IdP initiated SSO rejected")
return render_authentication_error(request, provider)
next_url = decode_relay_state(acs_request.POST.get("RelayState"))
login.state["process"] = AuthProcess.LOGIN
if next_url:
login.state["next"] = next_url
return complete_social_login(request, login)
finish_acs = FinishACSView.as_view()
@method_decorator(csrf_exempt, name="dispatch")
@method_decorator(login_not_required, name="dispatch")
class SLSView(SAMLViewMixin, View):
def dispatch(self, request, organization_slug):
provider = self.get_provider(organization_slug)
auth = build_auth(self.request, provider)
should_logout = request.user.is_authenticated
account_adapter = get_account_adapter(request)
def force_logout():
account_adapter.logout(request)
redirect_to = None
error_reason = None
try:
redirect_to = auth.process_slo(delete_session_cb=force_logout, keep_local_session=not should_logout)
except OneLogin_Saml2_Error as e:
error_reason = str(e)
errors = auth.get_errors()
if errors:
error_reason = auth.get_last_error_reason() or error_reason
logger.error("Error processing SAML SLS response: %s: %s" % (", ".join(errors), error_reason))
resp = HttpResponse(error_reason, content_type="text/plain")
resp.status_code = 400
return resp
if not redirect_to:
redirect_to = account_adapter.get_logout_redirect_url(request)
return HttpResponseRedirect(redirect_to)
sls = SLSView.as_view()
@method_decorator(login_not_required, name="dispatch")
class MetadataView(SAMLViewMixin, View):
def dispatch(self, request, organization_slug):
provider = self.get_provider(organization_slug)
config = build_saml_config(self.request, provider.app.settings, organization_slug)
saml_settings = OneLogin_Saml2_Settings(settings=config, sp_validation_only=True)
metadata = saml_settings.get_sp_metadata()
errors = saml_settings.validate_metadata(metadata)
if len(errors) > 0:
resp = JsonResponse({"errors": errors})
resp.status_code = 500
return resp
return HttpResponse(content=metadata, content_type="text/xml")
metadata = MetadataView.as_view()
@method_decorator(login_not_required, name="dispatch")
class LoginView(SAMLViewMixin, BaseLoginView):
def get_provider(self):
app = self.get_app(self.kwargs["organization_slug"])
return app.get_provider(self.request)
login = LoginView.as_view()