Compare commits

..

2 Commits

Author SHA1 Message Date
lebaudantoine 6f8f5d0386 🐛(frontend) accept meeting links with query params in join dialog
Meeting links inserted into events by the Outlook calendar add-in carry
tracking query parameters, e.g. `?from=outlook-addin` or `utm_*`.
Pasting such a link into the "Join a meeting" dialog failed: the
validation regex was anchored on the room id, so any trailing query
string made the link invalid, and the submit handler only stripped the
origin, so the query string would have ended up in the room id.

Parse the input with the URL API in a new `extractRoomId` helper, used
by both validation and submit. It accepts a bare room id or a link on
the current origin (trailing slash allowed), reads the room id from the
pathname only, and returns the query string and fragment separately so
they are kept when redirecting to the room (utm tracking is preserved).
2026-11-12 23:50:05 +01:00
lebaudantoine 25122deba2 🔒️(docker) move frontend to nginx/nginx-unprivileged image
The nginxinc/nginx-unprivileged image is no longer rebuilt and
ships libxslt 1.1.43-r3, affected by CVE-2026-125350,
CVE-2026-87636 and CVE-2026-9947.

Use nginx/nginx-unprivileged in both frontend images. Its
packages are up to date, so drop the manual package upgrades.
2026-11-12 23:29:14 +01:00
29 changed files with 166 additions and 697 deletions
+2 -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
@@ -41,6 +37,8 @@ and this project adheres to
- 🔒️(agents) upgrade python to 3.14.10 to fix CVE-2026-109518
- 🔒️(docker) upgrade musl-utils to 1.2.6-r3 to fix CVE-2026-107891
- ♿️(frontend) close the invite dialog with the Escape key #1916
- 🔒️(docker) move frontend to nginx/nginx-unprivileged image
- 🐛(frontend) accept meeting links with query params in join dialog
## [1.37.0] - 2026-11-01
-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
+2 -8
View File
@@ -58,19 +58,13 @@ RUN npx webpack --mode production
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.30.6-alpine3.24 AS frontend-production
FROM nginx/nginx-unprivileged:1.30.6-alpine3.24 AS frontend-production
USER root
RUN apk upgrade --no-cache libexpat && \
apk add --no-cache --upgrade 'pcre2>=10.49-r0' 'tiff>=4.7.2-r0' \
'libx11>=1.8.14-r0' 'ca-certificates>=20261020-r0' \
'brotli-libs>=1.2.1-r0' 'musl-utils>=1.2.6-r3' && \
apk del curl && \
RUN apk del curl && \
rm -f /usr/share/nginx/html/50x.html
USER nginx
USER nginx
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
+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
+2 -6
View File
@@ -47,14 +47,10 @@ ENV VITE_MEDIA_BASE_URL=${VITE_MEDIA_BASE_URL}
RUN npm run build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.30.6-alpine3.24 AS frontend-production
FROM nginx/nginx-unprivileged:1.30.6-alpine3.24 AS frontend-production
USER root
RUN apk upgrade --no-cache libexpat && \
apk add --no-cache --upgrade 'pcre2>=10.49-r0' 'tiff>=4.7.2-r0' \
'libx11>=1.8.14-r0' 'ca-certificates>=20261020-r0' \
'brotli-libs>=1.2.1-r0' 'musl-utils>=1.2.6-r3' && \
apk del curl && \
RUN apk del curl && \
rm -f /usr/share/nginx/html/50x.html
USER nginx
@@ -1,22 +1,21 @@
import { useTranslation } from 'react-i18next'
import { Field, Ul, H, P, Form, Dialog } from '@/primitives'
import { navigateTo } from '@/navigation/navigateTo'
import { isRoomValid } from '@/features/rooms'
import { extractRoomId } from '@/features/rooms'
export const JoinMeetingDialog = () => {
const { t } = useTranslation('home')
const handleSubmit = (data: { roomId?: FormDataEntryValue }) => {
const roomId = (data.roomId as string)
.trim()
.replace(`${window.location.origin}/`, '')
navigateTo('room', roomId)
const extracted = extractRoomId(data.roomId as string)
if (!extracted) return
navigateTo('room', `${extracted.roomId}${extracted.suffix}`)
}
const validateRoomId = (value: string) => {
const trimmed = value.trim()
if (!trimmed) return null
return !isRoomValid(trimmed) ? (
return !extractRoomId(trimmed) ? (
<>
<p>{t('joinInputError')}</p>
<Ul>
+1
View File
@@ -1,6 +1,7 @@
export {
roomIdPattern,
isRoomValid,
extractRoomId,
flexibleRoomIdPattern,
} from './utils/isRoomValid'
export { generateRoomId } from './utils/generateRoomId'
@@ -1,5 +1,45 @@
import { describe, expect, it } from 'vitest'
import { normalizeRoomId } from './isRoomValid'
import { extractRoomId, normalizeRoomId } from './isRoomValid'
describe('extractRoomId', () => {
const origin = 'https://meet.example.com'
const plain = { roomId: 'abc-defg-hij', suffix: '' }
it('accepts a bare room id', () => {
expect(extractRoomId('abc-defg-hij', origin)).toEqual(plain)
expect(extractRoomId(' abc-defg-hij ', origin)).toEqual(plain)
})
it('accepts a meeting URL on this origin', () => {
expect(extractRoomId(`${origin}/abc-defg-hij`, origin)).toEqual(plain)
expect(extractRoomId(`${origin}/abc-defg-hij/`, origin)).toEqual(plain)
})
it('keeps query strings and fragments in the suffix', () => {
expect(
extractRoomId(`${origin}/abc-defg-hij?from=outlook-addin`, origin)
).toEqual({ roomId: 'abc-defg-hij', suffix: '?from=outlook-addin' })
expect(
extractRoomId(
`${origin}/abc-defg-hij/?utm_source=mail&utm_medium=email#x`,
origin
)
).toEqual({
roomId: 'abc-defg-hij',
suffix: '?utm_source=mail&utm_medium=email#x',
})
})
it('rejects other origins and invalid ids', () => {
expect(
extractRoomId('https://evil.example/abc-defg-hij', origin)
).toBeNull()
expect(extractRoomId(`${origin}/not-a-room`, origin)).toBeNull()
expect(extractRoomId(`${origin}/abc-defg-hij/extra`, origin)).toBeNull()
expect(extractRoomId('abc-defg-hij?from=x', origin)).toBeNull()
})
})
describe('normalizeRoomId', () => {
it('lowercases and re-inserts the hyphens of a ten-letter id', () => {
@@ -10,6 +10,28 @@ export const isRoomValid = (roomIdOrUrl: string) =>
roomRegex.test(roomIdOrUrl) ||
new RegExp(`^${window.location.origin}/${roomIdPattern}$`).test(roomIdOrUrl)
export const extractRoomId = (
input: string,
origin: string = window.location.origin
): { roomId: string; suffix: string } | null => {
const value = input.trim()
if (roomRegex.test(value)) return { roomId: value, suffix: '' }
let url: URL
try {
url = new URL(value)
} catch {
return null
}
if (url.origin !== origin) return null
const path = url.pathname.slice(1)
const roomId = path.endsWith('/') ? path.slice(0, -1) : path
return roomRegex.test(roomId)
? { roomId, suffix: `${url.search}${url.hash}` }
: null
}
export const normalizeRoomId = (roomId: string) => {
const cleanId = roomId.toLowerCase().replace(/-/g, '')
if (cleanId.length === 10) {