Skip to content

LTI1p3 - handle instructor content selection without 3rd party cookies #755

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 122 additions & 13 deletions bases/rsptx/admin_server_api/routers/lti1p3.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@
create_instructor_course_entry,
fetch_lti1p3_config_by_lti_data,
fetch_lti1p3_course_by_lti_id,
fetch_lti1p3_course_by_id
fetch_lti1p3_course_by_id,
fetch_course,
fetch_instructor_courses,
validate_user_credentials
)

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

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

rslogger.debug(f"LTI1p3 - login redirect {target_link_uri}")
return redirect
Expand Down Expand Up @@ -635,19 +635,113 @@ async def get_jwks(request: Request):
return JSONResponse(keys)


@router.get("/dynamic-linking")
@router.post("/dynamic-linking")
@instructor_role_required()
@with_course()
# async def deep_link_entry(request: Request, course=None):
async def deep_link_entry(request: Request, course=None):
@router.post("/verify-user")
async def deep_link_entry(request: Request):
"""
Endpoint to verify the user is logged in and has access to the course.
This is used by the deep linking tool to ensure the user is authenticated.
"""
data = await request.json()
username = data.get('username')
password = data.get('password')

if not username or not password:
return JSONResponse(content={"success": False, "error": "Username and password are required"})

user = await validate_user_credentials(username, password)
if not user:
return JSONResponse(content={"success": False, "error": "Invalid username or password"})

rslogger.debug(f"LTI1p3 - User {user.__dict__} verified successfully by verify-user")

user_nonce = "lti1p3-verify-nonce-" + str(uuid.uuid4())
launch_data_storage = get_launch_data_storage()
launch_data_storage.set_value(user_nonce, user.username)

return JSONResponse(content={"success": True, "username": user.username, "nonce": user_nonce})


async def get_authenticated_user(
request: Request
) -> AuthUserValidator:
"""
Helper to either get the user from the auth manager or from a nonce.
If cookies are available, auth_manager will return the user. Otherwise,
rely on the authentication nonce provided by the rs-login workflow.

If the nonce is invalid or user is not found, return None.
"""
user = None
try:
user = await auth_manager(request)
return user
except Exception as e:
pass

# check if we have a form submission with an authentication nonce
form_data = await request.form()
authentication_nonce = form_data.get('authentication_nonce') or ""
launch_data_storage = get_launch_data_storage()
authorized_username = launch_data_storage.get_value(authentication_nonce)
if not authorized_username:
return None
user = await fetch_user(authorized_username)
return user


@router.post("/rs-login")
async def deep_link_entry(request: Request):
tool_conf = get_tool_config_mgr()
rslogger.info(f"Creating FastAPIRequest with request: {request.__dict__}")
fapi_request = await FastAPIRequest.create(request, session=get_session_service())
rslogger.info(f" Produced fapi_request: {fapi_request.__dict__}")
message_launch = await FastAPIMessageLaunch.create(
fapi_request, tool_conf, launch_data_storage=get_launch_data_storage()
)
rslogger.debug(f"LTI1p3 - rs-login request: {fapi_request.__dict__}")
templates = Jinja2Templates(directory=template_folder)
tpl_kwargs = {
"request": request,
"launch_id": message_launch.get_launch_id(),
"authenticity_token": fapi_request.get_param('authenticity_token'),
"id_token": fapi_request.get_param('id_token'),
"state": fapi_request.get_param('state'),
"lti_storage_target": fapi_request.get_param('lti_storage_target'),
}
resp = templates.TemplateResponse(
name="admin/lti1p3/rs_login.html",
context=tpl_kwargs
)
return resp


@router.get("/dynamic-linking")
@router.post("/dynamic-linking")
async def deep_link_entry(request: Request):
tool_conf = get_tool_config_mgr()
fapi_request = await FastAPIRequest.create(request, session=get_session_service())
message_launch = await FastAPIMessageLaunch.create(
fapi_request, tool_conf, launch_data_storage=get_launch_data_storage()
)

user = await get_authenticated_user(request)
if not user:
# Redirect to the rs-login page. It will submit back to this route but with
# an authentication nonce that will allow us to identify the user.
resp = RedirectResponse(
f"/admin/lti1p3/rs-login",
status_code=307
)
return resp

user_is_instructor = len(await fetch_instructor_courses(user.id, user.course_id)) > 0
if not user_is_instructor:
raise HTTPException(
status_code=403,
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.",
)

course = await fetch_course(user.course_name)

lti_context = message_launch.get_context()
# See if currently logged in RS course is already mapped to something else
Expand Down Expand Up @@ -719,6 +813,11 @@ async def deep_link_entry(request: Request, course=None):

assigns_dicts.append(d)

# if we used an authentication nonce to identify the user, we need to
# include it in the template context so it can be passed on by pick_links
form_data = await request.form()
authentication_nonce = form_data.get('authentication_nonce') or ""

tpl_kwargs = {
"launch_id": message_launch.get_launch_id(),
"assignments": assigns_dicts,
Expand All @@ -731,6 +830,7 @@ async def deep_link_entry(request: Request, course=None):
"mapping_mismatch": mapping_mismatch,
"current_mapped_course_name": lti_context.get("label"),
"request": request,
"authentication_nonce": authentication_nonce,
}

templates = Jinja2Templates(directory=template_folder)
Expand All @@ -752,8 +852,6 @@ def match_by_name(rs_assign, lti_lineitems):


@router.post("/assign-select/{launch_id}")
@instructor_role_required()
@with_course()
async def assign_select(launch_id: str, request: Request, course=None):
rslogger.debug("LTI1p3 - assignment select")
tool_conf = get_tool_config_mgr()
Expand All @@ -769,6 +867,17 @@ async def assign_select(launch_id: str, request: Request, course=None):
if not message_launch.is_deep_link_launch():
raise HTTPException(status_code=400, detail="Must be a deep link launch!")

