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
25 changes: 25 additions & 0 deletions fxsharing/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"allauth.account",
"allauth.socialaccount",
"allauth.socialaccount.providers.fxa",
"mozilla_django_oidc",
]

AUTH_USER_MODEL = "users.User"
Expand Down Expand Up @@ -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"


Expand Down
10 changes: 10 additions & 0 deletions fxsharing/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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}),
]
Empty file.
Empty file.
30 changes: 30 additions & 0 deletions fxsharing/users/management/commands/create_admin.py
Original file line number Diff line number Diff line change
@@ -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}"))
23 changes: 23 additions & 0 deletions fxsharing/users/migrations/0004_user_email_alter_user_fxa_id.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
17 changes: 15 additions & 2 deletions fxsharing/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading