diff --git a/.dockerignore b/.dockerignore index 29fb4d44..f564522d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,4 +12,8 @@ appstore/static/ .vscode/ build/ coverage/ -docs/ \ No newline at end of file +docs/ +venv/ +.venv/ +venv.bak/ +*.egg-info/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index b0e3742b..437f5e8f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.9.23-alpine +FROM python:3.10.20-alpine ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 @@ -12,6 +12,7 @@ ENV UID=1000 RUN mkdir $APP_HOME RUN set -x && \ + apk upgrade --no-cache && \ apk add --no-cache make git bash build-base xmlsec libxml2-dev linux-headers openssl && \ adduser -D -s /bin/bash -h $HOME -u $UID $USER && \ chown -R $UID:$UID $APP_HOME @@ -30,7 +31,8 @@ RUN chown -R $USER:0 $APP_HOME && \ RUN if [ -d whl -a "$(ls -A whl/*.whl)" ]; then pip install whl/*.whl; fi RUN export SET_BUILD_ENV_FROM_FILE=false \ - && pip install "cython<3.0.0" wheel \ + && pip install --upgrade "setuptools>=78.1.1" \ + && pip install "cython<3.0.0" "wheel>=0.46.2" \ && pip install "pyyaml==5.4.1" --no-build-isolation \ && make install \ && unset SET_BUILD_ENV_FROM_FILE diff --git a/appstore/api/v1/views.py b/appstore/api/v1/views.py index 5789ef04..4b6b0070 100644 --- a/appstore/api/v1/views.py +++ b/appstore/api/v1/views.py @@ -1137,14 +1137,9 @@ def _get_product_providers(self, settings): """ if settings.ALLOW_SAML_LOGIN == "true": - # TODO can we get the provider name from metadata so that if - # we support something beyond UNC we dont need another func - # or clause? What happens if we have multiple SAML SSO providers - # today it's handled with SAML_URL and the saml2_auth package, - # but appears to be setup for one provider at a time. return asdict( LoginProvider( - "UNC Chapel Hill Single Sign-On", + settings.SAML_PROVIDER_NAME, settings.SAML_URL, ) ) diff --git a/appstore/appstore/adapter.py b/appstore/appstore/adapter.py index 21bac816..8641532f 100644 --- a/appstore/appstore/adapter.py +++ b/appstore/appstore/adapter.py @@ -24,6 +24,14 @@ class LoginRedirectAdapter(DefaultAccountAdapter, DefaultSocialAccountAdapter): https://django-allauth.readthedocs.io/en/latest/advanced.html#custom-redirects """ + + def generate_unique_username(self, txts, regex=None): + # Split the email to use as generated username (jdoe@gmail.com -> jdoe) + # rather than the first name. allauth still sanitizes and suffixes on collision. + email = next((t for t in txts if t and "@" in t), None) + if email: + txts = [email] + [t for t in txts if t != email] + return super().generate_unique_username(txts, regex) def _login_url(self, request): if request.session.get("helx_frontend") == "django": @@ -62,13 +70,6 @@ def get_logout_redirect_url(self, request): return url class SocialAccountAdapter(DefaultSocialAccountAdapter): - - # debug commenting out for now. - # def populate_user(self, request, sociallogin, data): - # user = super().populate_user(request, sociallogin, data) - # print('sociallogin.account.extra_data:', sociallogin.account.extra_data) - # return user - def on_authentication_error(self, request, provider, error=None, exception=None, extra_context=None): provider_id = provider.id if provider else "unknown" error_code = error.name if error else "unknown" diff --git a/appstore/appstore/settings/base.py b/appstore/appstore/settings/base.py index 3c71efa2..cbc0ec43 100644 --- a/appstore/appstore/settings/base.py +++ b/appstore/appstore/settings/base.py @@ -83,7 +83,6 @@ "django.contrib.auth", "django.contrib.messages", "django.contrib.sites", - "django_saml2_auth", ] THIRD_PARTY_APPS = [ @@ -128,6 +127,64 @@ if PROVIDER != '': THIRD_PARTY_APPS.append(f"allauth.socialaccount.providers.{PROVIDER}") +SAML_URL = "/accounts/saml" +SAML_ACS_URL = "/saml2_auth/acs/" +SAML_PROVIDER_SLUG = os.environ.get("SAML_PROVIDER_SLUG", "saml") +SAML_PROVIDER_NAME = os.environ.get("SAML_PROVIDER_NAME", "Single Sign-On") +if ALLOW_SAML_LOGIN == "true": + THIRD_PARTY_APPS.append("allauth.socialaccount.providers.saml") + + SAML_SP_ENTITY_ID = os.environ["SAML2_AUTH_ENTITY_ID"] + _sp_host = "/".join(SAML_SP_ENTITY_ID.split("/", 3)[:3]) + SAML_SP_ACS_URL = os.environ.get("SAML_SP_ACS_URL") or (_sp_host + SAML_ACS_URL) + SAML_IDP_ENTITY_ID = os.environ.get("SAML_IDP_ENTITY_ID") or None + _saml_metadata_source = os.environ["SAML_METADATA_SOURCE"] + + if _saml_metadata_source.startswith(("http://", "https://")): + _saml_idp = { + "entity_id": SAML_IDP_ENTITY_ID, + "metadata_url": _saml_metadata_source, + } + else: + from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser + with open(_saml_metadata_source, "r") as _f: + _xml = _f.read() + _parsed = OneLogin_Saml2_IdPMetadataParser.parse( + _xml, + entity_id=SAML_IDP_ENTITY_ID, + )["idp"] + _saml_idp = { + "entity_id": _parsed["entityId"], + "sso_url": _parsed["singleSignOnService"]["url"], + "x509cert": _parsed["x509cert"], + } + _slo = _parsed.get("singleLogoutService") or {} + if _slo.get("url"): + _saml_idp["slo_url"] = _slo["url"] + + SOCIALACCOUNT_PROVIDERS["saml"] = { + "APPS": [{ + "provider_id": SAML_PROVIDER_SLUG, + "client_id": SAML_PROVIDER_SLUG, + "name": SAML_PROVIDER_NAME, + "settings": { + "idp": _saml_idp, + "attribute_mapping": { + "uid": ["urn:oid:0.9.2342.19200300.100.1.1"], + "username": ["urn:oid:0.9.2342.19200300.100.1.1"], + "email": ["urn:oid:0.9.2342.19200300.100.1.3", "urn:oid:1.3.6.1.4.1.5923.1.1.1.6"], + "first_name": ["urn:oid:2.5.4.42"], + "last_name": ["urn:oid:2.5.4.4"], + }, + "advanced": { + "want_assertion_signed": True, + "authn_request_signed": False, + "want_message_signed": False, + }, + }, + }], + } + # get the OIDC name if exists OIDC_NAME = os.environ.get("OIDC_NAME", "") @@ -199,8 +256,13 @@ LOGIN_URL = "/accounts/login" LOGIN_WHITELIST_URL = "/helx/workspaces/login?whitelist_required=true" OIDC_SESSION_MANAGEMENT_ENABLE = True -SAML_URL = "/accounts/saml" -SAML_ACS_URL = "/saml2_auth/acs/" +# The ingress terminates TLS and forwards to the pod over plain HTTP, setting +# X-Forwarded-Proto: https. Trust that header so request.build_absolute_uri() +# returns https:// URLs. Critical for allauth-SAML, which puts the ACS URL into +# the AuthnRequest — IdPs reject ACS URLs whose scheme doesn't match the +# registered SP. Only safe behind a proxy that strips/sets these headers. +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +USE_X_FORWARDED_HOST = True #SAML_ACS_URL = "/sso/acs/" SECURE_CROSS_ORIGIN_OPENER_POLICY = None @@ -461,47 +523,4 @@ # middleware that would disrupt in the process MIDDLEWARE[1:1] = DEBUG_MIDDLEWARE -SAML2_AUTH = { - # Optional settings below - "DEFAULT_NEXT_URL": "/helx/workspaces/login/success", # Custom target redirect URL after the user get logged in. Default to /admin if not set. This setting will be overwritten if you have parameter ?next= specificed in the login URL. - "CREATE_USER": "TRUE", # Create a new Django user when a new user logs in. Defaults to True. - "NEW_USER_PROFILE": { - "USER_GROUPS": [], # The default group name when a new user logs in - "ACTIVE_STATUS": True, # The default active status for new users - "STAFF_STATUS": True, # The staff status for new users - "SUPERUSER_STATUS": False, # The superuser status for new users - }, - "ATTRIBUTES_MAP": { # Change Email/UserName/FirstName/LastName to corresponding SAML2 userprofile attributes. - "email": "mail", - "username": "uid", - "first_name": "givenName", - "last_name": "sn", - }, - "TRIGGER": { - "CREATE_USER": "core.models.update_user", - }, - "ASSERTION_URL": os.environ.get("SAML2_AUTH_ASSERTION_URL"), - "ENTITY_ID": os.environ.get( - "SAML2_AUTH_ENTITY_ID" - ), # Populates the Issuer element in authn request - "USE_JWT": False, - 'WANT_ASSERTIONS_SIGNED': True, - 'AUTHN_REQUESTS_SIGNED': False, - 'WANT_RESPONSE_SIGNED': False, - 'TOKEN_REQUIRED': False, -} - -# Metadata is required, either remote url or local file path, check the environment -# determine the type based on the form of the value. Default to UNC if there's nothing - -metadata_source = os.environ.get("SAML_METADATA_SOURCE") -if metadata_source != None and type(metadata_source) is str and len(metadata_source) != 0: - metadata_source_components = metadata_source.split(':') - if len(metadata_source_components) > 1: - metadata_source_scheme = metadata_source_components[0] - if metadata_source_scheme == "http" or metadata_source_scheme == "https": - SAML2_AUTH["METADATA_AUTO_CONF_URL"] = metadata_source - else: SAML2_AUTH["METADATA_LOCAL_FILE_PATH"] = metadata_source - else: SAML2_AUTH["METADATA_LOCAL_FILE_PATH"] = metadata_source - DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/appstore/appstore/urls.py b/appstore/appstore/urls.py index ba91ab52..506a94ee 100644 --- a/appstore/appstore/urls.py +++ b/appstore/appstore/urls.py @@ -1,10 +1,10 @@ from django.conf import settings from django.contrib import admin from django.urls import include, path, re_path +from django.views.decorators.csrf import csrf_exempt from django.views.generic import RedirectView from django.views.static import serve -from django_saml2_auth import views as saml2_auth_views from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView from core.views import custom404 @@ -13,10 +13,22 @@ handler404 = custom404 + +def _saml_legacy_login(request): + from allauth.socialaccount.providers.saml import views as saml_views + return saml_views.login(request, organization_slug=settings.SAML_PROVIDER_SLUG) + + +@csrf_exempt +def _saml_legacy_acs(request): + from allauth.socialaccount.providers.saml import views as saml_views + return saml_views.acs(request, organization_slug=settings.SAML_PROVIDER_SLUG) + + urlpatterns = [ path("admin/", admin.site.urls), - path("saml2_auth/", include("django_saml2_auth.urls")), - path("accounts/saml/", saml2_auth_views.signin), + path("saml2_auth/acs/", _saml_legacy_acs), + path("accounts/saml/", _saml_legacy_login), path(r"accounts/login/", HelxLoginView.as_view(), name="helx_login"), path("accounts/", include("allauth.urls")), ] diff --git a/appstore/core/apps.py b/appstore/core/apps.py index 296eed9d..b36c1df9 100644 --- a/appstore/core/apps.py +++ b/appstore/core/apps.py @@ -1,8 +1,44 @@ from django.apps import AppConfig +from django.conf import settings class AppsCoreServicesConfig(AppConfig): name = 'core' def ready(self): - import core.signals \ No newline at end of file + import core.signals + self._pin_saml_sp_entity_id() + + @staticmethod + def _pin_saml_sp_entity_id(): + if getattr(settings, "ALLOW_SAML_LOGIN", "").lower() != "true": + return + sp_entity_id = getattr(settings, "SAML_SP_ENTITY_ID", None) + sp_acs_url = getattr(settings, "SAML_SP_ACS_URL", None) + if not sp_entity_id and not sp_acs_url: + return + from allauth.socialaccount.providers.saml import utils as saml_utils + original_build = saml_utils.build_sp_config + + def build_sp_config_pinned(request, provider_config, org): + config = original_build(request, provider_config, org) + if sp_entity_id: + config["entityId"] = sp_entity_id + if sp_acs_url: + config["assertionConsumerService"]["url"] = sp_acs_url + return config + + saml_utils.build_sp_config = build_sp_config_pinned + + # Ambassador overwrites X-Forwarded-Proto based on its own listener + # scheme (http), so Django's request.is_secure() always returns False. + # Force the scheme to match the SP entity ID. + if sp_entity_id and sp_entity_id.startswith("https://"): + original_prepare = saml_utils.prepare_django_request + + def prepare_django_request_pinned(request): + result = original_prepare(request) + result["https"] = "on" + return result + + saml_utils.prepare_django_request = prepare_django_request_pinned \ No newline at end of file diff --git a/appstore/core/models.py b/appstore/core/models.py index 77ebcbc8..ad2109e2 100644 --- a/appstore/core/models.py +++ b/appstore/core/models.py @@ -4,7 +4,6 @@ from django.contrib.auth import get_user_model from django.contrib.sessions.models import Session as SessionModel from django.core.exceptions import ValidationError -from django_saml2_auth.user import get_user from datetime import timedelta from string import ascii_letters, digits, punctuation @@ -20,17 +19,6 @@ def generate_token(): def user_token_expires(): return timezone.now() + timedelta(days=31) -def update_user(user): - # as of Django_saml2_auth v3.12.0 does not add email address by default - # to the created use entry in django db according to: - # https://github.com/grafana/django-saml2-auth/blob/11b97beaa2a431209e2c54103cb49c033c42ff54/django_saml2_auth/user.py#L93 - # https://github.com/grafana/django-saml2-auth/blob/11b97beaa2a431209e2c54103cb49c033c42ff54/django_saml2_auth/user.py#L165 - # This trigger gets and set the email field in the django user db - _user = get_user(user) - _user.email = user['email'] - _user.save() - return _user - class AuthorizedUser(models.Model): email = models.EmailField(max_length=254, blank=True) username = models.CharField(max_length=128, blank=True) diff --git a/appstore/middleware/filter_whitelist_middleware.py b/appstore/middleware/filter_whitelist_middleware.py index aa6d1fa2..d042104d 100644 --- a/appstore/middleware/filter_whitelist_middleware.py +++ b/appstore/middleware/filter_whitelist_middleware.py @@ -158,9 +158,9 @@ def is_authorized(user): if AllowWhiteListedUserOnly.is_auto_whitelisted_email(user): logger.debug("[AUTHZ] AUTO-pattern match → persisting email") - AuthorizedUser.objects.get_or_create( - email=user.email, defaults={"username": user.username} - ) + # AuthorizedUser.objects.get_or_create( + # email=user.email, defaults={"username": user.username} + # ) return True if AuthorizedUser.objects.filter(username=user.username).exists(): @@ -169,9 +169,9 @@ def is_authorized(user): if AllowWhiteListedUserOnly._ldap_group_member(user): logger.debug("[AUTHZ] LDAP group match → persisting email") - AuthorizedUser.objects.get_or_create( - email=user.email, defaults={"username": user.username} - ) + # AuthorizedUser.objects.get_or_create( + # email=user.email, defaults={"username": user.username} + # ) return True logger.debug("[AUTHZ] No rule matched; user is NOT authorised") diff --git a/appstore/tycho/template/pod.yaml b/appstore/tycho/template/pod.yaml index 125093fa..5c7c6c44 100644 --- a/appstore/tycho/template/pod.yaml +++ b/appstore/tycho/template/pod.yaml @@ -139,6 +139,11 @@ spec: fi fi + # Create /var/run/helx symlink. + if [ -d "/var/run/helx" ]; then + ln -sfn "/var/run/helx" "$HOME_PATH/helx" + fi + {% if system.enable_trash_cli == "true" %} HOME_TRASH="${XDG_DATA_HOME:-$HOME_PATH/.local/share}/Trash" TRASH_AGGREGATE="$HOME_PATH/Trash Bins" diff --git a/requirements.txt b/requirements.txt index ed4c76a2..54b1064d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,16 +1,15 @@ -Django==4.2.23 +Django==4.2.30 django-allauth==0.61.1 django-cors-headers==4.3.1 django-crispy-forms==2.1 django-debug-toolbar==4.3.0 django-extensions==3.2.3 -grafana-django-saml2-auth==3.12.0 djangorestframework==3.15.2 drf-spectacular==0.27.1 flake8==3.9.0 gunicorn==23.0.0 mock==4.0.2 -pysaml2==7.4.2 +python3-saml==1.16.0 python3-openid==3.2.0 requests-oauthlib==1.4.0 selenium==3.141.0 @@ -19,7 +18,7 @@ asgiref==3.7.2 psycopg2-binary python-irodsclient==1.1.5 deepmerge==1.0.1 -GitPython==3.1.44 +GitPython==3.1.50 Jinja2==3.1.6 jsonschema==3.2.0 kubernetes==25.3.0 @@ -28,4 +27,6 @@ requests==2.32.4 docker-compose==1.29.2 requests_cache==1.2.1 ldap3 -urllib3==2.5.0 +urllib3==2.7.0 +PyJWT==2.13.0 +cryptography==48.0.1