Skip to content

Commit dc60e55

Browse files
committed
feat: Add support for using LTI data to populate user profile
Currently the LTI provider implementation auto-creates a random user when logging in, however, the LTI launch can include relevant user details such as their email, full name and even a username. This change makes the LTI code use the provided details if the "Use lti pii" setting is set in the Django admin. (cherry picked from commit 0bed7d7)
1 parent 4b6df42 commit dc60e55

5 files changed

Lines changed: 151 additions & 18 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 4.2.23 on 2025-08-29 12:43
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('lti_provider', '0004_require_user_account'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='lticonsumer',
15+
name='use_lti_pii',
16+
field=models.BooleanField(blank=True, default=False, help_text='When checked, the platform will automatically use any personal information provided via LTI to create an account. Otherwise an anonymous account will be created.'),
17+
),
18+
]

lms/djangoapps/lti_provider/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ class LtiConsumer(models.Model):
4040
"in this instance. This is required only for linking learner accounts with "
4141
"the LTI consumer. See the Open edX LTI Provider documentation for more details."
4242
))
43+
use_lti_pii = models.BooleanField(blank=True, default=False, help_text=_(
44+
"When checked, the platform will automatically use any personal information provided "
45+
"via LTI to create an account. Otherwise an anonymous account will be created."
46+
))
4347

4448
@staticmethod
4549
def get_or_supplement(instance_guid, consumer_key):

lms/djangoapps/lti_provider/tests/test_users.py

