Skip to content

Commit 610948c

Browse files
committed
🐛(backend) fix create document for user when sub does not match
When creating a document on behalf of a user via the server-to-server API, a special edge case was broken that should should never happen but happens in our OIDC federation because one of the provider modifies the users "sub" each time they login. We end-up with existing users for who the email matches but not the sub. They were not correctly handled. I made a few additional fixes and improvements to the endpoint.
1 parent 96bb99d commit 610948c

File tree

8 files changed

+409
-42
lines changed

8 files changed

+409
-42
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ and this project adheres to
2929
- 💄(frontend) update doc versioning ui #463
3030
- 💄(frontend) update doc summary ui #473
3131

32+
## Fixed
33+
34+
- 🐛(backend) fix create document via s2s if sub unknown but email found #543
3235

3336
## [1.10.0] - 2024-12-17
3437

src/backend/core/api/serializers.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -264,13 +264,17 @@ def create(self, validated_data):
264264
"""Create the document and associate it with the user or send an invitation."""
265265
language = validated_data.get("language", settings.LANGUAGE_CODE)
266266

267-
# Get the user based on the sub (unique identifier)
267+
# Get the user on its sub (unique identifier). Default on email if allowed in settings
268+
email = validated_data["email"]
269+
268270
try:
269-
user = models.User.objects.get(sub=validated_data["sub"])
270-
except (models.User.DoesNotExist, KeyError):
271-
user = None
272-
email = validated_data["email"]
273-
else:
271+
user = models.User.objects.get_user_by_sub_or_email(
272+
validated_data["sub"], email
273+
)
274+
except models.DuplicateEmailError as err:
275+
raise serializers.ValidationError({"email": [err.message]}) from err
276+
277+
if user:
274278
email = user.email
275279
language = user.language or language
276280

@@ -279,7 +283,9 @@ def create(self, validated_data):
279283
validated_data["content"]
280284
)
281285
except ConversionError as err:
282-
raise exceptions.APIException(detail="could not convert content") from err
286+
raise serializers.ValidationError(
287+
{"content": ["Could not convert content"]}
288+
) from err
283289

284290
document = models.Document.objects.create(
285291
title=validated_data["title"],
@@ -302,7 +308,11 @@ def create(self, validated_data):
302308
role=models.RoleChoices.OWNER,
303309
)
304310

305-
# Notify the user about the newly created document
311+
self._send_email_notification(document, validated_data, email, language)
312+
return document
313+
314+
def _send_email_notification(self, document, validated_data, email, language):
315+
"""Notify the user about the newly created document."""
306316
subject = validated_data.get("subject") or _(
307317
"A new document was created on your behalf!"
308318
)
@@ -313,8 +323,6 @@ def create(self, validated_data):
313323
}
314324
document.send_email(subject, [email], context, language)
315325

316-
return document
317-
318326
def update(self, instance, validated_data):
319327
"""
320328
This serializer does not support updates.

src/backend/core/authentication/backends.py

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
1212
)
1313

14-
from core.models import User
14+
from core.models import DuplicateEmailError, User
1515

1616
logger = logging.getLogger(__name__)
1717

@@ -98,7 +98,10 @@ def get_or_create_user(self, access_token, id_token, payload):
9898
"short_name": short_name,
9999
}
100100

101-
user = self.get_existing_user(sub, email)
101+
try:
102+
user = User.objects.get_user_by_sub_or_email(sub, email)
103+
except DuplicateEmailError as err:
104+
raise SuspiciousOperation(err.message) from err
102105

103106
if user:
104107
if not user.is_active:
@@ -117,16 +120,6 @@ def compute_full_name(self, user_info):
117120
)
118121
return full_name or None
119122

120-
def get_existing_user(self, sub, email):
121-
"""Fetch an existing user by sub (or email as a fallback respecting fallback setting."""
122-
try:
123-
return User.objects.get(sub=sub)
124-
except User.DoesNotExist:
125-
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
126-
return User.objects.filter(email=email).first()
127-
128-
return None
129-
130123
def update_user_if_needed(self, user, claims):
131124
"""Update user claims if they have changed."""
132125
has_changed = any(

src/backend/core/models.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
Declare and configure the models for the impress core application
33
"""
4+
# pylint: disable=too-many-lines
45

56
import hashlib
67
import smtplib
@@ -89,6 +90,16 @@ class LinkReachChoices(models.TextChoices):
8990
PUBLIC = "public", _("Public") # Even anonymous users can access the document
9091

9192

93+
class DuplicateEmailError(Exception):
94+
"""Raised when an email is already associated with a pre-existing user."""
95+
96+
def __init__(self, message=None, email=None):
97+
"""Set message and email to describe the exception."""
98+
self.message = message
99+
self.email = email
100+
super().__init__(self.message)
101+
102+
92103
class BaseModel(models.Model):
93104
"""
94105
Serves as an abstract base model for other models, ensuring that records are validated
@@ -126,6 +137,35 @@ def save(self, *args, **kwargs):
126137
super().save(*args, **kwargs)
127138

128139

140+
class UserManager(auth_models.UserManager):
141+
"""Custom manager for User model with additional methods."""
142+
143+
def get_user_by_sub_or_email(self, sub, email):
144+
"""Fetch existing user by sub or email."""
145+
try:
146+
return self.get(sub=sub)
147+
except self.model.DoesNotExist as err:
148+
if not email:
149+
return None
150+
151+
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
152+
try:
153+
return self.get(email=email)
154+
except self.model.DoesNotExist:
155+
pass
156+
elif (
157+
self.filter(email=email).exists()
158+
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
159+
):
160+
raise DuplicateEmailError(
161+
_(
162+
"We couldn't find a user with this sub but the email is already "
163+
"associated with a registered user."
164+
)
165+
) from err
166+
return None
167+
168+
129169
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
130170
"""User model to work with OIDC only authentication."""
131171

@@ -192,7 +232,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
192232
),
193233
)
194234

195-
objects = auth_models.UserManager()
235+
objects = UserManager()
196236

197237
USERNAME_FIELD = "admin_email"
198238
REQUIRED_FIELDS = []
@@ -939,7 +979,10 @@ def clean(self):
939979
super().clean()
940980

941981
# Check if an identity already exists for the provided email
942-
if User.objects.filter(email=self.email).exists():
982+
if (
983+
User.objects.filter(email=self.email).exists()
984+
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
985+
):
943986
raise exceptions.ValidationError(
944987
{"email": _("This email is already associated to a registered user.")}
945988
)

src/backend/core/tests/authentication/test_backends.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Unit tests for the Authentication Backends."""
22

3+
import random
34
import re
45
from logging import Logger
56
from unittest import mock
@@ -64,7 +65,33 @@ def get_userinfo_mocked(*args):
6465
assert user == db_user
6566

6667

67-
def test_authentication_getter_existing_user_no_fallback_to_email(
68+
def test_authentication_getter_email_none(monkeypatch):
69+
"""
70+
If no user is found with the sub and no email is provided, a new user should be created.
71+
"""
72+
73+
klass = OIDCAuthenticationBackend()
74+
db_user = UserFactory(email=None)
75+
76+
def get_userinfo_mocked(*args):
77+
user_info = {"sub": "123"}
78+
if random.choice([True, False]):
79+
user_info["email"] = None
80+
return user_info
81+
82+
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
83+
84+
user = klass.get_or_create_user(
85+
access_token="test-token", id_token=None, payload=None
86+
)
87+
88+
# Since the sub and email didn't match, it should create a new user
89+
assert models.User.objects.count() == 2
90+
assert user != db_user
91+
assert user.sub == "123"
92+
93+
94+
def test_authentication_getter_existing_user_no_fallback_to_email_allow_duplicate(
6895
settings, monkeypatch
6996
):
7097
"""
@@ -77,6 +104,7 @@ def test_authentication_getter_existing_user_no_fallback_to_email(
77104

78105
# Set the setting to False
79106
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
107+
settings.OIDC_ALLOW_DUPLICATE_EMAILS = True
80108

81109
def get_userinfo_mocked(*args):
82110
return {"sub": "123", "email": db_user.email}
@@ -93,6 +121,39 @@ def get_userinfo_mocked(*args):
93121
assert user.sub == "123"
94122

95123

124+
def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate(
125+
settings, monkeypatch
126+
):
127+
"""
128+
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
129+
the system should not match users by email, even if the email matches.
130+
"""
131+
132+
klass = OIDCAuthenticationBackend()
133+
db_user = UserFactory()
134+
135+
# Set the setting to False
136+
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
137+
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
138+
139+
def get_userinfo_mocked(*args):
140+
return {"sub": "123", "email": db_user.email}
141+
142+
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
143+
144+
with pytest.raises(
145+
SuspiciousOperation,
146+
match=(
147+
"We couldn't find a user with this sub but the email is already associated "
148+
"with a registered user."
149+
),
150+
):
151+
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
152+
153+
# Since the sub doesn't match, it should not create a new user
154+
assert models.User.objects.count() == 1
155+
156+
96157
def test_authentication_getter_existing_user_with_email(
97158
django_assert_num_queries, monkeypatch
98159
):

0 commit comments

Comments
 (0)