Skip to content

Commit 019539b

Browse files
LTI1p3 - handle instructor content selection without 3rd party cookies
1 parent e43af67 commit 019539b

File tree

5 files changed

+256
-15
lines changed

5 files changed

+256
-15
lines changed

bases/rsptx/admin_server_api/routers/lti1p3.py

Lines changed: 120 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,10 @@
6767
create_instructor_course_entry,
6868
fetch_lti1p3_config_by_lti_data,
6969
fetch_lti1p3_course_by_lti_id,
70-
fetch_lti1p3_course_by_id
70+
fetch_lti1p3_course_by_id,
71+
fetch_course,
72+
fetch_instructor_courses,
73+
validate_user_credentials
7174
)
7275

7376
from rsptx.configuration import settings
@@ -279,10 +282,7 @@ async def login(request: Request):
279282
target_link_uri, params = target_link_uri.split("?")
280283
oidc_login.pass_params_to_launch({"query_params": params})
281284

282-
# I believe we are storing everything that needs storing without cookies
283-
# leave this here as a reminder for how to do check if needed:
284-
#redirect = await oidc_login.enable_check_cookies().redirect(target_link_uri)
285-
redirect = await oidc_login.redirect(target_link_uri)
285+
redirect = await oidc_login.enable_check_cookies().redirect(target_link_uri)
286286

287287
rslogger.debug(f"LTI1p3 - login redirect {target_link_uri}")
288288
return redirect
@@ -635,18 +635,112 @@ async def get_jwks(request: Request):
635635
return JSONResponse(keys)
636636

637637

638+
@router.post("/verify-user")
639+
async def deep_link_entry(request: Request):
640+
"""
641+
Endpoint to verify the user is logged in and has access to the course.
642+
This is used by the deep linking tool to ensure the user is authenticated.
643+
"""
644+
data = await request.json()
645+
username = data.get('username')
646+
password = data.get('password')
647+
648+
if not username or not password:
649+
return JSONResponse(content={"success": False, "error": "Username and password are required"})
650+
651+
user = await validate_user_credentials(username, password)
652+
if not user:
653+
return JSONResponse(content={"success": False, "error": "Invalid username or password"})
654+
655+
rslogger.debug(f"LTI1p3 - User {user.__dict__} verified successfully by verify-user")
656+
657+
user_nonce = "lti1p3-verify-nonce-" + str(uuid.uuid4())
658+
launch_data_storage = get_launch_data_storage()
659+
launch_data_storage.set_value(user_nonce, user.username)
660+
661+
return JSONResponse(content={"success": True, "username": user.username, "nonce": user_nonce})
662+
663+
664+
async def get_authenticated_user(
665+
request: Request
666+
) -> AuthUserValidator:
667+
"""
668+
Helper to either get the user from the auth manager or from a nonce.
669+
If cookies are available, auth_manager will return the user. Otherwise,
670+
rely on the authentication nonce provided by the rs-login workflow.
671+
672+
If the nonce is invalid or user is not found, return None.
673+
"""
674+
user = None
675+
try:
676+
user = await auth_manager(request)
677+
return user
678+
except Exception as e:
679+
pass
680+
681+
# check if we have a form submission with an authentication nonce
682+
form_data = await request.form()
683+
authentication_nonce = form_data.get('authentication_nonce') or ""
684+
launch_data_storage = get_launch_data_storage()
685+
authorized_username = launch_data_storage.get_value(authentication_nonce)
686+
if not authorized_username:
687+
return None
688+
user = await fetch_user(authorized_username)
689+
return user
690+
691+
692+
@router.post("/rs-login")
693+
async def deep_link_entry(request: Request):
694+
tool_conf = get_tool_config_mgr()
695+
fapi_request = await FastAPIRequest.create(request, session=get_session_service())
696+
message_launch = await FastAPIMessageLaunch.create(
697+
fapi_request, tool_conf, launch_data_storage=get_launch_data_storage()
698+
)
699+
rslogger.debug(f"LTI1p3 - rs-login request: {fapi_request.__dict__}")
700+
templates = Jinja2Templates(directory=template_folder)
701+
tpl_kwargs = {
702+
"request": request,
703+
"launch_id": message_launch.get_launch_id(),
704+
"authenticity_token": fapi_request.get_param('authenticity_token'),
705+
"id_token": fapi_request.get_param('id_token'),
706+
"state": fapi_request.get_param('state'),
707+
"lti_storage_target": fapi_request.get_param('lti_storage_target'),
708+
}
709+
resp = templates.TemplateResponse(
710+
name="admin/lti1p3/rs_login.html",
711+
context=tpl_kwargs
712+
)
713+
return resp
714+
715+
638716
@router.get("/dynamic-linking")
639717
@router.post("/dynamic-linking")
640-
@instructor_role_required()
641-
@with_course()
642-
# async def deep_link_entry(request: Request, course=None):
643-
async def deep_link_entry(request: Request, course=None):
718+
async def deep_link_entry(request: Request):
644719
tool_conf = get_tool_config_mgr()
645720
fapi_request = await FastAPIRequest.create(request, session=get_session_service())
646721
message_launch = await FastAPIMessageLaunch.create(
647722
fapi_request, tool_conf, launch_data_storage=get_launch_data_storage()
648723
)
649724

