Skip to content

Commit ff7914f

Browse files
committed
🛂(backend) match email if no existing user matches the sub
Some OIDC identity providers may provide a random value in the "sub" field instead of an identifying ID. In this case, it may be a good idea to fallback to matching the user on its email field.
1 parent 647e6c1 commit ff7914f

File tree

4 files changed

+105
-41
lines changed

4 files changed

+105
-41
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ and this project adheres to
2525

2626
## Fixed
2727

28+
- 🛂(frontend) match email if no existing user matches the sub
2829
- 🐛(backend) gitlab oicd userinfo endpoint #232
2930
- 🛂(frontend) redirect to the OIDC when private doc and unauthentified #292
3031
- ♻️(backend) getting list of document versions available for a user #258

src/backend/core/authentication/backends.py

Lines changed: 45 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -60,57 +60,61 @@ def get_userinfo(self, access_token, id_token, payload):
6060
return userinfo
6161

6262
def get_or_create_user(self, access_token, id_token, payload):
63-
"""Return a User based on userinfo. Get or create a new user if no user matches the Sub.
64-
65-
Parameters:
66-
- access_token (str): The access token.
67-
- id_token (str): The ID token.
68-
- payload (dict): The user payload.
69-
70-
Returns:
71-
- User: An existing or newly created User instance.
72-
73-
Raises:
74-
- Exception: Raised when user creation is not allowed and no existing user is found.
75-
"""
63+
"""Return a User based on userinfo. Create a new user if no match is found."""
7664

7765
user_info = self.get_userinfo(access_token, id_token, payload)
66+
email = user_info.get("email")
67+
68+
# Get user's full name from OIDC fields defined in settings
69+
full_name = self.compute_full_name(user_info)
70+
short_name = user_info.get(settings.USER_OIDC_FIELD_TO_SHORTNAME)
7871

79-
# Compute user full name from OIDC name fields as defined in settings
80-
names_list = [
81-
user_info[field]
82-
for field in settings.USER_OIDC_FIELDS_TO_FULLNAME
83-
if user_info.get(field)
84-
]
8572
claims = {
86-
"email": user_info.get("email"),
87-
"full_name": " ".join(names_list) or None,
88-
"short_name": user_info.get(settings.USER_OIDC_FIELD_TO_SHORTNAME),
73+
"email": email,
74+
"full_name": full_name,
75+
"short_name": short_name,
8976
}
9077

9178
sub = user_info.get("sub")
92-
if sub is None:
79+
if not sub:
9380
raise SuspiciousOperation(
9481
_("User info contained no recognizable user identification")
9582
)
9683

97-
try:
98-
user = User.objects.get(sub=sub, is_active=True)
99-
except User.DoesNotExist:
100-
if self.get_settings("OIDC_CREATE_USER", True):
101-
user = User.objects.create(
102-
sub=sub,
103-
password="!", # noqa: S106
104-
**claims,
105-
)
106-
else:
107-
user = None
108-
else:
109-
has_changed = any(
110-
value and value != getattr(user, key) for key, value in claims.items()
111-
)
112-
if has_changed:
113-
updated_claims = {key: value for key, value in claims.items() if value}
114-
self.UserModel.objects.filter(sub=sub).update(**updated_claims)
84+
user = self.get_existing_user(sub, email)
85+
86+
if user:
87+
self.update_user_if_needed(user, claims)
88+
elif self.get_settings("OIDC_CREATE_USER", True):
89+
user = User.objects.create(sub=sub, password="!", **claims) # noqa: S106
11590

11691
return user
92+
93+
def compute_full_name(self, user_info):
94+
"""Compute user's full name based on OIDC fields in settings."""
95+
name_fields = settings.USER_OIDC_FIELDS_TO_FULLNAME
96+
full_name = " ".join(
97+
user_info[field] for field in name_fields if user_info.get(field)
98+
)
99+
return full_name or None
100+
101+
def get_existing_user(self, sub, email):
102+
"""Fetch existing user by sub or email."""
103+
try:
104+
return User.objects.get(sub=sub, is_active=True)
105+
except User.DoesNotExist:
106+
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
107+
try:
108+
return User.objects.get(email=email, is_active=True)
109+
except User.DoesNotExist:
110+
pass
111+
return None
112+
113+
def update_user_if_needed(self, user, claims):
114+
"""Update user claims if they have changed."""
115+
has_changed = any(
116+
value and value != getattr(user, key) for key, value in claims.items()
117+
)
118+
if has_changed:
119+
updated_claims = {key: value for key, value in claims.items() if value}
120+
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,59 @@ def get_userinfo_mocked(*args):
3838
assert user == db_user
3939

4040

41+
def test_authentication_getter_existing_user_via_email(
42+
django_assert_num_queries, monkeypatch
43+
):
44+
"""
45+
If an existing user doesn't match the sub but matches the email,
46+
the user should be returned.
47+
"""
48+
49+
klass = OIDCAuthenticationBackend()
50+
db_user = UserFactory()
51+
52+
def get_userinfo_mocked(*args):
53+
return {"sub": "123", "email": db_user.email}
54+
55+
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
56+
57+
with django_assert_num_queries(2):
58+
user = klass.get_or_create_user(
59+
access_token="test-token", id_token=None, payload=None
60+
)
61+
62+
assert user == db_user
63+
64+
65+
def test_authentication_getter_existing_user_no_fallback_to_email(
66+
settings, monkeypatch
67+
):
68+
"""
69+
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
70+
the system should not match users by email, even if the email matches.
71+
"""
72+
73+
klass = OIDCAuthenticationBackend()
74+
db_user = UserFactory()
75+
76+
# Set the setting to False
77+
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
78+
79+
def get_userinfo_mocked(*args):
80+
return {"sub": "123", "email": db_user.email}
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 doesn'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+
4194
def test_authentication_getter_existing_user_with_email(
4295
django_assert_num_queries, monkeypatch
4396
):

src/backend/impress/settings.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,12 @@ class Base(Configuration):
384384
OIDC_STORE_ID_TOKEN = values.BooleanValue(
385385
default=True, environ_name="OIDC_STORE_ID_TOKEN", environ_prefix=None
386386
)
387+
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
388+
default=True,
389+
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
390+
environ_prefix=None,
391+
)
392+
387393
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
388394
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
389395
)

0 commit comments

Comments
 (0)