Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,8 @@ appstore/static/
.vscode/
build/
coverage/
docs/
docs/
venv/
.venv/
venv.bak/
*.egg-info/
6 changes: 4 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM python:3.9.23-alpine
FROM python:3.10.20-alpine

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
Expand All @@ -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
Expand All @@ -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
Expand Down
7 changes: 1 addition & 6 deletions appstore/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
)
Expand Down
15 changes: 8 additions & 7 deletions appstore/appstore/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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"
Expand Down
111 changes: 65 additions & 46 deletions appstore/appstore/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@
"django.contrib.auth",
"django.contrib.messages",
"django.contrib.sites",
"django_saml2_auth",
]

THIRD_PARTY_APPS = [
Expand Down Expand Up @@ -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", "")

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
18 changes: 15 additions & 3 deletions appstore/appstore/urls.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")),
]
Expand Down
38 changes: 37 additions & 1 deletion appstore/core/apps.py
Original file line number Diff line number Diff line change
@@ -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
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
12 changes: 0 additions & 12 deletions appstore/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down
12 changes: 6 additions & 6 deletions appstore/middleware/filter_whitelist_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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")
Expand Down
5 changes: 5 additions & 0 deletions appstore/tycho/template/pod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading