Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot] 538aba2477 ⬆️(dependencies) update GitHub Actions dependencies 2026-11-09 04:24:01 +00:00
24 changed files with 105 additions and 690 deletions
+14 -14
View File
@@ -87,7 +87,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -117,11 +117,11 @@ jobs:
timeout 30 sh -c 'until docker exec garage /garage health; do sleep 1; done'
- name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.13"
python-version: "3.15"
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
- name: Install the dependencies
run: uv sync --locked --all-extras
@@ -160,7 +160,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -170,12 +170,12 @@ jobs:
sudo apt-get install -y ffmpeg
- name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.13"
python-version: "3.15"
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
- name: Install the project
run: uv sync --locked --all-extras
@@ -189,7 +189,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -208,14 +208,14 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: "24.22.1"
node-version: "26.13.0"
- name: Install dependencies
run: cd src/frontend/ && npm ci --ignore-scripts
@@ -232,7 +232,7 @@ jobs:
working-directory: src/sdk/library
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -255,7 +255,7 @@ jobs:
working-directory: src/sdk/library
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
-4
View File
@@ -12,8 +12,6 @@ and this project adheres to
- ✨(frontend) add jittered exponential backoff to LiveKit reconnections
- ✨(frontend) tell participants when a recording is interrupted
- ✨(backend) show the last login of users in the admin
- ✨(backend) filter users sharing an email in the admin
### Changed
@@ -27,8 +25,6 @@ and this project adheres to
- 🔒️(backend) issue room access tokens via POST rooms/{id}/join/ only
- 🔒️(frontend) send the display name in the join request body
- 🔥(backend) drop is_administrable from unregistered room payloads
- ♻️(backend) match emails in a single normalized form
- ⚡️(backend) index user emails for exact lookups
### Fixed
-12
View File
@@ -45,18 +45,6 @@ parse as a UUID, yet to normalize to an existing room's id, matched no registere
unregistered one. The token minted for that unregistered room was valid for the registered room: anyone could join a
trusted or restricted room without being admitted through the lobby.
### User emails matched in a single normalized form
User emails are now stored stripped, with their ASCII letters lowercased, and compared exactly. A migration normalizes
the stored emails and stores blank ones as NULL. Other letters keep their case.
- With `OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION` disabled (the default), a new `sub` whose email is already used by
another user is now refused, by the OIDC login and by the external API, instead of getting a second account. To keep
creating user with a duplicate mail, set the new `OIDC_ALLOW_DUPLICATE_EMAILS=True`.
- Known limitation: provisional users still have a unique constraint comparing lowercased emails,
non-ASCII letters included. Provisioning an email that differs from another provisional user's only by the case of a
non-ASCII letter (e.g. `É` and `é`) returns a 409.
## v1.37.0
### Crisp support chat replaced by the La Suite support form
+1 -2
View File
@@ -16,8 +16,7 @@ Authentication relies on [django-lasuite](https://github.com/suitenumerique/djan
| OIDC_VERIFY_SSL | Verify SSL certificates when contacting the OIDC provider | `true` |
| OIDC_USE_NONCE | Use `nonce` to prevent replay attacks | `true` |
| OIDC_STORE_ID_TOKEN | Store the ID token returned by the OIDC provider (useful for backend validation) | `true` |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to identifying users by email if `sub` claim does not match. Cannot be enabled together with `OIDC_ALLOW_DUPLICATE_EMAILS`. | `false` |
| OIDC_ALLOW_DUPLICATE_EMAILS | When the email fallback is disabled, create a new user for an unknown `sub` even if its email is already used by another user. Otherwise, the login is refused. Emails are compared once stripped, with their ASCII letters lowercased. | `false` |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to identifying users by email if `sub` claim does not match. Enable only if emails are unique. | `false` |
| **Endpoints** | | |
| OIDC_OP_JWKS_ENDPOINT | URL to retrieve JSON Web Key Sets (for token verification) | — |
| OIDC_OP_AUTHORIZATION_ENDPOINT | URL for authorization requests | — |
+1 -2
View File
@@ -379,8 +379,7 @@ These are the environmental options available on meet backend.
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker options | {} |
| OIDC_CREATE_USER | Create OIDC user if not exists | true |
| OIDC_VERIFY_SSL | Verify SSL for OIDC | true |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification, cannot be enabled with OIDC_ALLOW_DUPLICATE_EMAILS | false |
| OIDC_ALLOW_DUPLICATE_EMAILS | Without the email fallback, create a user for a new sub whose email is already used | false |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | false |
| OIDC_RP_SIGN_ALGO | Token verification algorithm used by OIDC | RS256 |
| OIDC_RP_CLIENT_ID | OIDC client ID | meet |
| OIDC_RP_CLIENT_SECRET | OIDC client secret | |
+2 -40
View File
@@ -4,7 +4,6 @@ from django import forms
from django.contrib import admin, messages
from django.contrib.auth import admin as auth_admin
from django.db import transaction
from django.db.models import Exists, OuterRef
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
@@ -47,26 +46,6 @@ class FileInline(admin.TabularInline):
return super().get_queryset(request).filter(hard_deleted_at__isnull=True)
class DuplicateEmailFilter(admin.SimpleListFilter):
"""Filter the users whose stored email is shared with another user."""
title = _("duplicate email")
parameter_name = "duplicate_email"
def lookups(self, request, model_admin):
"""Return the filter options."""
return [("yes", _("Yes"))]
def queryset(self, request, queryset):
"""Keep the users sharing their email with another user."""
if self.value() != "yes":
return queryset
others = models.User.objects.filter(email=OuterRef("email")).exclude(
pk=OuterRef("pk")
)
return queryset.filter(Exists(others))
@admin.register(models.User)
class UserAdmin(auth_admin.UserAdmin):
"""Admin class for the User model"""
@@ -110,10 +89,7 @@ class UserAdmin(auth_admin.UserAdmin):
),
},
),
(
_("Important dates"),
{"fields": ("created_at", "updated_at", "last_login")},
),
(_("Important dates"), {"fields": ("created_at", "updated_at")}),
)
add_fieldsets = (
(
@@ -136,16 +112,9 @@ class UserAdmin(auth_admin.UserAdmin):
"is_superuser",
"is_device",
"created_at",
"last_login",
"updated_at",
)
list_filter = (
"is_staff",
"is_superuser",
"is_device",
"is_active",
DuplicateEmailFilter,
)
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
ordering = (
"is_active",
"-is_superuser",
@@ -162,16 +131,9 @@ class UserAdmin(auth_admin.UserAdmin):
"short_name",
"created_at",
"updated_at",
"last_login",
)
search_fields = ("id", "sub", "admin_email", "email", "full_name")
def get_ordering(self, request):
"""Group the users sharing an email when filtering on duplicates."""
if DuplicateEmailFilter.parameter_name in request.GET:
return ("email", "created_at")
return super().get_ordering(request)
@admin.register(models.File)
class FileAdmin(admin.ModelAdmin):
+13 -4
View File
@@ -13,7 +13,7 @@ from lasuite.oidc_login.backends import (
)
from rest_framework.authentication import SessionAuthentication
from core.models import DuplicateEmailError, User
from core.models import User
from core.validators import sub_validator
@@ -83,9 +83,18 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
) from err
try:
return User.objects.get_user_by_sub_or_email(sub, email)
except DuplicateEmailError as err:
raise SuspiciousOperation(err.message) from err
return User.objects.get(sub=sub)
except User.DoesNotExist:
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return User.objects.get(email__iexact=email)
except User.DoesNotExist:
pass
except User.MultipleObjectsReturned as e:
raise SuspiciousOperation(
"Multiple user accounts share a common email."
) from e
return None
class SessionAuthenticationWith401(SessionAuthentication):
@@ -20,7 +20,6 @@ from rest_framework import authentication, exceptions
from core import audit, auditing
from core.models import Application, ApplicationScope
from core.services import jwt_token
from core.utils import normalize_email
from core.validators import sub_validator
User = get_user_model()
@@ -64,10 +63,6 @@ def get_or_create_user_by_sub(sub, email=None, *, auth_method, client_id=None):
Shared by the resource server backends, so that tokens introspected with the
OIDC provider or with Menshen resolve users the same way.
Users are never identified by email here, but a user to create is refused
when its normalized email is already stored, unless
OIDC_ALLOW_DUPLICATE_EMAILS is set.
Args:
sub: Subject identifier from the introspected token
email: Email to store on a newly created user
@@ -95,10 +90,12 @@ def get_or_create_user_by_sub(sub, email=None, *, auth_method, client_id=None):
if not settings.OIDC_CREATE_USER:
return None
normalized_email = normalize_email(email)
# Users are never identified by email here. But a second account with the
# same email (e.g. provisionnal account) would break the lookups by email:
# refuse it.
existing_user = (
User.objects.filter(email=normalized_email).first()
if normalized_email and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
User.objects.filter(email__iexact=email).first()
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and email
else None
)
if existing_user is not None:
@@ -6,6 +6,7 @@ from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.db.models import Count
from django.db.models.functions import Lower
from core.models import File, RecordingAccess, ResourceAccess, RoleChoices
@@ -20,15 +21,14 @@ ROLE_PRIORITY = {
class Command(BaseCommand):
"""
Merge duplicate users sharing the same email into the most recently
created one.
Merge duplicate users sharing the same email (case-insensitive) into the
most recently created one.
Emails are stored normalized (stripped, ASCII letters lowercased) and are
compared exactly, so 'John@Example.com' and 'john@example.com' are both
stored as 'john@example.com' and treated as duplicates. The KEPT user is
the most recently created. All room memberships, recording accesses and
files are transferred to it. When a conflict exists, the higher-privilege
role wins. Stale users are then deleted.
Emails are compared case-insensitively, so 'John@Example.com' and
'john@example.com' are treated as duplicates. The KEPT user is the most
recently created. All room memberships, recording accesses and files are
transferred to it. When a conflict exists, the higher-privilege role wins.
Stale users are then deleted.
Each email group is processed inside a single database transaction.
"""
@@ -60,13 +60,16 @@ class Command(BaseCommand):
users_qs = users_qs.filter(email__icontains=email_filter)
self.stdout.write(f"[INFO] Filtering emails containing '{email_filter}'.\n")
# Group emails case-insensitively so 'John@X.com' and 'john@x.com'
# are detected as duplicates of each other.
duplicate_emails = (
users_qs.exclude(email__isnull=True)
.exclude(email="")
.values("email")
.annotate(email_lower=Lower("email"))
.values("email_lower")
.annotate(cnt=Count("id"))
.filter(cnt__gt=1)
.values_list("email", flat=True)
.values_list("email_lower", flat=True)
)
if not duplicate_emails:
@@ -82,9 +85,12 @@ class Command(BaseCommand):
failed_emails = []
for email in duplicate_emails:
# Case-insensitive lookup to fetch every casing variant of the email.
# Secondary sort by id ensures a stable, deterministic order when
# created_at timestamps are equal (common in tests and bulk imports).
users = list(User.objects.filter(email=email).order_by("created_at", "id"))
users = list(
User.objects.filter(email__iexact=email).order_by("created_at", "id")
)
kept_user = users[-1]
stale_users = users[:-1]
@@ -124,6 +130,10 @@ class Command(BaseCommand):
failed_emails.append(email)
self.stderr.write(f"[ERROR] Failed to merge '{email}': {exc}")
if not kept_user.email.islower():
kept_user.email = kept_user.email.lower()
kept_user.save(update_fields=["email"])
if failed_emails:
raise CommandError(
f"Failed to merge {len(failed_emails)} email group(s): {', '.join(failed_emails)}"
@@ -1,44 +0,0 @@
"""Store user emails stripped and ASCII-lowercased, and blank ones as NULL."""
import string
from django.db import migrations
BATCH_SIZE = 500
_ASCII_LOWERCASE_TABLE = str.maketrans(string.ascii_uppercase, string.ascii_lowercase)
def normalize_email(email):
"""Strip an email and lowercase its ASCII letters only."""
return (email or "").strip().translate(_ASCII_LOWERCASE_TABLE)
def forwards(apps, schema_editor):
"""Normalize the user emails, writing the changed rows in batches."""
User = apps.get_model("core", "User")
batch = []
for user_id, email in User.objects.values_list("id", "email").iterator(
chunk_size=BATCH_SIZE
):
normalized = normalize_email(email) or None
if normalized == email:
continue
batch.append(User(id=user_id, email=normalized))
if len(batch) == BATCH_SIZE:
User.objects.bulk_update(batch, ["email"])
batch = []
if batch:
User.objects.bulk_update(batch, ["email"])
class Migration(migrations.Migration):
atomic = False
dependencies = [
("core", "0029_alter_application_client_secret_sha256"),
]
operations = [
migrations.RunPython(forwards, migrations.RunPython.noop, elidable=True),
]
@@ -1,20 +0,0 @@
# Generated by Django 5.2.17 on 2026-11-10 16:28
import core.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0030_normalize_user_emails'),
]
operations = [
migrations.AlterModelManagers(
name='user',
managers=[
('objects', core.models.UserManager()),
],
),
]
@@ -1,26 +0,0 @@
# Generated by Django 5.2.17 on 2026-11-10 17:00
from django.contrib.postgres.operations import (
AddIndexConcurrently,
RemoveIndexConcurrently,
)
from django.db import migrations, models
class Migration(migrations.Migration):
atomic = False
dependencies = [
('core', '0031_alter_user_managers'),
]
operations = [
AddIndexConcurrently(
model_name="user",
index=models.Index(fields=["email"], name="user_email_idx"),
),
RemoveIndexConcurrently(
model_name="user",
name="user_email_upper_idx",
),
]
+2 -51
View File
@@ -115,16 +115,6 @@ class RoomAccessLevel(models.TextChoices):
RESTRICTED = "restricted", _("Restricted Access")
class DuplicateEmailError(Exception):
"""Raised when an email is already associated with a pre-existing user."""
def __init__(self, message=None, email=None):
"""Set message and email to describe the exception."""
self.message = message
self.email = email
super().__init__(self.message)
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
@@ -162,36 +152,6 @@ class BaseModel(models.Model):
super().save(*args, **kwargs)
class UserManager(auth_models.UserManager):
"""Custom manager for User model with additional methods."""
def get_user_by_sub_or_email(self, sub, email):
"""Fetch existing user by sub or email."""
try:
return self.get(sub=sub)
except self.model.DoesNotExist as err:
if not email:
return None
normalized_email = utils.normalize_email(email)
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
users = self.filter(email=normalized_email).order_by("-is_active")[:2]
if len(users) == 2 and users[1].is_active:
raise DuplicateEmailError(
"Several active users are associated with this email."
) from err
return users[0] if users else None
if (
not settings.OIDC_ALLOW_DUPLICATE_EMAILS
and self.filter(email=normalized_email).exists()
):
raise DuplicateEmailError(
"We couldn't find a user with this sub but the email is already "
"associated with a registered user."
) from err
return None
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
@@ -269,7 +229,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
),
)
objects = UserManager()
objects = auth_models.UserManager()
USERNAME_FIELD = "admin_email"
REQUIRED_FIELDS = []
@@ -287,21 +247,12 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
)
]
indexes = [
models.Index(fields=["email"], name="user_email_idx"),
models.Index(models.functions.Upper("email"), name="user_email_upper_idx"),
]
def __str__(self):
return self.email or self.admin_email or str(self.id)
def save(self, *args, **kwargs):
"""Store the email normalized, and a blank one as NULL, even on a partial save."""
email = utils.normalize_email(self.email) or None
update_fields = kwargs.get("update_fields")
if update_fields is not None and email != self.email:
kwargs["update_fields"] = {*update_fields, "email"}
self.email = email
super().save(*args, **kwargs)
def email_user(self, subject, message, from_email=None, **kwargs):
"""Email this user."""
if not self.email:
@@ -5,7 +5,6 @@ from django.core.exceptions import SuspiciousOperation, ValidationError
from django.db import IntegrityError
from core import audit, auditing, models
from core.utils import normalize_email
class ProvisionalUserError(Exception):
@@ -43,7 +42,7 @@ class ProvisionalUserService:
def _get_by_email(self, email: str) -> models.User | None:
"""Return the user with this email, or None if not found."""
try:
return models.User.objects.get(email=normalize_email(email))
return models.User.objects.get(email__iexact=email)
except models.User.DoesNotExist:
return None
except models.User.MultipleObjectsReturned as e:
@@ -296,26 +296,17 @@ def test_finds_user_by_sub(django_assert_num_queries):
def test_finds_user_when_email_fallback_disabled(django_assert_num_queries, settings):
"""Should refuse a sub that is not found when its email is already used, and email
fallback is disabled."""
"""Should not return a user when not found by sub, and email fallback is disabled."""
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email="foo@mail.com")
with (
django_assert_num_queries(2),
pytest.raises(
SuspiciousOperation,
match=(
"We couldn't find a user with this sub but the email is already "
"associated with a registered user."
),
),
):
klass.get_existing_user("wrong-sub", db_user.email)
with django_assert_num_queries(1):
user = klass.get_existing_user("wrong-sub", db_user.email)
assert user is None
def test_finds_user_when_email_is_none(django_assert_num_queries, settings):
@@ -349,7 +340,7 @@ def test_finds_user_by_email(django_assert_num_queries, settings):
def test_finds_user_case_insensitive_email(django_assert_num_queries, settings):
"""Should match email ignoring ASCII case when falling back to email."""
"""Should match email case-insensitively when falling back to email."""
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
klass = OIDCAuthenticationBackend()
@@ -362,7 +353,7 @@ def test_finds_user_case_insensitive_email(django_assert_num_queries, settings):
def test_finds_user_multiple_users_same_email(django_assert_num_queries, settings):
"""Should refuse to pick one of several active users sharing the email."""
"""Should handle multiple users with same email appropriately."""
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
@@ -375,178 +366,25 @@ def test_finds_user_multiple_users_same_email(django_assert_num_queries, setting
django_assert_num_queries(2),
pytest.raises(
SuspiciousOperation,
match="Several active users are associated with this email.",
match="Multiple user accounts share a common email.",
),
):
klass.get_existing_user("wrong-sub", email)
def test_finds_user_whitespace_email(django_assert_num_queries, settings):
"""Should match emails with surrounding whitespace once stripped."""
"""Should not match emails with whitespace."""
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_CREATE_USER = False
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email="foo@mail.com")
UserFactory(email="foo@mail.com")
with django_assert_num_queries(2):
user = klass.get_existing_user("wrong-sub", " foo@mail.com ")
assert user == db_user
def test_authentication_getter_existing_user_via_email_case_insensitive(
monkeypatch, settings
):
"""
If an existing user doesn't match the sub but matches the email with another
ASCII case, the user should be returned and its email should stay normalized.
"""
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email="john.doe@example.com")
def get_userinfo_mocked(*args):
return {"sub": "123", "email": "JOHN.DOE@EXAMPLE.COM"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
db_user.refresh_from_db()
assert db_user.email == "john.doe@example.com"
@pytest.mark.parametrize(
"email",
[
"dısk@example.com",
"diſk@example.com",
"disK@example.com",
],
ids=["dotless-i", "long-s", "kelvin-sign"],
)
@pytest.mark.parametrize("fallback", [True, False])
def test_authentication_getter_existing_user_via_email_non_ascii(
monkeypatch, settings, fallback, email
):
"""
If an existing user doesn't match the sub and the email only matches its email
through Unicode case folding, a new user should be created and the existing user
should be left untouched.
"""
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = fallback
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email="disk@example.com")
db_sub = db_user.sub
def get_userinfo_mocked(*args):
return {"sub": "other-sub", "email": email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user != db_user
assert user.sub == "other-sub"
assert user.email == email
assert models.User.objects.count() == 2
db_user.refresh_from_db()
assert db_user.sub == db_sub
assert db_user.email == "disk@example.com"
def test_authentication_getter_existing_inactive_user_via_email(monkeypatch, settings):
"""
If the only user matching the email is inactive, the login should be refused
instead of creating another user.
"""
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
klass = OIDCAuthenticationBackend()
UserFactory(email="john.doe@example.com", is_active=False)
def get_userinfo_mocked(*args):
return {"sub": "123", "email": "John.Doe@example.com"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with pytest.raises(SuspiciousOperation, match="User account is disabled"):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.count() == 1
def test_authentication_getter_no_fallback_to_email_no_duplicate(monkeypatch, settings):
"""
When the email fallback is disabled, an unknown sub whose email is already used,
in another ASCII case, should be refused instead of creating a second user.
"""
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email="john.doe@example.com")
def get_userinfo_mocked(*args):
return {"sub": "123", "email": "JOHN.DOE@EXAMPLE.COM"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with pytest.raises(
SuspiciousOperation,
match=(
"We couldn't find a user with this sub but the email is already "
"associated with a registered user."
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.count() == 1
db_user.refresh_from_db()
assert db_user.sub != "123"
def test_authentication_getter_no_fallback_to_email_allow_duplicate(
monkeypatch, settings
):
"""
When the email fallback is disabled and duplicate emails are allowed, an unknown
sub whose email is already used should get a new user.
"""
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
settings.OIDC_ALLOW_DUPLICATE_EMAILS = True
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email="john.doe@example.com")
def get_userinfo_mocked(*args):
return {"sub": "123", "email": "JOHN.DOE@EXAMPLE.COM"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user != db_user
assert user.sub == "123"
assert user.email == "john.doe@example.com"
assert models.User.objects.count() == 2
assert user is None
@pytest.mark.parametrize(
@@ -19,13 +19,6 @@ pytestmark = pytest.mark.django_db
# pylint: disable=W0613
def _user_with_stored_email(email):
"""Create a user storing the email as is, like the rows predating the normalization."""
user = UserFactory()
User.objects.filter(pk=user.pk).update(email=email)
return user
def test_merge_no_duplicates_does_nothing():
"""Command should do nothing when no duplicate users exist."""
user = UserFactory(email="unique@example.com")
@@ -43,30 +36,24 @@ def test_merge_keeps_most_recently_created_user():
assert User.objects.filter(id=user2.id).exists()
@pytest.mark.parametrize(
"lookalike_email",
[
"dısk@example.com",
"diſk@example.com",
"disK@example.com",
],
ids=["dotless-i", "long-s", "kelvin-sign"],
)
def test_merge_does_not_merge_lookalike_emails(lookalike_email):
"""A user whose email only matches the duplicates' email through Unicode case
folding should not be merged with them, even when created last."""
user1 = UserFactory(email="disk@example.com")
user2 = UserFactory(email="disk@example.com")
ra = UserResourceAccessFactory(user=user1)
lookalike = UserFactory(email=lookalike_email)
def test_merge_user_case_insensitive():
"""Emails differing only by case should be treated as duplicates and merged,
keeping the most recently created user."""
user1 = UserFactory(email="Dup@example.com")
user2 = UserFactory(email="dup@example.com")
call_command("merge_duplicate_users")
assert not User.objects.filter(id=user1.id).exists()
ra.refresh_from_db()
assert ra.user == user2
lookalike.refresh_from_db()
assert lookalike.email == lookalike_email
assert not ResourceAccess.objects.filter(user=lookalike).exists()
assert User.objects.filter(id=user2.id).exists()
user3 = UserFactory(email="joe@example.com")
user4 = UserFactory(email="Joe@example.com")
call_command("merge_duplicate_users")
assert not User.objects.filter(id=user3.id).exists()
assert User.objects.filter(id=user4.id).exists()
user4.refresh_from_db()
assert user4.email.islower()
def test_merge_deletes_all_stale_users():
@@ -422,11 +409,10 @@ def test_merge_does_not_merge_users_with_null_email():
assert User.objects.filter(id=user2.id).exists()
def test_merge_does_not_merge_users_with_legacy_blank_email():
"""Users with a legacy empty-string email must never be merged together, even if
multiple exist."""
user1 = _user_with_stored_email("")
user2 = _user_with_stored_email("")
def test_merge_does_not_merge_users_with_blank_email():
"""Users with empty-string email must never be merged together, even if multiple exist."""
user1 = UserFactory(email="")
user2 = UserFactory(email="")
call_command("merge_duplicate_users")
assert User.objects.filter(id=user1.id).exists()
assert User.objects.filter(id=user2.id).exists()
@@ -488,7 +474,7 @@ def test_merge_email_filter_no_match_does_nothing():
def test_merge_email_filter_is_case_insensitive():
"""Command should match emails case-insensitively when filtering."""
UserFactory(email="user1@example.com")
UserFactory(email="user1@example.com")
call_command("merge_duplicate_users", email_filter="@Example.COM")
UserFactory(email="user1@Example.com")
UserFactory(email="user1@Example.com")
call_command("merge_duplicate_users", email_filter="@example.com")
assert User.objects.filter(email="user1@example.com").count() == 1
@@ -5,7 +5,6 @@
import json
from unittest import mock
from django.core.exceptions import SuspiciousOperation
from django.db import IntegrityError
import pytest
@@ -39,20 +38,6 @@ def test_get_or_create_existing_user_is_not_audited(audit_events):
assert not find_events(audit_events, "user.provision")
def test_get_or_create_several_users_same_normalized_email(audit_events):
"""Several users sharing the normalized email should be refused, not picked."""
UserFactory(email="john.doe@example.com")
UserFactory(email="John.Doe@example.com")
with pytest.raises(
SuspiciousOperation, match="Multiple user accounts share a common email."
):
ProvisionalUserService().get_or_create(" JOHN.DOE@example.com", "my-client")
assert User.objects.count() == 2
assert not find_events(audit_events, "user.provision")
def test_get_or_create_creation_is_audited(audit_events):
"""A created user is identified by its id, without sub nor email."""
user, created = ProvisionalUserService().get_or_create(
@@ -1,62 +0,0 @@
"""Tests for the user admin class."""
import pytest
from core import factories
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def static_storage_without_manifest(settings):
"""Render the admin pages without collecting the static files first."""
settings.STORAGES = {
**settings.STORAGES,
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
}
def _admin_client(client):
"""Log a superuser in the client."""
client.force_login(factories.UserFactory(is_staff=True, is_superuser=True))
return client
def test_admin_users_duplicate_email_filter(client):
"""The filter should keep the users sharing their stored email, grouped by email
and ordered by creation, but not the users whose email only matches through
Unicode case folding."""
first_jim = factories.UserFactory(email="jim@example.com")
first_ann = factories.UserFactory(email="ann@example.com")
second_jim = factories.UserFactory(email="JIM@example.com")
second_ann = factories.UserFactory(email="ann@example.com")
factories.UserFactory(email="jım@example.com")
factories.UserFactory(email="bob@example.com")
factories.UserFactory(email=None)
factories.UserFactory(email=None)
response = _admin_client(client).get("/admin/core/user/?duplicate_email=yes")
assert response.status_code == 200
assert [user.pk for user in response.context["cl"].result_list] == [
first_ann.pk,
second_ann.pk,
first_jim.pk,
second_jim.pk,
]
def test_admin_users_last_login(client):
"""The last login of users should be shown in the list and on the change page."""
user = factories.UserFactory()
admin_client = _admin_client(client)
changelist = admin_client.get("/admin/core/user/")
change = admin_client.get(f"/admin/core/user/{user.pk}/change/")
assert changelist.status_code == 200
assert "column-last_login" in changelist.content.decode()
assert change.status_code == 200
assert "field-last_login" in change.content.decode()
@@ -280,22 +280,19 @@ def test_menshen_unknown_user_not_created(settings):
@responses.activate
@pytest.mark.parametrize("fallback", [True, False])
@pytest.mark.parametrize("existing_sub", [None, "other-sub"])
def test_menshen_unknown_user_email_already_used(settings, existing_sub, fallback):
def test_menshen_unknown_user_email_already_used(settings, existing_sub):
"""An unknown sub should not create a second account with an email already
used, by a provisional user or a user with another sub, whether
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION is set or not."""
used, by a provisional user or a user with another sub, when
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=True."""
settings.OIDC_CREATE_USER = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = fallback
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
existing_user = UserFactory(sub=existing_sub, email="user@example.com")
existing_user = UserFactory(sub=existing_sub, email="User@Example.com")
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json=_introspection("new-sub", email=" User@Example.COM "),
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
)
response = _create_room()
@@ -308,43 +305,6 @@ def test_menshen_unknown_user_email_already_used(settings, existing_sub, fallbac
assert not Room.objects.exists()
@responses.activate
@pytest.mark.parametrize("existing_sub", [None, "other-sub"])
def test_menshen_unknown_user_email_already_used_allowed(
audit_events, settings, existing_sub
):
"""An unknown sub should get its own account, even with an email already used,
when OIDC_ALLOW_DUPLICATE_EMAILS=True."""
settings.OIDC_CREATE_USER = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
settings.OIDC_ALLOW_DUPLICATE_EMAILS = True
existing_user = UserFactory(sub=existing_sub, email="user@example.com")
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json=_introspection("new-sub", email=" User@Example.COM "),
)
response = _create_room()
assert response.status_code == 201
assert User.objects.count() == 2
user = User.objects.get(sub="new-sub")
assert user != existing_user
assert user.email == "user@example.com"
existing_user.refresh_from_db()
assert existing_user.sub == existing_sub
[provision] = find_events(audit_events, "user.provision")
assert provision["event"]["outcome"] == "success"
assert provision["lasuite"]["outcome"] == "success"
assert provision["entity"]["target"]["id"] == str(user.pk)
@responses.activate
def test_menshen_is_audited_as_application(audit_events):
"""A call with a Menshen token is made by the client Menshen reports, acting for
@@ -422,12 +382,10 @@ def test_menshen_unknown_user_email_already_used_is_audited(audit_events, settin
settings.OIDC_CREATE_USER = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
existing_user = UserFactory(sub=None, email="user@example.com")
existing_user = UserFactory(sub=None, email="User@Example.com")
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json=_introspection("new-sub", email=" User@Example.COM "),
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
)
response = _create_room()
+4 -57
View File
@@ -4,12 +4,11 @@ Unit tests for the User model
from unittest import mock
from django.contrib.auth.models import update_last_login
from django.core.exceptions import ValidationError
import pytest
from core import factories, models
from core import factories
pytestmark = pytest.mark.django_db
@@ -118,13 +117,12 @@ def test_models_users_email_unique_when_sub_is_null():
def test_models_users_email_unique_case_insensitive_when_sub_is_null():
"""Email uniqueness among users with no sub (pending users) should also ignore the
case of non-ASCII letters, which the email normalization keeps."""
factories.UserFactory(sub=None, email="test@éxample.com")
"""Email uniqueness should be case-insensitive among users with no sub (pending users)."""
factories.UserFactory(sub=None, email="Test@example.com")
with pytest.raises(
ValidationError, match="Constraint “unique_email_when_sub_is_null” is violated."
):
factories.UserFactory(sub=None, email="test@Éxample.com")
factories.UserFactory(sub=None, email="test@example.com")
def test_models_users_email_not_unique_when_sub_is_set():
@@ -177,54 +175,3 @@ def test_models_users_sub_blank_is_accepted():
"""sub='' passes validation because blank=True; null is preferred but not enforced."""
user = factories.UserFactory.build(sub="")
user.full_clean()
@pytest.mark.parametrize(
"email, expected_email",
[
("John.Doe@Example.COM", "john.doe@example.com"),
(" john.doe@example.com ", "john.doe@example.com"),
],
)
def test_models_users_email_normalized_on_save(email, expected_email):
"""The email of a user should be stripped and have its ASCII letters only lowercased
when saved."""
user = factories.UserFactory(email=email)
user.refresh_from_db()
assert user.email == expected_email
def test_models_users_email_normalized_on_login():
"""The last login update at login should store a legacy email normalized."""
user = factories.UserFactory()
models.User.objects.filter(pk=user.pk).update(email="John.Doe@Example.COM")
user.refresh_from_db()
update_last_login(None, user)
user.refresh_from_db()
assert user.last_login is not None
assert user.email == "john.doe@example.com"
@pytest.mark.parametrize("email", ["", " "])
def test_models_users_email_blank_stored_as_null(email):
"""An empty or whitespace-only email should be stored as NULL."""
user = factories.UserFactory(email=email)
user.refresh_from_db()
assert user.email is None
def test_models_users_get_user_by_sub_or_email_active_preferred(settings):
"""An active user should be preferred over an inactive one sharing its email."""
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
factories.UserFactory(email="john.doe@example.com", is_active=False)
active_user = factories.UserFactory(email="John.Doe@example.com")
factories.UserFactory(email="john.doe@example.com", is_active=False)
assert (
models.User.objects.get_user_by_sub_or_email(
"unknown-sub", "JOHN.DOE@example.com"
)
== active_user
)
@@ -1,17 +0,0 @@
"""Unit tests for the OIDC_ALLOW_DUPLICATE_EMAILS setting."""
from unittest import mock
import pytest
from meet.settings import Test
def test_oidc_allow_duplicate_emails_with_email_fallback_is_refused():
"""Users identified by email cannot share one, so both settings stop the loading."""
with (
mock.patch.object(Test, "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION", True),
mock.patch.object(Test, "OIDC_ALLOW_DUPLICATE_EMAILS", True),
pytest.raises(ValueError, match="OIDC_ALLOW_DUPLICATE_EMAILS"),
):
Test.post_setup()
-12
View File
@@ -27,7 +27,6 @@ from core.utils import (
NotificationError,
create_livekit_client,
generate_token,
normalize_email,
notify_participants,
)
@@ -421,14 +420,3 @@ def test_notify_participants_success(mock_create_livekit_client):
# Verify aclose was called
mock_api_instance.aclose.assert_called_once()
def test_normalize_email():
"""The "normalize_email" function should strip and lowercase ASCII letters only."""
assert normalize_email(" John.Doe@Example.COM ") == "john.doe@example.com"
assert normalize_email("Dısk@Example.com") == "dısk@example.com"
assert normalize_email("Diſk@Example.com") == "diſk@example.com"
assert normalize_email("DisK@Example.com") == "disK@example.com"
assert normalize_email("Élise@Example.com") == "Élise@example.com"
assert normalize_email("") == ""
assert normalize_email(None) == ""
-11
View File
@@ -345,17 +345,6 @@ def is_uuid(value: str) -> bool:
return True
_ASCII_LOWERCASE_TABLE = str.maketrans(string.ascii_uppercase, string.ascii_lowercase)
def normalize_email(email):
"""Strip an email and lowercase its ASCII letters only.
Emails are stored in this form and compared exactly.
"""
return (email or "").strip().translate(_ASCII_LOWERCASE_TABLE)
def detect_mimetype(file_buffer: bytes, filename: str | None = None) -> str:
"""
Detect MIME type using multiple methods for better accuracy.
-17
View File
@@ -616,14 +616,6 @@ class Base(Configuration):
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
environ_prefix=None,
)
# WARNING: Enabling this setting allows multiple user accounts to share the same email
# address. This may cause security issues and is not recommended for production use when
# email is activated as fallback for identification
OIDC_ALLOW_DUPLICATE_EMAILS = values.BooleanValue(
default=False,
environ_name="OIDC_ALLOW_DUPLICATE_EMAILS",
environ_prefix=None,
)
OIDC_USER_SUB_FIELD_IMMUTABLE = values.BooleanValue(
default=True, environ_name="OIDC_USER_SUB_FIELD_IMMUTABLE", environ_prefix=None
)
@@ -1664,15 +1656,6 @@ class Base(Configuration):
if cls.LIVEKIT_API_TIMEOUT_SECONDS < 1:
raise ValueError("LIVEKIT_API_TIMEOUT_SECONDS must be at least 1")
if (
cls.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
and cls.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise ValueError(
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously."
)
if (
cls.SUMMARY_SERVICE_VERSION == 1
and cls.SUMMARY_SERVICE_ENDPOINT is not None