Lines changed: 98 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
Tests for the LTI user management functionality
33
"""
44

5-
5+
import itertools
66
import string
77
from unittest.mock import MagicMock, PropertyMock, patch
88

9+
import ddt
910
import pytest
1011
from django.contrib.auth.models import AnonymousUser, User # lint-amnesty, pylint: disable=imported-auth-user
1112
from django.core.exceptions import PermissionDenied
@@ -73,6 +74,7 @@ def test_random_username_generator(self):
7374
f"Username has forbidden character '{username[char]}'"
7475

7576

77+
@ddt.ddt
7678
@patch('lms.djangoapps.lti_provider.users.switch_user', autospec=True)
7779
@patch('lms.djangoapps.lti_provider.users.create_lti_user', autospec=True)
7880
class AuthenticateLtiUserTest(TestCase):
@@ -156,7 +158,10 @@ def test_auto_linking_of_users_using_lis_person_contact_email_primary(self, crea
156158
create_user.assert_called_with(self.lti_user_id, self.lti_consumer)
157159

158160
users.authenticate_lti_user(request, self.lti_user_id, self.auto_linking_consumer)
159-
create_user.assert_called_with(self.lti_user_id, self.auto_linking_consumer, self.old_user.email)
161+
create_user.assert_called_with(self.lti_user_id, self.auto_linking_consumer, {
162+
"email": self.old_user.email,
163+
"full_name": "",
164+
})
160165

161166
def test_auto_linking_of_users_using_lis_person_contact_email_primary_case_insensitive(self, create_user, switch_user): # pylint: disable=line-too-long
162167
request = RequestFactory().post("/", {"lis_person_contact_email_primary": self.old_user.email.upper()})
@@ -166,7 +171,10 @@ def test_auto_linking_of_users_using_lis_person_contact_email_primary_case_insen
166171
create_user.assert_called_with(self.lti_user_id, self.lti_consumer)
167172

168173
users.authenticate_lti_user(request, self.lti_user_id, self.auto_linking_consumer)
169-
create_user.assert_called_with(self.lti_user_id, self.auto_linking_consumer, request.user.email)
174+
create_user.assert_called_with(self.lti_user_id, self.auto_linking_consumer, {
175+
"email": self.old_user.email,
176+
"full_name": "",
177+
})
170178

171179
def test_raise_exception_trying_to_auto_link_unauthenticate_user(self, create_user, switch_user):
172180
request = RequestFactory().post("/")
@@ -190,7 +198,57 @@ def test_authenticate_unauthenticated_user_after_auto_linking_of_user_account(se
190198
assert not create_user.called
191199
switch_user.assert_called_with(self.request, lti_user, self.auto_linking_consumer)
192200

201+
@ddt.data(
202+
*itertools.product(
203+
(
204+
(
205+
{
206+
"lis_person_contact_email_primary": "some_email@example.com",
207+
"lis_person_name_given": "John",
208+
"lis_person_name_family": "Doe",
209+
},
210+
"some_email@example.com",
211+
"John Doe",
212+
),
213+
(
214+
{
215+
"lis_person_contact_email_primary": "some_email@example.com",
216+
"lis_person_name_full": "John Doe",
217+
"lis_person_name_given": "Jacob",
218+
},
219+
"some_email@example.com",
220+
"John Doe",
221+
),
222+
(
223+
{"lis_person_contact_email_primary": "some_email@example.com", "lis_person_name_full": "John Doe"},
224+
"some_email@example.com",
225+
"John Doe",
226+
),
227+
({"lis_person_contact_email_primary": "some_email@example.com"}, "some_email@example.com", ""),
228+
({"lis_person_contact_email_primary": ""}, "", ""),
229+
({"lis_person_contact_email_primary": ""}, "", ""),
230+
({}, "", ""),
231+
),
232+
[True, False],
233+
)
234+
)
235+
@ddt.unpack
236+
def test_create_user_when_user_account_not_required(self, params, enable_lti_pii, create_user, switch_user):
237+
post_params, email, name = params
238+
self.auto_linking_consumer.require_user_account = False
239+
self.auto_linking_consumer.use_lti_pii = enable_lti_pii
240+
self.auto_linking_consumer.save()
241+
request = RequestFactory().post("/", post_params)
242+
request.user = AnonymousUser()
243+
users.authenticate_lti_user(request, self.lti_user_id, self.auto_linking_consumer)
244+
if enable_lti_pii:
245+
profile = {"email": email, "full_name": name, "username": self.lti_user_id}
246+
create_user.assert_called_with(self.lti_user_id, self.auto_linking_consumer, profile)
247+
else:
248+
create_user.assert_called_with(self.lti_user_id, self.auto_linking_consumer)
249+
193250

251+
@ddt.ddt
194252
class CreateLtiUserTest(TestCase):
195253
"""
196254
Tests for the create_lti_user function in users.py
@@ -222,22 +280,22 @@ def test_create_lti_user_creates_correct_user(self, uuid_mock, _username_mock):
222280
@patch('lms.djangoapps.lti_provider.users.generate_random_edx_username', side_effect=['edx_id', 'new_edx_id'])
223281
def test_unique_username_created(self, username_mock):
224282
User(username='edx_id').save()
225-
users.create_lti_user('lti_user_id', self.lti_consumer)
283+
users.create_lti_user('lti_user_id', self.lti_consumer, None)
226284
assert username_mock.call_count == 2
227285
assert User.objects.count() == 3
228286
user = User.objects.get(username='new_edx_id')
229287
assert user.email == 'new_edx_id@lti.example.com'
230288

231289
def test_existing_user_is_linked(self):
232-
lti_user = users.create_lti_user('lti_user_id', self.lti_consumer, self.existing_user.email)
290+
lti_user = users.create_lti_user('lti_user_id', self.lti_consumer, {"email": self.existing_user.email})
233291
assert lti_user.lti_consumer == self.lti_consumer
234292
assert lti_user.edx_user == self.existing_user
235293

236294
def test_only_one_lti_user_edx_user_for_each_lti_consumer(self):
237-
users.create_lti_user('lti_user_id', self.lti_consumer, self.existing_user.email)
295+
users.create_lti_user('lti_user_id', self.lti_consumer, {"email": self.existing_user.email})
238296