user = await get_authenticated_user(request)
if not user:
raise HTTPException(status_code=403, detail="User information not found in request.")

course = await fetch_course(user.course_name)
if not course:
raise HTTPException(
status_code=404,
detail=f"Course {user.course_name} not found",
)

# Store or update the course to the database
# This is the first time we have all the necessary information and the instructor
# has confirmed that they want to create one or more links
Expand Down
1 change: 1 addition & 0 deletions components/rsptx/db/crud/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
upsert_lti1p3_config,
upsert_lti1p3_course,
upsert_lti1p3_user,
validate_user_credentials,
)

from .peer import fetch_last_useinfo_peergroup, get_peer_votes, did_send_messages
Expand Down
27 changes: 26 additions & 1 deletion components/rsptx/db/crud/lti.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
from typing import List
from typing import List, Optional
from rsptx.configuration import settings
from pydal.validators import CRYPT
from sqlalchemy import select, delete
from sqlalchemy.orm import joinedload
from ..crud import fetch_user
from ..models import (
Assignment,
AuthUserValidator,
CourseLtiMap,
CoursesValidator,
Lti1p3Assignment,
Expand Down Expand Up @@ -349,6 +353,27 @@ async def fetch_lti1p3_grading_data_for_assignment(
return assign


async def validate_user_credentials(username: str, password: str) -> Optional[AuthUserValidator]:
"""
Validate a user's credentials by their username and password.

:param username: str, the username of the user
:param password: str, the password of the user
:return: Optional[AuthUserValidator], the AuthUserValidator object representing the user if valid, None otherwise
"""
user = await fetch_user(username, True)
if not user:
return None

crypt = CRYPT(key=settings.web2py_private_key, salt=True)
if crypt(password)[0] == user.password:
return user
else:
return None




# /LTI 1.3
# -----------------------------------------------------------------------

Expand Down
1 change: 0 additions & 1 deletion components/rsptx/lti1p3/pylti1p3/service_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,6 @@ async def get_access_token(self, scopes: t.Sequence[str]) -> str:
raise LtiServiceException(r)
except Exception as e:
raw_body = await r.text()
(raw_body)
raise LtiServiceException(r)
if r.content_type == "application/json":
response = await r.json()
Expand Down
17 changes: 14 additions & 3 deletions components/rsptx/templates/admin/lti1p3/pick_links.html
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,14 @@ <h1>Runestone Content Linking</h1>
</div>
{% else %}

{% if not supports_multiple %}
<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>
{% endif %}
{% if not supports_multiple %}
<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>
{% endif %}

<div class="alert alert-danger" role="alert" id="top-level-warning" style="display: none;">
<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>
<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>
</div>

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

Expand Down Expand Up @@ -106,6 +111,7 @@ <h2>Assignment Links</h2>
</table>

<input type="hidden" name="launch_id" value="{{ launch_id }}">
<input type="hidden" name="authentication_nonce" value="{{ authentication_nonce }}">

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

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


<script>
const inIframe = window.self !== window.top;
if (!inIframe) {
document.getElementById('top-level-warning').style.display = 'block';
}

let bookLinkCount = 0;
document.getElementById('addBookLink').addEventListener('click', function() {
const bookLinks = document.getElementById('book-links');
Expand Down
103 changes: 103 additions & 0 deletions components/rsptx/templates/admin/lti1p3/rs_login.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Assignment Selection</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.3.0/css/bootstrap.min.css"/>
<style>
body {
padding: 20px;
}
h2 {
margin-top: 30px;
}
.subchapter {
background: #f0f0f0;
}
.alert p:last-child {
margin-bottom: 0;
}
.book-links {
list-style-type: none;
padding-left: 0;
}
.book-links li {
margin-bottom: 10px;
}
.table-striped tr:last-child td {
background: none;
border-bottom: none;
box-shadow: none;
}
tr.error td {
background: var(--bs-danger-bg-subtle) !important;
}
</style>
</head>
<body>
<h1>Runestone LTI 1.3 Login</h1>

<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>

<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>

<p>If you still reach this page, you can either enable third party cookies in your browser, or provide your Runestone credentials below.</p>


<form action="dynamic-linking" method="post">

<div class="mb-3">
<label for="username" class="form-label">Runestone Username:</label>
<input type="text" id="username" name="username" class="form-control" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Runestone Password:</label>
<input type="password" id="password" name="password" class="form-control" required>
</div>

<input type="hidden" name="authenticity_token" value="{{ authenticity_token }}">
<input type="hidden" name="id_token" value="{{ id_token }}">
<input type="hidden" name="state" value="{{ state }}">
<input type="hidden" name="lti_storage_target" value="{{ lti_storage_target }}">
<input type="hidden" name="authentication_nonce" id="authentication_nonce">
<button type="button" id="submit-button" class="btn btn-primary mt-3">Submit form</button>
</form>

<script>
document.getElementById('submit-button').addEventListener('click', async function() {
//document.querySelector('form').submit();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
//make ajax request to authenticate to verify-user
let response = await fetch('verify-user', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: username,
password: password
})
});

if (response.ok) {
//get body of response
const responseBody = await response.json();
if (responseBody.success) {
document.getElementById('authentication_nonce').value = responseBody.nonce;
document.querySelector('form').submit();
}
else {
// If the response indicates failure, show an error message
alert('Login failed: ' + responseBody.error);
}
} else {
// If the response is not OK, show an error message
const errorText = await response.text();
alert('Login failed: ' + errorText);
}
});
</script>
</body>
</html>