diff --git a/bases/rsptx/admin_server_api/routers/lti1p3.py b/bases/rsptx/admin_server_api/routers/lti1p3.py index e7ba54e4a..d28c6ea00 100644 --- a/bases/rsptx/admin_server_api/routers/lti1p3.py +++ b/bases/rsptx/admin_server_api/routers/lti1p3.py @@ -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 @@ -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 @@ -635,12 +635,62 @@ 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()) @@ -648,6 +698,50 @@ async def deep_link_entry(request: Request, course=None): 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 @@ -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, @@ -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) @@ -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() @@ -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 diff --git a/components/rsptx/db/crud/__init__.py b/components/rsptx/db/crud/__init__.py index a1ffc874d..106780142 100644 --- a/components/rsptx/db/crud/__init__.py +++ b/components/rsptx/db/crud/__init__.py @@ -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 diff --git a/components/rsptx/db/crud/lti.py b/components/rsptx/db/crud/lti.py index 61d969ba6..26daeef25 100644 --- a/components/rsptx/db/crud/lti.py +++ b/components/rsptx/db/crud/lti.py @@ -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, @@ -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 # ----------------------------------------------------------------------- diff --git a/components/rsptx/lti1p3/pylti1p3/service_connector.py b/components/rsptx/lti1p3/pylti1p3/service_connector.py index 350c400ac..b63a69277 100644 --- a/components/rsptx/lti1p3/pylti1p3/service_connector.py +++ b/components/rsptx/lti1p3/pylti1p3/service_connector.py @@ -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() diff --git a/components/rsptx/templates/admin/lti1p3/pick_links.html b/components/rsptx/templates/admin/lti1p3/pick_links.html index 783cad8a3..a3da72a9d 100644 --- a/components/rsptx/templates/admin/lti1p3/pick_links.html +++ b/components/rsptx/templates/admin/lti1p3/pick_links.html @@ -44,9 +44,14 @@
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.
- {% endif %} +{% if not supports_multiple %} +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.
+{% endif %} + +