725+
user = await get_authenticated_user(request)
726+
if not user:
727+
# Redirect to the rs-login page. It will submit back to this route but with
728+
# an authentication nonce that will allow us to identify the user.
729+
resp = RedirectResponse(
730+
f"/admin/lti1p3/rs-login",
731+
status_code=307
732+
)
733+
return resp
734+
735+
user_is_instructor = len(await fetch_instructor_courses(user.id, user.course_id)) > 0
736+
if not user_is_instructor:
737+
raise HTTPException(
738+
status_code=403,
739+
detail="You must be an instructor in your current Runestone course to use this tool. Make sure that you are logged into the correct course in Runestone.",
740+
)
741+
742+
course = await fetch_course(user.course_name)
743+
650744
lti_context = message_launch.get_context()
651745
# See if currently logged in RS course is already mapped to something else
652746
lti_course = await fetch_lti1p3_course_by_rs_course(
@@ -717,6 +811,11 @@ async def deep_link_entry(request: Request, course=None):
717811

718812
assigns_dicts.append(d)
719813

814+
# if we used an authentication nonce to identify the user, we need to
815+
# include it in the template context so it can be passed on by pick_links
816+
form_data = await request.form()
817+
authentication_nonce = form_data.get('authentication_nonce') or ""
818+
720819
tpl_kwargs = {
721820
"launch_id": message_launch.get_launch_id(),
722821
"assignments": assigns_dicts,
@@ -729,6 +828,7 @@ async def deep_link_entry(request: Request, course=None):
729828
"mapping_mismatch": mapping_mismatch,
730829
"current_mapped_course_name": lti_context.get("label"),
731830
"request": request,
831+
"authentication_nonce": authentication_nonce,
732832
}
733833

734834
templates = Jinja2Templates(directory=template_folder)
@@ -750,8 +850,6 @@ def match_by_name(rs_assign, lti_lineitems):
750850

751851

752852
@router.post("/assign-select/{launch_id}")
753-
@instructor_role_required()
754-
@with_course()
755853
async def assign_select(launch_id: str, request: Request, course=None):
756854
rslogger.debug("LTI1p3 - assignment select")
757855
tool_conf = get_tool_config_mgr()
@@ -767,6 +865,17 @@ async def assign_select(launch_id: str, request: Request, course=None):
767865
if not message_launch.is_deep_link_launch():
768866
raise HTTPException(status_code=400, detail="Must be a deep link launch!")
769867

868+
user = await get_authenticated_user(request)
869+
if not user:
870+
raise HTTPException(status_code=403, detail="User information not found in request.")
871+
872+
course = await fetch_course(user.course_name)
873+
if not course:
874+
raise HTTPException(
875+
status_code=404,
876+
detail=f"Course {user.course_name} not found",
877+
)
878+
770879
# Store or update the course to the database
771880
# This is the first time we have all the necessary information and the instructor
772881
# has confirmed that they want to create one or more links

components/rsptx/db/crud.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -839,6 +839,25 @@ async def update_user(user_id: int, new_vals: dict):
839839
rslogger.debug("SUCCESS")
840840

841841

842+
async def validate_user_credentials(username: str, password: str) -> Optional[AuthUserValidator]:
843+
"""
844+
Validate a user's credentials by their username and password.
845+
846+
:param username: str, the username of the user
847+
:param password: str, the password of the user
848+
:return: Optional[AuthUserValidator], the AuthUserValidator object representing the user if valid, None otherwise
849+
"""
850+
user = await fetch_user(username, True)
851+
if not user:
852+
return None
853+
854+
crypt = CRYPT(key=settings.web2py_private_key, salt=True)
855+
if crypt(password)[0] == user.password:
856+
return user
857+
else:
858+
return None
859+
860+
842861
async def delete_user(username):
843862
"""
844863
Delete a user by their username (username)

components/rsptx/lti1p3/pylti1p3/service_connector.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,6 @@ async def get_access_token(self, scopes: t.Sequence[str]) -> str:
114114
raise LtiServiceException(r)
115115
except Exception as e:
116116
raw_body = await r.text()
117-
(raw_body)
118117
raise LtiServiceException(r)
119118
if r.content_type == "application/json":
120119
response = await r.json()

components/rsptx/templates/admin/lti1p3/pick_links.html

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,14 @@ <h1>Runestone Content Linking</h1>
4444
</div>
4545
{% else %}
4646

47-
{% if not supports_multiple %}
48-
<p>Your LMS does not appear to support multiple items. You should only add one new book link or assignment link at a time. It is OK to "remap" multiple assignments at once.</p>
49-
{% endif %}
47+
{% if not supports_multiple %}
48+
<p>Your LMS does not appear to support multiple items. You should only add one new book link or assignment link at a time. It is OK to "remap" multiple assignments at once.</p>
49+
{% endif %}
50+
51+
<div class="alert alert-danger" role="alert" id="top-level-warning" style="display: none;">
52+
<p><em>Warning</em>: Behavior while linking content on a separate page may work differently. After you submit your form, you may end up on a page that has no content or shows some loading symbol. It is page your LMS displays while loading content from Runestone.</p>
53+
<p>When you reach that page, leave it open, load or refresh your LMS course in another tab and verify that your content appeared in the course. Then you can close the loading page.</p>
54+
</div>
5055

5156
<form action="assign-select/{{launch_id}}" method="post">
5257

@@ -106,6 +111,7 @@ <h2>Assignment Links</h2>
106111
</table>
107112

108113
<input type="hidden" name="launch_id" value="{{ launch_id }}">
114+
<input type="hidden" name="authentication_nonce" value="{{ authentication_nonce }}">
109115

110116
<button type="submit" class="btn btn-primary mt-5">Submit form</button>
111117

@@ -119,6 +125,11 @@ <h2>Assignment Links</h2>
119125

120126

121127
<script>
128+
const inIframe = window.self !== window.top;
129+
if (!inIframe) {
130+
document.getElementById('top-level-warning').style.display = 'block';
131+
}
132+
122133
let bookLinkCount = 0;
123134
document.getElementById('addBookLink').addEventListener('click', function() {
124135
const bookLinks = document.getElementById('book-links');
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>Assignment Selection</title>
7+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.3.0/css/bootstrap.min.css"/>
8+
<style>
9+
body {
10+
padding: 20px;
11+
}
12+
h2 {
13+
margin-top: 30px;
14+
}
15+
.subchapter {
16+
background: #f0f0f0;
17+
}
18+
.alert p:last-child {
19+
margin-bottom: 0;
20+
}
21+
.book-links {
22+
list-style-type: none;
23+
padding-left: 0;
24+
}
25+
.book-links li {
26+
margin-bottom: 10px;
27+
}
28+
.table-striped tr:last-child td {
29+
background: none;
30+
border-bottom: none;
31+
box-shadow: none;
32+
}
33+
tr.error td {
34+
background: var(--bs-danger-bg-subtle) !important;
35+
}
36+
</style>
37+
</head>
38+
<body>
39+
<h1>Runestone LTI 1.3 Login</h1>
40+
41+
<p>You are seeing this page because the cookies Runestone uses to authenticate you were blocked by your browser or you are not currently logged into Runestone.</p>
42+
43+
<p>First make sure you are logged into Runestone and have logged into the course you are trying to link to. Then relaunch the content selection tool.</p>
44+
45+
<p>If you still reach this page, you can either enable third party cookies in your browser, or provide your Runestone credentials below.</p>
46+
47+
48+
<form action="dynamic-linking" method="post">
49+
50+
<div class="mb-3">
51+
<label for="username" class="form-label">Runestone Username:</label>
52+
<input type="text" id="username" name="username" class="form-control" required>
53+
</div>
54+
<div class="mb-3">
55+
<label for="password" class="form-label">Runestone Password:</label>
56+
<input type="password" id="password" name="password" class="form-control" required>
57+
</div>
58+
59+
<input type="hidden" name="authenticity_token" value="{{ authenticity_token }}">
60+
<input type="hidden" name="id_token" value="{{ id_token }}">
61+
<input type="hidden" name="state" value="{{ state }}">
62+
<input type="hidden" name="lti_storage_target" value="{{ lti_storage_target }}">
63+
<input type="hidden" name="authentication_nonce" id="authentication_nonce">
64+
<button type="button" id="submit-button" class="btn btn-primary mt-3">Submit form</button>
65+
</form>
66+
67+
<script>
68+
document.getElementById('submit-button').addEventListener('click', async function() {
69+
//document.querySelector('form').submit();
70+
const username = document.getElementById('username').value;
71+
const password = document.getElementById('password').value;
72+
//make ajax request to authenticate to verify-user
73+
let response = await fetch('verify-user', {
74+
method: 'POST',
75+
headers: {
76+
'Content-Type': 'application/json',
77+
},
78+
body: JSON.stringify({
79+
username: username,
80+
password: password
81+
})
82+
});
83+
84+
if (response.ok) {
85+
//get body of response
86+
const responseBody = await response.json();
87+
if (responseBody.success) {
88+
document.getElementById('authentication_nonce').value = responseBody.nonce;
89+
document.querySelector('form').submit();
90+
}
91+
else {
92+
// If the response indicates failure, show an error message
93+
alert('Login failed: ' + responseBody.error);
94+
}
95+
} else {
96+
// If the response is not OK, show an error message
97+
const errorText = await response.text();
98+
alert('Login failed: ' + errorText);
99+
}
100+
});
101+
</script>
102+
</body>
103+
</html>

0 commit comments

Comments
 (0)