239297
with pytest.raises(IntegrityError):
240-
users.create_lti_user('lti_user_id', self.lti_consumer, self.existing_user.email)
298+
users.create_lti_user('lti_user_id', self.lti_consumer, {"email": self.existing_user.email})
241299

242300
def test_create_multiple_lti_users_for_edx_user_if_lti_consumer_varies(self):
243301
lti_consumer_2 = LtiConsumer(
@@ -247,11 +305,42 @@ def test_create_multiple_lti_users_for_edx_user_if_lti_consumer_varies(self):
247305
)
248306
lti_consumer_2.save()
249307

250-
lti_user_1 = users.create_lti_user('lti_user_id', self.lti_consumer, self.existing_user.email)
251-
lti_user_2 = users.create_lti_user('lti_user_id', lti_consumer_2, self.existing_user.email)
308+
lti_user_1 = users.create_lti_user('lti_user_id', self.lti_consumer, {"email": self.existing_user.email})
309+
lti_user_2 = users.create_lti_user('lti_user_id', lti_consumer_2, {"email": self.existing_user.email})
252310

253311
assert lti_user_1.edx_user == lti_user_2.edx_user
254312

313+
def test_create_lti_user_with_full_profile(self):
314+
lti_user = users.create_lti_user('lti_user_id', self.lti_consumer, {
315+
"email": "some.user@example.com",
316+
"full_name": "John Doe",
317+
"username": "john_doe",
318+
})
319+
assert lti_user.edx_user.email == "some.user@example.com"
320+
assert lti_user.edx_user.username == "john_doe"
321+
assert lti_user.edx_user.profile.name == "John Doe"
322+
323+
@patch('lms.djangoapps.lti_provider.users.generate_random_edx_username', side_effect=['edx_id'])
324+
def test_create_lti_user_with_missing_username_in_profile(self, mock):
325+
lti_user = users.create_lti_user('lti_user_id', self.lti_consumer, {
326+
"email": "some.user@example.com",
327+
"full_name": "John Doe",
328+
})
329+
assert lti_user.edx_user.email == "some.user@example.com"
330+
assert lti_user.edx_user.username == "edx_id"
331+
assert lti_user.edx_user.profile.name == "John Doe"
332+
333+
@patch('lms.djangoapps.lti_provider.users.generate_random_edx_username', side_effect=['edx_id', 'edx_id123'])
334+
def test_create_lti_user_with_duplicate_username_in_profile(self, mock):
335+
lti_user = users.create_lti_user('lti_user_id', self.lti_consumer, {
336+
"email": "some.user@example.com",
337+
"full_name": "John Doe",
338+
"username": self.existing_user.username,
339+
})
340+
assert lti_user.edx_user.email == "some.user@example.com"
341+
assert lti_user.edx_user.username == "edx_id"
342+
assert lti_user.edx_user.profile.name == "John Doe"
343+
255344

256345
class LtiBackendTest(TestCase):
257346
"""

lms/djangoapps/lti_provider/users.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@
1919
from openedx.core.djangoapps.safe_sessions.middleware import mark_user_change_as_expected
2020

2121

22+
def get_lti_user_details(request):
23+
"""
24+
Returns key LTI user details from the LTI launch request.
25+
"""
26+
post_data = request.POST
27+
email = post_data.get("lis_person_contact_email_primary", "").lower()
28+
full_name = post_data.get("lis_person_name_full", "")
29+
given_name = post_data.get("lis_person_name_given", "")
30+
family_name = post_data.get("lis_person_name_family", "")
31+
if not full_name and given_name:
32+
full_name = f"{given_name} {family_name}"
33+
return dict(email=email, full_name=full_name)
34+
35+
2236
def authenticate_lti_user(request, lti_user_id, lti_consumer):
2337
"""
2438
Determine whether the user specified by the LTI launch has an existing
@@ -28,7 +42,7 @@ def authenticate_lti_user(request, lti_user_id, lti_consumer):
2842
If the currently logged-in user does not match the user specified by the LTI
2943
launch, log out the old user and log in the LTI identity.
3044
"""
31-
lis_email = request.POST.get("lis_person_contact_email_primary")
45+
profile = get_lti_user_details(request)
3246

3347
try:
3448
lti_user = LtiUser.objects.get(
@@ -40,11 +54,14 @@ def authenticate_lti_user(request, lti_user_id, lti_consumer):
4054
if lti_consumer.require_user_account:
4155
# Verify that the email from the LTI Launch and the logged-in user are the same
4256
# before linking the LtiUser with the edx_user.
43-
if request.user.is_authenticated and request.user.email.lower() == lis_email.lower():
44-
lti_user = create_lti_user(lti_user_id, lti_consumer, request.user.email)
57+
if request.user.is_authenticated and request.user.email.lower() == profile["email"]:
58+
lti_user = create_lti_user(lti_user_id, lti_consumer, profile)
4559
else:
4660
# Ask the user to login before linking.
4761
raise PermissionDenied() from exc
62+
elif lti_consumer.use_lti_pii:
63+
profile["username"] = lti_user_id
64+
lti_user = create_lti_user(lti_user_id, lti_consumer, profile)
4865
else:
4966
lti_user = create_lti_user(lti_user_id, lti_consumer)
5067

@@ -55,20 +72,23 @@ def authenticate_lti_user(request, lti_user_id, lti_consumer):
5572
switch_user(request, lti_user, lti_consumer)
5673

5774

58-
def create_lti_user(lti_user_id, lti_consumer, email=None):
75+
def create_lti_user(lti_user_id, lti_consumer, profile=None):
5976
"""
6077
Generate a new user on the edX platform with a random username and password,
6178
and associates that account with the LTI identity.
6279
"""
80+
if profile is None:
81+
profile = {}
82+
email = profile.get("email")
83+
edx_user_id = profile.get("username") or generate_random_edx_username()
6384
edx_user = User.objects.filter(email=email).first() if email else None
6485

6586
if not edx_user:
6687
created = False
6788
edx_password = str(uuid.uuid4())
6889
while not created:
6990
try:
70-
edx_user_id = generate_random_edx_username()
71-
edx_email = f"{edx_user_id}@{settings.LTI_USER_EMAIL_DOMAIN}"
91+
edx_email = email if email else f"{edx_user_id}@{settings.LTI_USER_EMAIL_DOMAIN}"
7292
with transaction.atomic():
7393
edx_user = User.objects.create_user(
7494
username=edx_user_id,
@@ -78,13 +98,13 @@ def create_lti_user(lti_user_id, lti_consumer, email=None):
7898
# A profile is required if PREVENT_CONCURRENT_LOGINS flag is set.
7999
# TODO: We could populate user information from the LTI launch here,
80100
# but it's not necessary for our current uses.
81-
edx_user_profile = UserProfile(user=edx_user)
101+
edx_user_profile = UserProfile(user=edx_user, name=profile.get("full_name", ""))
82102
edx_user_profile.save()
83103
created = True
84104
except IntegrityError:
105+
edx_user_id = generate_random_edx_username()
85106
# The random edx_user_id wasn't unique. Since 'created' is still
86107
# False, we will retry with a different random ID.
87-
pass
88108

89109
lti_user = LtiUser(
90110
lti_consumer=lti_consumer,

lms/djangoapps/lti_provider/views.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@
3232

3333
OPTIONAL_PARAMETERS = [
3434
'context_title', 'context_label', 'lis_result_sourcedid',
35-
'lis_outcome_service_url', 'tool_consumer_instance_guid'
35+
'lis_outcome_service_url', 'tool_consumer_instance_guid',
36+
"lis_person_name_full", "lis_person_name_given", "lis_person_name_family",
37+
"lis_person_contact_email_primary",
3638
]
3739

3840

0 commit comments

Comments
 (0)