mirror of
https://github.com/mediacms-io/mediacms.git
synced 2026-06-07 17:34:21 -04:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e83b9f43a | |||
| 9da6a85ad8 | |||
| 51b1097509 | |||
| 95644dc961 | |||
| a3fe375a83 | |||
| 777b06bbeb | |||
| e89c4a3c85 | |||
| 7a02d25d0b | |||
| c7a673bbbf | |||
| b0c0d9a83f | |||
| ae63a5af64 | |||
| 98d5d6af8b | |||
| 9302559d4b | |||
| 279cccb980 |
@@ -1,5 +1,42 @@
|
||||
# Changelog
|
||||
|
||||
## [8.2.1](https://github.com/mediacms-io/mediacms/compare/v8.2.0...v8.2.1) (2026-06-07)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* SAML provider add guard to skip empty mappings before iterating ([#1536](https://github.com/mediacms-io/mediacms/issues/1536)) ([9da6a85](https://github.com/mediacms-io/mediacms/commit/9da6a85ad86f5092edb96495eeb1cca22d5334bf))
|
||||
|
||||
## [8.2.0](https://github.com/mediacms-io/mediacms/compare/v8.1.3...v8.2.0) (2026-05-31)
|
||||
|
||||
### Features
|
||||
|
||||
* configure SP certificate and private key via SAMLConfiguration ([#1531](https://github.com/mediacms-io/mediacms/issues/1531)) ([95644dc](https://github.com/mediacms-io/mediacms/commit/95644dc9615f428191d9fda0847c1b91a0b094a5))
|
||||
|
||||
## [8.1.3](https://github.com/mediacms-io/mediacms/compare/v8.1.2...v8.1.3) (2026-05-19)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* django connection settings ([#1529](https://github.com/mediacms-io/mediacms/issues/1529)) ([e89c4a3](https://github.com/mediacms-io/mediacms/commit/e89c4a3c8523574b5852a434ed67e281b6290584))
|
||||
* prestart.sh loaddata re-runs on every container restart ([#1502](https://github.com/mediacms-io/mediacms/issues/1502)) ([777b06b](https://github.com/mediacms-io/mediacms/commit/777b06bbebf141e5b1cb27e17533fe65d57eb6cd))
|
||||
|
||||
## [8.1.2](https://github.com/mediacms-io/mediacms/compare/v8.1.1...v8.1.2) (2026-05-18)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove redundant check ([#1528](https://github.com/mediacms-io/mediacms/issues/1528)) ([c7a673b](https://github.com/mediacms-io/mediacms/commit/c7a673bbbf46efc37621dc4a5109a85fc10e1317))
|
||||
|
||||
## [8.1.1](https://github.com/mediacms-io/mediacms/compare/v8.1.0...v8.1.1) (2026-05-18)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* x-accell headers on uploaded poster ([#1526](https://github.com/mediacms-io/mediacms/issues/1526)) ([ae63a5a](https://github.com/mediacms-io/mediacms/commit/ae63a5af647c8865b96e6e50dda1ea9d29b5bd0b))
|
||||
|
||||
## [8.1.0](https://github.com/mediacms-io/mediacms/compare/v8.0.8...v8.1.0) (2026-05-17)
|
||||
|
||||
### Features
|
||||
|
||||
* introduce x-accell headers ([9302559](https://github.com/mediacms-io/mediacms/commit/9302559d4bb3e4d0adb299ed37438b04c39e1864))
|
||||
|
||||
## [8.0.8](https://github.com/mediacms-io/mediacms/compare/v8.0.7...v8.0.8) (2026-05-13)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -3,7 +3,9 @@ from __future__ import absolute_import
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
from celery.signals import worker_process_init
|
||||
from django.conf import settings
|
||||
from django.db import connections
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cms.settings")
|
||||
app = Celery("cms")
|
||||
@@ -20,3 +22,13 @@ app.conf.task_always_eager = settings.CELERY_TASK_ALWAYS_EAGER
|
||||
|
||||
|
||||
app.conf.worker_prefetch_multiplier = 1
|
||||
|
||||
|
||||
@worker_process_init.connect
|
||||
def close_db_pool_on_fork(**_):
|
||||
# psycopg3's ConnectionPool is not fork-safe: children inherit dead sockets
|
||||
# from the parent's pool and block on getconn() until PoolTimeout. Dispose
|
||||
# the inherited pool here so each prefork child opens its own on first use.
|
||||
# NB: plain conn.close() would only putconn() back into the broken pool.
|
||||
for conn in connections.all():
|
||||
conn.close_pool()
|
||||
|
||||
+28
-1
@@ -202,6 +202,15 @@ THUMBNAIL_UPLOAD_DIR = f"{MEDIA_UPLOAD_DIR}/thumbnails/"
|
||||
SUBTITLES_UPLOAD_DIR = f"{MEDIA_UPLOAD_DIR}/subtitles/"
|
||||
HLS_DIR = os.path.join(MEDIA_ROOT, "hls/")
|
||||
|
||||
# Protect media files via nginx auth_request
|
||||
# When True, nginx delegates authorization for /media/<protected>/... to a
|
||||
# Django endpoint that checks the Media's state and the user's access.
|
||||
USE_X_ACCEL_REDIRECT = True
|
||||
# Subdirectories of MEDIA_ROOT that should be gated. "chunks" is intentionally
|
||||
# omitted (upload state, not playback).
|
||||
X_ACCEL_PROTECTED_PATHS = ["encoded", "hls", "original"]
|
||||
X_ACCEL_AUTH_CACHE_SECONDS = 300
|
||||
|
||||
FFMPEG_COMMAND = "ffmpeg" # this is the path
|
||||
FFPROBE_COMMAND = "ffprobe" # this is the path
|
||||
MP4HLS = "mp4hls"
|
||||
@@ -398,7 +407,25 @@ LOGGING = {
|
||||
},
|
||||
}
|
||||
|
||||
DATABASES = {"default": {"ENGINE": "django.db.backends.postgresql", "NAME": "mediacms", "HOST": "127.0.0.1", "PORT": "5432", "USER": "mediacms", "PASSWORD": "mediacms", "OPTIONS": {'pool': True}}}
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": "mediacms",
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": "5432",
|
||||
"USER": "mediacms",
|
||||
"PASSWORD": "mediacms",
|
||||
"OPTIONS": {
|
||||
"pool": {
|
||||
"min_size": 2,
|
||||
"max_size": 8,
|
||||
"timeout": 10,
|
||||
"max_lifetime": 30 * 60,
|
||||
"max_idle": 10 * 60,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
REDIS_LOCATION = "redis://127.0.0.1:6379/1"
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
VERSION = "8.0.7"
|
||||
VERSION = "8.2.1"
|
||||
|
||||
@@ -12,7 +12,15 @@ DATABASES = {
|
||||
"PORT": os.getenv('POSTGRES_PORT', '5432'),
|
||||
"USER": os.getenv('POSTGRES_USER', 'mediacms'),
|
||||
"PASSWORD": os.getenv('POSTGRES_PASSWORD', 'mediacms'),
|
||||
"OPTIONS": {'pool': True},
|
||||
"OPTIONS": {
|
||||
"pool": {
|
||||
"min_size": 2,
|
||||
"max_size": 8,
|
||||
"timeout": 10,
|
||||
"max_lifetime": 30 * 60,
|
||||
"max_idle": 10 * 60,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,27 @@ server {
|
||||
location /static {
|
||||
alias /home/mediacms.io/mediacms/static ;
|
||||
}
|
||||
location = /_media_auth {
|
||||
internal;
|
||||
proxy_pass http://127.0.0.1:9000/api/v1/media-auth;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Original-URI $request_uri;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
}
|
||||
|
||||
location /media/original {
|
||||
alias /home/mediacms.io/mediacms/media_files/original;
|
||||
location ~ ^/media/(encoded|hls|original)/(.*)$ {
|
||||
auth_request /_media_auth;
|
||||
alias /home/mediacms.io/mediacms/media_files/$1/$2;
|
||||
|
||||
add_header 'Access-Control-Allow-Origin' '*';
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
|
||||
}
|
||||
|
||||
location /media {
|
||||
|
||||
@@ -6,7 +6,7 @@ ADMIN_PASSWORD=${ADMIN_PASSWORD:-$RANDOM_ADMIN_PASS}
|
||||
if [ X"$ENABLE_MIGRATIONS" = X"yes" ]; then
|
||||
echo "Running migrations service"
|
||||
python manage.py migrate
|
||||
EXISTING_INSTALLATION=`echo "from users.models import User; print(User.objects.exists())" |python manage.py shell`
|
||||
EXISTING_INSTALLATION=`echo "from users.models import User; print(User.objects.exists())" |python manage.py shell 2>/dev/null | tail -1`
|
||||
if [ "$EXISTING_INSTALLATION" = "True" ]; then
|
||||
echo "Loaddata has already run"
|
||||
else
|
||||
|
||||
@@ -947,6 +947,8 @@ Select the SAML Configurations tab, create a new one and set:
|
||||
3. **SSO URL**:
|
||||
4. **SLO URL**:
|
||||
5. **SP Metadata URL**: The metadata URL that the IDP will utilize. This can be https://{portal}/saml/metadata and is autogenerated by MediaCMS
|
||||
6. **SP Certificate** (optional): SP x509 certificate (PEM). Enables encrypted/signed SAML communication. If set, the SP Private Key must also be provided, and the certificate is published in the SP metadata so the IDP can encrypt assertions to MediaCMS.
|
||||
7. **SP Private Key** (optional): SP private key (PEM). Used to sign AuthnRequests/LogoutRequests and to decrypt assertions encrypted by the IDP. Required if SP Certificate is provided.
|
||||
|
||||
- Step 3: Set other Options
|
||||
1. **Email Settings**:
|
||||
|
||||
@@ -10,7 +10,7 @@ from django.conf import settings
|
||||
from django.contrib.postgres.indexes import GinIndex
|
||||
from django.contrib.postgres.search import SearchVectorField
|
||||
from django.core.files import File
|
||||
from django.db import models
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Func, Value
|
||||
from django.db.models.signals import m2m_changed, post_delete, post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
@@ -536,7 +536,9 @@ class Media(models.Model):
|
||||
|
||||
from .. import tasks
|
||||
|
||||
tasks.produce_sprite_from_video.delay(self.friendly_token)
|
||||
# Defer until the surrounding transaction commits so the worker can
|
||||
# actually find the Media row. Runs immediately if not in a tx.
|
||||
transaction.on_commit(lambda token=self.friendly_token: tasks.produce_sprite_from_video.delay(token))
|
||||
return True
|
||||
|
||||
def encode(self, profiles=[], force=True, chunkize=True):
|
||||
|
||||
@@ -106,6 +106,7 @@ urlpatterns = [
|
||||
re_path(r"^api/v1/tasks$", views.TasksList.as_view()),
|
||||
re_path(r"^api/v1/tasks/$", views.TasksList.as_view()),
|
||||
re_path(r"^api/v1/tasks/(?P<friendly_token>[\w|\W]*)$", views.TaskDetail.as_view()),
|
||||
re_path(r"^api/v1/media-auth$", views.media_auth, name="media_auth"),
|
||||
re_path(r"^manage/comments$", views.manage_comments, name="manage_comments"),
|
||||
re_path(r"^manage/media$", views.manage_media, name="manage_media"),
|
||||
re_path(r"^manage/users$", views.manage_users, name="manage_users"),
|
||||
|
||||
@@ -10,6 +10,7 @@ from .media import MediaDetail # noqa: F401
|
||||
from .media import MediaList # noqa: F401
|
||||
from .media import MediaSearch # noqa: F401
|
||||
from .media import media_share # noqa: F401
|
||||
from .media_auth import media_auth # noqa: F401
|
||||
from .pages import about # noqa: F401
|
||||
from .pages import add_subtitle # noqa: F401
|
||||
from .pages import approval_required # noqa: F401
|
||||
|
||||
@@ -660,7 +660,9 @@ class MediaBulkUserActions(APIView):
|
||||
|
||||
# Prioritize category_uids
|
||||
if category_uids:
|
||||
categories = Category.objects.filter(uid__in=category_uids)
|
||||
requested = Category.objects.filter(uid__in=category_uids)
|
||||
allowed_ids = [cat.id for cat in requested if not cat.is_rbac_category or request.user.has_contributor_access_to_category(cat)]
|
||||
categories = Category.objects.filter(id__in=allowed_ids)
|
||||
elif lti_context_id:
|
||||
# Filter categories by lti_context_id and ensure they ARE RBAC categories
|
||||
potential_categories = Category.objects.filter(lti_context_id=lti_context_id, is_rbac_category=True)
|
||||
@@ -691,9 +693,11 @@ class MediaBulkUserActions(APIView):
|
||||
if not category_uids:
|
||||
return Response({"detail": "category_uids is required for remove_from_category action"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
categories = Category.objects.filter(uid__in=category_uids)
|
||||
requested = Category.objects.filter(uid__in=category_uids)
|
||||
allowed_ids = [cat.id for cat in requested if not cat.is_rbac_category or request.user.has_contributor_access_to_category(cat)]
|
||||
categories = Category.objects.filter(id__in=allowed_ids)
|
||||
if not categories:
|
||||
return Response({"detail": "No matching categories found"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response({"detail": "No matching categories found or access denied"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
removed_count = 0
|
||||
for category in categories:
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import re
|
||||
from urllib.parse import unquote
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_GET
|
||||
|
||||
from ..methods import is_mediacms_editor
|
||||
from ..models import Media
|
||||
|
||||
UID_RE = re.compile(r"[0-9a-f]{32}")
|
||||
THUMBNAILS_PREFIX = "original/thumbnails/"
|
||||
|
||||
|
||||
def _ttl():
|
||||
return getattr(settings, "X_ACCEL_AUTH_CACHE_SECONDS", 300)
|
||||
|
||||
|
||||
def _extract_uid(uri):
|
||||
if not uri:
|
||||
return None
|
||||
match = UID_RE.search(uri)
|
||||
return match.group(0) if match else None
|
||||
|
||||
|
||||
def _relpath_from_uri(uri):
|
||||
path = unquote(uri.split("?", 1)[0])
|
||||
media_url = settings.MEDIA_URL
|
||||
if path.startswith(media_url):
|
||||
return path[len(media_url) :]
|
||||
return None
|
||||
|
||||
|
||||
def _lookup_uid_by_path(relpath):
|
||||
path_key = f"xaccel:path:{relpath}"
|
||||
cached = cache.get(path_key)
|
||||
if cached is not None:
|
||||
return cached or None
|
||||
|
||||
parts = relpath.split("/", 4)
|
||||
if len(parts) < 5 or parts[2] != "user":
|
||||
cache.set(path_key, "", _ttl())
|
||||
return None
|
||||
username = parts[3]
|
||||
|
||||
row = Media.objects.filter(user__username=username).filter(Q(uploaded_thumbnail=relpath) | Q(uploaded_poster=relpath)).values("uid").first()
|
||||
uid_hex = row["uid"].hex if row else ""
|
||||
cache.set(path_key, uid_hex, _ttl())
|
||||
return uid_hex or None
|
||||
|
||||
|
||||
def _lookup_state(uid):
|
||||
"""Return (state, owner_id) for a uid, or (None, None) if missing.
|
||||
|
||||
Cached on uid alone since state/ownership do not depend on the requester.
|
||||
Uses .values() rather than .only() because Media.__init__ touches deferred
|
||||
file fields, which would otherwise recurse via refresh_from_db.
|
||||
"""
|
||||
state_key = f"xaccel:state:{uid}"
|
||||
cached = cache.get(state_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
row = Media.objects.filter(uid=uid).values("state", "user_id").first()
|
||||
value = (row["state"], row["user_id"]) if row else (None, None)
|
||||
cache.set(state_key, value, _ttl())
|
||||
return value
|
||||
|
||||
|
||||
def _decide(uid, user):
|
||||
state, owner_id = _lookup_state(uid)
|
||||
if state is None:
|
||||
return False
|
||||
if state in ("public", "unlisted"):
|
||||
return True
|
||||
# private
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
if owner_id == user.id:
|
||||
return True
|
||||
if is_mediacms_editor(user):
|
||||
return True
|
||||
# RBAC / MediaPermission path needs a full Media instance.
|
||||
try:
|
||||
media = Media.objects.get(uid=uid)
|
||||
except Media.DoesNotExist:
|
||||
return False
|
||||
return user.has_member_access_to_media(media)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_GET
|
||||
def media_auth(request):
|
||||
"""Authorize a protected media request from nginx auth_request.
|
||||
|
||||
nginx passes the original request URI in the X-Original-URI header. The
|
||||
Media.uid (32 hex chars, no dashes) is embedded somewhere in that URI for
|
||||
every protected path. No uid => deny. Unknown uid => deny.
|
||||
"""
|
||||
if not getattr(settings, "USE_X_ACCEL_REDIRECT", True):
|
||||
return HttpResponse(status=204)
|
||||
|
||||
uri = request.META.get("HTTP_X_ORIGINAL_URI", "")
|
||||
uid = _extract_uid(uri)
|
||||
if not uid:
|
||||
# User-uploaded thumbnails/posters don't have the uid in the filename.
|
||||
# Fall back to a per-path lookup, scoped to /original/thumbnails/.
|
||||
relpath = _relpath_from_uri(uri)
|
||||
if relpath and relpath.startswith(THUMBNAILS_PREFIX):
|
||||
uid = _lookup_uid_by_path(relpath)
|
||||
if not uid:
|
||||
return HttpResponse(status=403)
|
||||
|
||||
user = request.user
|
||||
cache_key = f"xaccel:auth:{uid}:{user.id if user.is_authenticated else 'anon'}"
|
||||
cached = cache.get(cache_key)
|
||||
if cached is None:
|
||||
allowed = _decide(uid, user)
|
||||
cache.set(cache_key, allowed, _ttl())
|
||||
else:
|
||||
allowed = cached
|
||||
|
||||
return HttpResponse(status=204 if allowed else 403)
|
||||
@@ -1,16 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
from .keys import ensure_keys_exist
|
||||
|
||||
|
||||
class LtiConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'lti'
|
||||
verbose_name = 'LTI 1.3 Integration'
|
||||
|
||||
def ready(self):
|
||||
"""Initialize LTI app - ensure keys exist"""
|
||||
try:
|
||||
ensure_keys_exist()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -21,10 +21,3 @@ def get_jwks():
|
||||
"""
|
||||
public_key = load_public_key()
|
||||
return {'keys': [public_key]}
|
||||
|
||||
|
||||
def ensure_keys_exist():
|
||||
"""Ensure key pair exists in database, generate if not"""
|
||||
from .models import LTIToolKeys
|
||||
|
||||
LTIToolKeys.get_or_create_keys()
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mediacms",
|
||||
"version": "8.0.8",
|
||||
"version": "8.2.1",
|
||||
"devDependencies": {
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
|
||||
+12
-5
@@ -1,5 +1,6 @@
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
|
||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
@@ -22,7 +23,10 @@ class SAMLAccountAdapter(DefaultSocialAccountAdapter):
|
||||
|
||||
def populate_user(self, request, sociallogin, data):
|
||||
user = sociallogin.user
|
||||
user.username = sociallogin.account.uid
|
||||
raw_uid = sociallogin.account.uid or ""
|
||||
# Match the user URL pattern in users/urls.py: only [\w@._-] is reverse-able.
|
||||
sanitized = re.sub(r"[^\w.@-]", "_", raw_uid, flags=re.ASCII)
|
||||
user.username = sanitized[:150] if sanitized else raw_uid
|
||||
for item in ["name", "first_name", "last_name"]:
|
||||
if data.get(item):
|
||||
setattr(user, item, data[item])
|
||||
@@ -69,7 +73,7 @@ def perform_user_actions(user, social_account, common_fields=None):
|
||||
if social_app:
|
||||
saml_configuration = social_app.saml_configurations.first()
|
||||
|
||||
add_user_logo(user, extra_data)
|
||||
add_user_logo(user, extra_data, saml_configuration)
|
||||
handle_role_mapping(user, extra_data, social_app, saml_configuration)
|
||||
if saml_configuration and saml_configuration.save_saml_response_logs:
|
||||
handle_saml_logs_save(user, extra_data, social_app)
|
||||
@@ -77,10 +81,13 @@ def perform_user_actions(user, social_account, common_fields=None):
|
||||
return user
|
||||
|
||||
|
||||
def add_user_logo(user, extra_data):
|
||||
def add_user_logo(user, extra_data, saml_configuration=None):
|
||||
# use the attribute name configured in the SAML Configuration, falling
|
||||
# back to "jpegPhoto" when it is left empty
|
||||
logo_key = (saml_configuration.user_logo if saml_configuration and saml_configuration.user_logo else None) or "jpegPhoto"
|
||||
try:
|
||||
if extra_data.get("jpegPhoto") and user.logo.name in ["userlogos/user.jpg", "", None]:
|
||||
base64_string = extra_data.get("jpegPhoto")[0]
|
||||
if extra_data.get(logo_key) and user.logo.name in ["userlogos/user.jpg", "", None]:
|
||||
base64_string = extra_data.get(logo_key)[0]
|
||||
image_data = base64.b64decode(base64_string)
|
||||
image_content = ContentFile(image_data)
|
||||
user.logo.save('user.jpg', image_content, save=True)
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ class SAMLConfigurationAdmin(admin.ModelAdmin):
|
||||
search_fields = ['social_app__name', 'idp_id', 'sp_metadata_url']
|
||||
|
||||
fieldsets = [
|
||||
('Provider Settings', {'fields': ['social_app', 'idp_id', 'idp_cert']}),
|
||||
('Provider Settings', {'fields': ['social_app', 'idp_id', 'idp_cert', 'sp_cert', 'sp_private_key']}),
|
||||
('URLs', {'fields': ['sso_url', 'slo_url', 'sp_metadata_url']}),
|
||||
('Group Management', {'fields': ['remove_from_groups', 'save_saml_response_logs']}),
|
||||
('Attribute Mapping', {'fields': ['uid', 'name', 'email', 'groups', 'first_name', 'last_name', 'user_logo', 'role']}),
|
||||
|
||||
@@ -18,14 +18,28 @@ class CustomSAMLProvider(SAMLProvider):
|
||||
provider_config = self.app.settings
|
||||
|
||||
raw_attributes = data.get_attributes()
|
||||
# get_attributes() keys attributes by their full Name. Some IdPs send
|
||||
# certain attributes only under their FriendlyName, so fall back to the
|
||||
# FriendlyName-keyed attributes when a Name lookup misses. The Name
|
||||
# lookup is always preferred, so attributes that already resolve are
|
||||
# unaffected.
|
||||
try:
|
||||
friendly_attributes = data.get_friendlyname_attributes()
|
||||
except AttributeError:
|
||||
friendly_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():
|
||||
# skip mappings left empty/None in the SAML Configuration
|
||||
if not provider_keys:
|
||||
continue
|
||||
if isinstance(provider_keys, str):
|
||||
provider_keys = [provider_keys]
|
||||
for provider_key in provider_keys:
|
||||
attribute_list = raw_attributes.get(provider_key, None)
|
||||
attribute_list = raw_attributes.get(provider_key)
|
||||
if attribute_list is None:
|
||||
attribute_list = friendly_attributes.get(provider_key)
|
||||
# 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)
|
||||
|
||||
@@ -53,16 +53,12 @@ def build_sp_config(request, provider_config, org):
|
||||
"binding": OneLogin_Saml2_Constants.BINDING_HTTP_REDIRECT,
|
||||
},
|
||||
}
|
||||
if _sp_config.get("x509cert"):
|
||||
sp_config["x509cert"] = _sp_config["x509cert"]
|
||||
if _sp_config.get("private_key"):
|
||||
sp_config["privateKey"] = _sp_config["private_key"]
|
||||
|
||||
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"]
|
||||
|
||||
|
||||
@@ -154,7 +154,9 @@ sls = SLSView.as_view()
|
||||
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)
|
||||
custom_configuration = provider.app.saml_configurations.first()
|
||||
provider_config = custom_configuration.saml_provider_settings if custom_configuration else provider.app.settings
|
||||
config = build_saml_config(self.request, provider_config, 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)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.6 on 2026-05-31 12:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('saml_auth', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='samlconfiguration',
|
||||
name='sp_cert',
|
||||
field=models.TextField(blank=True, help_text='SP x509cert (PEM). Optional; required if SP private key is set.', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='samlconfiguration',
|
||||
name='sp_private_key',
|
||||
field=models.TextField(blank=True, help_text='SP private key (PEM). Optional; required if SP certificate is set.', null=True),
|
||||
),
|
||||
]
|
||||
@@ -14,6 +14,8 @@ class SAMLConfiguration(models.Model):
|
||||
|
||||
# Certificates
|
||||
idp_cert = models.TextField(help_text='x509cert')
|
||||
sp_cert = models.TextField(blank=True, null=True, help_text='SP x509cert (PEM). Optional; required if SP private key is set.')
|
||||
sp_private_key = models.TextField(blank=True, null=True, help_text='SP private key (PEM). Optional; required if SP certificate is set.')
|
||||
|
||||
# Attribute Mapping Fields
|
||||
uid = models.CharField(max_length=100, help_text='eg eduPersonPrincipalName')
|
||||
@@ -49,6 +51,11 @@ class SAMLConfiguration(models.Model):
|
||||
if existing_conf.exists():
|
||||
raise ValidationError({'social_app': 'Cannot create configuration for the same social app because one configuration already exists.'})
|
||||
|
||||
if self.sp_cert and not self.sp_private_key:
|
||||
raise ValidationError({'sp_private_key': 'Required when SP certificate is provided.'})
|
||||
if self.sp_private_key and not self.sp_cert:
|
||||
raise ValidationError({'sp_cert': 'Required when SP private key is provided.'})
|
||||
|
||||
super().clean()
|
||||
|
||||
@property
|
||||
@@ -56,6 +63,10 @@ class SAMLConfiguration(models.Model):
|
||||
# provide settings in a way for Social App SAML provider
|
||||
provider_settings = {}
|
||||
provider_settings["sp"] = {"entity_id": self.sp_metadata_url}
|
||||
if self.sp_cert:
|
||||
provider_settings["sp"]["x509cert"] = self.sp_cert
|
||||
if self.sp_private_key:
|
||||
provider_settings["sp"]["private_key"] = self.sp_private_key
|
||||
provider_settings["idp"] = {"slo_url": self.slo_url, "sso_url": self.sso_url, "x509cert": self.idp_cert, "entity_id": self.idp_id}
|
||||
|
||||
provider_settings["attribute_mapping"] = {
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
exclude = .git,*migrations*
|
||||
max-line-length = 119
|
||||
#ignore=F401,F403,E501,W503
|
||||
ignore=E501
|
||||
ignore=E501,E203
|
||||
|
||||
+2
-2
@@ -7,8 +7,8 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@deconstructible
|
||||
class ASCIIUsernameValidator(validators.RegexValidator):
|
||||
regex = r"^[\w.@]+$"
|
||||
message = _("Enter a valid username. This value may contain only " "English letters and numbers")
|
||||
regex = r"^[\w.@-]+$"
|
||||
message = _("Enter a valid username. This value may contain only English letters, numbers, and '_', '.', '@', '-'.")
|
||||
flags = re.ASCII
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from allauth.account.adapter import get_adapter
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
@@ -277,6 +278,11 @@ class UserList(APIView):
|
||||
if not all([username, password, email, name]):
|
||||
return Response({"detail": "username, password, email, and name are required."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
username = get_adapter().clean_username(username, shallow=True)
|
||||
except DjangoValidationError as e:
|
||||
return Response({"detail": e.messages[0]}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if User.objects.filter(username=username).exists():
|
||||
return Response({"detail": "A user with that username already exists."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user