From c9f8011192c11ded033027bb842f5cc76c01b49d Mon Sep 17 00:00:00 2001 From: Nathan Barrett Date: Thu, 9 Jul 2026 17:38:37 -0500 Subject: [PATCH] Add OAuth flow to support SSO login through for the admin page --- fxsharing/settings.py | 25 ++++++++++++++++ fxsharing/urls.py | 10 +++++++ fxsharing/users/management/__init__.py | 0 .../users/management/commands/__init__.py | 0 .../users/management/commands/create_admin.py | 30 +++++++++++++++++++ .../0004_user_email_alter_user_fxa_id.py | 23 ++++++++++++++ fxsharing/users/models.py | 17 +++++++++-- pyproject.toml | 1 + uv.lock | 17 +++++++++++ 9 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 fxsharing/users/management/__init__.py create mode 100644 fxsharing/users/management/commands/__init__.py create mode 100644 fxsharing/users/management/commands/create_admin.py create mode 100644 fxsharing/users/migrations/0004_user_email_alter_user_fxa_id.py diff --git a/fxsharing/settings.py b/fxsharing/settings.py index 964e7a2..93f6743 100644 --- a/fxsharing/settings.py +++ b/fxsharing/settings.py @@ -57,6 +57,7 @@ "allauth.account", "allauth.socialaccount", "allauth.socialaccount.providers.fxa", + "mozilla_django_oidc", ] AUTH_USER_MODEL = "users.User" @@ -132,10 +133,34 @@ ] AUTHENTICATION_BACKENDS = [ + "mozilla_django_oidc.auth.OIDCAuthenticationBackend", "django.contrib.auth.backends.ModelBackend", "allauth.account.auth_backends.AuthenticationBackend", ] +# OIDC configuration (mozilla-django-oidc) for the internal admin. +OIDC_RP_CLIENT_ID = env("OIDC_RP_CLIENT_ID", default="") +OIDC_RP_CLIENT_SECRET = env("OIDC_RP_CLIENT_SECRET", default="") +OIDC_RP_SIGN_ALGO = env("OIDC_RP_SIGN_ALGO", default="RS256") +OIDC_OP_AUTHORIZATION_ENDPOINT = env( + "OIDC_OP_AUTHORIZATION_ENDPOINT", + default="https://auth.mozilla.auth0.com/authorize", +) +OIDC_OP_TOKEN_ENDPOINT = env( + "OIDC_OP_TOKEN_ENDPOINT", + default="https://auth.mozilla.auth0.com/oauth/token", +) +OIDC_OP_USER_ENDPOINT = env( + "OIDC_OP_USER_ENDPOINT", + default="https://auth.mozilla.auth0.com/userinfo", +) +OIDC_OP_JWKS_ENDPOINT = env( + "OIDC_OP_JWKS_ENDPOINT", + default="https://auth.mozilla.auth0.com/.well-known/jwks.json", +) +# Admins are pre-provisioned; never auto-create on first login. +OIDC_CREATE_USER = False + WSGI_APPLICATION = "fxsharing.wsgi.application" diff --git a/fxsharing/urls.py b/fxsharing/urls.py index bc3457a..d079646 100644 --- a/fxsharing/urls.py +++ b/fxsharing/urls.py @@ -17,6 +17,7 @@ from django.contrib import admin from django.urls import include, path, re_path +from django.views.generic.base import RedirectView from fxsharing.shares import views as shares_views @@ -32,6 +33,15 @@ ), # Curated subset of django-allauth routes — see fxsharing/users/urls.py. path("accounts/", include("fxsharing.users.urls")), + # Send the admin login through Auth0/LDAP OIDC instead of Django's password form + path( + "admin/login/", + RedirectView.as_view( + url="/oidc/authenticate/?next=/admin/", query_string=False + ), + name="admin_oidc_login", + ), path("admin/", admin.site.urls), + path("oidc/", include("mozilla_django_oidc.urls")), re_path(r"^.*$", shares_views.page_not_found, {"exception": None}), ] diff --git a/fxsharing/users/management/__init__.py b/fxsharing/users/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fxsharing/users/management/commands/__init__.py b/fxsharing/users/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fxsharing/users/management/commands/create_admin.py b/fxsharing/users/management/commands/create_admin.py new file mode 100644 index 0000000..91d56d4 --- /dev/null +++ b/fxsharing/users/management/commands/create_admin.py @@ -0,0 +1,30 @@ +from django.core.management.base import BaseCommand, CommandError + +from fxsharing.users.models import User + + +class Command(BaseCommand): + help = ( + "Pre-provision an internal admin account authenticated via Auth0/LDAP " + "The email must match the Mozilla LDAP account; login is further " + "gated by the fx-sharing-admin group in Auth0." + ) + + def add_arguments(self, parser): + parser.add_argument("--email", required=True) + parser.add_argument( + "--staff-only", + action="store_true", + help="Grant is_staff but not is_superuser.", + ) + + def handle(self, *args, **options): + email = options["email"].strip().lower() + if User.all_objects.filter(email__iexact=email).exists(): + raise CommandError(f"A user with email {email} already exists.") + + User.objects.create_admin( + email=email, + is_superuser=not options["staff_only"], + ) + self.stdout.write(self.style.SUCCESS(f"Created admin {email}")) diff --git a/fxsharing/users/migrations/0004_user_email_alter_user_fxa_id.py b/fxsharing/users/migrations/0004_user_email_alter_user_fxa_id.py new file mode 100644 index 0000000..b0cb8a6 --- /dev/null +++ b/fxsharing/users/migrations/0004_user_email_alter_user_fxa_id.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.6 on 2026-07-09 17:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0003_user_badness_counter'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='email', + field=models.EmailField(blank=True, max_length=254, null=True, unique=True), + ), + migrations.AlterField( + model_name='user', + name='fxa_id', + field=models.CharField(blank=True, max_length=32, null=True, unique=True), + ), + ] diff --git a/fxsharing/users/models.py b/fxsharing/users/models.py index b91e46b..d7c248c 100644 --- a/fxsharing/users/models.py +++ b/fxsharing/users/models.py @@ -25,13 +25,26 @@ def create_superuser(self, fxa_id, **extra_fields): extra_fields.setdefault("is_superuser", True) return self.create_user(fxa_id, **extra_fields) + def create_admin(self, email, **extra_fields): + # Internal admin authenticated via Auth0/LDAP OIDC. These accounts have + # no fxa_id (they never go through the FxA flow) + if not email: + raise ValueError("email is required") + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + user = self.model(email=self.normalize_email(email).lower(), **extra_fields) + user.set_unusable_password() + user.save(using=self._db) + return user + def get_queryset(self): return super().get_queryset().filter(deleted_at__isnull=True) class User(AbstractBaseUser, PermissionsMixin): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - fxa_id = models.CharField(max_length=32, unique=True) + fxa_id = models.CharField(max_length=32, unique=True, null=True, blank=True) + email = models.EmailField(unique=True, null=True, blank=True) is_banned = models.BooleanField(default=False) # Accumulates per-link policy hits from Cinder: +1 for a Web Risk match, # +3 for an NCMEC/CSAM match. At or above BAN_THRESHOLD the user is @@ -49,7 +62,7 @@ class User(AbstractBaseUser, PermissionsMixin): all_objects = BaseUserManager.from_queryset(SoftDeleteQuerySet)() def __str__(self): - return self.fxa_id + return self.fxa_id or self.email or str(self.id) def delete(self, *args, **kwargs): # Soft-delete only. There is no hard-delete path on this model. diff --git a/pyproject.toml b/pyproject.toml index a1d6c0e..30fb5ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "django-allauth[socialaccount]>=65.16.1", "django-environ>=0.11.0", "django-modern-csrf>=1.0.1", + "mozilla-django-oidc>=5.0.2", "dockerflow>=2026.3.0", "gunicorn>=23.0.0", "whitenoise>=6.0.0", diff --git a/uv.lock b/uv.lock index 63e2535..1ee89ae 100644 --- a/uv.lock +++ b/uv.lock @@ -401,6 +401,7 @@ dependencies = [ { name = "google-cloud-storage" }, { name = "gunicorn" }, { name = "jsonschema" }, + { name = "mozilla-django-oidc" }, { name = "opentelemetry-sdk" }, { name = "psycopg", extra = ["binary"] }, { name = "requests" }, @@ -436,6 +437,7 @@ requires-dist = [ { name = "google-cloud-storage", specifier = ">=3.10.1" }, { name = "gunicorn", specifier = ">=23.0.0" }, { name = "jsonschema", specifier = ">=4.26.0" }, + { name = "mozilla-django-oidc", specifier = ">=5.0.2" }, { name = "opentelemetry-sdk", specifier = ">=1.38.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2.0" }, { name = "requests", specifier = ">=2.32.5" }, @@ -687,6 +689,21 @@ redis = [ { name = "redis" }, ] +[[package]] +name = "mozilla-django-oidc" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "django" }, + { name = "pyjwt" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/5e/d5906dc016253d248eabf3608a6bbfd0df75a993a467cf7cf0a0c7f2b18a/mozilla_django_oidc-5.0.2.tar.gz", hash = "sha256:4e953dcd963c036daaa2ac42b5bb6ea89a1c6ea7be0387c2022a59aca2f83043", size = 57360, upload-time = "2025-12-19T16:11:40.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a9/c1664acf30ef0031ed06650039de1693bddf4cf7f58e07939e1aa80bffb7/mozilla_django_oidc-5.0.2-py3-none-any.whl", hash = "sha256:965a3533b0e299288cdf38ec2f8b550217c302ffe78ce5bd0b2d2f4bc436878b", size = 25910, upload-time = "2025-12-19T16:11:39.729Z" }, +] + [[package]] name = "oauthlib" version = "3.3.1"